Compare commits

..
Author SHA1 Message Date
Thomas Camlong 59b6f08a36 fix(board): harden grid edit transitions 2026-08-21 15:49:10 +02:00
Thomas Camlong 5cd627d66c perf(board): stabilize edit mode layout 2026-08-21 15:23:18 +02:00
652 changed files with 10043 additions and 19295 deletions

No files matched your search

-2
View File
@@ -25,8 +25,6 @@ packages/db/migrations/*/migrate.cjs
# production
build
tools/homarr-dev/homarr
tools/homarr-dev/homarr-dev
# misc
.DS_Store
-20
View File
@@ -30,26 +30,6 @@
"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",
@@ -1,175 +1,43 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Requirements
- Docker or OrbStack
- Go 1.25+ (when building the CLI from source)
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
## Interactive Development Browser
## Browse development images
```bash
pnpm dev:cli --
# or with global binary:
homarr
```
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
### Multi-Source Sidebar
Press `Tab` to cycle between sidebar tabs:
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
### Background Task Overlay
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
- Active build stages and logs.
- Task cancellation and status.
### Browser Shortcuts
| Key | Action |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
| `↑` / `↓` or `j` / `k` | Navigate rows |
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
| `R` | Build locally in background (or rebuild local image) |
| `p` | Pull remote PR image in background and start container |
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
| `t` | Toggle background tasks overlay |
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
| `f` | Follow or pause logs |
| `M` | Toggle demo mode for next launch |
| `b` | Toggle bot pull requests |
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
| `c` | Copy instance URL to clipboard |
| `r` | Refresh data |
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
## Instance Dashboard
## Build images
```bash
homarr dash
# or
pnpm dev:cli -- dash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
| Key | Action |
| ------------------ | ---------------------------------------- |
| `Enter` or `Space` | Start or stop selected instance |
| `s` / `S` | Stop / Restart instance |
| `x` | Remove container (preserves data volume) |
| `m` | Manage mode for selected instance |
| `R` | Rebuild from recorded build provenance |
## CLI Commands
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
### Launch Instances
## Rebuild images
```bash
homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
pnpm dev:cli -- rebuild <name>
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
- `--demo` (`-m`): Start with mock integrations and sample data.
- `--detach`: Start container in background.
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
### Build & Rebuild
## Run images
```bash
# Build current checkout as homarr:<name>
homarr build feature
# Build a pull request locally in a temporary checkout
homarr build --pr 6441
# Rebuild an existing image from recorded provenance
homarr rebuild feature
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
homarr run <name>
homarr run --pr <number>
```
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
### GitHub CI Checks
```bash
# View CI checks for a specific PR
homarr ci 6441
# View CI checks for the current branch
homarr ci
# Watch CI checks until completion
homarr ci 6441 --watch
```
### Interactive Shell
```bash
# Open interactive shell in running container
homarr shell homarr-dev
# Run specific command in container
homarr shell homarr-dev pnpm db:migration:sqlite:run
```
### Image & Volume Management
```bash
# List local images with build provenance (source, revision, PR)
homarr images
# Delete local image
homarr images rm feature
# List data volumes with owner PR/tag and in-use status
homarr data
# Delete data volume (safely stops and removes owning container first)
homarr data rm homarr_feature_data
# Remove stopped containers (prompts without --force)
homarr prune --force
```
### Instance Operations & Diagnostics
```bash
# List instances in a script-friendly table
homarr list
# Follow container logs
homarr logs homarr-dev
# Stop, restart, remove, or open container in browser
homarr stop homarr-dev
homarr restart homarr-dev
homarr remove homarr-dev
homarr open homarr-dev
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
homarr doctor
```
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -25,27 +25,3 @@ You should also create a backup of the database, in case you break something.
4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png)
5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png)
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png)
## Build a local branch harness
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
```sh
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:
```sh
pnpm cli harness port feature-board
```
Pass environment overrides by repeating `--env`:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
@@ -6,19 +6,19 @@ tags:
Homarr has the following keyboard shortcuts:
| Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar |
| `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 |
| Shortcut | Description |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| `Shift` + `Enter` on a supported widget | Opens its advanced view 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 |
## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
## Board editing
@@ -132,8 +132,8 @@ Here you have the following options in the header:
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
it directly. The current board is left out of the results.
- **Board switcher**: Use this button to open another board that you can access.
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
When entering the edit mode, you'll get some additional buttons to work with:
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
@@ -198,13 +198,9 @@ 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>
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
</figcaption>
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</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.
@@ -373,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
### Board tour
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
### Tour controls
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+3 -10
View File
@@ -27,11 +27,6 @@ opening it. Containers include their visible widgets and app icons instead of ap
Names of boards must be unique because this will be used in the URL to load the desired board.
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
arrow keys to move through the results and press `Enter` to open the selected board.
## Create a new board
When creating a board, the availability of the board name will be checked for you.
@@ -215,8 +210,7 @@ Containers as destinations before the widget is created, so the common flow does
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
selected item fits in the destination.
selection. A move is applied only when every selected item fits in the destination.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -335,9 +329,8 @@ This will disable the status check for apps.
Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
instead.
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
not use this widget menu, and available controls follow your board and integration permissions.
@@ -41,10 +41,6 @@ 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"
+6 -7
View File
@@ -1,6 +1,6 @@
---
title: "Bookmarks"
description: "Keeps useful links together"
description: "Displays multiple app links"
hide_title: true
---
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
The bookmarks widget allows you to display multiple links in a single widget.
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
For links that do not need an App, add their URLs under **Custom links** instead.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
### Screenshots
+12 -18
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark,
name: "Bookmarks",
description: "Keeps useful links together",
description: "Displays multiple app links",
path: "../../widgets/bookmarks",
configuration: {
items: [
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Layout",
description: "How bookmarks use the available widget space.",
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
defaultValue: "Adaptive",
},
{
name: "Appearance",
description: "Card surface style.",
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
defaultValue: "Soft",
},
{
name: "Card spacing",
description: "Space between bookmark cards.",
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
defaultValue: "Extra small",
description: "Layout in which the bookmarks are displayed.",
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
defaultValue: "Vertical",
},
{
name: "Hide title",
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Bookmarks",
description: "URLs and existing Apps shown by the widget.",
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
description: "List of bookmarks to display in the widget.",
values: "Select one or more Apps directly, then order them as you like.",
defaultValue: "No bookmarks selected",
},
{
name: "Custom links",
description: "URLs to display without first creating an App.",
values: "List of URLs.",
defaultValue: "No custom links",
},
],
},
};
@@ -69,7 +69,6 @@ import advancedView from "./img/advanced-view.png";
### Interactions
- Colored dots directly below a date mark its events. They scale with the widget and are hidden when the calendar is too short.
- Hover over a marked date to preview its events in a popover.
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
-1
View File
@@ -76,7 +76,6 @@
"@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,53 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
<title id="title">Abstract homelab network</title>
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
<defs>
<radialGradient id="base" cx="50%" cy="40%" r="80%">
<stop offset="0" stop-color="#172047"/>
<stop offset="0.48" stop-color="#0b1028"/>
<stop offset="1" stop-color="#060814"/>
</radialGradient>
<radialGradient id="indigoGlow">
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
</radialGradient>
<radialGradient id="cyanGlow">
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
</radialGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#748ffc" stop-opacity="0.05"/>
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur">
<feGaussianBlur stdDeviation="55"/>
</filter>
</defs>
<rect width="1920" height="1080" fill="url(#base)"/>
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
</g>
<g fill="#9aa8ff" opacity=".48">
<circle cx="248" cy="660" r="3"/>
<circle cx="718" cy="511" r="4"/>
<circle cx="1118" cy="287" r="3"/>
<circle cx="1450" cy="110" r="4"/>
</g>
<g fill="#5ee0ee" opacity=".42">
<circle cx="435" cy="850" r="3"/>
<circle cx="908" cy="604" r="4"/>
<circle cx="1350" cy="510" r="3"/>
<circle cx="1715" cy="285" r="4"/>
</g>
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
<circle cx="330" cy="90" r="260"/>
<circle cx="330" cy="90" r="345"/>
<circle cx="1680" cy="940" r="250"/>
<circle cx="1680" cy="940" r="335"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,350 +0,0 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersister,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
queryPersistenceMaxDataBytes,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data, { updatedAt });
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
};
const createMemoryStorage = (): Storage => {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("widget query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
});
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
});
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
const userAKey = getQueryPersistenceStorageKey("user-a");
window.sessionStorage.setItem(userAKey, "a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
});
test("evicts every other scope so signing out cannot leave data behind", () => {
const userAKey = getQueryPersistenceStorageKey("user-a");
const anonymousKey = getQueryPersistenceStorageKey(null);
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(anonymousKey, "anonymous");
window.sessionStorage.setItem("unrelated", "kept");
createSessionQueryPersister("user-b");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
});
test("stops persisting once the provider tears the cache down", async () => {
const storage = createMemoryStorage();
const persister = createSessionQueryPersister("user-a", storage);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
persister.stop();
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
expect(shouldPersistDashboardQuery(query)).toBe(true);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
persister.persistClient(persistedClient);
persister.flush();
const restored = await persister.restoreClient();
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
window.sessionStorage.setItem(key, "not-valid-superjson");
expect(persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ title: "safe" }],
);
const validQuery = dehydrate(queryClient).queries[0];
if (!validQuery) throw new Error("Expected a dehydrated query");
window.sessionStorage.setItem(
key,
stringify({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
{
...validQuery,
state: {
...validQuery.state,
error: new Error("private refresh error"),
fetchStatus: "fetching",
status: "error",
},
},
{
...validQuery,
queryHash: "unrelated",
queryKey: [["board", "getBoardByName"], { type: "query" }],
},
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
{
...validQuery,
queryHash: "missing-timestamp",
state: { ...validQuery.state, dataUpdatedAt: undefined },
},
],
},
}),
);
const restored = await createSessionQueryPersister(scope).restoreClient();
expect(restored?.clientState.mutations).toEqual([]);
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
});
test.each([
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const storage = createMemoryStorage();
const setItem = storage.setItem.bind(storage);
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
const client = parse<PersistedClient>(value);
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
setItem(key, value);
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient(persistedClient);
persister.flush();
expect(setItemSpy).toHaveBeenCalledTimes(2);
const restored = await persister.restoreClient();
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const setItem = vi.spyOn(storage, "setItem");
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
});
});
@@ -1,226 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
export const queryPersistenceBuster = "v2-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
export const getQueryPersistenceStorageKey = (scope: string | null) =>
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
interface PersistableQuery {
queryKey: QueryKey;
state: {
data: unknown;
dataUpdatedAt: number;
};
}
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
try {
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const fitsStorageBudget = (data: unknown) => {
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
const cached = budgetVerdictCache.get(data);
if (cached !== undefined) return cached;
const verdict = measureFitsBudget(data);
budgetVerdictCache.set(data, verdict);
return verdict;
};
const holdsDashboardData = (query: PersistableQuery) =>
query.state.data !== undefined &&
Number.isFinite(query.state.dataUpdatedAt) &&
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
return holdsDashboardData(value as unknown as PersistableQuery);
};
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
return {
timestamp: value.timestamp as number,
buster: value.buster,
clientState: {
mutations: [],
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
},
};
};
const getSessionStorage = (): Storage | undefined => {
if (typeof window === "undefined") return undefined;
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
return undefined;
}
};
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
try {
const foreignKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const storedKey = storage.key(index);
if (storedKey === null || storedKey === activeKey) continue;
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
}
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
} catch {
return undefined;
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
removeForeignScopes(storage, key);
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
if (candidate.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return;
}
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
return {
persistClient(client) {
if (!storage || stopped) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
restoreClient() {
try {
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return undefined;
}
return client;
} catch {
removeStorageItem(storage, key);
return undefined;
}
},
removeClient() {
clearPersistTimer();
pendingClient = undefined;
removeStorageItem(storage, key);
},
flush,
stop() {
stopped = true;
clearPersistTimer();
pendingClient = undefined;
},
};
};
@@ -1,11 +1,10 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
createWSClient,
httpBatchStreamLink,
@@ -36,13 +35,6 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import {
createSessionQueryPersister,
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -75,29 +67,21 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersister.removeClient();
reloadPage();
}, [queryPersister]);
return (
<SessionQueryScopeGuard
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
onScopeChange={reloadPage}
>
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({
children,
queryPersister,
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -152,15 +136,6 @@ const ScopedTRPCReactProvider = ({
return client;
});
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
queryPersister.stop();
};
}, [queryPersister]);
useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => {
@@ -211,18 +186,10 @@ const ScopedTRPCReactProvider = ({
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider>
</QueryClientProvider>
</clientApi.Provider>
);
};
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userRegistrationSchema } from "@homarr/validation/user";
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useI18n("user");
const t = useScopedI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(userRegistrationSchema, {
@@ -6,7 +6,7 @@ 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 { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form";
@@ -45,7 +45,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getI18n("user.page.invite");
const t = await getScopedI18n("user.page.invite");
return (
<OnboardingAuthShell
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user";
@@ -28,7 +28,7 @@ interface LoginFormProps {
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useI18n("user");
const t = useScopedI18n("user");
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
}
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useI18n("user.action.login.forgotPassword");
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
@@ -5,7 +5,7 @@ import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
@@ -26,7 +26,7 @@ export default async function Login(props: LoginProps) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
}
const t = await getI18n("user.page.login");
const t = await getScopedI18n("user.page.login");
return (
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
@@ -1,11 +1,11 @@
import { IconLayoutOff } from "@tabler/icons-react";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() {
const tNotFound = await getI18n("board.error.notFound");
const tNotFound = await getScopedI18n("board.error.notFound");
return (
<BoardNotFound
icon={IconLayoutOff}
@@ -19,11 +19,35 @@
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip;
}
.gutter[data-board-editing="true"] {
outline: 0.0625rem solid var(--mantine-color-default-border);
outline-offset: -0.0625rem;
outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -1px;
border-radius: var(--mantine-radius-md);
}
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,14 +1,11 @@
"use client";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
@@ -16,14 +13,13 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
getLogicalTrackSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
@@ -33,17 +29,10 @@ import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useI18n("board.landmark");
const tPreview = useI18n("board.setting.section.layout.preview");
const tTips = useI18n("tips");
const t = useScopedI18n("board.landmark");
const tPreview = useScopedI18n("board.setting.section.layout.preview");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
@@ -61,22 +50,22 @@ export const ClientBoard = () => {
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
.map(getLogicalTrackSize);
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 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
const content = (
<BoardSelectionGridProvider>
<BoardSelectionProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
@@ -140,19 +129,9 @@ export const ClientBoard = () => {
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
</BoardSelectionProvider>
);
if (representativeWidth === null) return content;
@@ -104,10 +104,10 @@ 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 t = await getI18n();
return {
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
@@ -29,9 +29,7 @@ const AddMenu = () => {
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -79,7 +77,7 @@ const AddMenu = () => {
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
@@ -88,19 +86,19 @@ const AddMenu = () => {
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{tBoard("emptyState.addWidget")}
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{tBoard("emptyState.addApp")}
{t("app.action.add")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{tIntegration("action.create")}
{t("integration.action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{tContainer("action.create")}
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -3,9 +3,18 @@
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
import {
IconDeviceFloppy,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconReplace,
IconSettings,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -15,7 +24,9 @@ import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button";
@@ -42,10 +53,12 @@ const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n("board");
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null;
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
return (
<>
@@ -55,11 +68,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
<SelectBoardsMenu />
</>
);
};
@@ -73,9 +88,8 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const commonT = useI18n();
const t = useScopedI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
@@ -90,7 +104,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
},
onSuccess() {
showSuccessNotification({
title: tCommon("notification.update.success"),
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
@@ -101,7 +115,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
},
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
@@ -164,9 +178,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
title: commonT("board.setting.section.layout.edit.cancel.title"),
children: commonT("board.setting.section.layout.edit.cancel.message"),
confirmProps: { children: commonT("common.action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
@@ -175,7 +189,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
})();
},
});
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
@@ -186,14 +200,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
<HeaderButton
onClick={() => saveBoard(board)}
loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")}
aria-label={commonT("board.setting.section.layout.edit.save")}
>
<IconDeviceFloppy stroke={1.5} />
</HeaderButton>
<HeaderButton
onClick={cancelLayoutEdit}
disabled={isPending}
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
>
<IconX stroke={1.5} />
</HeaderButton>
@@ -220,10 +234,50 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
);
};
const SelectBoardsMenu = () => {
const t = useI18n();
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return (
<TourTarget id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
@@ -239,13 +293,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.stopPropagation();
openConfirmModal({
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: tCommon("action.discard"),
children: t("common.action.discard"),
},
});
};
@@ -271,5 +325,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
}, [isDirty, openConfirmModal, router, t]);
};
@@ -16,15 +16,15 @@ export default async function NotFoundBoardHomePage() {
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards);
const t = await getI18n("board");
const t = await getI18n();
if (boardCount === 0) {
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("error.noBoard.title"),
description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
notice: t("error.noBoard.notice"),
title: t("board.error.noBoard.title"),
description: t("board.error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"),
};
}
@@ -39,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
return {
icon: IconHomeOff,
title: t("error.homeBoard.title"),
description: t(`error.homeBoard.${type}.description` as never),
link: { label: t(`error.homeBoard.${type}.link` as never), href },
notice: t(`error.homeBoard.${type}.notice` as never),
title: t(`board.error.homeBoard.title`),
description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`board.error.homeBoard.${type}.notice`),
};
};
@@ -37,23 +37,22 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const t = useI18n();
const theme = useMantineTheme();
return (
<SectionCard title={tBoard("setting.section.appearance.title")}>
<SectionCard title={t("board.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={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
@@ -64,7 +63,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={tBoard("field.opacity.label")}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
@@ -78,7 +77,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={tBoard("field.iconColor.label")}
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
@@ -91,20 +90,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{tBoard("field.clearColor.label")}
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
@@ -23,7 +23,7 @@ interface Props {
}
export const BackgroundSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board");
const t = useI18n();
const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return (
<SectionCard title={tBoard("setting.section.background.title")}>
<SectionCard title={t("board.setting.section.background.title")}>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}
// We filter it on the server
filter={({ options }) => options}
label={tBoard("field.backgroundImageUrl.label")}
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
data={[
{
group: tBoard("field.backgroundImageUrl.group.your"),
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: tBoard("field.backgroundImageUrl.group.other"),
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={tBoard("field.backgroundImageAttachment.label")}
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={tBoard("field.backgroundImageSize.label")}
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={tBoard("field.backgroundImageRepeat.label")}
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
@@ -176,20 +176,19 @@ const useBackgroundOptionData = <
defaultValue: keyof TOptions;
},
) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const t = useI18n();
return data.values.map(
(value) =>
({
label: tBoard(`field.${key}.option.${value as string}.label` as never),
description: tBoard(`field.${key}.option.${value as string}.description` as never),
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never),
value: value as string,
badge:
data.defaultValue === value
? {
color: "blue",
label: tCommon("select.badge.recommended"),
label: t("common.select.badge.recommended"),
}
: undefined,
}) satisfies SelectItemWithDescriptionBadge,
@@ -13,13 +13,13 @@ interface Props {
}
export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n("board");
const t = useI18n();
return (
<SectionCard title={t("setting.section.behavior.title")}>
<SectionCard title={t("board.setting.section.behavior.title")}>
<Switch
label={t("field.disableStatus.label")}
description={t("field.disableStatus.description")}
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</SectionCard>
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const tBoard = useI18n("board");
const t = useI18n();
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
mutate: userMutation.mutate,
isPending: userMutation.isPending,
}}
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
permission={{
items: boardPermissions,
default: "view",
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
@@ -24,11 +24,11 @@ interface Props {
export const CustomCssSettingsContent = ({ form }: Props) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n("board");
const customCssT = useI18n("board.field.customCss");
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
return (
<SectionCard title={t("setting.section.customCss.title")}>
<SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useI18n("board.field.customCss");
const customCssT = useScopedI18n("board.field.customCss");
return (
<Input.Wrapper
@@ -7,15 +7,14 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const t = useScopedI18n("board.setting");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
@@ -105,11 +104,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
/>
)}
<DangerZoneItem
label={tCommonAction("delete")}
label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{tCommonAction("delete")}
{t("section.dangerZone.action.delete.button")}
</Button>
}
/>
@@ -20,21 +20,25 @@ interface Props {
}
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n("board");
const t = useI18n();
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
return (
<SectionCard title={t("setting.section.general.title")}>
<SectionCard title={t("board.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("board.field.pageTitle.label")}
placeholder="Homarr"
{...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 }))}
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
@@ -42,7 +46,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("field.logoImageUrl.label")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
@@ -50,7 +54,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("field.faviconImageUrl.label")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
@@ -23,8 +23,6 @@ interface Props {
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const tBoard = useI18n("board");
const tSection = useI18n("section");
const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
@@ -44,13 +42,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
{representativeWidth}px
</Badge>
<Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
</Text>
</Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl">
{tBoard("setting.section.layout.preview.empty")}
{t("board.setting.section.layout.preview.empty")}
</Text>
) : (
<Stack gap="xs">
@@ -78,8 +76,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
const containerLabel =
container?.kind === "container" && container.options.title
? container.options.title
: tSection("container.untitled");
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
: t("section.container.untitled");
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return (
@@ -41,9 +41,7 @@ interface Props {
}
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const t = useI18n();
const router = useRouter();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
@@ -52,8 +50,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
message: tCommon("notification.update.error"),
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
},
});
@@ -86,14 +84,15 @@ 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" },
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: t("board.setting.section.layout.reset.confirm.message"),
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
onConfirm() {
setResettingLayoutId(layout.id);
void (async () => {
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
@@ -111,14 +110,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const sortedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
<SectionCard title={t("board.setting.section.layout.title")}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
</Stack>
<Button
type="button"
@@ -126,25 +128,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={nextBreakpoint === null || !baseLayout}
onClick={() => {
if (nextBreakpoint === null || !baseLayout) return;
const newLayout: FormValues["layouts"][number] = {
id: createId(),
name: tBoard("setting.section.layout.custom.defaultName"),
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
breakpoint: nextBreakpoint,
role: "custom",
};
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
form.insertListItem("layouts", newLayout, insertionIndex);
form.setFieldValue("layouts", [
...form.values.layouts,
{
id: createId(),
name: t("board.setting.section.layout.custom.defaultName"),
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
breakpoint: nextBreakpoint,
role: "custom",
},
]);
}}
>
{tBoard("setting.section.layout.responsive.action.add")}
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => {
{sortedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts
@@ -157,9 +159,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
{t(`board.setting.section.layout.role.${layout.role}`)}
</Badge>
</Group>
}
@@ -168,10 +170,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
mt="xs"
min={1}
max={24}
@@ -192,11 +194,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")}
label={t("layout.field.breakpoint.label")}
description={
layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
? t("board.setting.section.layout.mobile.breakpointDescription")
: t("layout.field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
@@ -244,7 +246,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={isSaving}
onClick={() => confirmReset(layout)}
>
{tBoard("setting.section.layout.reset.action")}
{t("board.setting.section.layout.reset.action")}
</Button>
)}
{layout.role === "custom" && (
@@ -260,7 +262,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
);
}}
>
{tCommon("action.remove")}
{t("common.action.remove")}
</Button>
)}
</Group>
@@ -273,8 +275,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
onClick={() => void openLayoutEditorAsync(layout)}
>
{form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
? t("board.setting.section.layout.edit.saveAndEdit")
: t("board.setting.section.layout.edit.action")}
</Button>
</Group>
</Fieldset>
@@ -320,16 +322,16 @@ const gutterMarks = [
];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n("layout");
const t = useI18n();
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return (
<Paper className={classes.gutterSettings} p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("field.gutters.label")}</Text>
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
<Text size="sm" c="dimmed">
{t("field.gutters.description")}
{t("layout.field.gutters.description")}
</Text>
</Box>
@@ -369,7 +371,7 @@ const GutterControl = ({
max: number;
onChange: (value: number) => void;
}) => {
const t = useI18n("layout");
const t = useI18n();
const enabled = value > 0;
return (
@@ -378,10 +380,10 @@ const GutterControl = ({
checked={enabled}
disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`field.gutters.${side}.label` as never)}
label={t(`layout.field.gutters.${side}.label`)}
/>
{enabled && (
<Input.Wrapper label={t("field.gutters.width.label")}>
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
<Slider
mt="xs"
min={1}
@@ -389,7 +391,7 @@ const GutterControl = ({
value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/>
</Input.Wrapper>
@@ -399,7 +401,7 @@ const GutterControl = ({
};
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
const t = useI18n("layout");
const t = useI18n();
const safeColumnCount = Math.max(1, columnCount);
const main = Math.max(1, safeColumnCount - left - right);
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
@@ -409,7 +411,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
return (
<Box
className={classes.gutterPreview}
aria-label={t("field.gutters.preview.label")}
aria-label={t("layout.field.gutters.preview.label")}
style={{ gridTemplateColumns: tracks }}
>
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
@@ -10,7 +10,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
@@ -86,8 +86,8 @@ interface BoardSettingsFormProps {
}
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n("common");
const tSection = useI18n("board.setting.section");
const t = useI18n();
const tSection = useScopedI18n("board.setting.section");
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
@@ -156,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
form.resetDirty(canonicalValues);
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({
title: t("notification.update.success"),
message: t("notification.update.success"),
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
});
return canonicalValues;
} catch {
showErrorNotification({
title: t("notification.update.error"),
message: t("notification.update.error"),
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
return null;
}
@@ -200,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("action.saveChanges")}
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardSettingsForm } from "./_settings-form";
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
const t = await getI18n("board.setting");
const t = await getScopedI18n("board.setting");
if (!hasChangeAccess) {
notFound();
@@ -16,7 +16,7 @@ import {
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
interface RequestDetails {
widgetName: string;
@@ -31,8 +31,7 @@ interface RequestDetails {
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const t = useI18n("customWidget.secretEntry");
const tSecret = useI18n("customWidget.secret");
const t = useScopedI18n("customWidget.secretEntry");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [baseUrl, setBaseUrl] = useState("");
@@ -121,7 +120,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
return (
<Input
key={kind}
label={tSecret(kind)}
label={t(`field.${kind}`)}
leftSection={<IconKey size={16} />}
value={values[kind] ?? ""}
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
@@ -1,12 +1,12 @@
import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui";
import classes from "./hero-banner.module.css";
export const HeroBanner = async () => {
const t = await getI18n("management.page.home");
const t = await getScopedI18n("management.page.home");
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -35,7 +35,7 @@ import {
import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -47,7 +47,7 @@ import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
const t = await getI18n("management");
const t = await getScopedI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
@@ -55,7 +55,7 @@ export async function generateMetadata() {
}
export default async function AboutPage() {
const t = await getI18n("management.page.about");
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = await getDependenciesAsync();
@@ -9,15 +9,15 @@ 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";
import { useScopedI18n } from "@homarr/translation/client";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const t = useScopedI18n("app.page.delete");
const tList = useScopedI18n("app.page.list.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
);
},
});
}, [app, mutate, t, tCommon, openConfirmModal]);
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
color="red"
size={44}
onClick={onClick}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
aria-label={tList("delete", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
interface AppEditFormProps {
@@ -17,14 +17,14 @@ interface AppEditFormProps {
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useI18n("app.page.edit.notification");
const tCommon = useI18n("common");
const tScoped = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.update.success"),
title: tScoped("success.title"),
message: tScoped("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
},
onError: () => {
showErrorNotification({
title: tCommon("notification.update.error"),
title: tScoped("error.title"),
message: tScoped("error.message"),
});
},
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
return (
<AppForm
buttonLabels={{
submit: tCommon("action.save"),
submit: t("common.action.save"),
}}
initialValues={app}
handleSubmit={handleSubmit}
@@ -9,7 +9,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
@@ -52,8 +52,7 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 };
const t = await getI18n("app");
const tCommon = await getI18n("common");
const t = await getScopedI18n("app");
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
const emptyState = !canManageAll ? (
@@ -68,7 +67,7 @@ export default async function AppsPage(props: AppsPageProps) {
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
/>
) : (
<NoResults
@@ -81,7 +80,7 @@ export default async function AppsPage(props: AppsPageProps) {
const page = (
<ManageCollectionPage
title={tCommon("entity.apps")}
title={t("page.list.title")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length}
emptyState={emptyState}
@@ -116,7 +115,7 @@ export default async function AppsPage(props: AppsPageProps) {
key={app.id}
app={app}
canDelete={canDelete}
editLabel={tCommon("action.editNamed", { name: app.name })}
editLabel={t("page.list.action.edit", { name: app.name })}
/>
))}
</ManageCollectionPage>
@@ -51,7 +51,7 @@ import {
} from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
@@ -140,8 +140,7 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
);
export const AssistantConfiguration = () => {
const t = useI18n("management.page.settings.section.assistant");
const tCommon = useI18n("common");
const t = useScopedI18n("management.page.settings.section.assistant");
const router = useRouter();
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
@@ -209,7 +208,7 @@ export const AssistantConfiguration = () => {
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"),
label: t(`provider.options.${providerId}.label`),
}));
return { group: t(`provider.groups.${category}`), items };
@@ -373,7 +372,7 @@ export const AssistantConfiguration = () => {
</ThemeIcon>
<Box className={classes.summaryProviderText}>
<Group gap="xs">
<Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text>
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
<Badge
size="sm"
variant="light"
@@ -419,7 +418,7 @@ export const AssistantConfiguration = () => {
description={t("connection.description")}
status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
{t(`provider.options.${provider}.label`)}
</Badge>
}
>
@@ -759,7 +758,7 @@ export const AssistantConfiguration = () => {
{t("serverTools.webSearch.label")}
</Text>
<Badge size="xs" variant="light">
{tCommon("beta")}
{t("serverTools.beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed" maw="66ch">
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getI18n("management.page.settings.section.assistant");
const t = await getScopedI18n("management.page.settings.section.assistant");
return {
title: `${t("title")} • Homarr`,
};
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
notFound();
}
const t = await getI18n("management.page.settings.section.assistant");
const t = await getScopedI18n("management.page.settings.section.assistant");
return (
<ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch">
@@ -10,7 +10,7 @@ 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 { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -28,9 +28,8 @@ interface BoardCardMenuDropdownProps {
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
@@ -58,7 +57,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tCommon("action.deleteNamed", { name: board.name }),
title: t("delete.confirm.title"),
children: t("delete.confirm.description", {
name: board.name,
}),
@@ -69,7 +68,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -98,7 +97,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")}
{t("duplicate.label")}
</Menu.Item>
)}
{hasChangeAccess && (
@@ -123,7 +122,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
onClick={handleDeletion}
disabled={deleteBoardMutation.isPending}
>
{tCommon("action.delete")}
{t("delete.label")}
</Menu.Item>
</>
)}
@@ -10,13 +10,13 @@ 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 t = useI18n();
const { openModal } = useModalAction(AddBoardModal);
return (
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("new.label")}
{t("management.page.board.action.new.label")}
</Button>
</ManageMobilePrimaryAction>
);
@@ -25,7 +25,7 @@ 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 { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -36,7 +36,7 @@ import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
const t = await getI18n("management.page.board");
const t = await getScopedI18n("management.page.board");
const session = await auth();
const boards = await api.board.getManageOverview();
const canCreateBoards = session?.user.permissions.includes("board-create");
@@ -3,12 +3,12 @@
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
const t = useI18n("customWidget.betaBanner");
const t = useScopedI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
@@ -1,50 +1,48 @@
"use client";
import { useMemo } from "react";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
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],
const t = useScopedI18n("customWidget.editor");
return (
<CustomWidgetCodeEditor
{...props}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("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: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
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,
)}`,
}}
/>
);
return <CustomWidgetCodeEditor {...props} messages={messages} />;
}
@@ -3,70 +3,37 @@
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonBaseProps {
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
request?: string | null;
documentationUrl?: string | null;
}
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({
rawResponse,
draft,
getDraft,
diagnostics,
getDiagnostics,
currentConfig,
request,
documentationUrl,
}: CopyAiPromptButtonProps) => {
const t = useI18n("customWidget");
const t = useScopedI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt(
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
};
const handleCopy = async () => {
try {
const value = buildCustomWidgetAiPrompt(
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
const value = prompt();
await navigator.clipboard.writeText(value);
showSuccessNotification({
title: t("action.copyAiPrompt"),
@@ -22,7 +22,7 @@ import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } fr
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 { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
@@ -39,7 +39,7 @@ interface WidgetRef {
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useI18n("customWidget");
const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -1,38 +1,27 @@
"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 { useScopedI18n } 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 t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(
{
$schema: "homarr-custom-widget-v2",
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,
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,
},
null,
2,
@@ -41,26 +30,33 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<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 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>
);
}
@@ -3,14 +3,13 @@
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { useI18n } from "@homarr/translation/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
interface AiCardProps {
getDraft(): CustomWidgetAiDraft;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
candidate: HomarrCustomWidgetV2 | null;
request: string;
onRequestChange(value: string): void;
documentationUrl: string;
@@ -19,7 +18,7 @@ interface AiCardProps {
}
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useI18n("customWidget.workbench.ai");
const t = useScopedI18n("customWidget.workbench.ai");
return (
<Accordion variant="contained">
<Accordion.Item value="ai">
@@ -28,15 +27,14 @@ export function CustomWidgetAiCard(props: AiCardProps) {
{t("title")}
</Accordion.Control>
<CopyAiPromptButton
getDraft={props.getDraft}
getDiagnostics={props.getDiagnostics}
currentConfig={props.candidate}
request={props.request}
documentationUrl={props.documentationUrl}
/>
</Group>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
@@ -2,10 +2,7 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(
values: Pick<CustomWidgetFormValues, "options" | "sources">,
requestIds: string[],
) {
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {});
return [
@@ -1,40 +1,14 @@
"use client";
import { useEffect, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { ReactNode } from "react";
import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import {
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconBraces,
IconCheck,
IconCode,
IconDatabase,
IconEye,
IconPlayerPlay,
IconSettings,
} from "@tabler/icons-react";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } 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,
@@ -78,8 +52,7 @@ export function SaveActions({
mode: "create" | "edit";
onPreview(): void;
}) {
const t = useI18n("customWidget.workbench");
const tCommon = useI18n("common");
const t = useScopedI18n("customWidget.workbench");
return (
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
@@ -104,89 +77,9 @@ export function SaveActions({
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
</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;
}
@@ -1,222 +0,0 @@
"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}
/>
);
}
@@ -1,142 +0,0 @@
"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],
);
}
@@ -28,7 +28,7 @@ describe("Custom Widget workbench preview options", () => {
const setValues = vi.fn();
const result = applyCustomWidgetAiResponse(
{
getValues: () => ({
values: {
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,6 +84,13 @@ 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,8 +12,6 @@ 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",
@@ -27,7 +25,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
});
}
export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) {
export function getChangedSecrets(values: CustomWidgetFormValues) {
return values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
@@ -48,7 +46,7 @@ export function filterSecretsForSourceAuthentication(
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.getValues(),
...form.values,
name: widget.name,
description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "",
@@ -104,43 +102,25 @@ export function isRuntimeParams(
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const requests = Object.entries(definition.requests).filter(
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const [requestId] of Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
);
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",
},
};
}
)) {
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",
};
}
};
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])),
};
}
return { data, status };
}
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
@@ -1,45 +1,45 @@
"use client";
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 { 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 type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
customWidgetFormSchema,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
} from "@homarr/custom-widgets/workbench";
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
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 { 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 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 { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -47,204 +47,240 @@ interface CustomWidgetFormProps {
initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string;
}
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>
);
});
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 CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
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 t = useScopedI18n("customWidget");
const w = useScopedI18n("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 [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) : {};
});
const actions = useCustomWidgetFormActions({
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({
mode,
definitionId,
form,
documentStore,
candidate,
preview,
setPreview,
setMobilePane,
optionsSnapshot,
setOptionsSnapshot,
});
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(), []);
useEffect(() => {
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions]);
const invalid = hasDiagnostics || !candidate.success;
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
form,
candidate,
invalidWidgetMessage: w("invalidWidget"),
});
return (
<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}
/>
<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={t("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>
);
}
@@ -5,7 +5,7 @@ import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
@@ -18,22 +18,20 @@ interface CustomWidgetListProps {
}
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const tEntities = useI18n("common.entity");
const t = useScopedI18n("customWidget");
if (definitions.length === 0) {
return (
<NoResults
icon={IconApi}
title={t("page.list.noResults")}
action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }}
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
/>
);
}
return (
<ManageCollectionList ariaLabel={tEntities("customWidgets")}>
<ManageCollectionList ariaLabel={t("page.list.title")}>
{definitions.map((definition) => (
<CustomWidgetRow key={definition.id} widget={definition} />
))}
@@ -44,8 +42,7 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const t = useScopedI18n("customWidget");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))];
@@ -97,7 +94,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{tCommon("action.edit")}
{t("action.edit")}
</Button>
)}
<CustomWidgetRowActions
@@ -125,8 +122,14 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
}
/** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
const t = useI18n("customWidget");
function CustomWidgetStatusBadges({
widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget");
if (widget.migrationRequired) {
return (
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
if (control === "switch")
return (
<Switch
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
return (
<Stack gap="xs">
{value.map((choice, index) => (
@@ -150,7 +150,7 @@ export function JsonValueEditor({
value: unknown;
onChange(value: unknown): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
@@ -16,7 +16,7 @@ import {
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests);
@@ -69,7 +69,7 @@ export function CustomWidgetOptionsEditor({
)
: [];
return (
<Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}>
<Fieldset key={index} 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={`${id}-default`}
id={`${index}-default`}
control={control}
value={option.default}
choices={choices}
@@ -8,7 +8,7 @@ import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } fro
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
@@ -19,7 +19,7 @@ export function PreviewActionControl({
request: CustomJsxRequest & { id: string };
sessionId?: string;
}) {
const t = useI18n("customWidget.workbench.preview");
const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
@@ -6,8 +6,6 @@ interface PreviewSnapshot {
session: { id: string; liveActions: boolean } | null;
}
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
export function createPreviewDisplayData({
candidate,
fixture,
@@ -15,7 +13,7 @@ export function createPreviewDisplayData({
options,
fixtureError,
}: {
candidate: PreviewCandidate | null;
candidate: HomarrCustomWidgetV2 | null;
fixture: "live" | "loading" | "empty" | "error";
preview: PreviewSnapshot;
options: Record<string, unknown>;
@@ -19,7 +19,7 @@ vi.mock("@homarr/api/client", () => ({
},
},
}));
vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key }));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn());
@@ -1,13 +1,13 @@
"use client";
import { memo, useMemo, useState } from "react";
import { useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor";
@@ -15,12 +15,9 @@ 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>;
@@ -39,65 +36,35 @@ interface PreviewPanelProps {
onLiveActionsChange(enabled: boolean): void;
}
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useScopedI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ 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 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 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 previewResult =
props.preview.outcome === "success"
? {
@@ -117,7 +84,37 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
<Card withBorder p="md">
<Stack gap="md">
<Group gap="xs" justify="space-between">
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
<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>
),
},
]}
/>
<SegmentedControl
size="xs"
value={fixture}
@@ -153,19 +150,12 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<Paper
withBorder
p="sm"
h={360}
w={previewWidths[props.size] ?? 480}
maw="100%"
style={{ overflow: "auto" }}
>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={actionT("tryAgain")}
retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
@@ -273,5 +263,3 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
</Card>
);
}
export const CustomWidgetPreviewPanel = memo(CustomWidgetPreviewPanelContent);
@@ -1,56 +0,0 @@
"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);
@@ -1,35 +1,34 @@
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
import { applyDefinition, buildDefinition } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { applyDefinition } from "./_custom-widget-form-utils";
import type { buildDefinition, 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) => {
const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
if (!input.candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
applyDefinition(input.form, renameCustomWidgetRequest(input.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) => {
const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
if (!input.candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
input.form.clearFieldError("options");
} catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord } from "./_custom-widget-form-utils";
@@ -21,9 +21,9 @@ export function QueryValuesEditor({
allowParams: boolean;
onChange(value: Record<string, unknown>): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
const [nameErrors, setNameErrors] = useState<Record<number, 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={name} align="end" wrap="wrap">
<Group key={index} align="end" wrap="wrap">
<CustomWidgetIdentifierInput
style={{ flex: "1 1 10rem" }}
label={t("queryKey")}
value={name}
error={nameErrors[name]}
error={nameErrors[index]}
onCommit={(nextName) => {
if (
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
) {
setNameErrors((currentErrors) => ({ ...currentErrors, [name]: t("queryKeyInvalid") }));
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
return;
}
setNameErrors((currentErrors) => {
const nextErrors = { ...currentErrors };
delete nextErrors[name];
delete nextErrors[index];
return nextErrors;
});
commit(nextName, kind, current);
@@ -150,7 +150,7 @@ export function RequestBodyEditor({
allowParams: boolean;
onChange(value: unknown): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
@@ -1,15 +1,14 @@
"use client";
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { Accordion, 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";
import { useScopedI18n } from "@homarr/translation/client";
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"];
@@ -20,13 +19,11 @@ export function CustomWidgetRequestsEditor({
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests);
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));
@@ -53,7 +50,7 @@ export function CustomWidgetRequestsEditor({
const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load";
return (
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
@@ -122,61 +119,71 @@ 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 })}
/>
<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>
<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>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
@@ -1,37 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
export function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -3,12 +3,12 @@
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
const t = useI18n("customWidget.workbench.saveError");
const t = useScopedI18n("customWidget.workbench.saveError");
if (issues.length === 0) return null;
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
@@ -5,7 +5,7 @@ import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -51,8 +51,7 @@ export function CustomWidgetSourceField({
onClearSecret,
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const t = useScopedI18n("customWidget.workbench.sources");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
@@ -119,7 +118,7 @@ export function CustomWidgetSourceField({
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
label={t(`secret.${kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -21,8 +21,8 @@ export function CustomWidgetSourcesEditor({
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const t = useI18n("customWidget.workbench.sources");
const w = useI18n("customWidget.workbench");
const t = useScopedI18n("customWidget.workbench.sources");
const w = useScopedI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
@@ -3,7 +3,7 @@
import { Tabs } from "@mantine/core";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
const tabs = {
@@ -13,7 +13,7 @@ const tabs = {
/** Switches between the widgets you have installed and the community Workshop. */
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
const t = useI18n("customWidget.page.tabs");
const t = useScopedI18n("customWidget.page.tabs");
return (
<Tabs value={tabs[active]}>
@@ -1,18 +0,0 @@
export function areCustomWidgetValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left)) {
if (!Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => areCustomWidgetValuesEqual(value, right[index]));
}
if (!isRecord(left) || !isRecord(right)) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -5,7 +5,7 @@ import { Component } from "react";
import { Alert, Button, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
interface FormErrorBoundaryState {
error: Error | null;
@@ -35,8 +35,7 @@ export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErr
}
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const t = useScopedI18n("customWidget");
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
<Stack gap="xs">
@@ -47,7 +46,7 @@ function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }
{error.message}
</Text>
<Button size="xs" variant="light" color="red" onClick={reset}>
{tCommon("action.tryAgain")}
{t("editor.errorBoundary.retry")}
</Button>
</Stack>
</Alert>
@@ -11,14 +11,13 @@ import {
} from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const ImportCustomWidgetButton = () => {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
@@ -39,7 +38,7 @@ export const ImportCustomWidgetButton = () => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: tCommon("action.import"),
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
@@ -48,7 +47,7 @@ export const ImportCustomWidgetButton = () => {
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t, tCommon]);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -62,13 +61,13 @@ export const ImportCustomWidgetButton = () => {
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: tCommon("action.import"),
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: tCommon("action.import"), message: t("notification.importError") });
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
});
reader.readAsText(file);
event.target.value = "";
@@ -82,7 +81,7 @@ export const ImportCustomWidgetButton = () => {
onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")}
>
{tCommon("action.import")}
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
@@ -9,9 +9,7 @@ import { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({
useI18n: (namespace: string) => (key: string) => `${namespace}.${key}`,
}));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props);
@@ -43,8 +41,6 @@ describe("preview JSON editor", () => {
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith(
expect.objectContaining({ error: "customWidget.workbench.builder.invalidJson" }),
);
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
});
});
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -18,7 +18,7 @@ export function JsonPreviewEditor({
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [invalid, setInvalid] = useState(false);
@@ -1,44 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useId, useState } from "react";
import { Accordion } from "@mantine/core";
interface LazyOnceAccordionProps {
label: ReactNode;
children: ReactNode;
}
export function LazyOnceAccordion({ label, children }: LazyOnceAccordionProps) {
const id = useId();
const [value, setValue] = useState<string | null>(null);
const [hasOpened, setHasOpened] = useState(false);
const controlId = `${id}-control`;
const panelId = `${id}-panel`;
const handleChange = (nextValue: string | null) => {
setValue(nextValue);
if (nextValue === "content") setHasOpened(true);
};
return (
<Accordion variant="contained" value={value} onChange={handleChange}>
<Accordion.Item value="content">
<Accordion.Control id={controlId} aria-controls={panelId}>
{label}
</Accordion.Control>
{/* Native hiding keeps editor effects and CodeMirror history alive while the panel is collapsed. */}
<div
id={panelId}
className={Accordion.classes.panel}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- The explicit role matches Mantine's panel contract.
role="region"
aria-labelledby={controlId}
hidden={value !== "content"}
>
<div className={Accordion.classes.content}>{hasOpened && children}</div>
</div>
</Accordion.Item>
</Accordion>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form";
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import {
applyCustomWidgetAiResponse,
@@ -22,14 +22,13 @@ import {
import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { clearSaveIssues, reportSaveIssues } from "./_custom-widget-save-issue-utils";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
interface FormActionsInput {
mode: "create" | "edit";
definitionId?: string;
form: UseFormReturnType<CustomWidgetFormValues>;
documentStore: CustomWidgetFormDocumentStore;
candidate: ReturnType<typeof buildDefinition>;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
optionsSnapshot: Record<string, unknown>;
@@ -37,45 +36,14 @@ interface FormActionsInput {
}
export function useCustomWidgetFormActions(input: FormActionsInput) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
const utils = clientApi.useUtils();
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false);
const setPreview = input.setPreview;
const previewGeneration = useRef(0);
const activePreviewGeneration = useRef<number | null>(null);
const invalidatePreview = useCallback(() => {
previewGeneration.current += 1;
if (activePreviewGeneration.current !== null) return;
setPreview((preview) => {
if (
preview.outcome === "idle" &&
preview.session === null &&
Object.keys(preview.data).length === 0 &&
Object.keys(preview.status).length === 0
)
return preview;
return { data: {}, status: {}, session: null, outcome: "idle" };
});
}, [setPreview]);
useLayoutEffect(() => {
invalidatePreview();
}, [input.optionsSnapshot, invalidatePreview]);
useEffect(() => input.documentStore.subscribe(invalidatePreview), [input.documentStore, invalidatePreview]);
useEffect(
() => () => {
previewGeneration.current += 1;
activePreviewGeneration.current = null;
},
[],
);
let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create");
const save = input.form.onSubmit(async (values) => {
clearSaveIssues(input.form, saveIssues);
@@ -86,7 +54,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
input.form,
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
setSaveIssues,
actionLabel,
t("action.save"),
(count) => w("saveError.more", { count }),
);
return;
@@ -97,12 +65,11 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
await utils.customWidget.list.invalidate();
showSuccessNotification({
title: tCommon("action.create"),
title: t("action.create"),
message: t("notification.created", { name: values.name }),
});
input.form.setInitialValues(values);
input.form.resetDirty();
input.documentStore.markSaved(values);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
window.location.assign(result.managementPath);
} else if (input.definitionId) {
@@ -118,35 +85,33 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
]);
input.form.setInitialValues(values);
input.form.resetDirty();
input.documentStore.markSaved(values);
showSuccessNotification({
title: tCommon("action.save"),
title: t("action.save"),
message: t("notification.updated", { name: values.name }),
});
}
} catch (error) {
const issues = extractCustomWidgetSaveIssues(error);
if (issues.length > 0) {
reportSaveIssues(input.form, issues, setSaveIssues, actionLabel, (count) => w("saveError.more", { count }));
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
w("saveError.more", { count }),
);
} else {
showErrorNotification({ title: actionLabel, message: t("notification.updateError") });
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
}
}
});
const runPreview = async () => {
if (activePreviewGeneration.current !== null) return;
const valuesAtStart = input.documentStore.getValues();
const candidateAtStart = buildDefinition(valuesAtStart);
if (!candidateAtStart.success) {
if (previewPending) return;
if (!input.candidate.success) {
showErrorNotification({
title: w("section.preview"),
message: candidateAtStart.error.issues[0]?.message ?? w("invalidWidget"),
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
});
return;
}
const optionsAtStart = input.optionsSnapshot;
const optionIssues = getCustomWidgetPreviewOptionIssues(candidateAtStart.data, optionsAtStart);
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
showErrorNotification({
@@ -155,24 +120,17 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
return;
}
const secretsAtStart = getChangedSecrets(valuesAtStart);
const generation = previewGeneration.current + 1;
previewGeneration.current = generation;
activePreviewGeneration.current = generation;
const isCurrent = () => previewGeneration.current === generation;
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true);
try {
const created = await previewMutation.mutateAsync({
definition: candidateAtStart.data,
definition: input.candidate.data,
definitionId: input.definitionId,
options: optionsAtStart,
secrets: secretsAtStart,
options: input.optionsSnapshot,
secrets: getChangedSecrets(input.form.values),
});
if (!isCurrent()) return;
const snapshot = await loadPreviewQueries(candidateAtStart.data, created.previewSession.id);
if (!isCurrent()) return;
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({
...snapshot,
@@ -197,15 +155,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
}
} catch {
if (!isCurrent()) return;
input.setPreview((current) => ({ ...current, outcome: "error" }));
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
} finally {
if (activePreviewGeneration.current === generation) {
activePreviewGeneration.current = null;
setPreviewPending(false);
if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
}
setPreviewPending(false);
}
};
@@ -216,6 +169,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
title: w("ai.response"),
message:
@@ -241,3 +195,33 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
previewPending,
};
}
function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -1,188 +0,0 @@
"use client";
import {
createContext,
createElement,
useCallback,
useContext,
useDeferredValue,
useLayoutEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import type { ReactNode } from "react";
import type { CustomWidgetAiDiagnostic } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetOptionsSchema } from "@homarr/custom-widgets/core";
import { analyzeJsxTemplate, analyzeRequestManifest } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { buildDefinition, isRecord, parseJson } from "./_custom-widget-form-utils";
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
const emptyPreviewValidationIssues: Array<{ path?: string; message: string }> = [];
const emptyCandidateIssues: Array<{ path: PropertyKey[] }> = [];
function normalizeEditorDiagnostics(
section: "requests" | "template",
diagnostics: readonly EditorDiagnostic[],
): CustomWidgetAiDiagnostic[] {
return diagnostics.map((diagnostic) => ({
section,
severity: diagnostic.severity,
code: diagnostic.code,
line: diagnostic.line,
column: diagnostic.column,
message: diagnostic.value,
}));
}
function createAiDiagnostics(
candidate: ReturnType<typeof buildDefinition>,
requestDiagnostics: readonly EditorDiagnostic[],
templateDiagnostics: readonly EditorDiagnostic[],
) {
const editorDiagnostics = [
...normalizeEditorDiagnostics("requests", requestDiagnostics),
...normalizeEditorDiagnostics("template", templateDiagnostics),
];
if (candidate.success) return editorDiagnostics;
return [
...candidate.error.issues.map((issue) => ({
section: String(issue.path[0] ?? "definition"),
severity: "error" as const,
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
})),
...editorDiagnostics,
];
}
export function analyzeCustomWidgetAiDiagnostics(values: CustomWidgetFormValues): CustomWidgetAiDiagnostic[] {
const parsedRequests = parseJson(values.requests);
const requestIds = isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
return createAiDiagnostics(
buildDefinition(values),
analyzeRequestManifest(values.requests),
analyzeJsxTemplate(values.template, { requestIds }),
);
}
export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
const analysisValues = useDeferredValue(values);
const candidate = useMemo(() => buildDefinition(analysisValues), [analysisValues]);
const parsedOptions = useMemo(
() => customWidgetOptionsSchema.safeParse(parseJson(analysisValues.options)),
[analysisValues.options],
);
const requestIds = useMemo(() => {
const parsedRequests = parseJson(analysisValues.requests);
return isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
}, [analysisValues.requests]);
const requestIdsSignature = requestIds.join("\0");
const jsxCompletions = useMemo(
() =>
createCustomWidgetCompletions(
{ options: analysisValues.options, sources: analysisValues.sources },
requestIdsSignature ? requestIdsSignature.split("\0") : [],
),
[analysisValues.options, analysisValues.sources, requestIdsSignature],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(analysisValues.template, { requestIds }),
[analysisValues.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(analysisValues.requests), [analysisValues.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
const candidateIssues = candidate.success ? emptyCandidateIssues : candidate.error.issues;
const invalidSections = useMemo(
() => getInvalidCustomWidgetSections(candidateIssues, requestDiagnostics, templateDiagnostics),
[candidateIssues, requestDiagnostics, templateDiagnostics],
);
const previewCandidate = useMemo(
() => (candidate.success ? candidate.data : null),
// The preview renderer consumes only these definition fields; other fields affect the validity boolean below.
// eslint-disable-next-line react-hooks/exhaustive-deps
[analysisValues.requests, analysisValues.template, candidate.success],
);
const previewValidationIssues = useMemo(
() =>
candidate.success
? emptyPreviewValidationIssues
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
})),
[candidate],
);
return useMemo(
() => ({
candidate,
candidateValid: candidate.success,
parsedOptions,
jsxCompletions,
templateDiagnostics,
requestDiagnostics,
hasDiagnostics,
invalidSections,
previewCandidate,
previewValidationIssues,
}),
[
candidate,
hasDiagnostics,
invalidSections,
jsxCompletions,
parsedOptions,
previewCandidate,
previewValidationIssues,
requestDiagnostics,
templateDiagnostics,
],
);
}
type CustomWidgetFormAnalysis = ReturnType<typeof useCustomWidgetFormAnalysis>;
interface CustomWidgetFormAnalysisStore {
getAnalysis(): CustomWidgetFormAnalysis;
setAnalysis(analysis: CustomWidgetFormAnalysis): void;
subscribe(listener: () => void): () => void;
}
function createCustomWidgetFormAnalysisStore(initialAnalysis: CustomWidgetFormAnalysis): CustomWidgetFormAnalysisStore {
let analysis = initialAnalysis;
const listeners = new Set<() => void>();
return {
getAnalysis: () => analysis,
setAnalysis: (nextAnalysis) => {
if (Object.is(analysis, nextAnalysis)) return;
analysis = nextAnalysis;
listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const CustomWidgetFormAnalysisContext = createContext<CustomWidgetFormAnalysisStore | null>(null);
export function CustomWidgetFormAnalysisProvider({ children }: { children: ReactNode }) {
const values = useDeferredCustomWidgetFormDocumentValues();
const analysis = useCustomWidgetFormAnalysis(values);
const [store] = useState(() => createCustomWidgetFormAnalysisStore(analysis));
useLayoutEffect(() => store.setAnalysis(analysis), [analysis, store]);
return createElement(CustomWidgetFormAnalysisContext.Provider, { value: store }, children);
}
export function useCustomWidgetFormAnalysisField<Key extends keyof CustomWidgetFormAnalysis>(field: Key) {
const store = useContext(CustomWidgetFormAnalysisContext);
if (!store) throw new Error("Custom widget form analysis provider is missing");
const getSnapshot = useCallback(() => store.getAnalysis()[field], [field, store]);
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
@@ -8,8 +8,7 @@ import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const t = useI18n();
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const dirtyRef = useRef(isDirty);
@@ -17,9 +16,9 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
dirtyRef.current = isDirty;
confirmNavigationRef.current = (href) => {
openConfirmModal({
title: tConfirmLeave("title"),
children: tConfirmLeave("message"),
confirmProps: { children: tCommon("action.discard") },
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
confirmProps: { children: t("common.action.discard") },
onConfirm: () => router.push(href),
});
};
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { Container, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CustomWidgetBetaBanner } from "../_beta-banner";
@@ -14,7 +14,7 @@ export default async function NewCustomWidgetPage() {
if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets");
}
const t = await getI18n("customWidget");
const t = await getScopedI18n("customWidget");
return (
<>
@@ -23,7 +23,7 @@ export default async function NewCustomWidgetPage() {
<Stack>
<div>
<Title>{t("page.create.title")}</Title>
<Text c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" size="sm" mt={4}>
<Text c="dimmed" size="sm" mt={4}>
{t("page.create.subtitle")}
</Text>
</div>
@@ -4,7 +4,7 @@ import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
@@ -21,12 +21,11 @@ export default async function CustomWidgetsPage() {
redirect(session ? "/" : "/auth/login");
}
const definitions = await api.customWidget.list();
const t = await getI18n("customWidget");
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
const t = await getScopedI18n("customWidget");
return (
<ManagePageLayout
title={tEntities("customWidgets")}
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<MobileAffixButton
@@ -41,7 +40,7 @@ export default async function CustomWidgetsPage() {
</MobileAffixButton>
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{tCommon("action.create")}
{t("action.create")}
</MobileAffixButton>
</Group>
}
@@ -26,7 +26,7 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
@@ -45,8 +45,7 @@ import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop
const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
@@ -232,7 +231,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
}
>
<Button component={Link} href={listHref} variant="default">
{tCommon("action.cancel")}
{t("publish.cancel")}
</Button>
<Button
size="md"
@@ -23,7 +23,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
@@ -44,7 +44,7 @@ const browseHref = "/manage/custom-widgets/workshop";
const installedHref = "/manage/custom-widgets";
export function WorkshopDetail({ id }: { id: string }) {
const t = useI18n("workshop");
const t = useScopedI18n("workshop");
const router = useRouter();
const session = useWorkshopSession();
const [reportOpened, reportControls] = useDisclosure(false);
@@ -5,12 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { useWorkshopQuery } from "@homarr/workshop/backend";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -22,12 +23,10 @@ interface WorkshopBrowseProps {
search: string | undefined;
sort: "top" | "newest";
page: number;
workshopWebUrl: string;
}
export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopBrowseProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common.action");
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const query = useWorkshopQuery(session.client, {
page,
@@ -46,7 +45,7 @@ export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopB
<WorkshopAccountButton session={session} />
<Button
component="a"
href={workshopWebUrl}
href={getWorkshopWebUrl()}
target="_blank"
rel="noopener noreferrer"
variant="default"
@@ -79,7 +78,7 @@ export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopB
title={t("emptyFilteredTitle")}
description={t("emptyFilteredDescription", { search })}
action={{
label: tCommon("clearSearch"),
label: t("clearSearch"),
href: "/manage/custom-widgets/workshop",
}}
/>
@@ -100,7 +99,7 @@ export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopB
<Tooltip label={t("openCommunity")}>
<ActionIcon
component="a"
href={`${workshopWebUrl.replace(/\/+$/u, "")}/${encodeURIComponent(item.id)}`}
href={getWorkshopWebUrl(item.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
@@ -123,7 +122,7 @@ export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopB
}
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
const t = useI18n("workshop");
const t = useScopedI18n("workshop");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -3,9 +3,6 @@ import { z } from "zod/v4";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { env } from "~/env";
import { WorkshopBrowse } from "./_workshop-browse";
@@ -29,13 +26,6 @@ export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps)
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
const { workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
return (
<WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} workshopWebUrl={workshopWebUrl} />
);
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
}
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export default function ManageErrorPage({
error,
@@ -13,8 +13,8 @@ export default function ManageErrorPage({
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const tError = useI18n("management.error");
const tCommon = useI18n("common");
const tError = useScopedI18n("management.error");
const t = useI18n();
useEffect(() => {
console.error(error);
@@ -34,7 +34,7 @@ export default function ManageErrorPage({
</Text>
)}
<Button variant="light" onClick={unstable_retry}>
{tCommon("action.tryAgain")}
{t("common.action.tryAgain")}
</Button>
</Stack>
</Center>
@@ -15,7 +15,7 @@ interface Props {
}
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
const tIntegration = useI18n("integration");
const t = useI18n();
const utils = clientApi.useUtils();
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
{
@@ -49,7 +49,7 @@ export const IntegrationAccessSettings = ({ integration, initialPermissions }: P
},
groupPermissionMapping: integrationPermissionsMap,
}}
translate={(key) => tIntegration(`permission.${key}` as never)}
translate={(key) => t(`integration.permission.${key}`)}
query={{
data,
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
@@ -25,8 +25,7 @@ interface SecretCardProps {
}
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const tIntegration = useI18n("integration");
const tCommon = useI18n("common");
const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
const [editMode, setEditMode] = useState(false);
@@ -41,20 +40,20 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar>
<KindIcon size={16} />
</Avatar>
<Text fw={500}>{tIntegration(`secrets.kind.${secret.kind}.label` as never)}</Text>
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
{secret.updatedAt ? (
<Text c="gray.6" size="sm">
{tIntegration("secrets.lastUpdated", {
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
) : (
<Tooltip label={tIntegration("secrets.notSet.tooltip")} position="left">
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm">
{tIntegration("secrets.notSet.label")}
{t("integration.secrets.notSet.label")}
</Badge>
</Tooltip>
)}
@@ -63,7 +62,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
aria-label={tCommon(publicSecretDisplayOpened ? "action.hide" : "action.show")}
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
>
<DisplayIcon size={16} stroke={1.5} />
</ActionIcon>
@@ -81,7 +80,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
setEditMode(false);
}}
>
{editMode ? tCommon("action.cancel") : tCommon("action.edit")}
{editMode ? t("common.action.cancel") : t("common.action.edit")}
</Button>
</Group>
</Group>
@@ -29,14 +29,14 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
};
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const tIntegration = useI18n("integration");
const t = useI18n();
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
@@ -48,7 +48,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
return (
<TextInput
{...props}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>
@@ -56,7 +56,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
};
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const tIntegration = useI18n("integration");
const t = useI18n();
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
@@ -64,8 +64,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
return (
<Textarea
{...props}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
description={tIntegration("secrets.secureNotice")}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
@@ -77,8 +77,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
return (
<PasswordInput
{...props}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
description={tIntegration("secrets.secureNotice")}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>
@@ -3,7 +3,7 @@ import { SegmentedControl } from "@mantine/core";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
interface FormType {
secrets: { kind: IntegrationSecretKind; value: string | null }[];
@@ -20,7 +20,7 @@ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
secretKinds,
form,
}: SecretKindsSegmentedControlProps<TFormType>) => {
const t = useI18n("integration.secrets");
const t = useScopedI18n("integration.secrets");
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
const secretKindGroups = secretKinds.map((kinds) => ({
@@ -14,7 +14,7 @@ interface IntegrationTestConnectionErrorProps {
}
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
const tIntegration = useI18n("integration");
const t = useI18n();
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return (
@@ -22,17 +22,17 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Stack>
<Stack gap="sm">
<Text size="lg" fw={500} c="red.8">
{tIntegration(`testConnection.error.${error.type}.title` as never)}
{t(`integration.testConnection.error.${error.type}.title`)}
</Text>
{error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? (
<Text size="md">{tIntegration(`testConnection.error.${error.type}.description` as never)}</Text>
<Text size="md">{t(`integration.testConnection.error.${error.type}.description`)}</Text>
) : null}
{error.type === "request" ? (
<Text size="md">
{tIntegration(
`testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
{t(
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
)}
</Text>
) : null}
@@ -40,7 +40,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{error.type === "statusCode" ? (
error.data.reason === "other" ? (
<Text size="md">
{tIntegration.rich("testConnection.error.statusCode.otherDescription", {
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(),
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
@@ -51,8 +51,8 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
</Text>
) : (
<Text size="md">
{tIntegration.rich("testConnection.error.statusCode.description", {
reason: tIntegration(`testConnection.error.statusCode.reason.${error.data.reason}` as never),
{t.rich("integration.testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
statusCode: error.data.statusCode.toString(),
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
@@ -71,7 +71,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Accordion variant="contained">
<Accordion.Item value="cause">
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
{tIntegration("testConnection.error.common.cause.title")}
{t("integration.testConnection.error.common.cause.title")}
</Accordion.Control>
<Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
Loaded 100 of 652 files, more files were not shown because too many files have changed in this diff. Show more