Compare commits

...
Author SHA1 Message Date
ajnart b6fc27e44d fix: preserve layout validation errors 2026-08-23 01:24:36 +02:00
ajnart 9eafd4f7a7 fix: preserve custom layout role type 2026-08-23 01:13:00 +02:00
ajnart 71ffa5d1be fix: keep responsive layout editors stable 2026-08-23 00:59:02 +02:00
Thomas 1b843817a4 feat: polish board switcher (#6670) 2026-08-22 17:21:10 +02:00
Thomas Camlong 8ae9630b3e Revert "fix: position calendar error indicator top-left (#6668)"
This reverts commit 63f5c43b69.
2026-08-22 17:03:15 +02:00
Thomas 63f5c43b69 fix: position calendar error indicator top-left (#6668) 2026-08-22 16:57:34 +02:00
Thomas e97fe7861d feat: rework bookmarks widget (#6666) 2026-08-22 16:55:31 +02:00
Thomas 19760e5722 fix: restore translucent dashboard styling (#6664)
* fix: restore translucent dashboard styling

* fix: restore dashboard widget transparency

* fix: address dashboard review feedback

* fix: apply board opacity to containers

* fix: flatten demo board container

* fix: default board roundness to sm

* fix: restore assistant widget transparency

* Revert "fix: default board roundness to sm"

This reverts commit c235cbdf2b.

* fix: address remaining dashboard review comments

* test: align grid geometry expectations
2026-08-22 15:51:43 +02:00
Thomas 2570de25a6 fix: hide default weather details (#6665) 2026-08-22 15:48:02 +02:00
Thomas f46ccadbe4 feat: redesign demo dashboard (#6663) 2026-08-22 13:08:27 +02:00
Thomas 6297ca4b16 fix: keep media stream status at table edge (#6661) 2026-08-22 11:48:33 +02:00
Thomas 2accdc2fad feat: rework board switcher (#6660)
* feat: rework board switcher

* fix: address board switcher review
2026-08-22 11:15:40 +02:00
Thomas 8a08293016 fix: restore bookmark grid fill (#6659)
* fix: restore bookmark grid fill

* fix: use scroll area viewport class
2026-08-22 11:14:41 +02:00
Bergasha 7591f7e313 feat(v2 Containers): scrollable containers (#6629) 2026-08-22 09:24:52 +02:00
Thomas 011ce8e5a3 fix: preserve board drafts and widget scroll state (#6637) 2026-08-22 09:13:32 +02:00
Thomas b2fa46c760 fix: restore release translation checks (#6658) 2026-08-22 08:49:14 +02:00
Thomas 9e14a07e64 refactor: consolidate English translations (#6640) 2026-08-21 19:00:27 +02:00
Thomas 81aa704537 feat(cli): rework developer CLI with non-blocking tasks and GitHub CI checks (#6651) 2026-08-21 18:59:11 +02:00
Thomas f0f9539466 feat(ui): add situational tips system (#6648) 2026-08-21 18:58:52 +02:00
Thomas 3c2f95d529 perf(board): stabilize edit mode layout (#6650) 2026-08-21 16:17:42 +02:00
Thomas 64e3d04517 fix(calendar): improve event indicators (#6652) 2026-08-21 15:55:38 +02:00
Bergashaandajnart 002381d294 feat(v2 docker widget): Fixes column-resize squashing (#6646)
* fix(dataTable): resizing a column no longer squashes the others

mantine-datatable's built-in column resize keeps the table's total
width fixed by shrinking every other column to compensate, which
collapses columns toward zero in a narrow widget (open upstream
issue, no fix: icflorescu/mantine-datatable#discussions-757).

Replaced it with our own resize handle that only ever changes the
width of the column being dragged - the table grows and scrolls
horizontally instead. Shared via usePersistedTableLayout, so docker,
downloads, and beszel-system-table all get it.

* feat(docker): enable hold-Shift advanced focus preview

The widget already renders differently for displayMode="advanced",
but was missing supportsAdvancedFocus: true, so the board never
offered the hold-Shift preview for it - matching downloads and
media-server, which both set this flag.

* style(docker): tighten row density, decouple icon size from row height

Circular avatar, denser row/footer padding, and action icons rendered
larger than their button via absolute positioning so they don't
inflate row height (the button stays small and fixed - it repeats
once per row, so any growth there multiplies across every visible
container).

* fix(data-table): harden shared column resizing

* fix(data-table): account for board scaling

* fix(data-table): compact shared headers

* fix(data-table): fit columns to widget

* fix(downloads): simplify advanced details

* test(widgets): align Docker advanced focus

* fix(downloads): widen expanded details

---------

Co-authored-by: ajnart <thomas@ajnart.dev>
2026-08-21 15:12:31 +02:00
Thomas 9b406d5d83 fix(beszel): restore balanced system grid layout (#6647) 2026-08-21 13:11:40 +02:00
612 changed files with 16105 additions and 9158 deletions

No files matched your search

@@ -0,0 +1,60 @@
---
name: homarr-local-harness
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
---
# Homarr Local Harness
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
## Set up the harness
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
```sh
pnpm cli harness setup feature-board
```
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
- `DEMO_MODE=true`
- `DEMO_READ_ONLY=false`
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
Pass repeatable environment overrides when the feature needs them:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
Always use the URL printed by the command. If the output is no longer available, query it directly:
```sh
pnpm cli harness port feature-board
```
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
## Browser validation
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
```sh
agent-browser skills get core
```
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
## Cleanup
Stop the named container when finished:
```sh
pnpm cli harness stop feature-board
```
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr Local Harness"
short_description: "Build and browse a writable local Homarr branch harness"
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
+2
View File
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
# production # production
build build
tools/homarr-dev/homarr
tools/homarr-dev/homarr-dev
# misc # misc
.DS_Store .DS_Store
@@ -1,43 +1,175 @@
# Developer Docker CLI # Developer Docker CLI
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. 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.
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. 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.
## Browse development images ## 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
```bash ```bash
pnpm dev:cli -- pnpm dev:cli --
# or with global binary:
homarr
``` ```
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. 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.
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. 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.
## Build images ### 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
```bash ```bash
pnpm dev:cli -- build <name> homarr dash
pnpm dev:cli -- build --pr <number> [name] # or
pnpm dev:cli -- dash
``` ```
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. The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
## Rebuild images | 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
```bash ```bash
pnpm dev:cli -- rebuild <name> 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/
``` ```
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. - `--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`).
## Run images ### Build & Rebuild
```bash ```bash
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/ # Build current checkout as homarr:<name>
homarr run <name> homarr build feature
homarr run --pr <number>
# Build a pull request locally in a temporary checkout
homarr build --pr 6441
# Rebuild an existing image from recorded provenance
homarr rebuild feature
``` ```
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the 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.
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
### 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
```
@@ -25,3 +25,27 @@ 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) 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) 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) 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: Homarr has the following keyboard shortcuts:
| Shortcut | Description | | Shortcut | Description |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar | | `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode | | `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode | | `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | | Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | | Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | | `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Widget advanced view ## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
`Shift` + `Enter`, or use its menu, to keep the advanced view open. on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
## Board editing ## 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) - **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. - **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, ...). - **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
- **Board switcher**: Use this button to open another board that you can access. - **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions. it directly. The current board is left out of the results.
When entering the edit mode, you'll get some additional buttons to work with: 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. 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 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. You can also focus that widget and press `Shift` + `Enter` to stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place. 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 To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
@@ -369,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
### Board tour ### Board tour
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu. When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
### Tour controls ### Tour controls
+10 -3
View File
@@ -27,6 +27,11 @@ 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. 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 ## Create a new board
When creating a board, the availability of the board name will be checked for you. When creating a board, the availability of the board name will be checked for you.
@@ -210,7 +215,8 @@ 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 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 toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
selection. A move is applied only when every selected item fits in the destination. 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.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test, 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 diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -329,8 +335,9 @@ This will disable the status check for apps.
Widgets expose their routine controls without requiring edit mode: 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 - 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. Focus that widget and press `Shift` + `Enter` to keep the view remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead. 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 - 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 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. not use this widget menu, and available controls follow your board and integration permissions.
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} /> <WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount. This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
+7 -6
View File
@@ -1,6 +1,6 @@
--- ---
title: "Bookmarks" title: "Bookmarks"
description: "Displays multiple app links" description: "Keeps useful links together"
hide_title: true hide_title: true
--- ---
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} /> <WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget allows you to display multiple links in a single widget. The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
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. Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
For links that do not need an App, add their URLs under **Custom links** instead. 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.
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. Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
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.
**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.
### Screenshots ### Screenshots
+18 -12
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = { export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark, icon: IconBookmark,
name: "Bookmarks", name: "Bookmarks",
description: "Displays multiple app links", description: "Keeps useful links together",
path: "../../widgets/bookmarks", path: "../../widgets/bookmarks",
configuration: { configuration: {
items: [ items: [
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
}, },
{ {
name: "Layout", name: "Layout",
description: "Layout in which the bookmarks are displayed.", description: "How bookmarks use the available widget space.",
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] }, values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
defaultValue: "Vertical", 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",
}, },
{ {
name: "Hide title", name: "Hide title",
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
}, },
{ {
name: "Bookmarks", name: "Bookmarks",
description: "List of bookmarks to display in the widget.", description: "URLs and existing Apps shown by the widget.",
values: "Select one or more Apps directly, then order them as you like.", values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
defaultValue: "No bookmarks selected", 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,6 +69,7 @@ import advancedView from "./img/advanced-view.png";
### Interactions ### 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. - 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. - 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. - If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
@@ -0,0 +1,53 @@
<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>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui"; import { CustomPasswordInput } from "@homarr/ui";
import { userRegistrationSchema } from "@homarr/validation/user"; import { userRegistrationSchema } from "@homarr/validation/user";
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
} }
export const RegistrationForm = ({ invite }: RegistrationFormProps) => { export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user"); const t = useI18n("user");
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation(); const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(userRegistrationSchema, { const form = useZodForm(userRegistrationSchema, {
@@ -6,7 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db"; import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema"; import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding"; import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form"; import { RegistrationForm } from "./_registration-form";
@@ -45,7 +45,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound(); if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite"); const t = await getI18n("user.page.invite");
return ( return (
<OnboardingAuthShell <OnboardingAuthShell
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user"; import { userSignInSchema } from "@homarr/validation/user";
@@ -28,7 +28,7 @@ interface LoginFormProps {
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) }); const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => { export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user"); const t = useI18n("user");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isError = searchParams.has("error"); const isError = searchParams.has("error");
const router = useRouter(); const router = useRouter();
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
} }
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => { const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false); const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword"); const tForgotPassword = useI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>"; 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 { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding"; import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env"; import { env as appEnv } from "~/env";
@@ -26,7 +26,7 @@ export default async function Login(props: LoginProps) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl)); redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
} }
const t = await getScopedI18n("user.page.login"); const t = await getI18n("user.page.login");
return ( return (
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}> <OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
@@ -1,11 +1,11 @@
import { IconLayoutOff } from "@tabler/icons-react"; import { IconLayoutOff } from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found"; import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() { export default async function BoardNotFoundPage() {
const tNotFound = await getScopedI18n("board.error.notFound"); const tNotFound = await getI18n("board.error.notFound");
return ( return (
<BoardNotFound <BoardNotFound
icon={IconLayoutOff} icon={IconLayoutOff}
@@ -19,35 +19,11 @@
align-self: start; align-self: start;
box-sizing: border-box; box-sizing: border-box;
height: var(--board-gutter-viewport-height); height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip; overflow: clip;
} }
.gutter[data-board-editing="true"] { .gutter[data-board-editing="true"] {
outline: 1px dashed var(--mantine-color-default-border); outline: 0.0625rem solid var(--mantine-color-default-border);
outline-offset: -1px; outline-offset: -0.0625rem;
border-radius: var(--mantine-radius-md); 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,11 +1,14 @@
"use client"; "use client";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core"; import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview"; import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state"; import { BoardEmptyState } from "~/components/board/board-empty-state";
@@ -20,7 +23,8 @@ import {
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas"; import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary"; import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host"; 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 { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context"; import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
@@ -29,10 +33,17 @@ import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32; const APP_SHELL_INLINE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => { export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useScopedI18n("board.landmark"); const t = useI18n("board.landmark");
const tPreview = useScopedI18n("board.setting.section.layout.preview"); const tPreview = useI18n("board.setting.section.layout.preview");
const tTips = useI18n("tips");
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth(); const initialViewportWidth = useInitialViewportWidth();
@@ -65,7 +76,7 @@ export const ClientBoard = () => {
.join(" "); .join(" ");
const content = ( const content = (
<BoardSelectionProvider> <BoardSelectionGridProvider>
<BoardAdvancedFocusProvider> <BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo /> <BoardBackgroundVideo />
@@ -80,7 +91,6 @@ export const ClientBoard = () => {
label={board.name} label={board.name}
> >
<BoardGridEditorBoundary key={currentLayoutId}> <BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}> <div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && ( {leftColumnCount > 0 && leftSection && (
<aside <aside
@@ -130,9 +140,19 @@ export const ClientBoard = () => {
</BoardGridPortalHost> </BoardGridPortalHost>
</BoardSectionCollapseProvider> </BoardSectionCollapseProvider>
<BoardSelectionToolbar /> <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> </Box>
</BoardAdvancedFocusProvider> </BoardAdvancedFocusProvider>
</BoardSelectionProvider> </BoardSelectionGridProvider>
); );
if (representativeWidth === null) return content; 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> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try { try {
const board = await getInitialBoard(await params); const board = await getInitialBoard(await params);
const t = await getI18n(); const t = await getI18n("board");
return { return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })), title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
icons: { icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
@@ -29,7 +29,9 @@ const AddMenu = () => {
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions(); const { addContainer } = useContainerActions();
const { createItem } = useItemActions(); const { createItem } = useItemActions();
const t = useI18n(); const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -77,7 +79,7 @@ const AddMenu = () => {
return ( return (
<Menu position="bottom-end"> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}> <HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} /> <IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} /> <IconChevronDown color="gray" size={16} />
@@ -86,19 +88,19 @@ const AddMenu = () => {
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}> <Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")} {tBoard("emptyState.addWidget")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}> <Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")} {tBoard("emptyState.addApp")}
</Menu.Item> </Menu.Item>
{session?.user.permissions.includes("integration-create") && ( {session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}> <Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")} {tIntegration("action.create")}
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}> <Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")} {tContainer("action.create")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
@@ -3,18 +3,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
IconDeviceFloppy,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconReplace,
IconSettings,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -24,9 +15,7 @@ import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader"; import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
@@ -53,12 +42,10 @@ const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n(); const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) { if (!hasChangeAccess) return null;
return <SelectBoardsMenu />;
}
return ( return (
<> <>
@@ -68,13 +55,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
{!demoReadOnly && ( {!demoReadOnly && (
<TourTarget id="board-settings"> <TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}> <HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} /> <IconSettings stroke={1.5} />
</HeaderButton> </HeaderButton>
</TourTarget> </TourTarget>
)} )}
<SelectBoardsMenu />
</> </>
); );
}; };
@@ -88,8 +73,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const commonT = useI18n(); const tCommon = useI18n("common");
const t = useScopedI18n("board.action.edit"); const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings = const returnToLayoutSettings =
isEditMode && isEditMode &&
searchParams.get("edit") === "true" && searchParams.get("edit") === "true" &&
@@ -104,7 +90,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}, },
onSuccess() { onSuccess() {
showSuccessNotification({ showSuccessNotification({
title: t("notification.success.title"), title: tCommon("notification.update.success"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
@@ -115,7 +101,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
title: t("notification.error.title"), title: tCommon("notification.update.error"),
message: t("notification.error.message"), message: t("notification.error.message"),
}); });
}, },
@@ -178,9 +164,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const cancelLayoutEdit = useCallback(() => { const cancelLayoutEdit = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: commonT("board.setting.section.layout.edit.cancel.title"), title: tBoardSetting("section.layout.edit.cancel.title"),
children: commonT("board.setting.section.layout.edit.cancel.message"), children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: commonT("common.action.discard"), color: "red" }, confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() { onConfirm() {
void (async () => { void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name }); await utils.board.getBoardByName.invalidate({ name: board.name });
@@ -189,7 +175,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
})(); })();
}, },
}); });
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]); }, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]); useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
@@ -200,14 +186,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
<HeaderButton <HeaderButton
onClick={() => saveBoard(board)} onClick={() => saveBoard(board)}
loading={isPending} loading={isPending}
aria-label={commonT("board.setting.section.layout.edit.save")} aria-label={tBoardSetting("section.layout.edit.save")}
> >
<IconDeviceFloppy stroke={1.5} /> <IconDeviceFloppy stroke={1.5} />
</HeaderButton> </HeaderButton>
<HeaderButton <HeaderButton
onClick={cancelLayoutEdit} onClick={cancelLayoutEdit}
disabled={isPending} disabled={isPending}
aria-label={commonT("board.setting.section.layout.edit.cancel.action")} aria-label={tBoardSetting("section.layout.edit.cancel.action")}
> >
<IconX stroke={1.5} /> <IconX stroke={1.5} />
</HeaderButton> </HeaderButton>
@@ -234,50 +220,10 @@ 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 anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => { const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n(); const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const router = useRouter(); const router = useRouter();
@@ -293,13 +239,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.stopPropagation(); event.stopPropagation();
openConfirmModal({ openConfirmModal({
title: t("board.action.edit.confirmLeave.title"), title: tBoard("action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"), children: tBoard("action.edit.confirmLeave.message"),
onConfirm() { onConfirm() {
router.push(target.href); router.push(target.href);
}, },
confirmProps: { confirmProps: {
children: t("common.action.discard"), children: tCommon("action.discard"),
}, },
}); });
}; };
@@ -325,5 +271,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState); window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
}; };
}, [isDirty, openConfirmModal, router, t]); }, [isDirty, openConfirmModal, router, tBoard, tCommon]);
}; };
@@ -16,15 +16,15 @@ export default async function NotFoundBoardHomePage() {
const getPropsAsync = async (): Promise<BoardNotFoundProps> => { const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards); const boardCount = await db.$count(boards);
const t = await getI18n(); const t = await getI18n("board");
if (boardCount === 0) { if (boardCount === 0) {
return { return {
icon: { src: "/favicon.ico", alt: "Homarr logo" }, icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("board.error.noBoard.title"), title: t("error.noBoard.title"),
description: t("board.error.noBoard.description"), description: t("error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" }, link: { label: t("error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"), notice: t("error.noBoard.notice"),
}; };
} }
@@ -39,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
return { return {
icon: IconHomeOff, icon: IconHomeOff,
title: t(`board.error.homeBoard.title`), title: t("error.homeBoard.title"),
description: t(`board.error.homeBoard.${type}.description`), description: t(`error.homeBoard.${type}.description` as never),
link: { label: t(`board.error.homeBoard.${type}.link`), href }, link: { label: t(`error.homeBoard.${type}.link` as never), href },
notice: t(`board.error.homeBoard.${type}.notice`), notice: t(`error.homeBoard.${type}.notice` as never),
}; };
}; };
@@ -37,22 +37,23 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => { export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n(); const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme(); const theme = useMantineTheme();
return ( return (
<SectionCard title={t("board.setting.section.appearance.title")}> <SectionCard title={tBoard("setting.section.appearance.title")}>
<Grid> <Grid>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs"> <Stack gap="xs">
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} /> <BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} /> <BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor> <Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse expanded={showPreview}> <Collapse expanded={showPreview}>
@@ -63,7 +64,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Collapse> </Collapse>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}> <InputWrapper label={tBoard("field.opacity.label")}>
<Slider <Slider
my={6} my={6}
min={0} min={0}
@@ -77,7 +78,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end"> <Group align="end">
<ColorInput <ColorInput
label={t("board.field.iconColor.label")} label={tBoard("field.iconColor.label")}
format="hex" format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])} swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1} flex={1}
@@ -90,20 +91,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
onClick={() => form.setFieldValue("iconColor", "")} onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor} disabled={!form.values.iconColor}
> >
{t("board.field.clearColor.label")} {tBoard("field.clearColor.label")}
</Button> </Button>
</Group> </Group>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Select <Select
label={t("board.field.itemRadius.label")} label={tBoard("field.itemRadius.label")}
description={t("board.field.itemRadius.description")} description={tBoard("field.itemRadius.description")}
data={[ data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" }, { label: tBoard("field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" }, { label: tBoard("field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" }, { label: tBoard("field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" }, { label: tBoard("field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" }, { label: tBoard("field.itemRadius.option.xl"), value: "xl" },
]} ]}
{...form.getInputProps("itemRadius")} {...form.getInputProps("itemRadius")}
/> />
@@ -23,7 +23,7 @@ interface Props {
} }
export const BackgroundSettingsContent = ({ form }: Props) => { export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n(); const tBoard = useI18n("board");
const { data: session } = useSession(); const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200); const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return ( return (
<SectionCard title={t("board.setting.section.background.title")}> <SectionCard title={tBoard("setting.section.background.title")}>
<Grid> <Grid>
<Grid.Col span={12}> <Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start"> <Group wrap="nowrap" gap="xs" w="100%" align="start">
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
} }
// We filter it on the server // We filter it on the server
filter={({ options }) => options} filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")} label={tBoard("field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`} placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => { renderOption={({ option }) => {
const current = imageMap.get(option.value); const current = imageMap.get(option.value);
if (!current) return null; if (!current) return null;
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}} }}
data={[ data={[
{ {
group: t("board.field.backgroundImageUrl.group.your"), group: tBoard("field.backgroundImageUrl.group.your"),
items: images items: images
.filter((media) => media.creatorId === session?.user.id) .filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`), .map((media) => `/api/user-medias/${media.id}`),
}, },
{ {
group: t("board.field.backgroundImageUrl.group.other"), group: tBoard("field.backgroundImageUrl.group.other"),
items: images items: images
.filter((media) => media.creatorId !== session?.user.id) .filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`), .map((media) => `/api/user-medias/${media.id}`),
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<SelectWithDescriptionBadge <SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")} label={tBoard("field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData} data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")} {...form.getInputProps("backgroundImageAttachment")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<SelectWithDescriptionBadge <SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")} label={tBoard("field.backgroundImageSize.label")}
data={backgroundImageSizeData} data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")} {...form.getInputProps("backgroundImageSize")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<SelectWithDescriptionBadge <SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")} label={tBoard("field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData} data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")} {...form.getInputProps("backgroundImageRepeat")}
/> />
@@ -176,19 +176,20 @@ const useBackgroundOptionData = <
defaultValue: keyof TOptions; defaultValue: keyof TOptions;
}, },
) => { ) => {
const t = useI18n(); const tBoard = useI18n("board");
const tCommon = useI18n("common");
return data.values.map( return data.values.map(
(value) => (value) =>
({ ({
label: t(`board.field.${key}.option.${value as string}.label` as never), label: tBoard(`field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never), description: tBoard(`field.${key}.option.${value as string}.description` as never),
value: value as string, value: value as string,
badge: badge:
data.defaultValue === value data.defaultValue === value
? { ? {
color: "blue", color: "blue",
label: t("common.select.badge.recommended"), label: tCommon("select.badge.recommended"),
} }
: undefined, : undefined,
}) satisfies SelectItemWithDescriptionBadge, }) satisfies SelectItemWithDescriptionBadge,
@@ -13,13 +13,13 @@ interface Props {
} }
export const BehaviorSettingsContent = ({ form }: Props) => { export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n(); const t = useI18n("board");
return ( return (
<SectionCard title={t("board.setting.section.behavior.title")}> <SectionCard title={t("setting.section.behavior.title")}>
<Switch <Switch
label={t("board.field.disableStatus.label")} label={t("field.disableStatus.label")}
description={t("board.field.disableStatus.description")} description={t("field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })} {...form.getInputProps("disableStatus", { type: "checkbox" })}
/> />
</SectionCard> </SectionCard>
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation(); const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation(); const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useI18n(); const tBoard = useI18n("board");
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery( const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{ {
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
mutate: userMutation.mutate, mutate: userMutation.mutate,
isPending: userMutation.isPending, isPending: userMutation.isPending,
}} }}
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)} translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
permission={{ permission={{
items: boardPermissions, items: boardPermissions,
default: "view", default: "view",
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
@@ -24,11 +24,11 @@ interface Props {
export const CustomCssSettingsContent = ({ form }: Props) => { export const CustomCssSettingsContent = ({ form }: Props) => {
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false; const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n(); const t = useI18n("board");
const customCssT = useScopedI18n("board.field.customCss"); const customCssT = useI18n("board.field.customCss");
return ( return (
<SectionCard title={t("board.setting.section.customCss.title")}> <SectionCard title={t("setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} /> <CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}> <Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
} }
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => { const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss"); const customCssT = useI18n("board.field.customCss");
return ( return (
<Input.Wrapper <Input.Wrapper
@@ -7,14 +7,15 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useScopedI18n("board.setting"); const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter(); const router = useRouter();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal); const { openModal } = useModalAction(BoardRenameModal);
@@ -104,11 +105,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
/> />
)} )}
<DangerZoneItem <DangerZoneItem
label={t("section.dangerZone.action.delete.label")} label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")} description={t("section.dangerZone.action.delete.description")}
action={ action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}> <Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{t("section.dangerZone.action.delete.button")} {tCommonAction("delete")}
</Button> </Button>
} }
/> />
@@ -20,25 +20,21 @@ interface Props {
} }
export const GeneralSettingsContent = ({ board, form }: Props) => { export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n(); const t = useI18n("board");
useLogoPreview(form.values.logoImageUrl); useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
return ( return (
<SectionCard title={t("board.setting.section.general.title")}> <SectionCard title={t("setting.section.general.title")}>
<Grid> <Grid>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.metaTitle.label")} label={t("field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))} placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />} rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
@@ -46,7 +42,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker <IconPicker
{...form.getInputProps("logoImageUrl")} {...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")} label={t("field.logoImageUrl.label")}
placeholder="/logo/logo.png" placeholder="/logo/logo.png"
withAsterisk={false} withAsterisk={false}
/> />
@@ -54,7 +50,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker <IconPicker
{...form.getInputProps("faviconImageUrl")} {...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")} label={t("field.faviconImageUrl.label")}
placeholder="/logo/logo.png" placeholder="/logo/logo.png"
withAsterisk={false} withAsterisk={false}
/> />
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview"; import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview"; import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui"; import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/widgets/catalog";
@@ -23,6 +23,8 @@ interface Props {
} }
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => { export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const tBoard = useI18n("board");
const tSection = useI18n("section");
const t = useI18n(); const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout); const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts); const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
@@ -42,13 +44,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
{representativeWidth}px {representativeWidth}px
</Badge> </Badge>
<Text size="xs"> <Text size="xs">
{layout.columnCount} {t("board.setting.section.layout.preview.columns")} {layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
</Text> </Text>
</Group> </Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}> <Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? ( {elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl"> <Text size="xs" c="dimmed" ta="center" py="xl">
{t("board.setting.section.layout.preview.empty")} {tBoard("setting.section.layout.preview.empty")}
</Text> </Text>
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
@@ -76,8 +78,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
const containerLabel = const containerLabel =
container?.kind === "container" && container.options.title container?.kind === "container" && container.options.title
? container.options.title ? container.options.title
: t("section.container.untitled"); : tSection("container.untitled");
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel); const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid; const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return ( return (
@@ -41,7 +41,9 @@ interface Props {
} }
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const t = useI18n(); const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -50,8 +52,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({ const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
onError() { onError() {
showErrorNotification({ showErrorNotification({
title: t("common.notification.update.error"), title: tCommon("notification.update.error"),
message: t("common.notification.update.error"), message: tCommon("notification.update.error"),
}); });
}, },
}); });
@@ -84,15 +86,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const confirmReset = (layout: FormValues["layouts"][number]) => { const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({ openConfirmModal({
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }), title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: t("board.setting.section.layout.reset.confirm.message"), children: tBoard("setting.section.layout.reset.confirm.message"),
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" }, confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
onConfirm() { onConfirm() {
setResettingLayoutId(layout.id); setResettingLayoutId(layout.id);
void (async () => { void (async () => {
try { try {
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values; const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
if (!canonicalLayout) return; if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
@@ -110,17 +111,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const sortedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return ( return (
<SectionCard title={t("board.setting.section.layout.title")}> <SectionCard title={tBoard("setting.section.layout.title")}>
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap"> <Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem"> <Stack gap={2} maw="52rem">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text> <Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text> <Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
</Stack> </Stack>
<Button <Button
type="button" type="button"
@@ -128,25 +126,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={nextBreakpoint === null || !baseLayout} disabled={nextBreakpoint === null || !baseLayout}
onClick={() => { onClick={() => {
if (nextBreakpoint === null || !baseLayout) return; if (nextBreakpoint === null || !baseLayout) return;
form.setFieldValue("layouts", [ const newLayout: FormValues["layouts"][number] = {
...form.values.layouts, id: createId(),
{ name: tBoard("setting.section.layout.custom.defaultName"),
id: createId(), columnCount: baseLayout.columnCount,
name: t("board.setting.section.layout.custom.defaultName"), leftGutterColumnCount: baseLayout.leftGutterColumnCount,
columnCount: baseLayout.columnCount, rightGutterColumnCount: baseLayout.rightGutterColumnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount, breakpoint: nextBreakpoint,
rightGutterColumnCount: baseLayout.rightGutterColumnCount, role: "custom",
breakpoint: nextBreakpoint, };
role: "custom", let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
}, if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
]); form.insertListItem("layouts", newLayout, insertionIndex);
}} }}
> >
{t("board.setting.section.layout.responsive.action.add")} {tBoard("setting.section.layout.responsive.action.add")}
</Button> </Button>
</Group> </Group>
{sortedLayouts.map(({ layout, index }) => { {form.values.layouts.map((layout, index) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts const customBreakpoints = form.values.layouts
@@ -159,9 +157,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
key={layout.id} key={layout.id}
legend={ legend={
<Group gap="xs"> <Group gap="xs">
<Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text> <Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default"> <Badge size="sm" variant="default">
{t(`board.setting.section.layout.role.${layout.role}`)} {tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge> </Badge>
</Group> </Group>
} }
@@ -170,10 +168,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start"> <Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}> <Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md"> <Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} /> <TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={t("layout.field.columnCount.label")}> <Input.Wrapper label={tLayout("field.columnCount.label")}>
<Slider <Slider
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`} thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs" mt="xs"
min={1} min={1}
max={24} max={24}
@@ -194,11 +192,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Input.Wrapper> </Input.Wrapper>
<NumberInput <NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)} {...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")} label={tLayout("field.breakpoint.label")}
description={ description={
layout.role === "mobile" layout.role === "mobile"
? t("board.setting.section.layout.mobile.breakpointDescription") ? tBoard("setting.section.layout.mobile.breakpointDescription")
: t("layout.field.breakpoint.description") : tLayout("field.breakpoint.description")
} }
disabled={layout.role === "mobile"} disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }} styles={{ description: { color: "var(--mantine-color-text)" } }}
@@ -246,7 +244,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={isSaving} disabled={isSaving}
onClick={() => confirmReset(layout)} onClick={() => confirmReset(layout)}
> >
{t("board.setting.section.layout.reset.action")} {tBoard("setting.section.layout.reset.action")}
</Button> </Button>
)} )}
{layout.role === "custom" && ( {layout.role === "custom" && (
@@ -262,7 +260,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
); );
}} }}
> >
{t("common.action.remove")} {tCommon("action.remove")}
</Button> </Button>
)} )}
</Group> </Group>
@@ -275,8 +273,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
onClick={() => void openLayoutEditorAsync(layout)} onClick={() => void openLayoutEditorAsync(layout)}
> >
{form.isDirty() || !persistedLayout {form.isDirty() || !persistedLayout
? t("board.setting.section.layout.edit.saveAndEdit") ? tBoard("setting.section.layout.edit.saveAndEdit")
: t("board.setting.section.layout.edit.action")} : tBoard("setting.section.layout.edit.action")}
</Button> </Button>
</Group> </Group>
</Fieldset> </Fieldset>
@@ -322,16 +320,16 @@ const gutterMarks = [
]; ];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => { const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n(); const t = useI18n("layout");
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1)); const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return ( return (
<Paper className={classes.gutterSettings} p="md"> <Paper className={classes.gutterSettings} p="md">
<Stack gap="md"> <Stack gap="md">
<Box> <Box>
<Text fw={600}>{t("layout.field.gutters.label")}</Text> <Text fw={600}>{t("field.gutters.label")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("layout.field.gutters.description")} {t("field.gutters.description")}
</Text> </Text>
</Box> </Box>
@@ -371,7 +369,7 @@ const GutterControl = ({
max: number; max: number;
onChange: (value: number) => void; onChange: (value: number) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n("layout");
const enabled = value > 0; const enabled = value > 0;
return ( return (
@@ -380,10 +378,10 @@ const GutterControl = ({
checked={enabled} checked={enabled}
disabled={max < 1} disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)} onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`layout.field.gutters.${side}.label`)} label={t(`field.gutters.${side}.label` as never)}
/> />
{enabled && ( {enabled && (
<Input.Wrapper label={t("layout.field.gutters.width.label")}> <Input.Wrapper label={t("field.gutters.width.label")}>
<Slider <Slider
mt="xs" mt="xs"
min={1} min={1}
@@ -391,7 +389,7 @@ const GutterControl = ({
value={Math.min(value, max)} value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)} marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks restrictToMarks
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)} thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))} onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/> />
</Input.Wrapper> </Input.Wrapper>
@@ -401,7 +399,7 @@ const GutterControl = ({
}; };
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => { const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
const t = useI18n(); const t = useI18n("layout");
const safeColumnCount = Math.max(1, columnCount); const safeColumnCount = Math.max(1, columnCount);
const main = Math.max(1, safeColumnCount - left - right); const main = Math.max(1, safeColumnCount - left - right);
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null] const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
@@ -411,7 +409,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
return ( return (
<Box <Box
className={classes.gutterPreview} className={classes.gutterPreview}
aria-label={t("layout.field.gutters.preview.label")} aria-label={t("field.gutters.preview.label")}
style={{ gridTemplateColumns: tracks }} style={{ gridTemplateColumns: tracks }}
> >
{left > 0 && <Box className={classes.previewGutter} data-side="left" />} {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 { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board"; import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
@@ -86,8 +86,8 @@ interface BoardSettingsFormProps {
} }
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => { export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n(); const t = useI18n("common");
const tSection = useScopedI18n("board.setting.section"); const tSection = useI18n("board.setting.section");
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board); const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board); const saveLayouts = useSaveLayoutsMutation(board);
@@ -156,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
form.resetDirty(canonicalValues); form.resetDirty(canonicalValues);
await revalidatePathActionAsync(`/boards/${board.name}/settings`); await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({ showSuccessNotification({
title: t("common.notification.update.success"), title: t("notification.update.success"),
message: t("common.notification.update.success"), message: t("notification.update.success"),
}); });
return canonicalValues; return canonicalValues;
} catch { } catch {
showErrorNotification({ showErrorNotification({
title: t("common.notification.update.error"), title: t("notification.update.error"),
message: t("common.notification.update.error"), message: t("notification.update.error"),
}); });
return null; return null;
} }
@@ -200,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
{form.isDirty() && ( {form.isDirty() && (
<UnsavedChangesBar> <UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}> <Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")} {t("action.discard")}
</Button> </Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}> <Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")} {t("action.saveChanges")}
</Button> </Button>
</UnsavedChangesBar> </UnsavedChangesBar>
)} )}
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common"; import { capitalize } from "@homarr/common";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardSettingsForm } from "./_settings-form"; import { BoardSettingsForm } from "./_settings-form";
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params); const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board"); const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting"); const t = await getI18n("board.setting");
if (!hasChangeAccess) { if (!hasChangeAccess) {
notFound(); notFound();
@@ -16,7 +16,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react"; import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface RequestDetails { interface RequestDetails {
widgetName: string; widgetName: string;
@@ -31,7 +31,8 @@ interface RequestDetails {
} }
export function CustomWidgetConfigurationEntry({ token }: { token: string }) { export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const t = useScopedI18n("customWidget.secretEntry"); const t = useI18n("customWidget.secretEntry");
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null); const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
@@ -120,7 +121,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
return ( return (
<Input <Input
key={kind} key={kind}
label={t(`field.${kind}`)} label={tSecret(kind)}
leftSection={<IconKey size={16} />} leftSection={<IconKey size={16} />}
value={values[kind] ?? ""} value={values[kind] ?? ""}
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))} onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
@@ -1,12 +1,12 @@
import { Box, Group, Image, Stack, Title } from "@mantine/core"; import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui"; import { IntegrationMarquee } from "@homarr/ui";
import classes from "./hero-banner.module.css"; import classes from "./hero-banner.module.css";
export const HeroBanner = async () => { export const HeroBanner = async () => {
const t = await getScopedI18n("management.page.home"); const t = await getI18n("management.page.home");
return ( return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative"> <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 { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -47,7 +47,7 @@ import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css"; import classes from "./about.module.css";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: createMetaTitle(t("metaTitle")),
@@ -55,7 +55,7 @@ export async function generateMetadata() {
} }
export default async function AboutPage() { export default async function AboutPage() {
const t = await getScopedI18n("management.page.about"); const t = await getI18n("management.page.about");
const version = getPackageVersion(); const version = getPackageVersion();
const dependencies = await getDependenciesAsync(); const dependencies = await getDependenciesAsync();
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface AppDeleteButtonProps { interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number]; app: RouterOutputs["app"]["all"][number];
} }
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => { export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete"); const t = useI18n("app.page.delete");
const tList = useScopedI18n("app.page.list.action"); const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation(); const { mutate, isPending } = clientApi.app.delete.useMutation();
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
{ {
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: t("notification.success.title"), title: tCommon("notification.delete.success"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/apps"); void revalidatePathActionAsync("/manage/apps");
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: t("notification.error.title"), title: tCommon("notification.delete.error"),
message: t("notification.error.message"), message: t("notification.error.message"),
}); });
}, },
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
); );
}, },
}); });
}, [app, mutate, t, openConfirmModal]); }, [app, mutate, t, tCommon, openConfirmModal]);
return ( return (
<ActionIcon <ActionIcon
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
color="red" color="red"
size={44} size={44}
onClick={onClick} onClick={onClick}
aria-label={tList("delete", { name: app.name })} aria-label={tCommon("action.deleteNamed", { name: app.name })}
> >
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection"; import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app"; import type { appManageSchema } from "@homarr/validation/app";
interface AppEditFormProps { interface AppEditFormProps {
@@ -17,14 +17,14 @@ interface AppEditFormProps {
} }
export const AppEditForm = ({ app }: AppEditFormProps) => { export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useScopedI18n("app.page.edit.notification"); const tScoped = useI18n("app.page.edit.notification");
const t = useI18n(); const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({ const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: tScoped("success.title"), title: tCommon("notification.update.success"),
message: tScoped("success.message"), message: tScoped("success.message"),
}); });
void revalidatePathActionAsync("/manage/apps").then(() => { void revalidatePathActionAsync("/manage/apps").then(() => {
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: tScoped("error.title"), title: tCommon("notification.update.error"),
message: tScoped("error.message"), message: tScoped("error.message"),
}); });
}, },
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
return ( return (
<AppForm <AppForm
buttonLabels={{ buttonLabels={{
submit: t("common.action.save"), submit: tCommon("action.save"),
}} }}
initialValues={app} initialValues={app}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
@@ -9,7 +9,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common"; import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { TourTarget } from "~/components/layout/header/tour-target";
@@ -52,7 +52,8 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = canManageAll const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams) ? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 }; : { items: [], totalCount: 0 };
const t = await getScopedI18n("app"); const t = await getI18n("app");
const tCommon = await getI18n("common");
const hasSearch = canManageAll && Boolean(searchParams.search?.trim()); const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
const emptyState = !canManageAll ? ( const emptyState = !canManageAll ? (
@@ -67,7 +68,7 @@ export default async function AppsPage(props: AppsPageProps) {
icon={IconBox} icon={IconBox}
title={t("page.list.noResults.filteredTitle")} title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })} description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }} action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
/> />
) : ( ) : (
<NoResults <NoResults
@@ -80,7 +81,7 @@ export default async function AppsPage(props: AppsPageProps) {
const page = ( const page = (
<ManageCollectionPage <ManageCollectionPage
title={t("page.list.title")} title={tCommon("entity.apps")}
ariaLabel={t("page.list.ariaLabel")} ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length} itemCount={apps.length}
emptyState={emptyState} emptyState={emptyState}
@@ -115,7 +116,7 @@ export default async function AppsPage(props: AppsPageProps) {
key={app.id} key={app.id}
app={app} app={app}
canDelete={canDelete} canDelete={canDelete}
editLabel={t("page.list.action.edit", { name: app.name })} editLabel={tCommon("action.editNamed", { name: app.name })}
/> />
))} ))}
</ManageCollectionPage> </ManageCollectionPage>
@@ -51,7 +51,7 @@ import {
} from "@homarr/definitions"; } from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions"; import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client"; import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
@@ -140,7 +140,8 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
); );
export const AssistantConfiguration = () => { export const AssistantConfiguration = () => {
const t = useScopedI18n("management.page.settings.section.assistant"); const t = useI18n("management.page.settings.section.assistant");
const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery(); const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
@@ -208,7 +209,7 @@ export const AssistantConfiguration = () => {
.filter((providerId) => assistantProviderPresets[providerId].category === category) .filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({ .map((providerId) => ({
value: providerId, value: providerId,
label: t(`provider.options.${providerId}.label`), label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"),
})); }));
return { group: t(`provider.groups.${category}`), items }; return { group: t(`provider.groups.${category}`), items };
@@ -372,7 +373,7 @@ export const AssistantConfiguration = () => {
</ThemeIcon> </ThemeIcon>
<Box className={classes.summaryProviderText}> <Box className={classes.summaryProviderText}>
<Group gap="xs"> <Group gap="xs">
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text> <Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text>
<Badge <Badge
size="sm" size="sm"
variant="light" variant="light"
@@ -418,7 +419,7 @@ export const AssistantConfiguration = () => {
description={t("connection.description")} description={t("connection.description")}
status={ status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}> <Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{t(`provider.options.${provider}.label`)} {assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
</Badge> </Badge>
} }
> >
@@ -758,7 +759,7 @@ export const AssistantConfiguration = () => {
{t("serverTools.webSearch.label")} {t("serverTools.webSearch.label")}
</Text> </Text>
<Badge size="xs" variant="light"> <Badge size="xs" variant="light">
{t("serverTools.beta")} {tCommon("beta")}
</Badge> </Badge>
</Group> </Group>
<Text size="sm" c="dimmed" maw="66ch"> <Text size="sm" c="dimmed" maw="66ch">
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration"; import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management.page.settings.section.assistant"); const t = await getI18n("management.page.settings.section.assistant");
return { return {
title: `${t("title")} • Homarr`, title: `${t("title")} • Homarr`,
}; };
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
notFound(); notFound();
} }
const t = await getScopedI18n("management.page.settings.section.assistant"); const t = await getI18n("management.page.settings.section.assistant");
return ( return (
<ManagePageLayout title={t("title")}> <ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch"> <Text c="dimmed" maw="70ch">
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection"; import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -28,8 +28,9 @@ interface BoardCardMenuDropdownProps {
} }
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => { export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action"); const t = useI18n("management.page.board.action");
const tCommon = useScopedI18n("common"); const tRoot = useI18n();
const tCommon = useI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession(); const { data: session } = useSession();
@@ -57,7 +58,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const handleDeletion = useCallback(() => { const handleDeletion = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: t("delete.confirm.title"), title: tCommon("action.deleteNamed", { name: board.name }),
children: t("delete.confirm.description", { children: t("delete.confirm.description", {
name: board.name, name: board.name,
}), }),
@@ -68,7 +69,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
}); });
}, },
}); });
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]); }, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
const handleSetHomeBoard = useCallback(async () => { const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id }); await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -97,7 +98,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
</Menu.Item> </Menu.Item>
{session?.user.permissions.includes("board-create") && ( {session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}> <Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")} {tRoot("board.action.duplicate.title")}
</Menu.Item> </Menu.Item>
)} )}
{hasChangeAccess && ( {hasChangeAccess && (
@@ -122,7 +123,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
onClick={handleDeletion} onClick={handleDeletion}
disabled={deleteBoardMutation.isPending} disabled={deleteBoardMutation.isPending}
> >
{t("delete.label")} {tCommon("action.delete")}
</Menu.Item> </Menu.Item>
</> </>
)} )}
@@ -10,13 +10,13 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n(); const t = useI18n("management.page.board.action");
const { openModal } = useModalAction(AddBoardModal); const { openModal } = useModalAction(AddBoardModal);
return ( return (
<ManageMobilePrimaryAction> <ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}> <Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("management.page.board.action.new.label")} {t("new.label")}
</Button> </Button>
</ManageMobilePrimaryAction> </ManageMobilePrimaryAction>
); );
@@ -25,7 +25,7 @@ import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; 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"; import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() { export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board"); const t = await getI18n("management.page.board");
const session = await auth(); const session = await auth();
const boards = await api.board.getManageOverview(); const boards = await api.board.getManageOverview();
const canCreateBoards = session?.user.permissions.includes("board-create"); const canCreateBoards = session?.user.permissions.includes("board-create");
@@ -3,12 +3,12 @@
import { Alert, Anchor, Text } from "@mantine/core"; import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react"; import { IconFlask } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets"; const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() { export function CustomWidgetBetaBanner() {
const t = useScopedI18n("customWidget.betaBanner"); const t = useI18n("customWidget.betaBanner");
return ( return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}> <Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
@@ -3,20 +3,22 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client"; import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>; type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">; type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) { export function CodeEditor(props: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor"); const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
return ( return (
<CustomWidgetCodeEditor <CustomWidgetCodeEditor
{...props} {...props}
messages={{ messages={{
languageJsx: t("language.jsx"), languageJsx: invariantTechnicalLabels.jsx,
languageJson: t("language.json"), languageJson: invariantTechnicalLabels.json,
undo: t("action.undo"), undo: tCommon("action.undo"),
redo: t("action.redo"), redo: t("action.redo"),
components: t("action.components"), components: t("action.components"),
componentSearch: t("componentReference.search"), componentSearch: t("componentReference.search"),
@@ -24,10 +26,10 @@ export function CodeEditor(props: CodeEditorProps) {
componentCount: (count) => t("componentReference.count", { count }), componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"), insertStarter: t("action.insertStarter"),
format: t("action.format"), format: t("action.format"),
copy: t("action.copy"), copy: tCommon("action.copy"),
copied: t("action.copied"), copied: t("action.copied"),
schema: t("action.schema"), schema: t("action.schema"),
schemaTab: t("reference.schema"), schemaTab: invariantTechnicalLabels.jsonSchema,
minimalTab: t("reference.minimal"), minimalTab: t("reference.minimal"),
fullTab: t("reference.full"), fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }), errors: (count) => t("status.errors", { count }),
@@ -5,7 +5,7 @@ import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
@@ -22,7 +22,7 @@ export const CopyAiPromptButton = ({
request, request,
documentationUrl, documentationUrl,
}: CopyAiPromptButtonProps) => { }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant(); const assistant = useOptionalHomarrAssistant();
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl); const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
@@ -22,7 +22,7 @@ import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } fr
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
@@ -39,7 +39,7 @@ interface WidgetRef {
} }
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation(); const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -4,14 +4,14 @@ import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core"; import { Accordion } from "@mantine/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils"; import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) { export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify( const serialized = JSON.stringify(
{ {
$schema: "homarr-custom-widget-v2", $schema: "homarr-custom-widget-v2",
@@ -4,7 +4,7 @@ import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@man
import { IconClipboard, IconSparkles } from "@tabler/icons-react"; import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
@@ -18,7 +18,7 @@ interface AiCardProps {
} }
export function CustomWidgetAiCard(props: AiCardProps) { export function CustomWidgetAiCard(props: AiCardProps) {
const t = useScopedI18n("customWidget.workbench.ai"); const t = useI18n("customWidget.workbench.ai");
return ( return (
<Accordion variant="contained"> <Accordion variant="contained">
<Accordion.Item value="ai"> <Accordion.Item value="ai">
@@ -5,7 +5,7 @@ import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react"; import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import classes from "./_custom-widget-form.module.css"; import classes from "./_custom-widget-form.module.css";
@@ -52,7 +52,8 @@ export function SaveActions({
mode: "create" | "edit"; mode: "create" | "edit";
onPreview(): void; onPreview(): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench"); const t = useI18n("customWidget.workbench");
const tCommon = useI18n("common");
return ( return (
<Group justify="space-between" wrap="wrap"> <Group justify="space-between" wrap="wrap">
<Group gap="xs"> <Group gap="xs">
@@ -77,7 +78,7 @@ export function SaveActions({
{previewPending ? t("action.previewLoading") : t("action.preview")} {previewPending ? t("action.previewLoading") : t("action.preview")}
</Button> </Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}> <Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? t("action.create") : t("action.save")} {mode === "create" ? tCommon("action.create") : tCommon("action.save")}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -22,7 +22,7 @@ import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homar
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection"; import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card"; import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
@@ -57,8 +57,9 @@ const sectionLinks = [
] as const; ] as const;
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) { export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const w = useScopedI18n("customWidget.workbench"); const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues }; const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues }); const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure"); const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
@@ -169,7 +170,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
/> />
<CustomWidgetAdvancedManifest form={form} /> <CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}> <EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} /> <TextInput label={tCommon("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} /> <Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker <IconPicker
withAsterisk={false} withAsterisk={false}
@@ -5,7 +5,7 @@ import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react"; import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog"; import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
@@ -18,20 +18,22 @@ interface CustomWidgetListProps {
} }
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => { export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const tCommon = useI18n("common");
const tEntities = useI18n("common.entity");
if (definitions.length === 0) { if (definitions.length === 0) {
return ( return (
<NoResults <NoResults
icon={IconApi} icon={IconApi}
title={t("page.list.noResults")} title={t("page.list.noResults")}
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }} action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }}
/> />
); );
} }
return ( return (
<ManageCollectionList ariaLabel={t("page.list.title")}> <ManageCollectionList ariaLabel={tEntities("customWidgets")}>
{definitions.map((definition) => ( {definitions.map((definition) => (
<CustomWidgetRow key={definition.id} widget={definition} /> <CustomWidgetRow key={definition.id} widget={definition} />
))} ))}
@@ -42,7 +44,8 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
type WidgetDef = RouterOutputs["customWidget"]["list"][number]; type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) { function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const tCommon = useI18n("common");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false); const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))]; const origins = [...new Set(widget.sources.map((source) => source.origin))];
@@ -94,7 +97,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
size="sm" size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />} leftSection={<IconPencil size={16} stroke={1.5} />}
> >
{t("action.edit")} {tCommon("action.edit")}
</Button> </Button>
)} )}
<CustomWidgetRowActions <CustomWidgetRowActions
@@ -122,14 +125,8 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
} }
/** Everything that decides whether this widget can actually run right now. */ /** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
widget, const t = useI18n("customWidget");
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget");
if (widget.migrationRequired) { if (widget.migrationRequired) {
return ( return (
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core"; import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
choices?: Array<{ label: string; value: string | number }>; choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void; onChange: (value: unknown) => void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
if (control === "switch") if (control === "switch")
return ( return (
<Switch <Switch
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
value: Array<{ label: string; value: string | number }>; value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void; onChange(value: Array<{ label: string; value: string | number }>): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
return ( return (
<Stack gap="xs"> <Stack gap="xs">
{value.map((choice, index) => ( {value.map((choice, index) => (
@@ -150,7 +150,7 @@ export function JsonValueEditor({
value: unknown; value: unknown;
onChange(value: unknown): void; onChange(value: unknown): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2); const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized); const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -16,7 +16,7 @@ import {
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core"; import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils"; import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void; onRename(currentName: string, nextName: string): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options); const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : []; const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests); const requests = parseJson(form.values.requests);
@@ -8,7 +8,7 @@ import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } fro
import type { CustomJsxRequest } from "@homarr/custom-widgets/core"; import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils"; import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
@@ -19,7 +19,7 @@ export function PreviewActionControl({
request: CustomJsxRequest & { id: string }; request: CustomJsxRequest & { id: string };
sessionId?: string; sessionId?: string;
}) { }) {
const t = useScopedI18n("customWidget.workbench.preview"); const t = useI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation(); const actionMutation = clientApi.customWidget.previewAction.useMutation();
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted(); const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
@@ -19,7 +19,7 @@ vi.mock("@homarr/api/client", () => ({
}, },
}, },
})); }));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key })); vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() })); vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn()); const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn()); const codeEditorProps = vi.hoisted(() => vi.fn());
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench"; import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display"; import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
@@ -37,8 +37,9 @@ interface PreviewPanelProps {
} }
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useScopedI18n("customWidget.workbench.preview"); const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary"); const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live"); const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation(); const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery( const journalQuery = clientApi.customWidget.previewJournal.useQuery(
@@ -155,7 +156,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
<PreviewErrorBoundary <PreviewErrorBoundary
title={errorBoundaryMessages("title")} title={errorBoundaryMessages("title")}
description={t("containedFailures")} description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")} retryLabel={actionT("tryAgain")}
resetKeys={[rendererResetKey]} resetKeys={[rendererResetKey]}
> >
<CustomJsxDisplay data={displayData} /> <CustomJsxDisplay data={displayData} />
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core"; import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { isRecord } from "./_custom-widget-form-utils"; import { isRecord } from "./_custom-widget-form-utils";
@@ -21,7 +21,7 @@ export function QueryValuesEditor({
allowParams: boolean; allowParams: boolean;
onChange(value: Record<string, unknown>): void; onChange(value: Record<string, unknown>): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const entries = Object.entries(value); const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<number, string>>({}); const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
return ( return (
@@ -150,7 +150,7 @@ export function RequestBodyEditor({
allowParams: boolean; allowParams: boolean;
onChange(value: unknown): void; onChange(value: unknown): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2); const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized); const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -3,7 +3,7 @@
import { Accordion, 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 { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils"; import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -19,7 +19,7 @@ export function CustomWidgetRequestsEditor({
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void; onRename(currentId: string, nextId: string): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests); const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : []; const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources); const sources = parseJson(form.values.sources);
@@ -3,12 +3,12 @@
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core"; import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react"; import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors"; import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) { export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
const t = useScopedI18n("customWidget.workbench.saveError"); const t = useI18n("customWidget.workbench.saveError");
if (issues.length === 0) return null; if (issues.length === 0) return null;
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n"); 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 { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input"; import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -51,7 +51,8 @@ export function CustomWidgetSourceField({
onClearSecret, onClearSecret,
onRemove, onRemove,
}: CustomWidgetSourceFieldProps) { }: CustomWidgetSourceFieldProps) {
const t = useScopedI18n("customWidget.workbench.sources"); const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl); const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type; const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
@@ -118,7 +119,7 @@ export function CustomWidgetSourceField({
<Group key={kind} align="end" wrap="nowrap"> <Group key={kind} align="end" wrap="nowrap">
<Input <Input
style={{ flex: 1 }} style={{ flex: 1 }}
label={t(`secret.${kind}`)} label={tSecret(kind)}
value={secret?.value ?? ""} value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined} placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />} leftSection={<IconKey size={15} />}
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications"; import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -21,8 +21,8 @@ export function CustomWidgetSourcesEditor({
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
definitionId?: string; definitionId?: string;
}) { }) {
const t = useScopedI18n("customWidget.workbench.sources"); const t = useI18n("customWidget.workbench.sources");
const w = useScopedI18n("customWidget.workbench"); const w = useI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation(); const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
@@ -3,7 +3,7 @@
import { Tabs } from "@mantine/core"; import { Tabs } from "@mantine/core";
import { IconApi, IconBuildingStore } from "@tabler/icons-react"; import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
const tabs = { const tabs = {
@@ -13,7 +13,7 @@ const tabs = {
/** Switches between the widgets you have installed and the community Workshop. */ /** Switches between the widgets you have installed and the community Workshop. */
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => { export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
const t = useScopedI18n("customWidget.page.tabs"); const t = useI18n("customWidget.page.tabs");
return ( return (
<Tabs value={tabs[active]}> <Tabs value={tabs[active]}>
@@ -5,7 +5,7 @@ import { Component } from "react";
import { Alert, Button, Stack, Text } from "@mantine/core"; import { Alert, Button, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface FormErrorBoundaryState { interface FormErrorBoundaryState {
error: Error | null; error: Error | null;
@@ -35,7 +35,8 @@ export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErr
} }
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) { function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const tCommon = useI18n("common");
return ( return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md"> <Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
<Stack gap="xs"> <Stack gap="xs">
@@ -46,7 +47,7 @@ function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }
{error.message} {error.message}
</Text> </Text>
<Button size="xs" variant="light" color="red" onClick={reset}> <Button size="xs" variant="light" color="red" onClick={reset}>
{t("editor.errorBoundary.retry")} {tCommon("action.tryAgain")}
</Button> </Button>
</Stack> </Stack>
</Alert> </Alert>
@@ -11,13 +11,14 @@ import {
} from "@homarr/custom-widgets/core"; } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const ImportCustomWidgetButton = () => { export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const tCommon = useI18n("common");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null); const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false); const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
@@ -38,7 +39,7 @@ export const ImportCustomWidgetButton = () => {
const result = parseCustomWidgetClipboardDetailed(text); const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) { if (!result.success) {
showErrorNotification({ showErrorNotification({
title: t("action.import"), title: tCommon("action.import"),
message: formatCustomWidgetImportIssues(result.issues), message: formatCustomWidgetImportIssues(result.issues),
}); });
return; return;
@@ -47,7 +48,7 @@ export const ImportCustomWidgetButton = () => {
}; };
window.addEventListener("paste", handlePaste); window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t]); }, [queueImport, t, tCommon]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => { const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -61,13 +62,13 @@ export const ImportCustomWidgetButton = () => {
queueImport(result.widget); queueImport(result.widget);
} catch (error) { } catch (error) {
showErrorNotification({ showErrorNotification({
title: t("action.import"), title: tCommon("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"), message: error instanceof Error ? error.message : t("notification.importError"),
}); });
} }
}); });
reader.addEventListener("error", () => { reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); showErrorNotification({ title: tCommon("action.import"), message: t("notification.importError") });
}); });
reader.readAsText(file); reader.readAsText(file);
event.target.value = ""; event.target.value = "";
@@ -81,7 +82,7 @@ export const ImportCustomWidgetButton = () => {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")} title={t("action.pasteImportHint")}
> >
{t("action.import")} {tCommon("action.import")}
</MobileAffixButton> </MobileAffixButton>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -9,7 +9,9 @@ import { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn()); const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key })); vi.mock("@homarr/translation/client", () => ({
useI18n: (namespace: string) => (key: string) => `${namespace}.${key}`,
}));
vi.mock("./_code-editor", () => ({ vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => { CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props); editorProps(props);
@@ -41,6 +43,8 @@ describe("preview JSON editor", () => {
await act(async () => (host.querySelector("button") as HTMLButtonElement).click()); await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({}); expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" })); expect(editorProps).toHaveBeenLastCalledWith(
expect.objectContaining({ error: "customWidget.workbench.builder.invalidJson" }),
);
}); });
}); });
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils"; import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -18,7 +18,7 @@ export function JsonPreviewEditor({
value: Record<string, unknown>; value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void; onChange(value: Record<string, unknown>): void;
}) { }) {
const t = useScopedI18n("customWidget.workbench.builder"); const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2); const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized); const [draft, setDraft] = useState(serialized);
const [invalid, setInvalid] = useState(false); const [invalid, setInvalid] = useState(false);
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core"; import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { import {
applyCustomWidgetAiResponse, applyCustomWidgetAiResponse,
@@ -36,14 +36,17 @@ interface FormActionsInput {
} }
export function useCustomWidgetFormActions(input: FormActionsInput) { export function useCustomWidgetFormActions(input: FormActionsInput) {
const t = useScopedI18n("customWidget"); const t = useI18n("customWidget");
const w = useScopedI18n("customWidget.workbench"); const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const createMutation = clientApi.customWidget.create.useMutation(); const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation(); const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation(); const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]); const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false); const [previewPending, setPreviewPending] = useState(false);
let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create");
const save = input.form.onSubmit(async (values) => { const save = input.form.onSubmit(async (values) => {
clearSaveIssues(input.form, saveIssues); clearSaveIssues(input.form, saveIssues);
@@ -54,7 +57,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
input.form, input.form,
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })), definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
setSaveIssues, setSaveIssues,
t("action.save"), actionLabel,
(count) => w("saveError.more", { count }), (count) => w("saveError.more", { count }),
); );
return; return;
@@ -65,7 +68,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets }); const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
await utils.customWidget.list.invalidate(); await utils.customWidget.list.invalidate();
showSuccessNotification({ showSuccessNotification({
title: t("action.create"), title: tCommon("action.create"),
message: t("notification.created", { name: values.name }), message: t("notification.created", { name: values.name }),
}); });
input.form.setInitialValues(values); input.form.setInitialValues(values);
@@ -86,18 +89,16 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
input.form.setInitialValues(values); input.form.setInitialValues(values);
input.form.resetDirty(); input.form.resetDirty();
showSuccessNotification({ showSuccessNotification({
title: t("action.save"), title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }), message: t("notification.updated", { name: values.name }),
}); });
} }
} catch (error) { } catch (error) {
const issues = extractCustomWidgetSaveIssues(error); const issues = extractCustomWidgetSaveIssues(error);
if (issues.length > 0) { if (issues.length > 0) {
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) => reportSaveIssues(input.form, issues, setSaveIssues, actionLabel, (count) => w("saveError.more", { count }));
w("saveError.more", { count }),
);
} else { } else {
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") }); showErrorNotification({ title: actionLabel, message: t("notification.updateError") });
} }
} }
}); });
@@ -8,7 +8,8 @@ import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard"; import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) { export function useUnsavedChangesGuard(isDirty: boolean) {
const t = useI18n(); const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const dirtyRef = useRef(isDirty); const dirtyRef = useRef(isDirty);
@@ -16,9 +17,9 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
dirtyRef.current = isDirty; dirtyRef.current = isDirty;
confirmNavigationRef.current = (href) => { confirmNavigationRef.current = (href) => {
openConfirmModal({ openConfirmModal({
title: t("board.action.edit.confirmLeave.title"), title: tConfirmLeave("title"),
children: t("board.action.edit.confirmLeave.message"), children: tConfirmLeave("message"),
confirmProps: { children: t("common.action.discard") }, confirmProps: { children: tCommon("action.discard") },
onConfirm: () => router.push(href), onConfirm: () => router.push(href),
}); });
}; };
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { Container, Stack, Text, Title } from "@mantine/core"; import { Container, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CustomWidgetBetaBanner } from "../_beta-banner"; import { CustomWidgetBetaBanner } from "../_beta-banner";
@@ -14,7 +14,7 @@ export default async function NewCustomWidgetPage() {
if (!session || !session.user.permissions.includes("admin")) { if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets"); redirect("/manage/custom-widgets");
} }
const t = await getScopedI18n("customWidget"); const t = await getI18n("customWidget");
return ( return (
<> <>
@@ -4,7 +4,7 @@ import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
@@ -21,11 +21,12 @@ export default async function CustomWidgetsPage() {
redirect(session ? "/" : "/auth/login"); redirect(session ? "/" : "/auth/login");
} }
const definitions = await api.customWidget.list(); const definitions = await api.customWidget.list();
const t = await getScopedI18n("customWidget"); const t = await getI18n("customWidget");
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
return ( return (
<ManagePageLayout <ManagePageLayout
title={t("page.list.title")} title={tEntities("customWidgets")}
primaryAction={ primaryAction={
<Group gap="xs"> <Group gap="xs">
<MobileAffixButton <MobileAffixButton
@@ -40,7 +41,7 @@ export default async function CustomWidgetsPage() {
</MobileAffixButton> </MobileAffixButton>
<ImportCustomWidgetButton /> <ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}> <MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")} {tCommon("action.create")}
</MobileAffixButton> </MobileAffixButton>
</Group> </Group>
} }
@@ -26,7 +26,7 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend"; import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema"; import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
@@ -45,7 +45,8 @@ import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop
const listHref = "/manage/custom-widgets"; const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) { export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
const t = useScopedI18n("workshop"); const t = useI18n("workshop");
const tCommon = useI18n("common");
const session = useWorkshopSession(); const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name); const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -231,7 +232,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
} }
> >
<Button component={Link} href={listHref} variant="default"> <Button component={Link} href={listHref} variant="default">
{t("publish.cancel")} {tCommon("action.cancel")}
</Button> </Button>
<Button <Button
size="md" size="md"
@@ -23,7 +23,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core"; import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench"; import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend"; import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema"; import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
@@ -44,7 +44,7 @@ const browseHref = "/manage/custom-widgets/workshop";
const installedHref = "/manage/custom-widgets"; const installedHref = "/manage/custom-widgets";
export function WorkshopDetail({ id }: { id: string }) { export function WorkshopDetail({ id }: { id: string }) {
const t = useScopedI18n("workshop"); const t = useI18n("workshop");
const router = useRouter(); const router = useRouter();
const session = useWorkshopSession(); const session = useWorkshopSession();
const [reportOpened, reportControls] = useDisclosure(false); const [reportOpened, reportControls] = useDisclosure(false);
@@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core"; import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react"; import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { useWorkshopQuery } from "@homarr/workshop/backend"; import { useWorkshopQuery } from "@homarr/workshop/backend";
@@ -26,7 +26,8 @@ interface WorkshopBrowseProps {
} }
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) { export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
const t = useScopedI18n("workshop"); const t = useI18n("workshop");
const tCommon = useI18n("common.action");
const session = useWorkshopSession(); const session = useWorkshopSession();
const query = useWorkshopQuery(session.client, { const query = useWorkshopQuery(session.client, {
page, page,
@@ -78,7 +79,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
title={t("emptyFilteredTitle")} title={t("emptyFilteredTitle")}
description={t("emptyFilteredDescription", { search })} description={t("emptyFilteredDescription", { search })}
action={{ action={{
label: t("clearSearch"), label: tCommon("clearSearch"),
href: "/manage/custom-widgets/workshop", href: "/manage/custom-widgets/workshop",
}} }}
/> />
@@ -122,7 +123,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
} }
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) { function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
const t = useScopedI18n("workshop"); const t = useI18n("workshop");
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core"; import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
export default function ManageErrorPage({ export default function ManageErrorPage({
error, error,
@@ -13,8 +13,8 @@ export default function ManageErrorPage({
error: Error & { digest?: string }; error: Error & { digest?: string };
unstable_retry: () => void; unstable_retry: () => void;
}) { }) {
const tError = useScopedI18n("management.error"); const tError = useI18n("management.error");
const t = useI18n(); const tCommon = useI18n("common");
useEffect(() => { useEffect(() => {
console.error(error); console.error(error);
@@ -34,7 +34,7 @@ export default function ManageErrorPage({
</Text> </Text>
)} )}
<Button variant="light" onClick={unstable_retry}> <Button variant="light" onClick={unstable_retry}>
{t("common.action.tryAgain")} {tCommon("action.tryAgain")}
</Button> </Button>
</Stack> </Stack>
</Center> </Center>
@@ -15,7 +15,7 @@ interface Props {
} }
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => { export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { data } = clientApi.integration.getIntegrationPermissions.useQuery( const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
{ {
@@ -49,7 +49,7 @@ export const IntegrationAccessSettings = ({ integration, initialPermissions }: P
}, },
groupPermissionMapping: integrationPermissionsMap, groupPermissionMapping: integrationPermissionsMap,
}} }}
translate={(key) => t(`integration.permission.${key}`)} translate={(key) => tIntegration(`permission.${key}` as never)}
query={{ query={{
data, data,
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(), invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
@@ -25,7 +25,8 @@ interface SecretCardProps {
} }
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => { export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const tCommon = useI18n("common");
const { isPublic } = integrationSecretKindObject[secret.kind]; const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false); const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
@@ -40,20 +41,20 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar> <Avatar>
<KindIcon size={16} /> <KindIcon size={16} />
</Avatar> </Avatar>
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text> <Text fw={500}>{tIntegration(`secrets.kind.${secret.kind}.label` as never)}</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null} {publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group> </Group>
<Group> <Group>
{secret.updatedAt ? ( {secret.updatedAt ? (
<Text c="gray.6" size="sm"> <Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", { {tIntegration("secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)), date: dayjs().to(dayjs(secret.updatedAt)),
})} })}
</Text> </Text>
) : ( ) : (
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left"> <Tooltip label={tIntegration("secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm"> <Badge color="orange" variant="light" size="sm">
{t("integration.secrets.notSet.label")} {tIntegration("secrets.notSet.label")}
</Badge> </Badge>
</Tooltip> </Tooltip>
)} )}
@@ -62,7 +63,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
color="gray" color="gray"
variant="subtle" variant="subtle"
onClick={togglePublicSecretDisplay} onClick={togglePublicSecretDisplay}
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")} aria-label={tCommon(publicSecretDisplayOpened ? "action.hide" : "action.show")}
> >
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -80,7 +81,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
setEditMode(false); setEditMode(false);
}} }}
> >
{editMode ? t("common.action.cancel") : t("common.action.edit")} {editMode ? tCommon("action.cancel") : tCommon("action.edit")}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -29,14 +29,14 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
}; };
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => { const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind]; const { multiline } = integrationSecretKindObject[kind];
if (multiline) { if (multiline) {
return ( return (
<Textarea <Textarea
{...props} {...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)} label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
w="100%" w="100%"
leftSection={<Icon size={20} stroke={1.5} />} leftSection={<Icon size={20} stroke={1.5} />}
autosize autosize
@@ -48,7 +48,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
return ( return (
<TextInput <TextInput
{...props} {...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)} label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
w="100%" w="100%"
leftSection={<Icon size={20} stroke={1.5} />} leftSection={<Icon size={20} stroke={1.5} />}
/> />
@@ -56,7 +56,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
}; };
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => { const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind]; const { multiline } = integrationSecretKindObject[kind];
@@ -64,8 +64,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
return ( return (
<Textarea <Textarea
{...props} {...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)} label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
description={t("integration.secrets.secureNotice")} description={tIntegration("secrets.secureNotice")}
w="100%" w="100%"
leftSection={<Icon size={20} stroke={1.5} />} leftSection={<Icon size={20} stroke={1.5} />}
autosize autosize
@@ -77,8 +77,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
return ( return (
<PasswordInput <PasswordInput
{...props} {...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)} label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
description={t("integration.secrets.secureNotice")} description={tIntegration("secrets.secureNotice")}
w="100%" w="100%"
leftSection={<Icon size={20} stroke={1.5} />} leftSection={<Icon size={20} stroke={1.5} />}
/> />
@@ -3,7 +3,7 @@ import { SegmentedControl } from "@mantine/core";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface FormType { interface FormType {
secrets: { kind: IntegrationSecretKind; value: string | null }[]; secrets: { kind: IntegrationSecretKind; value: string | null }[];
@@ -20,7 +20,7 @@ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
secretKinds, secretKinds,
form, form,
}: SecretKindsSegmentedControlProps<TFormType>) => { }: SecretKindsSegmentedControlProps<TFormType>) => {
const t = useScopedI18n("integration.secrets"); const t = useI18n("integration.secrets");
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-"); const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
const secretKindGroups = secretKinds.map((kinds) => ({ const secretKindGroups = secretKinds.map((kinds) => ({
@@ -14,7 +14,7 @@ interface IntegrationTestConnectionErrorProps {
} }
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => { export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]); const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return ( return (
@@ -22,17 +22,17 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Stack> <Stack>
<Stack gap="sm"> <Stack gap="sm">
<Text size="lg" fw={500} c="red.8"> <Text size="lg" fw={500} c="red.8">
{t(`integration.testConnection.error.${error.type}.title`)} {tIntegration(`testConnection.error.${error.type}.title` as never)}
</Text> </Text>
{error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? ( {error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? (
<Text size="md">{t(`integration.testConnection.error.${error.type}.description`)}</Text> <Text size="md">{tIntegration(`testConnection.error.${error.type}.description` as never)}</Text>
) : null} ) : null}
{error.type === "request" ? ( {error.type === "request" ? (
<Text size="md"> <Text size="md">
{t( {tIntegration(
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never, `testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
)} )}
</Text> </Text>
) : null} ) : null}
@@ -40,7 +40,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{error.type === "statusCode" ? ( {error.type === "statusCode" ? (
error.data.reason === "other" ? ( error.data.reason === "other" ? (
<Text size="md"> <Text size="md">
{t.rich("integration.testConnection.error.statusCode.otherDescription", { {tIntegration.rich("testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(), statusCode: error.data.statusCode.toString(),
url: () => ( url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer"> <Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
@@ -51,8 +51,8 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
</Text> </Text>
) : ( ) : (
<Text size="md"> <Text size="md">
{t.rich("integration.testConnection.error.statusCode.description", { {tIntegration.rich("testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`), reason: tIntegration(`testConnection.error.statusCode.reason.${error.data.reason}` as never),
statusCode: error.data.statusCode.toString(), statusCode: error.data.statusCode.toString(),
url: () => ( url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer"> <Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
@@ -71,7 +71,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Accordion variant="contained"> <Accordion variant="contained">
<Accordion.Item value="cause"> <Accordion.Item value="cause">
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}> <Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
{t("integration.testConnection.error.common.cause.title")} {tIntegration("testConnection.error.common.cause.title")}
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
@@ -9,7 +9,7 @@ import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals"; import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection"; import { AddCertificateModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n, useScopedI18n } from "@homarr/translation/client"; import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types"; import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
@@ -19,7 +19,7 @@ interface CertificateErrorDetailsProps {
} }
export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => { export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => {
const tError = useScopedI18n("integration.testConnection.error"); const tError = useI18n("integration.testConnection.error");
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false; const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false); const [showRetryButton, setShowRetryButton] = useState(false);
@@ -160,46 +160,35 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
}; };
const NotEnoughPermissionsAlert = () => { const NotEnoughPermissionsAlert = () => {
const t = useI18n(); const t = useI18n("integration.testConnection.error.certificate.alert.permission");
return ( return (
<Alert <Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
icon={<IconAlertTriangle size={16} />} {t("message")}
title={t("integration.testConnection.error.certificate.alert.permission.title")}
color="yellow"
>
{t("integration.testConnection.error.certificate.alert.permission.message")}
</Alert> </Alert>
); );
}; };
const HostnameMismatchAlert = () => { const HostnameMismatchAlert = () => {
const t = useI18n(); const t = useI18n("integration.testConnection.error.certificate.alert.hostnameMismatch");
return ( return (
<Alert <Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
icon={<IconAlertTriangle size={16} />} {t("message")}
title={t("integration.testConnection.error.certificate.alert.hostnameMismatch.title")}
color="yellow"
>
{t("integration.testConnection.error.certificate.alert.hostnameMismatch.message")}
</Alert> </Alert>
); );
}; };
const CertificateExtractAlert = () => { const CertificateExtractAlert = () => {
const t = useI18n(); const t = useI18n("integration.testConnection.error.certificate.alert.extract");
const tCommon = useI18n("common");
return ( return (
<Alert <Alert icon={<IconExclamationCircle size={16} />} title={t("title")} color="red">
icon={<IconExclamationCircle size={16} />} {t.rich("message", {
title={t("integration.testConnection.error.certificate.alert.extract.title")}
color="red"
>
{t.rich("integration.testConnection.error.certificate.alert.extract.message", {
docsLink: () => ( docsLink: () => (
<Anchor <Anchor
href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")} href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")}
target="_blank" target="_blank"
> >
{t("common.here")} {tCommon("here")}
</Anchor> </Anchor>
), ),
})} })}
@@ -214,8 +203,8 @@ interface CertificateDetailsProps {
export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => { export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => {
const { openModal } = useModalAction(PemContentModal); const { openModal } = useModalAction(PemContentModal);
const locale = useCurrentLocale(); const locale = useCurrentLocale();
const tDetails = useScopedI18n("integration.testConnection.error.certificate.details"); const tDetails = useI18n("integration.testConnection.error.certificate.details");
const tCertificateField = useScopedI18n("certificate.field"); const tCertificateField = useI18n("certificate.field");
return ( return (
<Card> <Card>
@@ -291,7 +280,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
}; };
const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => { const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => {
const t = useI18n(); const tCommon = useI18n("common");
return ( return (
<Stack> <Stack>
@@ -318,7 +307,7 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
</Card> </Card>
<Button variant="light" color="gray" onClick={actions.closeModal}> <Button variant="light" color="gray" onClick={actions.closeModal}>
{t("common.action.close")} {tCommon("action.close")}
</Button> </Button>
</Stack> </Stack>
); );
@@ -7,15 +7,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface DeleteIntegrationActionButtonProps { interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string }; integration: { id: string; name: string };
} }
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => { export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete"); const t = useI18n("integration.page.delete");
const tList = useScopedI18n("integration.page.list.action"); const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation(); const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
@@ -36,7 +36,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
{ {
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: t("notification.success.title"), title: tCommon("notification.delete.success"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/integrations"); void revalidatePathActionAsync("/manage/integrations");
@@ -44,7 +44,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: t("notification.error.title"), title: tCommon("notification.delete.error"),
message: t("notification.error.message"), message: t("notification.error.message"),
}); });
}, },
@@ -53,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
}, },
}); });
}} }}
aria-label={tList("delete", { name: integration.name })} aria-label={tCommon("action.deleteNamed", { name: integration.name })}
> >
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -10,7 +10,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import { getAllSecretKindOptions, getDefaultSecretKinds, invariantTechnicalLabels } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection"; import { AppSelectModal } from "@homarr/modals-collection";
@@ -56,7 +56,8 @@ export const EditIntegrationForm = ({
onSuccess, onSuccess,
formRef, formRef,
}: EditIntegrationFormProps) => { }: EditIntegrationFormProps) => {
const t = useI18n(); const tCommon = useI18n("common");
const tIntegration = useI18n("integration");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind); const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -83,8 +84,8 @@ export const EditIntegrationForm = ({
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({ const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
onError() { onError() {
showErrorNotification({ showErrorNotification({
title: t("integration.page.edit.notification.error.title"), title: tCommon("notification.update.error"),
message: t("integration.page.edit.notification.error.message"), message: tIntegration("page.edit.notification.error.message"),
}); });
}, },
}); });
@@ -103,8 +104,8 @@ export const EditIntegrationForm = ({
: values.url; : values.url;
} catch { } catch {
showErrorNotification({ showErrorNotification({
title: t("integration.page.edit.notification.error.title"), title: tCommon("notification.update.error"),
message: t("integration.page.edit.notification.error.message"), message: tIntegration("page.edit.notification.error.message"),
}); });
return false; return false;
} }
@@ -124,16 +125,16 @@ export const EditIntegrationForm = ({
if (data?.error) { if (data?.error) {
setError(data.error); setError(data.error);
showErrorNotification({ showErrorNotification({
title: t("integration.page.edit.notification.error.title"), title: tCommon("notification.update.error"),
message: t("integration.page.edit.notification.error.message"), message: tIntegration("page.edit.notification.error.message"),
}); });
requestAnimationFrame(() => errorRef.current?.focus()); requestAnimationFrame(() => errorRef.current?.focus());
return false; return false;
} }
showSuccessNotification({ showSuccessNotification({
title: t("integration.page.edit.notification.success.title"), title: tCommon("notification.update.success"),
message: t("integration.page.edit.notification.success.message"), message: tIntegration("page.edit.notification.success.message"),
}); });
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]); void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
onSuccess?.(); onSuccess?.();
@@ -142,7 +143,18 @@ export const EditIntegrationForm = ({
} }
return true; return true;
}, },
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget], [
hasUrlSecret,
hideButtons,
integration.id,
mutateAsync,
onSuccess,
router,
tCommon,
tIntegration,
utils.integration,
utils.widget,
],
); );
useImperativeHandle( useImperativeHandle(
@@ -172,13 +184,13 @@ export const EditIntegrationForm = ({
const formFields = ( const formFields = (
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <TextInput withAsterisk label={tCommon("field.name")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={invariantTechnicalLabels.url} {...form.getInputProps("url")} />
)} )}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={tIntegration("secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{allSecretKinds.length > 1 && ( {allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} /> <SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
@@ -196,8 +208,8 @@ export const EditIntegrationForm = ({
return resolve(true); return resolve(true);
} }
openConfirmModal({ openConfirmModal({
title: t("integration.secrets.reset.title"), title: tIntegration("secrets.reset.title"),
children: t("integration.secrets.reset.message"), children: tIntegration("secrets.reset.message"),
onCancel: () => resolve(false), onCancel: () => resolve(false),
onConfirm: () => { onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? ""); form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
@@ -208,7 +220,7 @@ export const EditIntegrationForm = ({
} }
> >
<IntegrationSecretInput <IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)} label={tIntegration(`secrets.kind.${secret.kind}.newLabel` as never)}
key={secret.kind} key={secret.kind}
kind={secret.kind} kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
@@ -227,7 +239,7 @@ export const EditIntegrationForm = ({
))} ))}
{form.values.secrets.length === 0 && ( {form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}> <Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> <Text c={"blue"}>{tIntegration("secrets.noSecretsRequired.text")}</Text>
</Alert> </Alert>
)} )}
</Stack> </Stack>
@@ -244,10 +256,10 @@ export const EditIntegrationForm = ({
{!hideButtons && ( {!hideButtons && (
<Group justify="end" align="center"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {tCommon("action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")} {tIntegration("testConnection.action.edit")}
</Button> </Button>
</Group> </Group>
)} )}
@@ -270,7 +282,8 @@ interface IntegrationAppSelectProps {
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => { const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
const { openModal } = useModalAction(AppSelectModal); const { openModal } = useModalAction(AppSelectModal);
const t = useI18n(); const tIntegration = useI18n("integration");
const tCommon = useI18n("common");
const { data: session } = useSession(); const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false; const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
@@ -281,7 +294,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
withCreate: canCreateApps, withCreate: canCreateApps,
}, },
{ {
title: t("integration.page.edit.app.action.select"), title: tIntegration("page.edit.app.action.select"),
}, },
); );
@@ -294,13 +307,13 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
fullWidth fullWidth
onClick={handleChange} onClick={handleChange}
> >
{t("integration.page.edit.app.action.add")} {tIntegration("page.edit.app.action.add")}
</Button> </Button>
); );
} }
return ( return (
<Fieldset legend={t("integration.field.app.sectionTitle")}> <Fieldset legend={tIntegration("field.app.sectionTitle")}>
<Group justify="space-between"> <Group justify="space-between">
<Group gap="sm"> <Group gap="sm">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -323,7 +336,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
leftSection={<IconUnlink size={16} stroke={1.5} />} leftSection={<IconUnlink size={16} stroke={1.5} />}
onClick={() => onChange(null)} onClick={() => onChange(null)}
> >
{t("integration.page.edit.app.action.remove")} {tIntegration("page.edit.app.action.remove")}
</Button> </Button>
<Button <Button
variant="subtle" variant="subtle"
@@ -331,7 +344,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
leftSection={<IconPencil size={16} stroke={1.5} />} leftSection={<IconPencil size={16} stroke={1.5} />}
onClick={handleChange} onClick={handleChange}
> >
{t("common.action.change")} {tCommon("action.change")}
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</Group> </Group>
@@ -2,7 +2,7 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -16,7 +16,7 @@ interface EditIntegrationPageProps {
export default async function EditIntegrationPage(props: EditIntegrationPageProps) { export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
const params = await props.params; const params = await props.params;
const editT = await getScopedI18n("integration.page.edit"); const editT = await getI18n("integration.page.edit");
const t = await getI18n(); const t = await getI18n();
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound); const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id }); const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
@@ -31,6 +31,7 @@ import {
getIntegrationApiKeyUrl, getIntegrationApiKeyUrl,
getIntegrationDefaultUrl, getIntegrationDefaultUrl,
getIntegrationName, getIntegrationName,
invariantTechnicalLabels,
integrationDefs, integrationDefs,
} from "@homarr/definitions"; } from "@homarr/definitions";
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form"; import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
@@ -65,7 +66,8 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
); );
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => { export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
const t = useI18n(); const tCommon = useI18n("common");
const tIntegration = useI18n("integration");
const secretKinds = getAllSecretKindOptions(kind); const secretKinds = getAllSecretKindOptions(kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const { data: session } = useSession(); const { data: session } = useSession();
@@ -74,7 +76,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
? formSchema ? formSchema
: formSchema.superRefine((values, context) => { : formSchema.superRefine((values, context) => {
if (!values.hasApp || values.appId !== null) return; if (!values.hasApp || values.appId !== null) return;
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] }); context.addIssue({ code: "custom", message: tCommon("zod.errors.required"), path: ["appId"] });
}); });
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
@@ -116,15 +118,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
if (data && "error" in data && data.error) { if (data && "error" in data && data.error) {
setError(data.error); setError(data.error);
showErrorNotification({ showErrorNotification({
title: t("integration.page.create.notification.error.title"), title: tCommon("notification.create.error"),
message: t("integration.page.create.notification.error.message"), message: tIntegration("page.create.notification.error.message"),
}); });
return; return;
} }
showSuccessNotification({ showSuccessNotification({
title: t("integration.page.create.notification.success.title"), title: tCommon("notification.create.success"),
message: t("integration.page.create.notification.success.message"), message: tIntegration("page.create.notification.success.message"),
}); });
onSuccess(data && "integration" in data ? data : undefined); onSuccess(data && "integration" in data ? data : undefined);
@@ -132,8 +134,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const onMutationError = () => { const onMutationError = () => {
showErrorNotification({ showErrorNotification({
title: t("integration.page.create.notification.error.title"), title: tCommon("notification.create.error"),
message: t("integration.page.create.notification.error.message"), message: tIntegration("page.create.notification.error.message"),
}); });
}; };
@@ -166,13 +168,13 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} /> <TextInput withAsterisk label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={invariantTechnicalLabels.url} {...form.getInputProps("url")} />
)} )}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={tIntegration("secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />} {secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
{form.values.secrets.map(({ kind }, index) => ( {form.values.secrets.map(({ kind }, index) => (
@@ -185,7 +187,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
))} ))}
{form.values.secrets.length === 0 && ( {form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}> <Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> <Text c={"blue"}>{tIntegration("secrets.noSecretsRequired.text")}</Text>
</Alert> </Alert>
)} )}
<ApiKeySettingsLink kind={kind} url={form.values.url} /> <ApiKeySettingsLink kind={kind} url={form.values.url} />
@@ -196,8 +198,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
{supportsSearchEngine && ( {supportsSearchEngine && (
<Checkbox <Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")} label={tIntegration("field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", { description={tIntegration("field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(kind), kind: getIntegrationName(kind),
})} })}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })} {...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
@@ -209,15 +211,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
<Group justify="end" align="center"> <Group justify="end" align="center">
{onCancel ? ( {onCancel ? (
<Button variant="default" onClick={onCancel}> <Button variant="default" onClick={onCancel}>
{t("common.action.backToOverview")} {tCommon("action.backToOverview")}
</Button> </Button>
) : ( ) : (
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {tCommon("action.backToOverview")}
</Button> </Button>
)} )}
<Button type="submit" loading={isCreatePending}> <Button type="submit" loading={isCreatePending}>
{t("integration.testConnection.action.create")} {tIntegration("testConnection.action.create")}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -228,7 +230,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
type FormType = z.infer<typeof formSchema>; type FormType = z.infer<typeof formSchema>;
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => { const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" }); const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
return ( return (
@@ -242,20 +244,18 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
checkboxInputProps.onChange(event); checkboxInputProps.onChange(event);
}); });
}} }}
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")} label={tIntegration(canCreateApps ? "field.createApp.label" : "field.linkApp.label")}
description={t( description={tIntegration(canCreateApps ? "field.createApp.description" : "field.linkApp.description")}
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
)}
/> />
<Collapse expanded={form.values.hasApp}> <Collapse expanded={form.values.hasApp}>
<Fieldset legend={t("integration.field.app.sectionTitle")}> <Fieldset legend={tIntegration("field.app.sectionTitle")}>
<Stack gap="sm"> <Stack gap="sm">
{canCreateApps && ( {canCreateApps && (
<SegmentedControl <SegmentedControl
data={(["new", "existing"] as const).map((value) => ({ data={(["new", "existing"] as const).map((value) => ({
value, value,
label: t(`integration.page.create.app.option.${value}.title`), label: tIntegration(`page.create.app.option.${value}.title` as never),
}))} }))}
value={form.values.appHref === null ? "existing" : "new"} value={form.values.appHref === null ? "existing" : "new"}
onChange={(value) => { onChange={(value) => {
@@ -272,10 +272,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
{typeof form.values.appHref === "string" && canCreateApps ? ( {typeof form.values.appHref === "string" && canCreateApps ? (
<TextInput <TextInput
placeholder={t("integration.field.appHref.placeholder")} placeholder={tIntegration("field.appHref.placeholder")}
withAsterisk withAsterisk
label={t("integration.page.create.app.option.new.url.label")} label={tIntegration("page.create.app.option.new.url.label")}
description={t("integration.page.create.app.option.new.url.description")} description={tIntegration("page.create.app.option.new.url.description")}
{...form.getInputProps("appHref")} {...form.getInputProps("appHref")}
/> />
) : ( ) : (
@@ -302,7 +302,7 @@ const normalizeUrl = (raw: string): string | null => {
}; };
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => { const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
const t = useI18n(); const tIntegration = useI18n("integration");
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind); const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
if (!apiKeyUrl) return null; if (!apiKeyUrl) return null;
@@ -315,7 +315,7 @@ const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm"> <Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
<Group gap={4}> <Group gap={4}>
<IconKey size={14} stroke={1.5} /> <IconKey size={14} stroke={1.5} />
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text> <Text size="sm">{tIntegration("field.apiKeySettings.label")}</Text>
<IconExternalLink size={14} stroke={1.5} /> <IconExternalLink size={14} stroke={1.5} />
</Group> </Group>
</Anchor> </Anchor>
@@ -332,14 +332,14 @@ type IntegrationAppSelectProps = Modify<
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => { const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
const { data, isPending } = clientApi.app.selectable.useQuery(); const { data, isPending } = clientApi.app.selectable.useQuery();
const t = useI18n(); const tIntegration = useI18n("integration");
const appMap = new Map(data?.map((app) => [app.id, app] as const)); const appMap = new Map(data?.map((app) => [app.id, app] as const));
return ( return (
<Select <Select
withAsterisk withAsterisk
label={t("integration.page.create.app.option.existing.label")} label={tIntegration("page.create.app.option.existing.label")}
searchable searchable
clearable clearable
leftSection={ leftSection={
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -30,7 +30,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
const result = z.enum(integrationKinds).safeParse(searchParams.kind); const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
const t = await getScopedI18n("integration"); const t = await getI18n("integration");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
@@ -44,7 +44,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
); );
} }
const tCreate = await getScopedI18n("integration.page.create"); const tCreate = await getI18n("integration.page.create");
const currentKind = result.data; const currentKind = result.data;
@@ -8,7 +8,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getSafeApplicationUrl } from "@homarr/common"; import { getSafeApplicationUrl } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui"; import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { TourTarget } from "~/components/layout/header/tour-target";
@@ -37,7 +37,8 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
} = getIntegrationsSectionAccess(session, integrations); } = getIntegrationsSectionAccess(session, integrations);
if (!canAccess) notFound(); if (!canAccess) notFound();
const t = await getScopedI18n("integration"); const t = await getI18n("integration");
const tCommon = await getI18n("common");
// Without integration-full-all only the integrations that were explicitly delegated to the user // Without integration-full-all only the integrations that were explicitly delegated to the user
// are manageable, so the list never shows an integration they cannot open. // are manageable, so the list never shows an integration they cannot open.
const manageableIntegrations = integrations.filter( const manageableIntegrations = integrations.filter(
@@ -61,7 +62,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const page = ( const page = (
<ManageCollectionPage <ManageCollectionPage
title={t("page.list.title")} title={tCommon("entity.integrations")}
ariaLabel={t("page.list.ariaLabel")} ariaLabel={t("page.list.ariaLabel")}
itemCount={filteredIntegrations.length} itemCount={filteredIntegrations.length}
emptyState={ emptyState={
@@ -77,7 +78,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
icon={IconPlugX} icon={IconPlugX}
title={t("page.list.noResults.filteredTitle")} title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })} description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/integrations" }} action={{ label: tCommon("action.clearSearch"), href: "/manage/integrations" }}
/> />
) : ( ) : (
<NoResults <NoResults
@@ -139,7 +140,7 @@ interface IntegrationItemProps {
} }
const IntegrationItem = async ({ integration }: IntegrationItemProps) => { const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
const t = await getScopedI18n("integration"); const tCommon = await getI18n("common");
const kindName = getIntegrationName(integration.kind); const kindName = getIntegrationName(integration.kind);
const safeUrl = getSafeApplicationUrl(integration.url); const safeUrl = getSafeApplicationUrl(integration.url);
@@ -178,7 +179,7 @@ const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
variant="subtle" variant="subtle"
color="gray" color="gray"
size={44} size={44}
aria-label={t("page.list.action.edit", { name: integration.name })} aria-label={tCommon("action.editNamed", { name: integration.name })}
> >
<IconPencil size={18} stroke={1.5} /> <IconPencil size={18} stroke={1.5} />
</ActionIcon> </ActionIcon>
+16 -15
View File
@@ -35,7 +35,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
@@ -61,8 +61,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
return false; return false;
} }
}); });
const [t, session, shouldRunManageTour] = await Promise.all([ const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
getScopedI18n("management.navbar"), getI18n("management.navbar"),
getI18n("common.entity"),
sessionPromise, sessionPromise,
shouldRunManageTourPromise, shouldRunManageTourPromise,
]); ]);
@@ -78,13 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
icon: IconLayoutDashboardFilled, icon: IconLayoutDashboardFilled,
href: "/manage/boards", href: "/manage/boards",
label: t("items.boards"), label: tEntities("boards"),
"data-onboarding-tour-id": "manage-boards", "data-onboarding-tour-id": "manage-boards",
}, },
{ {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: tEntities("apps"),
hidden: !appsAccess.canAccess, hidden: !appsAccess.canAccess,
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
@@ -94,20 +95,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
icon: IconAffiliateFilled, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: tEntities("integrations"),
hidden: !integrationsAccess.canAccess, hidden: !integrationsAccess.canAccess,
"data-onboarding-tour-id": "manage-integrations", "data-onboarding-tour-id": "manage-integrations",
}, },
{ {
icon: IconApi, icon: IconApi,
href: "/manage/custom-widgets", href: "/manage/custom-widgets",
label: t("items.customWidgets"), label: tEntities("customWidgets"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
icon: IconSearch, icon: IconSearch,
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: tEntities("searchEngines"),
hidden: !session?.user.permissions.includes("search-engine-create"), hidden: !session?.user.permissions.includes("search-engine-create"),
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
@@ -117,13 +118,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
icon: IconPhotoFilled, icon: IconPhotoFilled,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: tEntities("media"),
hidden: !session?.user.permissions.includes("media-upload"), hidden: !session?.user.permissions.includes("media-upload"),
"data-onboarding-tour-id": "manage-medias", "data-onboarding-tour-id": "manage-medias",
}, },
{ {
icon: IconUserFilled, icon: IconUserFilled,
label: t("items.users.label"), label: tEntities("users"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users", "data-onboarding-tour-id": "manage-users",
items: [ items: [
@@ -133,13 +134,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/users", href: "/manage/users",
}, },
{ {
label: t("items.users.items.invites"), label: tEntities("invites"),
icon: IconMailForward, icon: IconMailForward,
href: "/manage/users/invites", href: "/manage/users/invites",
hidden: !isProviderEnabled("credentials"), hidden: !isProviderEnabled("credentials"),
}, },
{ {
label: t("items.users.items.groups"), label: tEntities("groups"),
icon: IconUsersGroup, icon: IconUsersGroup,
href: "/manage/users/groups", href: "/manage/users/groups",
}, },
@@ -170,19 +171,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.logs"), label: tEntities("logs"),
icon: IconBrandTablerFilled, icon: IconBrandTablerFilled,
href: "/manage/tools/logs", href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"), hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{ {
label: t("items.tools.items.certificates"), label: tEntities("certificates"),
icon: IconCertificate, icon: IconCertificate,
href: "/manage/tools/certificates", href: "/manage/tools/certificates",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.tasks"), label: tEntities("tasks"),
icon: IconClipboardListFilled, icon: IconClipboardListFilled,
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
@@ -11,7 +11,7 @@ interface CopyMediaProps {
} }
export const CopyMedia = ({ media }: CopyMediaProps) => { export const CopyMedia = ({ media }: CopyMediaProps) => {
const t = useI18n(); const t = useI18n("media");
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : ""; const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
@@ -23,7 +23,7 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
return ( return (
<CopyButton value={url}> <CopyButton value={url}>
{({ copy, copied }) => ( {({ copy, copied }) => (
<Tooltip label={t("media.action.copy.label")} openDelay={500}> <Tooltip label={t("action.copy.label")} openDelay={500}>
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle"> <ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />} {copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
</ActionIcon> </ActionIcon>
@@ -15,13 +15,13 @@ interface DeleteMediaProps {
export const DeleteMedia = ({ media }: DeleteMediaProps) => { export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const t = useI18n(); const t = useI18n("media");
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation(); const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onClick = () => { const onClick = () => {
openConfirmModal({ openConfirmModal({
title: t("media.action.delete.label"), title: t("action.delete.label"),
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }), children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
onConfirm: async () => { onConfirm: async () => {
await mutateAsync({ id: media.id }); await mutateAsync({ id: media.id });
@@ -31,7 +31,7 @@ export const DeleteMedia = ({ media }: DeleteMediaProps) => {
}; };
return ( return (
<Tooltip label={t("media.action.delete.label")} openDelay={500}> <Tooltip label={t("action.delete.label")} openDelay={500}>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}> <ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -8,7 +8,7 @@ import { UploadMedia } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
export const UploadMediaButton = () => { export const UploadMediaButton = () => {
const t = useI18n(); const t = useI18n("media");
const onSettledAsync = async () => { const onSettledAsync = async () => {
await revalidatePathActionAsync("/manage/medias"); await revalidatePathActionAsync("/manage/medias");
}; };
@@ -17,7 +17,7 @@ export const UploadMediaButton = () => {
<UploadMedia onSettled={onSettledAsync} multiple> <UploadMedia onSettled={onSettledAsync} multiple>
{({ onClick, loading }) => ( {({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}> <Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")} {t("action.upload.label")}
</Button> </Button>
)} )}
</UploadMedia> </UploadMedia>
@@ -54,14 +54,15 @@ export default async function MediaListPage(props: MediaListPageProps) {
return notFound(); return notFound();
} }
const t = await getI18n(); const tMedia = await getI18n("media");
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const canUpload = session.user.permissions.includes("media-upload"); const canUpload = session.user.permissions.includes("media-upload");
return ( return (
<ManagePageLayout <ManagePageLayout
title={t("media.plural")} title={tEntities("media")}
primaryAction={ primaryAction={
canUpload ? ( canUpload ? (
<ManageMobilePrimaryAction> <ManageMobilePrimaryAction>
@@ -72,8 +73,8 @@ export default async function MediaListPage(props: MediaListPageProps) {
toolbar={ toolbar={
<Group> <Group>
<SearchInput <SearchInput
placeholder={`${t("media.search")}...`} placeholder={`${tMedia("search")}...`}
ariaLabel={t("media.search")} ariaLabel={tMedia("search")}
defaultValue={searchParams.search} defaultValue={searchParams.search}
/> />
{session.user.permissions.includes("media-view-all") && ( {session.user.permissions.includes("media-view-all") && (
@@ -84,15 +85,15 @@ export default async function MediaListPage(props: MediaListPageProps) {
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />} footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canUpload} floatingPrimaryAction={canUpload}
> >
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />} {medias.length === 0 && <NoResults icon={IconPhoto} title={tMedia("noResults.title")} />}
{medias.length > 0 && ( {medias.length > 0 && (
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh></TableTh> <TableTh></TableTh>
<TableTh>{t("media.field.name")}</TableTh> <TableTh>{tCommon("field.name")}</TableTh>
<TableTh>{t("media.field.size")}</TableTh> <TableTh>{tMedia("field.size")}</TableTh>
<TableTh>{t("media.field.creator")}</TableTh> <TableTh>{tMedia("field.creator")}</TableTh>
<TableTh></TableTh> <TableTh></TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -113,7 +114,7 @@ interface RowProps {
const Row = async ({ media }: RowProps) => { const Row = async ({ media }: RowProps) => {
const session = await auth(); const session = await auth();
const t = await getI18n(); const tMedia = await getI18n("media");
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all"); const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return ( return (
@@ -145,7 +146,7 @@ const Row = async ({ media }: RowProps) => {
<TableTd w={64}> <TableTd w={64}>
<Group wrap="nowrap" gap="xs"> <Group wrap="nowrap" gap="xs">
<CopyMedia media={media} /> <CopyMedia media={media} />
<Tooltip label={t("media.action.open.label")} openDelay={500}> <Tooltip label={tMedia("action.open.label")} openDelay={500}>
<ActionIcon <ActionIcon
component="a" component="a"
href={createLocalImageUrl(media.id)} href={createLocalImageUrl(media.id)}
@@ -1,10 +1,10 @@
import { Anchor, Center, Stack, Text, Title } from "@mantine/core"; import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
export default async function NotFound() { export default async function NotFound() {
const t = await getScopedI18n("management.notFound"); const t = await getI18n("management.notFound");
return ( return (
<Center h="100%"> <Center h="100%">
<Stack align="center"> <Stack align="center">
+41 -24
View File
@@ -3,7 +3,7 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -11,7 +11,7 @@ import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner"; import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getScopedI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: createMetaTitle(t("metaTitle")),
@@ -20,7 +20,21 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function ManagementPage() { export default async function ManagementPage() {
const statistics = await api.home.getStats(); const statistics = await api.home.getStats();
const t = await getScopedI18n("management.page.home"); const t = await getI18n("management.page.home");
const tEntities = await getI18n("common.entity");
const statisticEntities = {
app: "apps",
board: "boards",
group: "groups",
integration: "integrations",
invite: "invites",
media: "media",
searchEngine: "searchEngines",
user: "users",
} as const;
const getStatisticTitle = (titleKey: (typeof statistics)[number]["titleKey"]) => {
return tEntities(statisticEntities[titleKey]);
};
return ( return (
<> <>
@@ -28,29 +42,32 @@ export default async function ManagementPage() {
<HeroBanner /> <HeroBanner />
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => ( {statistics.map((statistic) => {
<Card const title = getStatisticTitle(statistic.titleKey);
component={Link} return (
href={statistic.path} <Card
key={statistic.path} component={Link}
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`} href={statistic.path}
> key={statistic.path}
<Group justify="space-between" wrap="nowrap"> aria-label={`${statistic.count} ${title}`}
<Group wrap="nowrap"> >
<Text size="xl" fw={700}> <Group justify="space-between" wrap="nowrap">
{statistic.count} <Group wrap="nowrap">
</Text> <Text size="xl" fw={700}>
<Stack gap={0}> {statistic.count}
<Text c="dimmed" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)}
</Text> </Text>
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text> <Stack gap={0}>
</Stack> <Text c="dimmed" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)}
</Text>
<Text fw={600}>{title}</Text>
</Stack>
</Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
</Group> </Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" /> </Card>
</Group> );
</Card> })}
))}
</SimpleGrid> </SimpleGrid>
</> </>
); );
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions"; import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection"; import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation"; import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { searchEngineManageSchema } from "@homarr/validation/search-engine"; import { searchEngineManageSchema } from "@homarr/validation/search-engine";
@@ -17,7 +17,7 @@ import { searchEngineManageSchema } from "@homarr/validation/search-engine";
type FormType = z.infer<typeof searchEngineManageSchema>; type FormType = z.infer<typeof searchEngineManageSchema>;
interface SearchEngineFormProps { interface SearchEngineFormProps {
submitButtonTranslation: (t: TranslationFunction) => string; submitButtonTranslation: (t: ScopedTranslationFunction<"common">) => string;
initialValues?: FormType; initialValues?: FormType;
handleSubmit: (values: FormType) => void; handleSubmit: (values: FormType) => void;
isPending: boolean; isPending: boolean;
@@ -26,7 +26,8 @@ interface SearchEngineFormProps {
export const SearchEngineForm = (props: SearchEngineFormProps) => { export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props; const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n(); const t = useI18n("search.engine");
const tCommon = useI18n("common");
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery(); const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
@@ -46,14 +47,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
<Stack> <Stack>
<Grid> <Grid>
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}> <Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} /> <TextInput {...form.getInputProps("name")} withAsterisk label={tCommon("field.name")} />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}> <Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
<TextInput <TextInput
{...form.getInputProps("short")} {...form.getInputProps("short")}
disabled={disableShort} disabled={disableShort}
withAsterisk withAsterisk
label={t("search.engine.field.short.label")} label={t("field.short.label")}
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
@@ -62,12 +63,12 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
suggestedSearch={initialValues === undefined ? form.values.name : undefined} suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/> />
<Fieldset legend={t("search.engine.page.edit.configControl")}> <Fieldset legend={t("page.edit.configControl")}>
<SegmentedControl <SegmentedControl
data={searchEngineTypes.map( data={searchEngineTypes.map(
(type) => (type) =>
({ ({
label: t(`search.engine.page.edit.searchEngineType.${type}`), label: t(`page.edit.searchEngineType.${type}` as never),
value: type, value: type,
}) satisfies SegmentedControlItem, }) satisfies SegmentedControlItem,
)} )}
@@ -76,11 +77,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/> />
{form.values.type === "generic" && ( {form.values.type === "generic" && (
<TextInput <TextInput {...form.getInputProps("urlTemplate")} withAsterisk label={t("field.urlTemplate.label")} />
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
)} )}
{form.values.type === "fromIntegration" && ( {form.values.type === "fromIntegration" && (
@@ -95,14 +92,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
)} )}
</Fieldset> </Fieldset>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} /> <Textarea {...form.getInputProps("description")} label={t("field.description.label")} />
<Group justify="end"> <Group justify="end">
<Button variant="default" component={Link} href="/manage/search-engines"> <Button variant="default" component={Link} href="/manage/search-engines">
{t("common.action.backToOverview")} {tCommon("action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{submitButtonTranslation(t)} {submitButtonTranslation(tCommon)}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface SearchEngineDeleteButtonProps { interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number]; searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
} }
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => { export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useScopedI18n("search.engine.page.delete"); const t = useI18n("search.engine.page.delete");
const tList = useScopedI18n("search.engine.page.list.action"); const actionT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation(); const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
@@ -57,7 +57,7 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
color="red" color="red"
size={44} size={44}
onClick={onClick} onClick={onClick}
aria-label={tList("delete", { name: searchEngine.name })} aria-label={actionT("deleteNamed", { name: searchEngine.name })}
> >
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -8,8 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { searchEngineManageSchema } from "@homarr/validation/search-engine"; import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
import { SearchEngineForm } from "../../_form"; import { SearchEngineForm } from "../../_form";
@@ -19,13 +19,14 @@ interface SearchEngineEditFormProps {
} }
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => { export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
const t = useScopedI18n("search.engine.page.edit.notification"); const t = useI18n("search.engine.page.edit.notification");
const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({ const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: t("success.title"), title: tCommon("notification.update.success"),
message: t("success.message"), message: t("success.message"),
}); });
void revalidatePathActionAsync("/manage/search-engines").then(() => { void revalidatePathActionAsync("/manage/search-engines").then(() => {
@@ -34,7 +35,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: t("error.title"), title: tCommon("notification.update.error"),
message: t("error.message"), message: t("error.message"),
}); });
}, },
@@ -50,7 +51,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
[mutate, searchEngine.id], [mutate, searchEngine.id],
); );
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []); const submitButtonTranslation = useCallback((t: ScopedTranslationFunction<"common">) => t("action.save"), []);
return ( return (
<SearchEngineForm <SearchEngineForm
Loaded 100 of 612 files, more files were not shown because too many files have changed in this diff. Show more