Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777b59359c | ||
|
|
9284949b9a | ||
|
|
7591f7e313 | ||
|
|
011ce8e5a3 | ||
|
|
b2fa46c760 | ||
|
|
9e14a07e64 | ||
|
|
81aa704537 | ||
|
|
f0f9539466 | ||
|
|
3c2f95d529 | ||
|
|
64e3d04517 |
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."
|
||||
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,43 +1,175 @@
|
||||
# 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
|
||||
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
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
homarr dash
|
||||
# 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
|
||||
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
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
# Build current checkout as homarr:<name>
|
||||
homarr build feature
|
||||
|
||||
# Build a pull request locally in a temporary checkout
|
||||
homarr build --pr 6441
|
||||
|
||||
# Rebuild an existing image from recorded provenance
|
||||
homarr rebuild feature
|
||||
```
|
||||
|
||||
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
|
||||
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
|
||||
|
||||
### GitHub CI Checks
|
||||
|
||||
```bash
|
||||
# View CI checks for a specific PR
|
||||
homarr ci 6441
|
||||
|
||||
# View CI checks for the current branch
|
||||
homarr ci
|
||||
|
||||
# Watch CI checks until completion
|
||||
homarr ci 6441 --watch
|
||||
```
|
||||
|
||||
### Interactive Shell
|
||||
|
||||
```bash
|
||||
# Open interactive shell in running container
|
||||
homarr shell homarr-dev
|
||||
|
||||
# Run specific command in container
|
||||
homarr shell homarr-dev pnpm db:migration:sqlite:run
|
||||
```
|
||||
|
||||
### Image & Volume Management
|
||||
|
||||
```bash
|
||||
# List local images with build provenance (source, revision, PR)
|
||||
homarr images
|
||||
|
||||
# Delete local image
|
||||
homarr images rm feature
|
||||
|
||||
# List data volumes with owner PR/tag and in-use status
|
||||
homarr data
|
||||
|
||||
# Delete data volume (safely stops and removes owning container first)
|
||||
homarr data rm homarr_feature_data
|
||||
|
||||
# Remove stopped containers (prompts without --force)
|
||||
homarr prune --force
|
||||
```
|
||||
|
||||
### Instance Operations & Diagnostics
|
||||
|
||||
```bash
|
||||
# List instances in a script-friendly table
|
||||
homarr list
|
||||
|
||||
# Follow container logs
|
||||
homarr logs homarr-dev
|
||||
|
||||
# Stop, restart, remove, or open container in browser
|
||||
homarr stop homarr-dev
|
||||
homarr restart homarr-dev
|
||||
homarr remove homarr-dev
|
||||
homarr open homarr-dev
|
||||
|
||||
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
|
||||
homarr doctor
|
||||
```
|
||||
@@ -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. 
|
||||
5. Select /bin/bash and click Execute 
|
||||
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
||||
|
||||
## Build a local branch harness
|
||||
|
||||
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board
|
||||
```
|
||||
|
||||
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
Pass environment overrides by repeating `--env`:
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board \
|
||||
--env WORKSHOP_WEB_URL=https://example.test \
|
||||
--env FEATURE_FLAG=true
|
||||
```
|
||||
|
||||
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
|
||||
@@ -6,19 +6,19 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
| Shortcut | Description |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
|
||||
## Widget advanced view
|
||||
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
|
||||
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
||||
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
|
||||
|
||||
## Board editing
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
|
||||
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
|
||||
stays visible but dimmed. Release `Shift` to close it. 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.
|
||||
|
||||
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
|
||||
|
||||
@@ -210,7 +210,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
|
||||
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,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
@@ -329,8 +330,9 @@ This will disable the status check for apps.
|
||||
Widgets expose their routine controls without requiring edit mode:
|
||||
|
||||
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
|
||||
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
|
||||
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
|
||||
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
|
||||
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
|
||||
instead.
|
||||
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
|
||||
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
|
||||
not use this widget menu, and available controls follow your board and integration permissions.
|
||||
|
||||
@@ -69,6 +69,7 @@ import advancedView from "./img/advanced-view.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
- Colored dots directly below a date mark its events. They scale with the widget and are hidden when the calendar is too short.
|
||||
- Hover over a marked date to preview its events in a popover.
|
||||
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
|
||||
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userRegistrationSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
|
||||
}
|
||||
|
||||
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const t = useI18n("user");
|
||||
const router = useRouter();
|
||||
const { mutate, isPending } = clientApi.user.register.useMutation();
|
||||
const form = useZodForm(userRegistrationSchema, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const t = await getScopedI18n("user.page.invite");
|
||||
const t = await getI18n("user.page.invite");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
|
||||
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
import { userSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -28,7 +28,7 @@ interface LoginFormProps {
|
||||
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
|
||||
|
||||
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const t = useI18n("user");
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
|
||||
}
|
||||
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
|
||||
const [visible, { toggle }] = useDisclosure(false);
|
||||
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
|
||||
const tForgotPassword = useI18n("user.action.login.forgotPassword");
|
||||
|
||||
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconLogin } from "@tabler/icons-react";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
import { env as appEnv } from "~/env";
|
||||
@@ -26,7 +26,7 @@ export default async function Login(props: LoginProps) {
|
||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("user.page.login");
|
||||
const t = await getI18n("user.page.login");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IconLayoutOff } from "@tabler/icons-react";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
|
||||
export default async function BoardNotFoundPage() {
|
||||
const tNotFound = await getScopedI18n("board.error.notFound");
|
||||
const tNotFound = await getI18n("board.error.notFound");
|
||||
return (
|
||||
<BoardNotFound
|
||||
icon={IconLayoutOff}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconBulb } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { 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 { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||
@@ -31,8 +33,9 @@ const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const tPreview = useScopedI18n("board.setting.section.layout.preview");
|
||||
const t = useI18n("board.landmark");
|
||||
const tPreview = useI18n("board.setting.section.layout.preview");
|
||||
const tTips = useI18n("tips");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
@@ -80,7 +83,6 @@ export const ClientBoard = () => {
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
@@ -130,6 +132,16 @@ export const ClientBoard = () => {
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
<FloatingTip
|
||||
opened={isEditMode}
|
||||
showDelay={2_000}
|
||||
dismissAfter={3_000}
|
||||
transitionDuration={200}
|
||||
closable={false}
|
||||
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
||||
>
|
||||
{tTips("multiSelectApps")}
|
||||
</FloatingTip>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionProvider>
|
||||
|
||||
@@ -104,10 +104,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n();
|
||||
const t = await getI18n("board");
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
||||
icons: {
|
||||
icon: !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 { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tIntegration = useI18n("integration");
|
||||
const tContainer = useI18n("section.container");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -77,7 +79,7 @@ const AddMenu = () => {
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<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">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
@@ -86,19 +88,19 @@ const AddMenu = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
{tBoard("emptyState.addWidget")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
{tBoard("emptyState.addApp")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("integration-create") && (
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
{tIntegration("action.create")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
{tContainer("action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
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";
|
||||
@@ -53,7 +53,7 @@ const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
@@ -68,7 +68,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
{!demoReadOnly && (
|
||||
<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} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
@@ -88,8 +88,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const commonT = useI18n();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const tCommon = useI18n("common");
|
||||
const tBoardSetting = useI18n("board.setting");
|
||||
const t = useI18n("board.action.edit");
|
||||
const returnToLayoutSettings =
|
||||
isEditMode &&
|
||||
searchParams.get("edit") === "true" &&
|
||||
@@ -104,7 +105,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
title: tCommon("notification.update.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
@@ -115,7 +116,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -178,9 +179,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
const cancelLayoutEdit = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: commonT("board.setting.section.layout.edit.cancel.title"),
|
||||
children: commonT("board.setting.section.layout.edit.cancel.message"),
|
||||
confirmProps: { children: commonT("common.action.discard"), color: "red" },
|
||||
title: tBoardSetting("section.layout.edit.cancel.title"),
|
||||
children: tBoardSetting("section.layout.edit.cancel.message"),
|
||||
confirmProps: { children: tCommon("action.discard"), color: "red" },
|
||||
onConfirm() {
|
||||
void (async () => {
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
@@ -189,7 +190,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()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
@@ -200,14 +201,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
<HeaderButton
|
||||
onClick={() => saveBoard(board)}
|
||||
loading={isPending}
|
||||
aria-label={commonT("board.setting.section.layout.edit.save")}
|
||||
aria-label={tBoardSetting("section.layout.edit.save")}
|
||||
>
|
||||
<IconDeviceFloppy stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<HeaderButton
|
||||
onClick={cancelLayoutEdit}
|
||||
disabled={isPending}
|
||||
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
|
||||
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
</HeaderButton>
|
||||
@@ -235,7 +236,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
@@ -246,7 +247,7 @@ const SelectBoardsMenu = () => {
|
||||
<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")}>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
@@ -277,7 +278,8 @@ const SelectBoardsMenu = () => {
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -293,13 +295,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
title: tBoard("action.edit.confirmLeave.title"),
|
||||
children: tBoard("action.edit.confirmLeave.message"),
|
||||
onConfirm() {
|
||||
router.push(target.href);
|
||||
},
|
||||
confirmProps: {
|
||||
children: t("common.action.discard"),
|
||||
children: tCommon("action.discard"),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -325,5 +327,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
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 boardCount = await db.$count(boards);
|
||||
const t = await getI18n();
|
||||
const t = await getI18n("board");
|
||||
|
||||
if (boardCount === 0) {
|
||||
return {
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
title: t("board.error.noBoard.title"),
|
||||
description: t("board.error.noBoard.description"),
|
||||
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("board.error.noBoard.notice"),
|
||||
title: t("error.noBoard.title"),
|
||||
description: t("error.noBoard.description"),
|
||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("error.noBoard.notice"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
|
||||
return {
|
||||
icon: IconHomeOff,
|
||||
title: t(`board.error.homeBoard.title`),
|
||||
description: t(`board.error.homeBoard.${type}.description`),
|
||||
link: { label: t(`board.error.homeBoard.${type}.link`), href },
|
||||
notice: t(`board.error.homeBoard.${type}.notice`),
|
||||
title: t("error.homeBoard.title"),
|
||||
description: t(`error.homeBoard.${type}.description` as never),
|
||||
link: { label: t(`error.homeBoard.${type}.link` as never), href },
|
||||
notice: t(`error.homeBoard.${type}.notice` as never),
|
||||
};
|
||||
};
|
||||
@@ -37,22 +37,23 @@ const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<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 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 span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
@@ -63,7 +64,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<InputWrapper label={tBoard("field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
@@ -77,7 +78,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
label={tBoard("field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
@@ -90,20 +91,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
{tBoard("field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
label={tBoard("field.itemRadius.label")}
|
||||
description={tBoard("field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
|
||||
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<SectionCard title={tBoard("setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
label={tBoard("field.backgroundImageUrl.label")}
|
||||
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
group: tBoard("field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
group: tBoard("field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
label={tBoard("field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
label={tBoard("field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
label={tBoard("field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
@@ -176,19 +176,20 @@ const useBackgroundOptionData = <
|
||||
defaultValue: keyof TOptions;
|
||||
},
|
||||
) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
return data.values.map(
|
||||
(value) =>
|
||||
({
|
||||
label: t(`board.field.${key}.option.${value as string}.label` as never),
|
||||
description: t(`board.field.${key}.option.${value as string}.description` as never),
|
||||
label: tBoard(`field.${key}.option.${value as string}.label` as never),
|
||||
description: tBoard(`field.${key}.option.${value as string}.description` as never),
|
||||
value: value as string,
|
||||
badge:
|
||||
data.defaultValue === value
|
||||
? {
|
||||
color: "blue",
|
||||
label: t("common.select.badge.recommended"),
|
||||
label: tCommon("select.badge.recommended"),
|
||||
}
|
||||
: undefined,
|
||||
}) satisfies SelectItemWithDescriptionBadge,
|
||||
|
||||
@@ -13,13 +13,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<SectionCard title={t("setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
label={t("field.disableStatus.label")}
|
||||
description={t("field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
|
||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||
{
|
||||
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||
mutate: userMutation.mutate,
|
||||
isPending: userMutation.isPending,
|
||||
}}
|
||||
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
||||
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
|
||||
permission={{
|
||||
items: boardPermissions,
|
||||
default: "view",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
@@ -24,11 +24,11 @@ interface Props {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const t = useI18n("board");
|
||||
const customCssT = useI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<SectionCard title={t("setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const customCssT = useI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
|
||||
@@ -7,14 +7,15 @@ import { Button } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
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 { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.setting");
|
||||
const t = useI18n("board.setting");
|
||||
const tCommonAction = useI18n("common.action");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
@@ -104,11 +105,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
/>
|
||||
)}
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
label={tCommonAction("delete")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
{tCommonAction("delete")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -20,25 +20,21 @@ interface Props {
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<SectionCard title={t("setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
label={t("field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
@@ -46,7 +42,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
label={t("field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
@@ -54,7 +50,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
label={t("field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
@@ -23,6 +23,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tSection = useI18n("section");
|
||||
const t = useI18n();
|
||||
const elements = projectBoardLayout(board, sourceLayout, layout);
|
||||
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
|
||||
@@ -42,13 +44,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
|
||||
{representativeWidth}px
|
||||
</Badge>
|
||||
<Text size="xs">
|
||||
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
|
||||
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
|
||||
{elements.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" ta="center" py="xl">
|
||||
{t("board.setting.section.layout.preview.empty")}
|
||||
{tBoard("setting.section.layout.preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
@@ -76,8 +78,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
|
||||
const containerLabel =
|
||||
container?.kind === "container" && container.options.title
|
||||
? container.options.title
|
||||
: t("section.container.untitled");
|
||||
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
|
||||
: tSection("container.untitled");
|
||||
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
|
||||
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,7 +41,9 @@ interface 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 utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -50,8 +52,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
title: tCommon("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]) => {
|
||||
openConfirmModal({
|
||||
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: t("board.setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
|
||||
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: tBoard("setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
|
||||
onConfirm() {
|
||||
setResettingLayoutId(layout.id);
|
||||
void (async () => {
|
||||
try {
|
||||
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
|
||||
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
|
||||
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
@@ -115,12 +116,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
|
||||
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -132,7 +133,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.custom.defaultName"),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
@@ -142,7 +143,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
{tBoard("setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -159,9 +160,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
key={layout.id}
|
||||
legend={
|
||||
<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">
|
||||
{t(`board.setting.section.layout.role.${layout.role}`)}
|
||||
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
@@ -170,10 +171,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
|
||||
<Input.Wrapper label={tLayout("field.columnCount.label")}>
|
||||
<Slider
|
||||
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
|
||||
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
@@ -194,11 +195,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Input.Wrapper>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
label={tLayout("field.breakpoint.label")}
|
||||
description={
|
||||
layout.role === "mobile"
|
||||
? t("board.setting.section.layout.mobile.breakpointDescription")
|
||||
: t("layout.field.breakpoint.description")
|
||||
? tBoard("setting.section.layout.mobile.breakpointDescription")
|
||||
: tLayout("field.breakpoint.description")
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
@@ -246,7 +247,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={isSaving}
|
||||
onClick={() => confirmReset(layout)}
|
||||
>
|
||||
{t("board.setting.section.layout.reset.action")}
|
||||
{tBoard("setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
@@ -262,7 +263,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
{tCommon("action.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -275,8 +276,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
onClick={() => void openLayoutEditorAsync(layout)}
|
||||
>
|
||||
{form.isDirty() || !persistedLayout
|
||||
? t("board.setting.section.layout.edit.saveAndEdit")
|
||||
: t("board.setting.section.layout.edit.action")}
|
||||
? tBoard("setting.section.layout.edit.saveAndEdit")
|
||||
: tBoard("setting.section.layout.edit.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
@@ -322,16 +323,16 @@ const gutterMarks = [
|
||||
];
|
||||
|
||||
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));
|
||||
|
||||
return (
|
||||
<Paper className={classes.gutterSettings} p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
|
||||
<Text fw={600}>{t("field.gutters.label")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("layout.field.gutters.description")}
|
||||
{t("field.gutters.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -371,7 +372,7 @@ const GutterControl = ({
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("layout");
|
||||
const enabled = value > 0;
|
||||
|
||||
return (
|
||||
@@ -380,10 +381,10 @@ const GutterControl = ({
|
||||
checked={enabled}
|
||||
disabled={max < 1}
|
||||
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 && (
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
<Input.Wrapper label={t("field.gutters.width.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
@@ -391,7 +392,7 @@ const GutterControl = ({
|
||||
value={Math.min(value, max)}
|
||||
marks={gutterMarks.filter((mark) => mark.value <= max)}
|
||||
restrictToMarks
|
||||
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
|
||||
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
|
||||
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
@@ -401,7 +402,7 @@ const GutterControl = ({
|
||||
};
|
||||
|
||||
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 main = Math.max(1, safeColumnCount - left - right);
|
||||
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
|
||||
@@ -411,7 +412,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
|
||||
return (
|
||||
<Box
|
||||
className={classes.gutterPreview}
|
||||
aria-label={t("layout.field.gutters.preview.label")}
|
||||
aria-label={t("field.gutters.preview.label")}
|
||||
style={{ gridTemplateColumns: tracks }}
|
||||
>
|
||||
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
@@ -86,8 +86,8 @@ interface BoardSettingsFormProps {
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const t = useI18n("common");
|
||||
const tSection = useI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
@@ -156,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
form.resetDirty(canonicalValues);
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
title: t("notification.update.success"),
|
||||
message: t("notification.update.success"),
|
||||
});
|
||||
return canonicalValues;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
title: t("notification.update.error"),
|
||||
message: t("notification.update.error"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -200,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
{t("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
{t("action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { BoardSettingsForm } from "./_settings-form";
|
||||
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||
const t = await getScopedI18n("board.setting");
|
||||
const t = await getI18n("board.setting");
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
notFound();
|
||||
|
||||
+4
-3
@@ -16,7 +16,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface RequestDetails {
|
||||
widgetName: string;
|
||||
@@ -31,7 +31,8 @@ interface RequestDetails {
|
||||
}
|
||||
|
||||
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 [values, setValues] = useState<Record<string, string>>({});
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
@@ -120,7 +121,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||
return (
|
||||
<Input
|
||||
key={kind}
|
||||
label={t(`field.${kind}`)}
|
||||
label={tSecret(kind)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={values[kind] ?? ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
const t = await getI18n("management.page.home");
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
|
||||
import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -47,7 +47,7 @@ import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
@@ -55,7 +55,7 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const t = await getI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AppDeleteButtonProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const tList = useScopedI18n("app.page.list.action");
|
||||
const t = useI18n("app.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
color="red"
|
||||
size={44}
|
||||
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} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface AppEditFormProps {
|
||||
@@ -17,14 +17,14 @@ interface AppEditFormProps {
|
||||
}
|
||||
|
||||
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
const tScoped = useScopedI18n("app.page.edit.notification");
|
||||
const t = useI18n();
|
||||
const tScoped = useI18n("app.page.edit.notification");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tScoped("success.title"),
|
||||
title: tCommon("notification.update.success"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tScoped("error.title"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tScoped("error.message"),
|
||||
});
|
||||
},
|
||||
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
return (
|
||||
<AppForm
|
||||
buttonLabels={{
|
||||
submit: t("common.action.save"),
|
||||
submit: tCommon("action.save"),
|
||||
}}
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeAppHref } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
@@ -52,7 +52,8 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { 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 emptyState = !canManageAll ? (
|
||||
@@ -67,7 +68,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
|
||||
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
@@ -80,7 +81,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
title={tCommon("entity.apps")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={apps.length}
|
||||
emptyState={emptyState}
|
||||
@@ -115,7 +116,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
key={app.id}
|
||||
app={app}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
editLabel={tCommon("action.editNamed", { name: app.name })}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
|
||||
+7
-6
@@ -51,7 +51,7 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
|
||||
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";
|
||||
|
||||
@@ -140,7 +140,8 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
);
|
||||
|
||||
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 utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
@@ -208,7 +209,7 @@ export const AssistantConfiguration = () => {
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((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 };
|
||||
@@ -372,7 +373,7 @@ export const AssistantConfiguration = () => {
|
||||
</ThemeIcon>
|
||||
<Box className={classes.summaryProviderText}>
|
||||
<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
|
||||
size="sm"
|
||||
variant="light"
|
||||
@@ -418,7 +419,7 @@ export const AssistantConfiguration = () => {
|
||||
description={t("connection.description")}
|
||||
status={
|
||||
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
|
||||
{t(`provider.options.${provider}.label`)}
|
||||
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
@@ -758,7 +759,7 @@ export const AssistantConfiguration = () => {
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{t("serverTools.beta")}
|
||||
{tCommon("beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
|
||||
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
<Text c="dimmed" maw="70ch">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
@@ -28,8 +28,9 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const t = useI18n("management.page.board.action");
|
||||
const tRoot = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
@@ -57,7 +58,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
|
||||
const handleDeletion = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("delete.confirm.title"),
|
||||
title: tCommon("action.deleteNamed", { name: board.name }),
|
||||
children: t("delete.confirm.description", {
|
||||
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 () => {
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
@@ -97,7 +98,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
{tRoot("board.action.duplicate.title")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasChangeAccess && (
|
||||
@@ -122,7 +123,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
onClick={handleDeletion}
|
||||
disabled={deleteBoardMutation.isPending}
|
||||
>
|
||||
{t("delete.label")}
|
||||
{tCommon("action.delete")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,13 +10,13 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("management.page.board.action");
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
@@ -36,7 +36,7 @@ import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const t = await getI18n("management.page.board");
|
||||
const session = await auth();
|
||||
const boards = await api.board.getManageOverview();
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
const t = useI18n("customWidget.betaBanner");
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
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 CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const t = useI18n("customWidget.editor");
|
||||
const tCommon = useI18n("common");
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
{...props}
|
||||
messages={{
|
||||
languageJsx: t("language.jsx"),
|
||||
languageJson: t("language.json"),
|
||||
undo: t("action.undo"),
|
||||
languageJsx: invariantTechnicalLabels.jsx,
|
||||
languageJson: invariantTechnicalLabels.json,
|
||||
undo: tCommon("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
@@ -24,10 +26,10 @@ export function CodeEditor(props: CodeEditorProps) {
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: t("action.copy"),
|
||||
copy: tCommon("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: t("reference.schema"),
|
||||
schemaTab: invariantTechnicalLabels.jsonSchema,
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
@@ -22,7 +22,7 @@ export const CopyAiPromptButton = ({
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
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 { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
@@ -39,7 +39,7 @@ interface WidgetRef {
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ import { useEffect, useState } from "react";
|
||||
import { Accordion } from "@mantine/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 { applyDefinition, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(
|
||||
{
|
||||
$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 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";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface AiCardProps {
|
||||
}
|
||||
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
const t = useI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Accordion variant="contained">
|
||||
<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 { 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";
|
||||
|
||||
@@ -52,7 +52,8 @@ export function SaveActions({
|
||||
mode: "create" | "edit";
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench");
|
||||
const t = useI18n("customWidget.workbench");
|
||||
const tCommon = useI18n("common");
|
||||
return (
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
@@ -77,7 +78,7 @@ export function SaveActions({
|
||||
{previewPending ? t("action.previewLoading") : t("action.preview")}
|
||||
</Button>
|
||||
<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>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homar
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
@@ -57,8 +57,9 @@ const sectionLinks = [
|
||||
] as const;
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
|
||||
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
@@ -169,7 +170,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<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")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
||||
@@ -18,20 +18,22 @@ interface 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) {
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconApi}
|
||||
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 (
|
||||
<ManageCollectionList ariaLabel={t("page.list.title")}>
|
||||
<ManageCollectionList ariaLabel={tEntities("customWidgets")}>
|
||||
{definitions.map((definition) => (
|
||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
||||
))}
|
||||
@@ -42,7 +44,8 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
@@ -94,7 +97,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
{tCommon("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
@@ -122,14 +125,8 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({
|
||||
widget,
|
||||
onConfigureSources,
|
||||
}: {
|
||||
widget: WidgetDef;
|
||||
onConfigureSources(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
|
||||
const t = useI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
|
||||
choices?: Array<{ label: string; value: string | number }>;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
if (control === "switch")
|
||||
return (
|
||||
<Switch
|
||||
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
|
||||
value: Array<{ label: string; value: string | number }>;
|
||||
onChange(value: Array<{ label: string; value: string | number }>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{value.map((choice, index) => (
|
||||
@@ -150,7 +150,7 @@ export function JsonValueEditor({
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import {
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
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 type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
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 entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const requests = parseJson(form.values.requests);
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } fro
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
@@ -19,7 +19,7 @@ export function PreviewActionControl({
|
||||
request: CustomJsxRequest & { id: string };
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const actionMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
|
||||
|
||||
+1
-1
@@ -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() }));
|
||||
const rendererAttempt = 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 { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
|
||||
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 { CodeEditor } from "./_code-editor";
|
||||
@@ -37,8 +37,9 @@ interface PreviewPanelProps {
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
|
||||
const actionT = useI18n("common.action");
|
||||
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
@@ -155,7 +156,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
retryLabel={actionT("tryAgain")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord } from "./_custom-widget-form-utils";
|
||||
@@ -21,7 +21,7 @@ export function QueryValuesEditor({
|
||||
allowParams: boolean;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const entries = Object.entries(value);
|
||||
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
|
||||
return (
|
||||
@@ -150,7 +150,7 @@ export function RequestBodyEditor({
|
||||
allowParams: boolean;
|
||||
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 [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
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 type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -19,7 +19,7 @@ export function CustomWidgetRequestsEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
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 entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const sources = parseJson(form.values.sources);
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
|
||||
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";
|
||||
|
||||
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
|
||||
const t = useScopedI18n("customWidget.workbench.saveError");
|
||||
const t = useI18n("customWidget.workbench.saveError");
|
||||
if (issues.length === 0) return null;
|
||||
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconKey, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
@@ -51,7 +51,8 @@ export function CustomWidgetSourceField({
|
||||
onClearSecret,
|
||||
onRemove,
|
||||
}: CustomWidgetSourceFieldProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const t = useI18n("customWidget.workbench.sources");
|
||||
const tSecret = useI18n("customWidget.secret");
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
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">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${kind}`)}
|
||||
label={tSecret(kind)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -21,8 +21,8 @@ export function CustomWidgetSourcesEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const t = useI18n("customWidget.workbench.sources");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
const tabs = {
|
||||
@@ -13,7 +13,7 @@ const tabs = {
|
||||
|
||||
/** Switches between the widgets you have installed and the community Workshop. */
|
||||
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
|
||||
const t = useScopedI18n("customWidget.page.tabs");
|
||||
const t = useI18n("customWidget.page.tabs");
|
||||
|
||||
return (
|
||||
<Tabs value={tabs[active]}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Component } from "react";
|
||||
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FormErrorBoundaryState {
|
||||
error: Error | null;
|
||||
@@ -35,7 +35,8 @@ export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErr
|
||||
}
|
||||
|
||||
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
|
||||
<Stack gap="xs">
|
||||
@@ -46,7 +47,7 @@ function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }
|
||||
{error.message}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" color="red" onClick={reset}>
|
||||
{t("editor.errorBoundary.retry")}
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
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 { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
@@ -38,7 +39,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
title: tCommon("action.import"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
@@ -47,7 +48,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [queueImport, t]);
|
||||
}, [queueImport, t, tCommon]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -61,13 +62,13 @@ export const ImportCustomWidgetButton = () => {
|
||||
queueImport(result.widget);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
title: tCommon("action.import"),
|
||||
message: error instanceof Error ? error.message : t("notification.importError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
showErrorNotification({ title: tCommon("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
@@ -81,7 +82,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
{tCommon("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
|
||||
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", () => ({
|
||||
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
|
||||
editorProps(props);
|
||||
@@ -41,6 +43,8 @@ describe("preview JSON editor", () => {
|
||||
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
|
||||
|
||||
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 { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
@@ -18,7 +18,7 @@ export function JsonPreviewEditor({
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
|
||||
+11
-10
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import {
|
||||
applyCustomWidgetAiResponse,
|
||||
@@ -36,14 +36,17 @@ interface FormActionsInput {
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const utils = clientApi.useUtils();
|
||||
const createMutation = clientApi.customWidget.create.useMutation();
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
|
||||
const [previewPending, setPreviewPending] = useState(false);
|
||||
let actionLabel = tCommon("action.save");
|
||||
if (input.mode === "create") actionLabel = tCommon("action.create");
|
||||
|
||||
const save = input.form.onSubmit(async (values) => {
|
||||
clearSaveIssues(input.form, saveIssues);
|
||||
@@ -54,7 +57,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
input.form,
|
||||
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
|
||||
setSaveIssues,
|
||||
t("action.save"),
|
||||
actionLabel,
|
||||
(count) => w("saveError.more", { count }),
|
||||
);
|
||||
return;
|
||||
@@ -65,7 +68,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
|
||||
await utils.customWidget.list.invalidate();
|
||||
showSuccessNotification({
|
||||
title: t("action.create"),
|
||||
title: tCommon("action.create"),
|
||||
message: t("notification.created", { name: values.name }),
|
||||
});
|
||||
input.form.setInitialValues(values);
|
||||
@@ -86,18 +89,16 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
input.form.setInitialValues(values);
|
||||
input.form.resetDirty();
|
||||
showSuccessNotification({
|
||||
title: t("action.save"),
|
||||
title: tCommon("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const issues = extractCustomWidgetSaveIssues(error);
|
||||
if (issues.length > 0) {
|
||||
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
|
||||
w("saveError.more", { count }),
|
||||
);
|
||||
reportSaveIssues(input.form, issues, setSaveIssues, actionLabel, (count) => w("saveError.more", { count }));
|
||||
} 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";
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
const t = useI18n();
|
||||
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const dirtyRef = useRef(isDirty);
|
||||
@@ -16,9 +17,9 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
dirtyRef.current = isDirty;
|
||||
confirmNavigationRef.current = (href) => {
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
confirmProps: { children: t("common.action.discard") },
|
||||
title: tConfirmLeave("title"),
|
||||
children: tConfirmLeave("message"),
|
||||
confirmProps: { children: tCommon("action.discard") },
|
||||
onConfirm: () => router.push(href),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { Container, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetBetaBanner } from "../_beta-banner";
|
||||
@@ -14,7 +14,7 @@ export default async function NewCustomWidgetPage() {
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
const t = await getScopedI18n("customWidget");
|
||||
const t = await getI18n("customWidget");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconExternalLink, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
@@ -21,11 +21,12 @@ export default async function CustomWidgetsPage() {
|
||||
redirect(session ? "/" : "/auth/login");
|
||||
}
|
||||
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 (
|
||||
<ManagePageLayout
|
||||
title={t("page.list.title")}
|
||||
title={tEntities("customWidgets")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<MobileAffixButton
|
||||
@@ -40,7 +41,7 @@ export default async function CustomWidgetsPage() {
|
||||
</MobileAffixButton>
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
{tCommon("action.create")}
|
||||
</MobileAffixButton>
|
||||
</Group>
|
||||
}
|
||||
|
||||
+4
-3
@@ -26,7 +26,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
|
||||
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
|
||||
@@ -45,7 +45,8 @@ import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop
|
||||
const listHref = "/manage/custom-widgets";
|
||||
|
||||
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 [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -231,7 +232,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
|
||||
}
|
||||
>
|
||||
<Button component={Link} href={listHref} variant="default">
|
||||
{t("publish.cancel")}
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
|
||||
@@ -44,7 +44,7 @@ const browseHref = "/manage/custom-widgets/workshop";
|
||||
const installedHref = "/manage/custom-widgets";
|
||||
|
||||
export function WorkshopDetail({ id }: { id: string }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const t = useI18n("workshop");
|
||||
const router = useRouter();
|
||||
const session = useWorkshopSession();
|
||||
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 { 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 { useWorkshopQuery } from "@homarr/workshop/backend";
|
||||
|
||||
@@ -26,7 +26,8 @@ interface 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 query = useWorkshopQuery(session.client, {
|
||||
page,
|
||||
@@ -78,7 +79,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
title={t("emptyFilteredTitle")}
|
||||
description={t("emptyFilteredDescription", { search })}
|
||||
action={{
|
||||
label: t("clearSearch"),
|
||||
label: tCommon("clearSearch"),
|
||||
href: "/manage/custom-widgets/workshop",
|
||||
}}
|
||||
/>
|
||||
@@ -122,7 +123,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
}
|
||||
|
||||
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const t = useI18n("workshop");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { Button, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export default function ManageErrorPage({
|
||||
error,
|
||||
@@ -13,8 +13,8 @@ export default function ManageErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
unstable_retry: () => void;
|
||||
}) {
|
||||
const tError = useScopedI18n("management.error");
|
||||
const t = useI18n();
|
||||
const tError = useI18n("management.error");
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
@@ -34,7 +34,7 @@ export default function ManageErrorPage({
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={unstable_retry}>
|
||||
{t("common.action.tryAgain")}
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
|
||||
{
|
||||
@@ -49,7 +49,7 @@ export const IntegrationAccessSettings = ({ integration, initialPermissions }: P
|
||||
},
|
||||
groupPermissionMapping: integrationPermissionsMap,
|
||||
}}
|
||||
translate={(key) => t(`integration.permission.${key}`)}
|
||||
translate={(key) => tIntegration(`permission.${key}` as never)}
|
||||
query={{
|
||||
data,
|
||||
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
|
||||
|
||||
+8
-7
@@ -25,7 +25,8 @@ interface 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 [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -40,20 +41,20 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
<Avatar>
|
||||
<KindIcon size={16} />
|
||||
</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}
|
||||
</Group>
|
||||
<Group>
|
||||
{secret.updatedAt ? (
|
||||
<Text c="gray.6" size="sm">
|
||||
{t("integration.secrets.lastUpdated", {
|
||||
{tIntegration("secrets.lastUpdated", {
|
||||
date: dayjs().to(dayjs(secret.updatedAt)),
|
||||
})}
|
||||
</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">
|
||||
{t("integration.secrets.notSet.label")}
|
||||
{tIntegration("secrets.notSet.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -62,7 +63,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
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} />
|
||||
</ActionIcon>
|
||||
@@ -80,7 +81,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
>
|
||||
{editMode ? t("common.action.cancel") : t("common.action.edit")}
|
||||
{editMode ? tCommon("action.cancel") : tCommon("action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
+8
-8
@@ -29,14 +29,14 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
|
||||
};
|
||||
|
||||
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
const { multiline } = integrationSecretKindObject[kind];
|
||||
if (multiline) {
|
||||
return (
|
||||
<Textarea
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
autosize
|
||||
@@ -48,7 +48,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
@@ -56,7 +56,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
};
|
||||
|
||||
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
const { multiline } = integrationSecretKindObject[kind];
|
||||
|
||||
@@ -64,8 +64,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
||||
return (
|
||||
<Textarea
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t("integration.secrets.secureNotice")}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
description={tIntegration("secrets.secureNotice")}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
autosize
|
||||
@@ -77,8 +77,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
||||
return (
|
||||
<PasswordInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t("integration.secrets.secureNotice")}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
description={tIntegration("secrets.secureNotice")}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { SegmentedControl } from "@mantine/core";
|
||||
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FormType {
|
||||
secrets: { kind: IntegrationSecretKind; value: string | null }[];
|
||||
@@ -20,7 +20,7 @@ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
||||
secretKinds,
|
||||
form,
|
||||
}: SecretKindsSegmentedControlProps<TFormType>) => {
|
||||
const t = useScopedI18n("integration.secrets");
|
||||
const t = useI18n("integration.secrets");
|
||||
|
||||
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
|
||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||
|
||||
+9
-9
@@ -14,7 +14,7 @@ interface IntegrationTestConnectionErrorProps {
|
||||
}
|
||||
|
||||
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||
|
||||
return (
|
||||
@@ -22,17 +22,17 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={500} c="red.8">
|
||||
{t(`integration.testConnection.error.${error.type}.title`)}
|
||||
{tIntegration(`testConnection.error.${error.type}.title` as never)}
|
||||
</Text>
|
||||
|
||||
{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}
|
||||
|
||||
{error.type === "request" ? (
|
||||
<Text size="md">
|
||||
{t(
|
||||
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
|
||||
{tIntegration(
|
||||
`testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -40,7 +40,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{error.type === "statusCode" ? (
|
||||
error.data.reason === "other" ? (
|
||||
<Text size="md">
|
||||
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
||||
{tIntegration.rich("testConnection.error.statusCode.otherDescription", {
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -51,8 +51,8 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="md">
|
||||
{t.rich("integration.testConnection.error.statusCode.description", {
|
||||
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
||||
{tIntegration.rich("testConnection.error.statusCode.description", {
|
||||
reason: tIntegration(`testConnection.error.statusCode.reason.${error.data.reason}` as never),
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -71,7 +71,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="cause">
|
||||
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
|
||||
{t("integration.testConnection.error.common.cause.title")}
|
||||
{tIntegration("testConnection.error.common.cause.title")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
|
||||
+17
-28
@@ -9,7 +9,7 @@ import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
||||
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";
|
||||
|
||||
@@ -19,7 +19,7 @@ interface CertificateErrorDetailsProps {
|
||||
}
|
||||
|
||||
export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => {
|
||||
const tError = useScopedI18n("integration.testConnection.error");
|
||||
const tError = useI18n("integration.testConnection.error");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const [showRetryButton, setShowRetryButton] = useState(false);
|
||||
@@ -160,46 +160,35 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
};
|
||||
|
||||
const NotEnoughPermissionsAlert = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("integration.testConnection.error.certificate.alert.permission");
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("integration.testConnection.error.certificate.alert.permission.title")}
|
||||
color="yellow"
|
||||
>
|
||||
{t("integration.testConnection.error.certificate.alert.permission.message")}
|
||||
<Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
|
||||
{t("message")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const HostnameMismatchAlert = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("integration.testConnection.error.certificate.alert.hostnameMismatch");
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("integration.testConnection.error.certificate.alert.hostnameMismatch.title")}
|
||||
color="yellow"
|
||||
>
|
||||
{t("integration.testConnection.error.certificate.alert.hostnameMismatch.message")}
|
||||
<Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
|
||||
{t("message")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateExtractAlert = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("integration.testConnection.error.certificate.alert.extract");
|
||||
const tCommon = useI18n("common");
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconExclamationCircle size={16} />}
|
||||
title={t("integration.testConnection.error.certificate.alert.extract.title")}
|
||||
color="red"
|
||||
>
|
||||
{t.rich("integration.testConnection.error.certificate.alert.extract.message", {
|
||||
<Alert icon={<IconExclamationCircle size={16} />} title={t("title")} color="red">
|
||||
{t.rich("message", {
|
||||
docsLink: () => (
|
||||
<Anchor
|
||||
href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")}
|
||||
target="_blank"
|
||||
>
|
||||
{t("common.here")}
|
||||
{tCommon("here")}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
@@ -214,8 +203,8 @@ interface CertificateDetailsProps {
|
||||
export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => {
|
||||
const { openModal } = useModalAction(PemContentModal);
|
||||
const locale = useCurrentLocale();
|
||||
const tDetails = useScopedI18n("integration.testConnection.error.certificate.details");
|
||||
const tCertificateField = useScopedI18n("certificate.field");
|
||||
const tDetails = useI18n("integration.testConnection.error.certificate.details");
|
||||
const tCertificateField = useI18n("certificate.field");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -291,7 +280,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
||||
};
|
||||
|
||||
const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -318,7 +307,7 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
|
||||
</Card>
|
||||
|
||||
<Button variant="light" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.close")}
|
||||
{tCommon("action.close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const tList = useScopedI18n("integration.page.list.action");
|
||||
const t = useI18n("integration.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
@@ -36,7 +36,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/integrations");
|
||||
@@ -44,7 +44,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
title: tCommon("notification.delete.error"),
|
||||
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} />
|
||||
</ActionIcon>
|
||||
|
||||
+39
-26
@@ -10,7 +10,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds, invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
@@ -56,7 +56,8 @@ export const EditIntegrationForm = ({
|
||||
onSuccess,
|
||||
formRef,
|
||||
}: EditIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
const tIntegration = useI18n("integration");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
|
||||
@@ -83,8 +84,8 @@ export const EditIntegrationForm = ({
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tIntegration("page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -103,8 +104,8 @@ export const EditIntegrationForm = ({
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tIntegration("page.edit.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -124,16 +125,16 @@ export const EditIntegrationForm = ({
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tIntegration("page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return false;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
title: tCommon("notification.update.success"),
|
||||
message: tIntegration("page.edit.notification.success.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
@@ -142,7 +143,18 @@ export const EditIntegrationForm = ({
|
||||
}
|
||||
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(
|
||||
@@ -172,13 +184,13 @@ export const EditIntegrationForm = ({
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
<TextInput withAsterisk label={tCommon("field.name")} {...form.getInputProps("name")} />
|
||||
|
||||
{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">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
||||
@@ -196,8 +208,8 @@ export const EditIntegrationForm = ({
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
title: tIntegration("secrets.reset.title"),
|
||||
children: tIntegration("secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
@@ -208,7 +220,7 @@ export const EditIntegrationForm = ({
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
label={tIntegration(`secrets.kind.${secret.kind}.newLabel` as never)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
@@ -227,7 +239,7 @@ export const EditIntegrationForm = ({
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -244,10 +256,10 @@ export const EditIntegrationForm = ({
|
||||
{!hideButtons && (
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
{tCommon("action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
{tIntegration("testConnection.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
@@ -270,7 +282,8 @@ interface IntegrationAppSelectProps {
|
||||
|
||||
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
const { openModal } = useModalAction(AppSelectModal);
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const tCommon = useI18n("common");
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
@@ -281,7 +294,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
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
|
||||
onClick={handleChange}
|
||||
>
|
||||
{t("integration.page.edit.app.action.add")}
|
||||
{tIntegration("page.edit.app.action.add")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||
<Fieldset legend={tIntegration("field.app.sectionTitle")}>
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
{/* 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} />}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{t("integration.page.edit.app.action.remove")}
|
||||
{tIntegration("page.edit.app.action.remove")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -331,7 +344,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{t("common.action.change")}
|
||||
{tCommon("action.change")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Group>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -16,7 +16,7 @@ interface EditIntegrationPageProps {
|
||||
|
||||
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
|
||||
const params = await props.params;
|
||||
const editT = await getScopedI18n("integration.page.edit");
|
||||
const editT = await getI18n("integration.page.edit");
|
||||
const t = await getI18n();
|
||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getIntegrationApiKeyUrl,
|
||||
getIntegrationDefaultUrl,
|
||||
getIntegrationName,
|
||||
invariantTechnicalLabels,
|
||||
integrationDefs,
|
||||
} from "@homarr/definitions";
|
||||
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) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
const tIntegration = useI18n("integration");
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const { data: session } = useSession();
|
||||
@@ -74,7 +76,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
? formSchema
|
||||
: formSchema.superRefine((values, context) => {
|
||||
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) ?? "";
|
||||
@@ -116,15 +118,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
if (data && "error" in data && data.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
title: tCommon("notification.create.error"),
|
||||
message: tIntegration("page.create.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.create.notification.success.title"),
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
title: tCommon("notification.create.success"),
|
||||
message: tIntegration("page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
onSuccess(data && "integration" in data ? data : undefined);
|
||||
@@ -132,8 +134,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
|
||||
const onMutationError = () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
title: tCommon("notification.create.error"),
|
||||
message: tIntegration("page.create.notification.error.message"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -166,13 +168,13 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
<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 : (
|
||||
<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">
|
||||
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
|
||||
{form.values.secrets.map(({ kind }, index) => (
|
||||
@@ -185,7 +187,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySettingsLink kind={kind} url={form.values.url} />
|
||||
@@ -196,8 +198,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
label={tIntegration("field.attemptSearchEngineCreation.label")}
|
||||
description={tIntegration("field.attemptSearchEngineCreation.description", {
|
||||
kind: getIntegrationName(kind),
|
||||
})}
|
||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||
@@ -209,15 +211,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
<Group justify="end" align="center">
|
||||
{onCancel ? (
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{t("common.action.backToOverview")}
|
||||
{tCommon("action.backToOverview")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
{tCommon("action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isCreatePending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
{tIntegration("testConnection.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -228,7 +230,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
type FormType = z.infer<typeof formSchema>;
|
||||
|
||||
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
|
||||
|
||||
return (
|
||||
@@ -242,20 +244,18 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
||||
checkboxInputProps.onChange(event);
|
||||
});
|
||||
}}
|
||||
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
|
||||
description={t(
|
||||
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
|
||||
)}
|
||||
label={tIntegration(canCreateApps ? "field.createApp.label" : "field.linkApp.label")}
|
||||
description={tIntegration(canCreateApps ? "field.createApp.description" : "field.linkApp.description")}
|
||||
/>
|
||||
|
||||
<Collapse expanded={form.values.hasApp}>
|
||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||
<Fieldset legend={tIntegration("field.app.sectionTitle")}>
|
||||
<Stack gap="sm">
|
||||
{canCreateApps && (
|
||||
<SegmentedControl
|
||||
data={(["new", "existing"] as const).map((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"}
|
||||
onChange={(value) => {
|
||||
@@ -272,10 +272,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
||||
|
||||
{typeof form.values.appHref === "string" && canCreateApps ? (
|
||||
<TextInput
|
||||
placeholder={t("integration.field.appHref.placeholder")}
|
||||
placeholder={tIntegration("field.appHref.placeholder")}
|
||||
withAsterisk
|
||||
label={t("integration.page.create.app.option.new.url.label")}
|
||||
description={t("integration.page.create.app.option.new.url.description")}
|
||||
label={tIntegration("page.create.app.option.new.url.label")}
|
||||
description={tIntegration("page.create.app.option.new.url.description")}
|
||||
{...form.getInputProps("appHref")}
|
||||
/>
|
||||
) : (
|
||||
@@ -302,7 +302,7 @@ const normalizeUrl = (raw: string): string | null => {
|
||||
};
|
||||
|
||||
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
|
||||
const t = useI18n();
|
||||
const tIntegration = useI18n("integration");
|
||||
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
|
||||
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">
|
||||
<Group gap={4}>
|
||||
<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} />
|
||||
</Group>
|
||||
</Anchor>
|
||||
@@ -332,14 +332,14 @@ type IntegrationAppSelectProps = Modify<
|
||||
|
||||
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
|
||||
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));
|
||||
|
||||
return (
|
||||
<Select
|
||||
withAsterisk
|
||||
label={t("integration.page.create.app.option.existing.label")}
|
||||
label={tIntegration("page.create.app.option.existing.label")}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
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);
|
||||
|
||||
if (!result.success) {
|
||||
const t = await getScopedI18n("integration");
|
||||
const t = await getI18n("integration");
|
||||
return (
|
||||
<>
|
||||
<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;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeApplicationUrl } from "@homarr/common";
|
||||
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 { TourTarget } from "~/components/layout/header/tour-target";
|
||||
@@ -37,7 +37,8 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
} = getIntegrationsSectionAccess(session, integrations);
|
||||
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
|
||||
// are manageable, so the list never shows an integration they cannot open.
|
||||
const manageableIntegrations = integrations.filter(
|
||||
@@ -61,7 +62,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
title={tCommon("entity.integrations")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
@@ -77,7 +78,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
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
|
||||
@@ -139,7 +140,7 @@ interface IntegrationItemProps {
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const tCommon = await getI18n("common");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
|
||||
@@ -178,7 +179,7 @@ const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
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} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/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 { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
@@ -61,8 +61,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, session, shouldRunManageTour] = await Promise.all([
|
||||
getScopedI18n("management.navbar"),
|
||||
const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
|
||||
getI18n("management.navbar"),
|
||||
getI18n("common.entity"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
@@ -78,13 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
icon: IconLayoutDashboardFilled,
|
||||
href: "/manage/boards",
|
||||
label: t("items.boards"),
|
||||
label: tEntities("boards"),
|
||||
"data-onboarding-tour-id": "manage-boards",
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
label: tEntities("apps"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
@@ -94,20 +95,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
label: tEntities("integrations"),
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: t("items.customWidgets"),
|
||||
label: tEntities("customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
label: tEntities("searchEngines"),
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
@@ -117,13 +118,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
label: tEntities("media"),
|
||||
hidden: !session?.user.permissions.includes("media-upload"),
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
icon: IconUserFilled,
|
||||
label: t("items.users.label"),
|
||||
label: tEntities("users"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-users",
|
||||
items: [
|
||||
@@ -133,13 +134,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/users",
|
||||
},
|
||||
{
|
||||
label: t("items.users.items.invites"),
|
||||
label: tEntities("invites"),
|
||||
icon: IconMailForward,
|
||||
href: "/manage/users/invites",
|
||||
hidden: !isProviderEnabled("credentials"),
|
||||
},
|
||||
{
|
||||
label: t("items.users.items.groups"),
|
||||
label: tEntities("groups"),
|
||||
icon: IconUsersGroup,
|
||||
href: "/manage/users/groups",
|
||||
},
|
||||
@@ -170,19 +171,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.logs"),
|
||||
label: tEntities("logs"),
|
||||
icon: IconBrandTablerFilled,
|
||||
href: "/manage/tools/logs",
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.certificates"),
|
||||
label: tEntities("certificates"),
|
||||
icon: IconCertificate,
|
||||
href: "/manage/tools/certificates",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.tasks"),
|
||||
label: tEntities("tasks"),
|
||||
icon: IconClipboardListFilled,
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
|
||||
@@ -11,7 +11,7 @@ interface 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}` : "";
|
||||
|
||||
@@ -23,7 +23,7 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
|
||||
return (
|
||||
<CopyButton value={url}>
|
||||
{({ 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">
|
||||
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -15,13 +15,13 @@ interface DeleteMediaProps {
|
||||
|
||||
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const t = useI18n("media");
|
||||
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||
|
||||
const onClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("media.action.delete.label"),
|
||||
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }),
|
||||
title: t("action.delete.label"),
|
||||
children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onConfirm: async () => {
|
||||
await mutateAsync({ id: media.id });
|
||||
@@ -31,7 +31,7 @@ export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||
};
|
||||
|
||||
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}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { UploadMedia } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const UploadMediaButton = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("media");
|
||||
const onSettledAsync = async () => {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export const UploadMediaButton = () => {
|
||||
<UploadMedia onSettled={onSettledAsync} multiple>
|
||||
{({ onClick, loading }) => (
|
||||
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
{t("action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</UploadMedia>
|
||||
|
||||
@@ -54,14 +54,15 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
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 { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
const canUpload = session.user.permissions.includes("media-upload");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("media.plural")}
|
||||
title={tEntities("media")}
|
||||
primaryAction={
|
||||
canUpload ? (
|
||||
<ManageMobilePrimaryAction>
|
||||
@@ -72,8 +73,8 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
toolbar={
|
||||
<Group>
|
||||
<SearchInput
|
||||
placeholder={`${t("media.search")}...`}
|
||||
ariaLabel={t("media.search")}
|
||||
placeholder={`${tMedia("search")}...`}
|
||||
ariaLabel={tMedia("search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
{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)} />}
|
||||
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 && (
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh></TableTh>
|
||||
<TableTh>{t("media.field.name")}</TableTh>
|
||||
<TableTh>{t("media.field.size")}</TableTh>
|
||||
<TableTh>{t("media.field.creator")}</TableTh>
|
||||
<TableTh>{tCommon("field.name")}</TableTh>
|
||||
<TableTh>{tMedia("field.size")}</TableTh>
|
||||
<TableTh>{tMedia("field.creator")}</TableTh>
|
||||
<TableTh></TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
@@ -113,7 +114,7 @@ interface RowProps {
|
||||
|
||||
const Row = async ({ media }: RowProps) => {
|
||||
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");
|
||||
|
||||
return (
|
||||
@@ -145,7 +146,7 @@ const Row = async ({ media }: RowProps) => {
|
||||
<TableTd w={64}>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<CopyMedia media={media} />
|
||||
<Tooltip label={t("media.action.open.label")} openDelay={500}>
|
||||
<Tooltip label={tMedia("action.open.label")} openDelay={500}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={createLocalImageUrl(media.id)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
const t = await getI18n("management.notFound");
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -11,7 +11,7 @@ import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getScopedI18n("management");
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
@@ -20,7 +20,21 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
export default async function ManagementPage() {
|
||||
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 (
|
||||
<>
|
||||
@@ -28,29 +42,32 @@ export default async function ManagementPage() {
|
||||
<HeroBanner />
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card
|
||||
component={Link}
|
||||
href={statistic.path}
|
||||
key={statistic.path}
|
||||
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="xl" fw={700}>
|
||||
{statistic.count}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||
{statistics.map((statistic) => {
|
||||
const title = getStatisticTitle(statistic.titleKey);
|
||||
return (
|
||||
<Card
|
||||
component={Link}
|
||||
href={statistic.path}
|
||||
key={statistic.path}
|
||||
aria-label={`${statistic.count} ${title}`}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="xl" fw={700}>
|
||||
{statistic.count}
|
||||
</Text>
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<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>
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { searchEngineTypes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
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 { Link } from "@homarr/ui";
|
||||
import { searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
@@ -17,7 +17,7 @@ import { searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
type FormType = z.infer<typeof searchEngineManageSchema>;
|
||||
|
||||
interface SearchEngineFormProps {
|
||||
submitButtonTranslation: (t: TranslationFunction) => string;
|
||||
submitButtonTranslation: (t: ScopedTranslationFunction<"common">) => string;
|
||||
initialValues?: FormType;
|
||||
handleSubmit: (values: FormType) => void;
|
||||
isPending: boolean;
|
||||
@@ -26,7 +26,8 @@ interface SearchEngineFormProps {
|
||||
|
||||
export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
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();
|
||||
|
||||
@@ -46,14 +47,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
<Stack>
|
||||
<Grid>
|
||||
<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 span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
|
||||
<TextInput
|
||||
{...form.getInputProps("short")}
|
||||
disabled={disableShort}
|
||||
withAsterisk
|
||||
label={t("search.engine.field.short.label")}
|
||||
label={t("field.short.label")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
@@ -62,12 +63,12 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<Fieldset legend={t("page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
data={searchEngineTypes.map(
|
||||
(type) =>
|
||||
({
|
||||
label: t(`search.engine.page.edit.searchEngineType.${type}`),
|
||||
label: t(`page.edit.searchEngineType.${type}` as never),
|
||||
value: type,
|
||||
}) satisfies SegmentedControlItem,
|
||||
)}
|
||||
@@ -76,11 +77,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
|
||||
{form.values.type === "generic" && (
|
||||
<TextInput
|
||||
{...form.getInputProps("urlTemplate")}
|
||||
withAsterisk
|
||||
label={t("search.engine.field.urlTemplate.label")}
|
||||
/>
|
||||
<TextInput {...form.getInputProps("urlTemplate")} withAsterisk label={t("field.urlTemplate.label")} />
|
||||
)}
|
||||
|
||||
{form.values.type === "fromIntegration" && (
|
||||
@@ -95,14 +92,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
)}
|
||||
</Fieldset>
|
||||
|
||||
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
||||
<Textarea {...form.getInputProps("description")} label={t("field.description.label")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="default" component={Link} href="/manage/search-engines">
|
||||
{t("common.action.backToOverview")}
|
||||
{tCommon("action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{submitButtonTranslation(t)}
|
||||
{submitButtonTranslation(tCommon)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface SearchEngineDeleteButtonProps {
|
||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||
const t = useScopedI18n("search.engine.page.delete");
|
||||
const tList = useScopedI18n("search.engine.page.list.action");
|
||||
const t = useI18n("search.engine.page.delete");
|
||||
const actionT = useI18n("common.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
||||
|
||||
@@ -57,7 +57,7 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: searchEngine.name })}
|
||||
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
+7
-6
@@ -8,8 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
|
||||
import { SearchEngineForm } from "../../_form";
|
||||
@@ -19,13 +19,14 @@ interface 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 { mutate, isPending } = clientApi.searchEngine.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("success.title"),
|
||||
title: tCommon("notification.update.success"),
|
||||
message: t("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/search-engines").then(() => {
|
||||
@@ -34,7 +35,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("error.title"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: t("error.message"),
|
||||
});
|
||||
},
|
||||
@@ -50,7 +51,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
|
||||
[mutate, searchEngine.id],
|
||||
);
|
||||
|
||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
||||
const submitButtonTranslation = useCallback((t: ScopedTranslationFunction<"common">) => t("action.save"), []);
|
||||
|
||||
return (
|
||||
<SearchEngineForm
|
||||
|
||||
@@ -22,13 +22,13 @@ export default async function SearchEngineEditPage(props: SearchEngineEditPagePr
|
||||
}
|
||||
|
||||
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
||||
const t = await getI18n();
|
||||
const t = await getI18n("search.engine.page.edit");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
|
||||
<Stack>
|
||||
<Title>{t("search.engine.page.edit.title")}</Title>
|
||||
<Title>{t("title")}</Title>
|
||||
<SearchEngineEditForm searchEngine={searchEngine} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
|
||||
@@ -7,14 +7,14 @@ import type { z } from "zod/v4";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
|
||||
import { SearchEngineForm } from "../_form";
|
||||
|
||||
export const SearchEngineNewForm = () => {
|
||||
const t = useScopedI18n("search.engine.page.create.notification");
|
||||
const t = useI18n("search.engine.page.create.notification");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
|
||||
@@ -41,7 +41,7 @@ export const SearchEngineNewForm = () => {
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
||||
const submitButtonTranslation = useCallback((t: ScopedTranslationFunction<"common">) => t("action.create"), []);
|
||||
|
||||
return (
|
||||
<SearchEngineForm
|
||||
|
||||
@@ -15,13 +15,13 @@ export default async function SearchEngineNewPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const t = await getI18n("search.engine.page.create");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("search.engine.page.create.title")}</Title>
|
||||
<Title>{t("title")}</Title>
|
||||
<SearchEngineNewForm />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeApplicationUrl } from "@homarr/common";
|
||||
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 { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
@@ -32,7 +32,8 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const t = await getI18n("search.engine");
|
||||
const tCommon = await getI18n("common");
|
||||
const canCreate = session.user.permissions.includes("search-engine-create");
|
||||
const canModify = session.user.permissions.includes("search-engine-modify-all");
|
||||
const canDelete = session.user.permissions.includes("search-engine-full-all");
|
||||
@@ -40,7 +41,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
|
||||
return (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
title={tCommon("entity.searchEngines")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={searchEngines.length}
|
||||
emptyState={
|
||||
@@ -49,7 +50,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
icon={IconSearch}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/search-engines" }}
|
||||
action={{ label: tCommon("action.clearSearch"), href: "/manage/search-engines" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
@@ -105,7 +106,8 @@ interface SearchEngineItemProps {
|
||||
}
|
||||
|
||||
const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => {
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const t = await getI18n("search.engine");
|
||||
const actionT = await getI18n("common.action");
|
||||
const isIntegration = searchEngine.type === "fromIntegration";
|
||||
const previewUrl =
|
||||
searchEngine.type === "generic" && searchEngine.urlTemplate !== null
|
||||
@@ -158,7 +160,7 @@ const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEn
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: searchEngine.name })}
|
||||
aria-label={actionT("editNamed", { name: searchEngine.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
@@ -16,7 +16,7 @@ interface AnalyticsSettingsProps {
|
||||
}
|
||||
|
||||
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section");
|
||||
const t = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("analytics.title")}>
|
||||
|
||||
Loaded 100 of 543 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user