Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193a536a2d | ||
|
|
c8df2fbe5f | ||
|
|
7c4c1ffbe7 | ||
|
|
84b000afda | ||
|
|
7710abcf26 |
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."
|
||||
@@ -6,19 +6,19 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
|
||||
## Widget advanced view
|
||||
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
||||
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
|
||||
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
|
||||
|
||||
## Board editing
|
||||
|
||||
|
||||
@@ -132,8 +132,8 @@ Here you have the following options in the header:
|
||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
|
||||
it directly. The current board is left out of the results.
|
||||
- **Board switcher**: Use this button to open another board that you can access.
|
||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
|
||||
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
|
||||
stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
|
||||
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
|
||||
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
|
||||
|
||||
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
|
||||
@@ -198,13 +198,9 @@ In the modal you can edit the app that you want to display:
|
||||
|
||||
<figure>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>
|
||||
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
|
||||
</figcaption>
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
</figure>
|
||||
|
||||
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
||||
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
||||
@@ -373,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
|
||||
|
||||
### Board tour
|
||||
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
|
||||
|
||||
### Tour controls
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 68 KiB |
@@ -27,11 +27,6 @@ opening it. Containers include their visible widgets and app icons instead of ap
|
||||
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
||||
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
||||
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
|
||||
arrow keys to move through the results and press `Enter` to open the selected board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
@@ -215,8 +210,7 @@ Containers as destinations before the widget is created, so the common flow does
|
||||
|
||||
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
|
||||
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
|
||||
selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
|
||||
selected item fits in the destination.
|
||||
selection. A move is applied only when every selected item fits in the destination.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
@@ -335,9 +329,8 @@ This will disable the status check for apps.
|
||||
Widgets expose their routine controls without requiring edit mode:
|
||||
|
||||
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
|
||||
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
|
||||
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
|
||||
instead.
|
||||
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
|
||||
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
|
||||
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
|
||||
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
|
||||
not use this widget menu, and available controls follow your board and integration permissions.
|
||||
|
||||
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Bookmarks"
|
||||
description: "Keeps useful links together"
|
||||
description: "Displays multiple app links"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
|
||||
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||
|
||||
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
|
||||
The bookmarks widget allows you to display multiple links in a single widget.
|
||||
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
||||
export const bookmarksWidget: WidgetDefinition = {
|
||||
icon: IconBookmark,
|
||||
name: "Bookmarks",
|
||||
description: "Keeps useful links together",
|
||||
description: "Displays multiple app links",
|
||||
path: "../../widgets/bookmarks",
|
||||
configuration: {
|
||||
items: [
|
||||
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "How bookmarks use the available widget space.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
defaultValue: "Adaptive",
|
||||
},
|
||||
{
|
||||
name: "Appearance",
|
||||
description: "Card surface style.",
|
||||
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
|
||||
defaultValue: "Soft",
|
||||
},
|
||||
{
|
||||
name: "Card spacing",
|
||||
description: "Space between bookmark cards.",
|
||||
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
|
||||
defaultValue: "Extra small",
|
||||
description: "Layout in which the bookmarks are displayed.",
|
||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
||||
defaultValue: "Vertical",
|
||||
},
|
||||
{
|
||||
name: "Hide title",
|
||||
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Bookmarks",
|
||||
description: "URLs and existing Apps shown by the widget.",
|
||||
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||
description: "List of bookmarks to display in the widget.",
|
||||
values: "Select one or more Apps directly, then order them as you like.",
|
||||
defaultValue: "No bookmarks selected",
|
||||
},
|
||||
{
|
||||
name: "Custom links",
|
||||
description: "URLs to display without first creating an App.",
|
||||
values: "List of URLs.",
|
||||
defaultValue: "No custom links",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -69,7 +69,6 @@ import advancedView from "./img/advanced-view.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
- Colored dots directly below a date mark its events. They scale with the widget and are hidden when the calendar is too short.
|
||||
- Hover over a marked date to preview its events in a popover.
|
||||
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
|
||||
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
|
||||
<title id="title">Abstract homelab network</title>
|
||||
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
|
||||
<defs>
|
||||
<radialGradient id="base" cx="50%" cy="40%" r="80%">
|
||||
<stop offset="0" stop-color="#172047"/>
|
||||
<stop offset="0.48" stop-color="#0b1028"/>
|
||||
<stop offset="1" stop-color="#060814"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="indigoGlow">
|
||||
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
|
||||
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="cyanGlow">
|
||||
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop stop-color="#748ffc" stop-opacity="0.05"/>
|
||||
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
|
||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur">
|
||||
<feGaussianBlur stdDeviation="55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1920" height="1080" fill="url(#base)"/>
|
||||
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
|
||||
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
|
||||
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
|
||||
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
|
||||
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
|
||||
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
|
||||
</g>
|
||||
<g fill="#9aa8ff" opacity=".48">
|
||||
<circle cx="248" cy="660" r="3"/>
|
||||
<circle cx="718" cy="511" r="4"/>
|
||||
<circle cx="1118" cy="287" r="3"/>
|
||||
<circle cx="1450" cy="110" r="4"/>
|
||||
</g>
|
||||
<g fill="#5ee0ee" opacity=".42">
|
||||
<circle cx="435" cy="850" r="3"/>
|
||||
<circle cx="908" cy="604" r="4"/>
|
||||
<circle cx="1350" cy="510" r="3"/>
|
||||
<circle cx="1715" cy="285" r="4"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
|
||||
<circle cx="330" cy="90" r="260"/>
|
||||
<circle cx="330" cy="90" r="345"/>
|
||||
<circle cx="1680" cy="940" r="250"/>
|
||||
<circle cx="1680" cy="940" r="335"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userRegistrationSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
|
||||
}
|
||||
|
||||
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
const t = useI18n("user");
|
||||
const t = useScopedI18n("user");
|
||||
const router = useRouter();
|
||||
const { mutate, isPending } = clientApi.user.register.useMutation();
|
||||
const form = useZodForm(userRegistrationSchema, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const t = await getI18n("user.page.invite");
|
||||
const t = await getScopedI18n("user.page.invite");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
|
||||
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
import { userSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -28,7 +28,7 @@ interface LoginFormProps {
|
||||
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
|
||||
|
||||
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
||||
const t = useI18n("user");
|
||||
const t = useScopedI18n("user");
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
|
||||
}
|
||||
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
|
||||
const [visible, { toggle }] = useDisclosure(false);
|
||||
const tForgotPassword = useI18n("user.action.login.forgotPassword");
|
||||
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
|
||||
|
||||
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconLogin } from "@tabler/icons-react";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
import { env as appEnv } from "~/env";
|
||||
@@ -26,7 +26,7 @@ export default async function Login(props: LoginProps) {
|
||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||
}
|
||||
|
||||
const t = await getI18n("user.page.login");
|
||||
const t = await getScopedI18n("user.page.login");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IconLayoutOff } from "@tabler/icons-react";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
|
||||
export default async function BoardNotFoundPage() {
|
||||
const tNotFound = await getI18n("board.error.notFound");
|
||||
const tNotFound = await getScopedI18n("board.error.notFound");
|
||||
return (
|
||||
<BoardNotFound
|
||||
icon={IconLayoutOff}
|
||||
|
||||
@@ -19,11 +19,35 @@
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 0.0625rem solid var(--mantine-color-default-border);
|
||||
outline-offset: -0.0625rem;
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"]::before {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
height: 24px;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
margin-top: -24px;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
line-height: 24px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
content: attr(aria-label);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconBulb } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { FloatingTip } from "@homarr/ui";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||
@@ -16,15 +13,14 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalGridSize,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } 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";
|
||||
@@ -33,17 +29,10 @@ import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||
<GridEditorRegistryProvider>
|
||||
<BoardSelectionProvider>{children}</BoardSelectionProvider>
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board.landmark");
|
||||
const tPreview = useI18n("board.setting.section.layout.preview");
|
||||
const tTips = useI18n("tips");
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const tPreview = useScopedI18n("board.setting.section.layout.preview");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
@@ -61,22 +50,22 @@ export const ClientBoard = () => {
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalGridSize);
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
@@ -91,6 +80,7 @@ export const ClientBoard = () => {
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
@@ -140,19 +130,9 @@ export const ClientBoard = () => {
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
<FloatingTip
|
||||
opened={isEditMode}
|
||||
showDelay={2_000}
|
||||
dismissAfter={3_000}
|
||||
transitionDuration={200}
|
||||
closable={false}
|
||||
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
||||
>
|
||||
{tTips("multiSelectApps")}
|
||||
</FloatingTip>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionGridProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -104,10 +104,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n("board");
|
||||
const t = await getI18n();
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||
icons: {
|
||||
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
|
||||
@@ -29,9 +29,7 @@ const AddMenu = () => {
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const tBoard = useI18n("board");
|
||||
const tIntegration = useI18n("integration");
|
||||
const tContainer = useI18n("section.container");
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -79,7 +77,7 @@ const AddMenu = () => {
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
@@ -88,19 +86,19 @@ const AddMenu = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{tBoard("emptyState.addWidget")}
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{tBoard("emptyState.addApp")}
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("integration-create") && (
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{tIntegration("action.create")}
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{tContainer("action.create")}
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -15,7 +24,9 @@ import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
@@ -42,10 +53,12 @@ const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) return null;
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,11 +68,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -73,9 +88,8 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const tCommon = useI18n("common");
|
||||
const tBoardSetting = useI18n("board.setting");
|
||||
const t = useI18n("board.action.edit");
|
||||
const commonT = useI18n();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const returnToLayoutSettings =
|
||||
isEditMode &&
|
||||
searchParams.get("edit") === "true" &&
|
||||
@@ -90,7 +104,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.update.success"),
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
@@ -101,7 +115,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -164,9 +178,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
const cancelLayoutEdit = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: tBoardSetting("section.layout.edit.cancel.title"),
|
||||
children: tBoardSetting("section.layout.edit.cancel.message"),
|
||||
confirmProps: { children: tCommon("action.discard"), color: "red" },
|
||||
title: commonT("board.setting.section.layout.edit.cancel.title"),
|
||||
children: commonT("board.setting.section.layout.edit.cancel.message"),
|
||||
confirmProps: { children: commonT("common.action.discard"), color: "red" },
|
||||
onConfirm() {
|
||||
void (async () => {
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
@@ -175,7 +189,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
})();
|
||||
},
|
||||
});
|
||||
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
|
||||
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
@@ -186,14 +200,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
<HeaderButton
|
||||
onClick={() => saveBoard(board)}
|
||||
loading={isPending}
|
||||
aria-label={tBoardSetting("section.layout.edit.save")}
|
||||
aria-label={commonT("board.setting.section.layout.edit.save")}
|
||||
>
|
||||
<IconDeviceFloppy stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<HeaderButton
|
||||
onClick={cancelLayoutEdit}
|
||||
disabled={isPending}
|
||||
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
|
||||
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
</HeaderButton>
|
||||
@@ -220,10 +234,50 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -239,13 +293,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: tBoard("action.edit.confirmLeave.title"),
|
||||
children: tBoard("action.edit.confirmLeave.message"),
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
onConfirm() {
|
||||
router.push(target.href);
|
||||
},
|
||||
confirmProps: {
|
||||
children: tCommon("action.discard"),
|
||||
children: t("common.action.discard"),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -271,5 +325,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -16,15 +16,15 @@ export default async function NotFoundBoardHomePage() {
|
||||
|
||||
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
const boardCount = await db.$count(boards);
|
||||
const t = await getI18n("board");
|
||||
const t = await getI18n();
|
||||
|
||||
if (boardCount === 0) {
|
||||
return {
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
title: t("error.noBoard.title"),
|
||||
description: t("error.noBoard.description"),
|
||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("error.noBoard.notice"),
|
||||
title: t("board.error.noBoard.title"),
|
||||
description: t("board.error.noBoard.description"),
|
||||
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("board.error.noBoard.notice"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
|
||||
return {
|
||||
icon: IconHomeOff,
|
||||
title: t("error.homeBoard.title"),
|
||||
description: t(`error.homeBoard.${type}.description` as never),
|
||||
link: { label: t(`error.homeBoard.${type}.link` as never), href },
|
||||
notice: t(`error.homeBoard.${type}.notice` as never),
|
||||
title: t(`board.error.homeBoard.title`),
|
||||
description: t(`board.error.homeBoard.${type}.description`),
|
||||
link: { label: t(`board.error.homeBoard.${type}.link`), href },
|
||||
notice: t(`board.error.homeBoard.${type}.notice`),
|
||||
};
|
||||
};
|
||||
@@ -37,23 +37,22 @@ const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
||||
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
@@ -64,7 +63,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={tBoard("field.opacity.label")}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
@@ -78,7 +77,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={tBoard("field.iconColor.label")}
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
@@ -91,20 +90,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{tBoard("field.clearColor.label")}
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={tBoard("field.itemRadius.label")}
|
||||
description={tBoard("field.itemRadius.description")}
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
|
||||
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.background.title")}>
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={tBoard("field.backgroundImageUrl.label")}
|
||||
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: tBoard("field.backgroundImageUrl.group.your"),
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: tBoard("field.backgroundImageUrl.group.other"),
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={tBoard("field.backgroundImageAttachment.label")}
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={tBoard("field.backgroundImageSize.label")}
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={tBoard("field.backgroundImageRepeat.label")}
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
@@ -176,20 +176,19 @@ const useBackgroundOptionData = <
|
||||
defaultValue: keyof TOptions;
|
||||
},
|
||||
) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
|
||||
return data.values.map(
|
||||
(value) =>
|
||||
({
|
||||
label: tBoard(`field.${key}.option.${value as string}.label` as never),
|
||||
description: tBoard(`field.${key}.option.${value as string}.description` as never),
|
||||
label: t(`board.field.${key}.option.${value as string}.label` as never),
|
||||
description: t(`board.field.${key}.option.${value as string}.description` as never),
|
||||
value: value as string,
|
||||
badge:
|
||||
data.defaultValue === value
|
||||
? {
|
||||
color: "blue",
|
||||
label: tCommon("select.badge.recommended"),
|
||||
label: t("common.select.badge.recommended"),
|
||||
}
|
||||
: undefined,
|
||||
}) satisfies SelectItemWithDescriptionBadge,
|
||||
|
||||
@@ -13,13 +13,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n("board");
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SectionCard title={t("setting.section.behavior.title")}>
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("field.disableStatus.label")}
|
||||
description={t("field.disableStatus.description")}
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const tBoard = useI18n("board");
|
||||
const t = useI18n();
|
||||
|
||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||
{
|
||||
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||
mutate: userMutation.mutate,
|
||||
isPending: userMutation.isPending,
|
||||
}}
|
||||
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
|
||||
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
||||
permission={{
|
||||
items: boardPermissions,
|
||||
default: "view",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
@@ -24,11 +24,11 @@ interface Props {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n("board");
|
||||
const customCssT = useI18n("board.field.customCss");
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("setting.section.customCss.title")}>
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useI18n("board.field.customCss");
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
|
||||
@@ -7,15 +7,14 @@ import { Button } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board.setting");
|
||||
const tCommonAction = useI18n("common.action");
|
||||
const t = useScopedI18n("board.setting");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
@@ -105,11 +104,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
/>
|
||||
)}
|
||||
<DangerZoneItem
|
||||
label={tCommonAction("delete")}
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{tCommonAction("delete")}
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -20,21 +20,25 @@ interface Props {
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n("board");
|
||||
const t = useI18n();
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("setting.section.general.title")}>
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
@@ -42,7 +46,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("field.logoImageUrl.label")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
@@ -50,7 +54,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("field.faviconImageUrl.label")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
@@ -23,8 +23,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tSection = useI18n("section");
|
||||
const t = useI18n();
|
||||
const elements = projectBoardLayout(board, sourceLayout, layout);
|
||||
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
|
||||
@@ -44,13 +42,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
|
||||
{representativeWidth}px
|
||||
</Badge>
|
||||
<Text size="xs">
|
||||
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
|
||||
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
|
||||
{elements.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" ta="center" py="xl">
|
||||
{tBoard("setting.section.layout.preview.empty")}
|
||||
{t("board.setting.section.layout.preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
@@ -78,8 +76,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
|
||||
const containerLabel =
|
||||
container?.kind === "container" && container.options.title
|
||||
? container.options.title
|
||||
: tSection("container.untitled");
|
||||
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
|
||||
: t("section.container.untitled");
|
||||
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
|
||||
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,9 +41,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tLayout = useI18n("layout");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -52,8 +50,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tCommon("notification.update.error"),
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -86,14 +84,15 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const confirmReset = (layout: FormValues["layouts"][number]) => {
|
||||
openConfirmModal({
|
||||
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: tBoard("setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
|
||||
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: t("board.setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
|
||||
onConfirm() {
|
||||
setResettingLayoutId(layout.id);
|
||||
void (async () => {
|
||||
try {
|
||||
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
|
||||
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
@@ -111,14 +110,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const sortedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -126,25 +128,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={nextBreakpoint === null || !baseLayout}
|
||||
onClick={() => {
|
||||
if (nextBreakpoint === null || !baseLayout) return;
|
||||
const newLayout: FormValues["layouts"][number] = {
|
||||
id: createId(),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
};
|
||||
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
||||
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
||||
form.insertListItem("layouts", newLayout, insertionIndex);
|
||||
form.setFieldValue("layouts", [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{tBoard("setting.section.layout.responsive.action.add")}
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
{sortedLayouts.map(({ layout, index }) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
@@ -157,9 +159,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
key={layout.id}
|
||||
legend={
|
||||
<Group gap="xs">
|
||||
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
|
||||
<Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text>
|
||||
<Badge size="sm" variant="default">
|
||||
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
|
||||
{t(`board.setting.section.layout.role.${layout.role}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
@@ -168,10 +170,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
|
||||
<Input.Wrapper label={tLayout("field.columnCount.label")}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
||||
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
@@ -192,11 +194,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Input.Wrapper>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={tLayout("field.breakpoint.label")}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={
|
||||
layout.role === "mobile"
|
||||
? tBoard("setting.section.layout.mobile.breakpointDescription")
|
||||
: tLayout("field.breakpoint.description")
|
||||
? t("board.setting.section.layout.mobile.breakpointDescription")
|
||||
: t("layout.field.breakpoint.description")
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
@@ -244,7 +246,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={isSaving}
|
||||
onClick={() => confirmReset(layout)}
|
||||
>
|
||||
{tBoard("setting.section.layout.reset.action")}
|
||||
{t("board.setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
@@ -260,7 +262,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tCommon("action.remove")}
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -273,8 +275,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
onClick={() => void openLayoutEditorAsync(layout)}
|
||||
>
|
||||
{form.isDirty() || !persistedLayout
|
||||
? tBoard("setting.section.layout.edit.saveAndEdit")
|
||||
: tBoard("setting.section.layout.edit.action")}
|
||||
? t("board.setting.section.layout.edit.saveAndEdit")
|
||||
: t("board.setting.section.layout.edit.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
@@ -320,16 +322,16 @@ const gutterMarks = [
|
||||
];
|
||||
|
||||
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
|
||||
const t = useI18n("layout");
|
||||
const t = useI18n();
|
||||
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
|
||||
|
||||
return (
|
||||
<Paper className={classes.gutterSettings} p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("field.gutters.label")}</Text>
|
||||
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("field.gutters.description")}
|
||||
{t("layout.field.gutters.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -369,7 +371,7 @@ const GutterControl = ({
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n("layout");
|
||||
const t = useI18n();
|
||||
const enabled = value > 0;
|
||||
|
||||
return (
|
||||
@@ -378,10 +380,10 @@ const GutterControl = ({
|
||||
checked={enabled}
|
||||
disabled={max < 1}
|
||||
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
|
||||
label={t(`field.gutters.${side}.label` as never)}
|
||||
label={t(`layout.field.gutters.${side}.label`)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Input.Wrapper label={t("field.gutters.width.label")}>
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
@@ -389,7 +391,7 @@ const GutterControl = ({
|
||||
value={Math.min(value, max)}
|
||||
marks={gutterMarks.filter((mark) => mark.value <= max)}
|
||||
restrictToMarks
|
||||
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
|
||||
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
|
||||
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
@@ -399,7 +401,7 @@ const GutterControl = ({
|
||||
};
|
||||
|
||||
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
|
||||
const t = useI18n("layout");
|
||||
const t = useI18n();
|
||||
const safeColumnCount = Math.max(1, columnCount);
|
||||
const main = Math.max(1, safeColumnCount - left - right);
|
||||
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
|
||||
@@ -409,7 +411,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
|
||||
return (
|
||||
<Box
|
||||
className={classes.gutterPreview}
|
||||
aria-label={t("field.gutters.preview.label")}
|
||||
aria-label={t("layout.field.gutters.preview.label")}
|
||||
style={{ gridTemplateColumns: tracks }}
|
||||
>
|
||||
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
@@ -86,8 +86,8 @@ interface BoardSettingsFormProps {
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n("common");
|
||||
const tSection = useI18n("board.setting.section");
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
@@ -156,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
form.resetDirty(canonicalValues);
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("notification.update.success"),
|
||||
message: t("notification.update.success"),
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
return canonicalValues;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("notification.update.error"),
|
||||
message: t("notification.update.error"),
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -200,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("action.discard")}
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("action.saveChanges")}
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { BoardSettingsForm } from "./_settings-form";
|
||||
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||
const t = await getI18n("board.setting");
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
notFound();
|
||||
|
||||
+3
-4
@@ -16,7 +16,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface RequestDetails {
|
||||
widgetName: string;
|
||||
@@ -31,8 +31,7 @@ interface RequestDetails {
|
||||
}
|
||||
|
||||
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||
const t = useI18n("customWidget.secretEntry");
|
||||
const tSecret = useI18n("customWidget.secret");
|
||||
const t = useScopedI18n("customWidget.secretEntry");
|
||||
const [details, setDetails] = useState<RequestDetails | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
@@ -121,7 +120,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||
return (
|
||||
<Input
|
||||
key={kind}
|
||||
label={tSecret(kind)}
|
||||
label={t(`field.${kind}`)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={values[kind] ?? ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getI18n("management.page.home");
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
|
||||
import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -47,7 +47,7 @@ import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
const t = await getScopedI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
@@ -55,7 +55,7 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const t = await getI18n("management.page.about");
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AppDeleteButtonProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useI18n("app.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const tList = useScopedI18n("app.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.delete.success"),
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tCommon("action.deleteNamed", { name: app.name })}
|
||||
aria-label={tList("delete", { name: app.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface AppEditFormProps {
|
||||
@@ -17,14 +17,14 @@ interface AppEditFormProps {
|
||||
}
|
||||
|
||||
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
const tScoped = useI18n("app.page.edit.notification");
|
||||
const tCommon = useI18n("common");
|
||||
const tScoped = useScopedI18n("app.page.edit.notification");
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.update.success"),
|
||||
title: tScoped("success.title"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
title: tScoped("error.title"),
|
||||
message: tScoped("error.message"),
|
||||
});
|
||||
},
|
||||
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
return (
|
||||
<AppForm
|
||||
buttonLabels={{
|
||||
submit: tCommon("action.save"),
|
||||
submit: t("common.action.save"),
|
||||
}}
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeAppHref } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
@@ -52,8 +52,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { items: [], totalCount: 0 };
|
||||
const t = await getI18n("app");
|
||||
const tCommon = await getI18n("common");
|
||||
const t = await getScopedI18n("app");
|
||||
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
|
||||
|
||||
const emptyState = !canManageAll ? (
|
||||
@@ -68,7 +67,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
@@ -81,7 +80,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={tCommon("entity.apps")}
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={apps.length}
|
||||
emptyState={emptyState}
|
||||
@@ -116,7 +115,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
key={app.id}
|
||||
app={app}
|
||||
canDelete={canDelete}
|
||||
editLabel={tCommon("action.editNamed", { name: app.name })}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
|
||||
+6
-7
@@ -51,7 +51,7 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
|
||||
|
||||
@@ -140,8 +140,7 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
);
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useI18n("management.page.settings.section.assistant");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
@@ -209,7 +208,7 @@ export const AssistantConfiguration = () => {
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
value: providerId,
|
||||
label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"),
|
||||
label: t(`provider.options.${providerId}.label`),
|
||||
}));
|
||||
|
||||
return { group: t(`provider.groups.${category}`), items };
|
||||
@@ -373,7 +372,7 @@ export const AssistantConfiguration = () => {
|
||||
</ThemeIcon>
|
||||
<Box className={classes.summaryProviderText}>
|
||||
<Group gap="xs">
|
||||
<Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text>
|
||||
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
@@ -419,7 +418,7 @@ export const AssistantConfiguration = () => {
|
||||
description={t("connection.description")}
|
||||
status={
|
||||
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
|
||||
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
|
||||
{t(`provider.options.${provider}.label`)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
@@ -759,7 +758,7 @@ export const AssistantConfiguration = () => {
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{tCommon("beta")}
|
||||
{t("serverTools.beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
|
||||
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
<Text c="dimmed" maw="70ch">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
@@ -28,9 +28,8 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useI18n("management.page.board.action");
|
||||
const tRoot = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
@@ -58,7 +57,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
|
||||
const handleDeletion = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: tCommon("action.deleteNamed", { name: board.name }),
|
||||
title: t("delete.confirm.title"),
|
||||
children: t("delete.confirm.description", {
|
||||
name: board.name,
|
||||
}),
|
||||
@@ -69,7 +68,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
|
||||
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
|
||||
|
||||
const handleSetHomeBoard = useCallback(async () => {
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
@@ -98,7 +97,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{tRoot("board.action.duplicate.title")}
|
||||
{t("duplicate.label")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasChangeAccess && (
|
||||
@@ -123,7 +122,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
onClick={handleDeletion}
|
||||
disabled={deleteBoardMutation.isPending}
|
||||
>
|
||||
{tCommon("action.delete")}
|
||||
{t("delete.label")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,13 +10,13 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n("management.page.board.action");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("new.label")}
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
@@ -36,7 +36,7 @@ import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getI18n("management.page.board");
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const session = await auth();
|
||||
const boards = await api.board.getManageOverview();
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useI18n("customWidget.betaBanner");
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
|
||||
@@ -3,22 +3,20 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
|
||||
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const t = useI18n("customWidget.editor");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
{...props}
|
||||
messages={{
|
||||
languageJsx: invariantTechnicalLabels.jsx,
|
||||
languageJson: invariantTechnicalLabels.json,
|
||||
undo: tCommon("action.undo"),
|
||||
languageJsx: t("language.jsx"),
|
||||
languageJson: t("language.json"),
|
||||
undo: t("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
@@ -26,10 +24,10 @@ export function CodeEditor(props: CodeEditorProps) {
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: tCommon("action.copy"),
|
||||
copy: t("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: invariantTechnicalLabels.jsonSchema,
|
||||
schemaTab: t("reference.schema"),
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
@@ -22,7 +22,7 @@ export const CopyAiPromptButton = ({
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useI18n("customWidget");
|
||||
const t = useScopedI18n("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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
@@ -39,7 +39,7 @@ interface WidgetRef {
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useI18n("customWidget");
|
||||
const t = useScopedI18n("customWidget");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ import { useEffect, useState } from "react";
|
||||
import { Accordion } from "@mantine/core";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface AiCardProps {
|
||||
}
|
||||
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useI18n("customWidget.workbench.ai");
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="ai">
|
||||
|
||||
@@ -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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
@@ -52,8 +52,7 @@ export function SaveActions({
|
||||
mode: "create" | "edit";
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("customWidget.workbench");
|
||||
return (
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
@@ -78,7 +77,7 @@ export function SaveActions({
|
||||
{previewPending ? t("action.previewLoading") : t("action.preview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
|
||||
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
@@ -57,9 +57,8 @@ const sectionLinks = [
|
||||
] as const;
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
|
||||
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
@@ -170,7 +169,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput label={tCommon("field.name")} required {...form.getInputProps("name")} />
|
||||
<TextInput label={t("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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
||||
@@ -18,22 +18,20 @@ interface CustomWidgetListProps {
|
||||
}
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const tEntities = useI18n("common.entity");
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (definitions.length === 0) {
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconApi}
|
||||
title={t("page.list.noResults")}
|
||||
action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }}
|
||||
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageCollectionList ariaLabel={tEntities("customWidgets")}>
|
||||
<ManageCollectionList ariaLabel={t("page.list.title")}>
|
||||
{definitions.map((definition) => (
|
||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
||||
))}
|
||||
@@ -44,8 +42,7 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
@@ -97,7 +94,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{tCommon("action.edit")}
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
@@ -125,8 +122,14 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
|
||||
const t = useI18n("customWidget");
|
||||
function CustomWidgetStatusBadges({
|
||||
widget,
|
||||
onConfigureSources,
|
||||
}: {
|
||||
widget: WidgetDef;
|
||||
onConfigureSources(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
|
||||
choices?: Array<{ label: string; value: string | number }>;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
if (control === "switch")
|
||||
return (
|
||||
<Switch
|
||||
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
|
||||
value: Array<{ label: string; value: string | number }>;
|
||||
onChange(value: Array<{ label: string; value: string | number }>): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{value.map((choice, index) => (
|
||||
@@ -150,7 +150,7 @@ export function JsonValueEditor({
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import {
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentName: string, nextName: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const parsed = parseJson(form.values.options);
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const requests = parseJson(form.values.requests);
|
||||
|
||||
+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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
@@ -19,7 +19,7 @@ export function PreviewActionControl({
|
||||
request: CustomJsxRequest & { id: string };
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const actionMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ vi.mock("@homarr/api/client", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key }));
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
|
||||
const rendererAttempt = vi.hoisted(() => vi.fn());
|
||||
const codeEditorProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
@@ -37,9 +37,8 @@ interface PreviewPanelProps {
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
|
||||
const actionT = useI18n("common.action");
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
|
||||
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
@@ -156,7 +155,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={actionT("tryAgain")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } 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 = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("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 = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
+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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -19,7 +19,7 @@ export function CustomWidgetRequestsEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentId: string, nextId: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const parsed = parseJson(form.values.requests);
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const sources = parseJson(form.values.sources);
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
|
||||
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
|
||||
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
|
||||
const t = useI18n("customWidget.workbench.saveError");
|
||||
const t = useScopedI18n("customWidget.workbench.saveError");
|
||||
if (issues.length === 0) return null;
|
||||
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconKey, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
@@ -51,8 +51,7 @@ export function CustomWidgetSourceField({
|
||||
onClearSecret,
|
||||
onRemove,
|
||||
}: CustomWidgetSourceFieldProps) {
|
||||
const t = useI18n("customWidget.workbench.sources");
|
||||
const tSecret = useI18n("customWidget.secret");
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
|
||||
@@ -119,7 +118,7 @@ export function CustomWidgetSourceField({
|
||||
<Group key={kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={tSecret(kind)}
|
||||
label={t(`secret.${kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
|
||||
+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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -21,8 +21,8 @@ export function CustomWidgetSourcesEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.sources");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
const tabs = {
|
||||
@@ -13,7 +13,7 @@ const tabs = {
|
||||
|
||||
/** Switches between the widgets you have installed and the community Workshop. */
|
||||
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
|
||||
const t = useI18n("customWidget.page.tabs");
|
||||
const t = useScopedI18n("customWidget.page.tabs");
|
||||
|
||||
return (
|
||||
<Tabs value={tabs[active]}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Component } from "react";
|
||||
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FormErrorBoundaryState {
|
||||
error: Error | null;
|
||||
@@ -35,8 +35,7 @@ export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErr
|
||||
}
|
||||
|
||||
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("customWidget");
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
|
||||
<Stack gap="xs">
|
||||
@@ -47,7 +46,7 @@ function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }
|
||||
{error.message}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" color="red" onClick={reset}>
|
||||
{tCommon("action.tryAgain")}
|
||||
{t("editor.errorBoundary.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
@@ -11,14 +11,13 @@ import {
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
@@ -39,7 +38,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: tCommon("action.import"),
|
||||
title: t("action.import"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
@@ -48,7 +47,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [queueImport, t, tCommon]);
|
||||
}, [queueImport, t]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -62,13 +61,13 @@ export const ImportCustomWidgetButton = () => {
|
||||
queueImport(result.widget);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: tCommon("action.import"),
|
||||
title: t("action.import"),
|
||||
message: error instanceof Error ? error.message : t("notification.importError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: tCommon("action.import"), message: t("notification.importError") });
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
@@ -82,7 +81,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{tCommon("action.import")}
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -9,9 +9,7 @@ import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
|
||||
const editorProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: (namespace: string) => (key: string) => `${namespace}.${key}`,
|
||||
}));
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("./_code-editor", () => ({
|
||||
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
|
||||
editorProps(props);
|
||||
@@ -43,8 +41,6 @@ describe("preview JSON editor", () => {
|
||||
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({});
|
||||
expect(editorProps).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ error: "customWidget.workbench.builder.invalidJson" }),
|
||||
);
|
||||
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
@@ -18,7 +18,7 @@ export function JsonPreviewEditor({
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
|
||||
+10
-11
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import {
|
||||
applyCustomWidgetAiResponse,
|
||||
@@ -36,17 +36,14 @@ interface FormActionsInput {
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const utils = clientApi.useUtils();
|
||||
const createMutation = clientApi.customWidget.create.useMutation();
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
|
||||
const [previewPending, setPreviewPending] = useState(false);
|
||||
let actionLabel = tCommon("action.save");
|
||||
if (input.mode === "create") actionLabel = tCommon("action.create");
|
||||
|
||||
const save = input.form.onSubmit(async (values) => {
|
||||
clearSaveIssues(input.form, saveIssues);
|
||||
@@ -57,7 +54,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
input.form,
|
||||
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
|
||||
setSaveIssues,
|
||||
actionLabel,
|
||||
t("action.save"),
|
||||
(count) => w("saveError.more", { count }),
|
||||
);
|
||||
return;
|
||||
@@ -68,7 +65,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
|
||||
await utils.customWidget.list.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tCommon("action.create"),
|
||||
title: t("action.create"),
|
||||
message: t("notification.created", { name: values.name }),
|
||||
});
|
||||
input.form.setInitialValues(values);
|
||||
@@ -89,16 +86,18 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
input.form.setInitialValues(values);
|
||||
input.form.resetDirty();
|
||||
showSuccessNotification({
|
||||
title: tCommon("action.save"),
|
||||
title: t("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const issues = extractCustomWidgetSaveIssues(error);
|
||||
if (issues.length > 0) {
|
||||
reportSaveIssues(input.form, issues, setSaveIssues, actionLabel, (count) => w("saveError.more", { count }));
|
||||
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
|
||||
w("saveError.more", { count }),
|
||||
);
|
||||
} else {
|
||||
showErrorNotification({ title: actionLabel, message: t("notification.updateError") });
|
||||
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,8 +8,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const dirtyRef = useRef(isDirty);
|
||||
@@ -17,9 +16,9 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
dirtyRef.current = isDirty;
|
||||
confirmNavigationRef.current = (href) => {
|
||||
openConfirmModal({
|
||||
title: tConfirmLeave("title"),
|
||||
children: tConfirmLeave("message"),
|
||||
confirmProps: { children: tCommon("action.discard") },
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
confirmProps: { children: t("common.action.discard") },
|
||||
onConfirm: () => router.push(href),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { Container, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetBetaBanner } from "../_beta-banner";
|
||||
@@ -14,7 +14,7 @@ export default async function NewCustomWidgetPage() {
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
const t = await getI18n("customWidget");
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconExternalLink, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
@@ -21,12 +21,11 @@ export default async function CustomWidgetsPage() {
|
||||
redirect(session ? "/" : "/auth/login");
|
||||
}
|
||||
const definitions = await api.customWidget.list();
|
||||
const t = await getI18n("customWidget");
|
||||
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={tEntities("customWidgets")}
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<MobileAffixButton
|
||||
@@ -41,7 +40,7 @@ export default async function CustomWidgetsPage() {
|
||||
</MobileAffixButton>
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{tCommon("action.create")}
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</Group>
|
||||
}
|
||||
|
||||
+3
-4
@@ -26,7 +26,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
|
||||
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
|
||||
@@ -45,8 +45,7 @@ import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop
|
||||
const listHref = "/manage/custom-widgets";
|
||||
|
||||
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
|
||||
const t = useI18n("workshop");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -232,7 +231,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
|
||||
}
|
||||
>
|
||||
<Button component={Link} href={listHref} variant="default">
|
||||
{tCommon("action.cancel")}
|
||||
{t("publish.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
+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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
|
||||
@@ -44,7 +44,7 @@ const browseHref = "/manage/custom-widgets/workshop";
|
||||
const installedHref = "/manage/custom-widgets";
|
||||
|
||||
export function WorkshopDetail({ id }: { id: string }) {
|
||||
const t = useI18n("workshop");
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const session = useWorkshopSession();
|
||||
const [reportOpened, reportControls] = useDisclosure(false);
|
||||
|
||||
@@ -5,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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { useWorkshopQuery } from "@homarr/workshop/backend";
|
||||
|
||||
@@ -26,8 +26,7 @@ interface WorkshopBrowseProps {
|
||||
}
|
||||
|
||||
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
const t = useI18n("workshop");
|
||||
const tCommon = useI18n("common.action");
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const query = useWorkshopQuery(session.client, {
|
||||
page,
|
||||
@@ -79,7 +78,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
title={t("emptyFilteredTitle")}
|
||||
description={t("emptyFilteredDescription", { search })}
|
||||
action={{
|
||||
label: tCommon("clearSearch"),
|
||||
label: t("clearSearch"),
|
||||
href: "/manage/custom-widgets/workshop",
|
||||
}}
|
||||
/>
|
||||
@@ -123,7 +122,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
}
|
||||
|
||||
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
|
||||
const t = useI18n("workshop");
|
||||
const t = useScopedI18n("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 } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export default function ManageErrorPage({
|
||||
error,
|
||||
@@ -13,8 +13,8 @@ export default function ManageErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
unstable_retry: () => void;
|
||||
}) {
|
||||
const tError = useI18n("management.error");
|
||||
const tCommon = useI18n("common");
|
||||
const tError = useScopedI18n("management.error");
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
@@ -34,7 +34,7 @@ export default function ManageErrorPage({
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={unstable_retry}>
|
||||
{tCommon("action.tryAgain")}
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
|
||||
const tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
|
||||
{
|
||||
@@ -49,7 +49,7 @@ export const IntegrationAccessSettings = ({ integration, initialPermissions }: P
|
||||
},
|
||||
groupPermissionMapping: integrationPermissionsMap,
|
||||
}}
|
||||
translate={(key) => tIntegration(`permission.${key}` as never)}
|
||||
translate={(key) => t(`integration.permission.${key}`)}
|
||||
query={{
|
||||
data,
|
||||
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
|
||||
|
||||
+7
-8
@@ -25,8 +25,7 @@ interface SecretCardProps {
|
||||
}
|
||||
|
||||
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
const tIntegration = useI18n("integration");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
const { isPublic } = integrationSecretKindObject[secret.kind];
|
||||
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -41,20 +40,20 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
<Avatar>
|
||||
<KindIcon size={16} />
|
||||
</Avatar>
|
||||
<Text fw={500}>{tIntegration(`secrets.kind.${secret.kind}.label` as never)}</Text>
|
||||
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
|
||||
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||
</Group>
|
||||
<Group>
|
||||
{secret.updatedAt ? (
|
||||
<Text c="gray.6" size="sm">
|
||||
{tIntegration("secrets.lastUpdated", {
|
||||
{t("integration.secrets.lastUpdated", {
|
||||
date: dayjs().to(dayjs(secret.updatedAt)),
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Tooltip label={tIntegration("secrets.notSet.tooltip")} position="left">
|
||||
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{tIntegration("secrets.notSet.label")}
|
||||
{t("integration.secrets.notSet.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -63,7 +62,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
onClick={togglePublicSecretDisplay}
|
||||
aria-label={tCommon(publicSecretDisplayOpened ? "action.hide" : "action.show")}
|
||||
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
|
||||
>
|
||||
<DisplayIcon size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
@@ -81,7 +80,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
>
|
||||
{editMode ? tCommon("action.cancel") : tCommon("action.edit")}
|
||||
{editMode ? t("common.action.cancel") : t("common.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
+8
-8
@@ -29,14 +29,14 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
|
||||
};
|
||||
|
||||
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
const tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
const { multiline } = integrationSecretKindObject[kind];
|
||||
if (multiline) {
|
||||
return (
|
||||
<Textarea
|
||||
{...props}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
autosize
|
||||
@@ -48,7 +48,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
@@ -56,7 +56,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
};
|
||||
|
||||
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
const tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
const { multiline } = integrationSecretKindObject[kind];
|
||||
|
||||
@@ -64,8 +64,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
||||
return (
|
||||
<Textarea
|
||||
{...props}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
description={tIntegration("secrets.secureNotice")}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t("integration.secrets.secureNotice")}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
autosize
|
||||
@@ -77,8 +77,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
||||
return (
|
||||
<PasswordInput
|
||||
{...props}
|
||||
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
|
||||
description={tIntegration("secrets.secureNotice")}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t("integration.secrets.secureNotice")}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { SegmentedControl } from "@mantine/core";
|
||||
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FormType {
|
||||
secrets: { kind: IntegrationSecretKind; value: string | null }[];
|
||||
@@ -20,7 +20,7 @@ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
||||
secretKinds,
|
||||
form,
|
||||
}: SecretKindsSegmentedControlProps<TFormType>) => {
|
||||
const t = useI18n("integration.secrets");
|
||||
const t = useScopedI18n("integration.secrets");
|
||||
|
||||
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
|
||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||
|
||||
+9
-9
@@ -14,7 +14,7 @@ interface IntegrationTestConnectionErrorProps {
|
||||
}
|
||||
|
||||
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
|
||||
const tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||
|
||||
return (
|
||||
@@ -22,17 +22,17 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={500} c="red.8">
|
||||
{tIntegration(`testConnection.error.${error.type}.title` as never)}
|
||||
{t(`integration.testConnection.error.${error.type}.title`)}
|
||||
</Text>
|
||||
|
||||
{error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? (
|
||||
<Text size="md">{tIntegration(`testConnection.error.${error.type}.description` as never)}</Text>
|
||||
<Text size="md">{t(`integration.testConnection.error.${error.type}.description`)}</Text>
|
||||
) : null}
|
||||
|
||||
{error.type === "request" ? (
|
||||
<Text size="md">
|
||||
{tIntegration(
|
||||
`testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
|
||||
{t(
|
||||
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -40,7 +40,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{error.type === "statusCode" ? (
|
||||
error.data.reason === "other" ? (
|
||||
<Text size="md">
|
||||
{tIntegration.rich("testConnection.error.statusCode.otherDescription", {
|
||||
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -51,8 +51,8 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="md">
|
||||
{tIntegration.rich("testConnection.error.statusCode.description", {
|
||||
reason: tIntegration(`testConnection.error.statusCode.reason.${error.data.reason}` as never),
|
||||
{t.rich("integration.testConnection.error.statusCode.description", {
|
||||
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -71,7 +71,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="cause">
|
||||
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
|
||||
{tIntegration("testConnection.error.common.cause.title")}
|
||||
{t("integration.testConnection.error.common.cause.title")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
|
||||
+28
-17
@@ -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 } from "@homarr/translation/client";
|
||||
import { useCurrentLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
|
||||
|
||||
@@ -19,7 +19,7 @@ interface CertificateErrorDetailsProps {
|
||||
}
|
||||
|
||||
export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => {
|
||||
const tError = useI18n("integration.testConnection.error");
|
||||
const tError = useScopedI18n("integration.testConnection.error");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const [showRetryButton, setShowRetryButton] = useState(false);
|
||||
@@ -160,35 +160,46 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
};
|
||||
|
||||
const NotEnoughPermissionsAlert = () => {
|
||||
const t = useI18n("integration.testConnection.error.certificate.alert.permission");
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
|
||||
{t("message")}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const HostnameMismatchAlert = () => {
|
||||
const t = useI18n("integration.testConnection.error.certificate.alert.hostnameMismatch");
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
|
||||
{t("message")}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateExtractAlert = () => {
|
||||
const t = useI18n("integration.testConnection.error.certificate.alert.extract");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert icon={<IconExclamationCircle size={16} />} title={t("title")} color="red">
|
||||
{t.rich("message", {
|
||||
<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", {
|
||||
docsLink: () => (
|
||||
<Anchor
|
||||
href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")}
|
||||
target="_blank"
|
||||
>
|
||||
{tCommon("here")}
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
@@ -203,8 +214,8 @@ interface CertificateDetailsProps {
|
||||
export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => {
|
||||
const { openModal } = useModalAction(PemContentModal);
|
||||
const locale = useCurrentLocale();
|
||||
const tDetails = useI18n("integration.testConnection.error.certificate.details");
|
||||
const tCertificateField = useI18n("certificate.field");
|
||||
const tDetails = useScopedI18n("integration.testConnection.error.certificate.details");
|
||||
const tCertificateField = useScopedI18n("certificate.field");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -280,7 +291,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
||||
};
|
||||
|
||||
const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => {
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -307,7 +318,7 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
|
||||
</Card>
|
||||
|
||||
<Button variant="light" color="gray" onClick={actions.closeModal}>
|
||||
{tCommon("action.close")}
|
||||
{t("common.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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useI18n("integration.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const tList = useScopedI18n("integration.page.list.action");
|
||||
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: tCommon("notification.delete.success"),
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/integrations");
|
||||
@@ -44,7 +44,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -53,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
|
||||
aria-label={tList("delete", { name: integration.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
+26
-39
@@ -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, invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
@@ -56,8 +56,7 @@ export const EditIntegrationForm = ({
|
||||
onSuccess,
|
||||
formRef,
|
||||
}: EditIntegrationFormProps) => {
|
||||
const tCommon = useI18n("common");
|
||||
const tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
|
||||
@@ -84,8 +83,8 @@ export const EditIntegrationForm = ({
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tIntegration("page.edit.notification.error.message"),
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -104,8 +103,8 @@ export const EditIntegrationForm = ({
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tIntegration("page.edit.notification.error.message"),
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -125,16 +124,16 @@ export const EditIntegrationForm = ({
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tIntegration("page.edit.notification.error.message"),
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return false;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.update.success"),
|
||||
message: tIntegration("page.edit.notification.success.message"),
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
@@ -143,18 +142,7 @@ export const EditIntegrationForm = ({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[
|
||||
hasUrlSecret,
|
||||
hideButtons,
|
||||
integration.id,
|
||||
mutateAsync,
|
||||
onSuccess,
|
||||
router,
|
||||
tCommon,
|
||||
tIntegration,
|
||||
utils.integration,
|
||||
utils.widget,
|
||||
],
|
||||
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -184,13 +172,13 @@ export const EditIntegrationForm = ({
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={tCommon("field.name")} {...form.getInputProps("name")} />
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={invariantTechnicalLabels.url} {...form.getInputProps("url")} />
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={tIntegration("secrets.title")}>
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
||||
@@ -208,8 +196,8 @@ export const EditIntegrationForm = ({
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: tIntegration("secrets.reset.title"),
|
||||
children: tIntegration("secrets.reset.message"),
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
@@ -220,7 +208,7 @@ export const EditIntegrationForm = ({
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={tIntegration(`secrets.kind.${secret.kind}.newLabel` as never)}
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
@@ -239,7 +227,7 @@ export const EditIntegrationForm = ({
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{tIntegration("secrets.noSecretsRequired.text")}</Text>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -256,10 +244,10 @@ export const EditIntegrationForm = ({
|
||||
{!hideButtons && (
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{tCommon("action.backToOverview")}
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tIntegration("testConnection.action.edit")}
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
@@ -282,8 +270,7 @@ interface IntegrationAppSelectProps {
|
||||
|
||||
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
const { openModal } = useModalAction(AppSelectModal);
|
||||
const tIntegration = useI18n("integration");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
@@ -294,7 +281,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
withCreate: canCreateApps,
|
||||
},
|
||||
{
|
||||
title: tIntegration("page.edit.app.action.select"),
|
||||
title: t("integration.page.edit.app.action.select"),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -307,13 +294,13 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
fullWidth
|
||||
onClick={handleChange}
|
||||
>
|
||||
{tIntegration("page.edit.app.action.add")}
|
||||
{t("integration.page.edit.app.action.add")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fieldset legend={tIntegration("field.app.sectionTitle")}>
|
||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -336,7 +323,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
leftSection={<IconUnlink size={16} stroke={1.5} />}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{tIntegration("page.edit.app.action.remove")}
|
||||
{t("integration.page.edit.app.action.remove")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -344,7 +331,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{tCommon("action.change")}
|
||||
{t("common.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 } from "@homarr/translation/server";
|
||||
import { getI18n, getScopedI18n } 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 getI18n("integration.page.edit");
|
||||
const editT = await getScopedI18n("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,7 +31,6 @@ import {
|
||||
getIntegrationApiKeyUrl,
|
||||
getIntegrationDefaultUrl,
|
||||
getIntegrationName,
|
||||
invariantTechnicalLabels,
|
||||
integrationDefs,
|
||||
} from "@homarr/definitions";
|
||||
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
|
||||
@@ -66,8 +65,7 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
);
|
||||
|
||||
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
|
||||
const tCommon = useI18n("common");
|
||||
const tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const { data: session } = useSession();
|
||||
@@ -76,7 +74,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: tCommon("zod.errors.required"), path: ["appId"] });
|
||||
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
|
||||
});
|
||||
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
@@ -118,15 +116,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
if (data && "error" in data && data.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.create.error"),
|
||||
message: tIntegration("page.create.notification.error.message"),
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.create.success"),
|
||||
message: tIntegration("page.create.notification.success.message"),
|
||||
title: t("integration.page.create.notification.success.title"),
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
onSuccess(data && "integration" in data ? data : undefined);
|
||||
@@ -134,8 +132,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
|
||||
const onMutationError = () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.create.error"),
|
||||
message: tIntegration("page.create.notification.error.message"),
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -168,13 +166,13 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
|
||||
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={invariantTechnicalLabels.url} {...form.getInputProps("url")} />
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={tIntegration("secrets.title")}>
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
|
||||
{form.values.secrets.map(({ kind }, index) => (
|
||||
@@ -187,7 +185,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{tIntegration("secrets.noSecretsRequired.text")}</Text>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<ApiKeySettingsLink kind={kind} url={form.values.url} />
|
||||
@@ -198,8 +196,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={tIntegration("field.attemptSearchEngineCreation.label")}
|
||||
description={tIntegration("field.attemptSearchEngineCreation.description", {
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
kind: getIntegrationName(kind),
|
||||
})}
|
||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||
@@ -211,15 +209,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
<Group justify="end" align="center">
|
||||
{onCancel ? (
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{tCommon("action.backToOverview")}
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{tCommon("action.backToOverview")}
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isCreatePending}>
|
||||
{tIntegration("testConnection.action.create")}
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -230,7 +228,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 tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
|
||||
|
||||
return (
|
||||
@@ -244,18 +242,20 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
||||
checkboxInputProps.onChange(event);
|
||||
});
|
||||
}}
|
||||
label={tIntegration(canCreateApps ? "field.createApp.label" : "field.linkApp.label")}
|
||||
description={tIntegration(canCreateApps ? "field.createApp.description" : "field.linkApp.description")}
|
||||
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
|
||||
description={t(
|
||||
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapse expanded={form.values.hasApp}>
|
||||
<Fieldset legend={tIntegration("field.app.sectionTitle")}>
|
||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||
<Stack gap="sm">
|
||||
{canCreateApps && (
|
||||
<SegmentedControl
|
||||
data={(["new", "existing"] as const).map((value) => ({
|
||||
value,
|
||||
label: tIntegration(`page.create.app.option.${value}.title` as never),
|
||||
label: t(`integration.page.create.app.option.${value}.title`),
|
||||
}))}
|
||||
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={tIntegration("field.appHref.placeholder")}
|
||||
placeholder={t("integration.field.appHref.placeholder")}
|
||||
withAsterisk
|
||||
label={tIntegration("page.create.app.option.new.url.label")}
|
||||
description={tIntegration("page.create.app.option.new.url.description")}
|
||||
label={t("integration.page.create.app.option.new.url.label")}
|
||||
description={t("integration.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 tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
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">{tIntegration("field.apiKeySettings.label")}</Text>
|
||||
<Text size="sm">{t("integration.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 tIntegration = useI18n("integration");
|
||||
const t = useI18n();
|
||||
|
||||
const appMap = new Map(data?.map((app) => [app.id, app] as const));
|
||||
|
||||
return (
|
||||
<Select
|
||||
withAsterisk
|
||||
label={tIntegration("page.create.app.option.existing.label")}
|
||||
label={t("integration.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 { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } 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 getI18n("integration");
|
||||
const t = await getScopedI18n("integration");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
@@ -44,7 +44,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
||||
);
|
||||
}
|
||||
|
||||
const tCreate = await getI18n("integration.page.create");
|
||||
const tCreate = await getScopedI18n("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 { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
@@ -37,8 +37,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
} = getIntegrationsSectionAccess(session, integrations);
|
||||
if (!canAccess) notFound();
|
||||
|
||||
const t = await getI18n("integration");
|
||||
const tCommon = await getI18n("common");
|
||||
const t = await getScopedI18n("integration");
|
||||
// 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(
|
||||
@@ -62,7 +61,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={tCommon("entity.integrations")}
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
@@ -78,7 +77,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: tCommon("action.clearSearch"), href: "/manage/integrations" }}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/integrations" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
@@ -140,7 +139,7 @@ interface IntegrationItemProps {
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
const tCommon = await getI18n("common");
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
|
||||
@@ -179,7 +178,7 @@ const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={tCommon("action.editNamed", { name: integration.name })}
|
||||
aria-label={t("page.list.action.edit", { 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 { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
@@ -61,9 +61,8 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
|
||||
getI18n("management.navbar"),
|
||||
getI18n("common.entity"),
|
||||
const [t, session, shouldRunManageTour] = await Promise.all([
|
||||
getScopedI18n("management.navbar"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
@@ -79,13 +78,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
icon: IconLayoutDashboardFilled,
|
||||
href: "/manage/boards",
|
||||
label: tEntities("boards"),
|
||||
label: t("items.boards"),
|
||||
"data-onboarding-tour-id": "manage-boards",
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: tEntities("apps"),
|
||||
label: t("items.apps"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
@@ -95,20 +94,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: tEntities("integrations"),
|
||||
label: t("items.integrations"),
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: tEntities("customWidgets"),
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
label: tEntities("searchEngines"),
|
||||
label: t("items.searchEngies"),
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
@@ -118,13 +117,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: tEntities("media"),
|
||||
label: t("items.medias"),
|
||||
hidden: !session?.user.permissions.includes("media-upload"),
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
icon: IconUserFilled,
|
||||
label: tEntities("users"),
|
||||
label: t("items.users.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-users",
|
||||
items: [
|
||||
@@ -134,13 +133,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/users",
|
||||
},
|
||||
{
|
||||
label: tEntities("invites"),
|
||||
label: t("items.users.items.invites"),
|
||||
icon: IconMailForward,
|
||||
href: "/manage/users/invites",
|
||||
hidden: !isProviderEnabled("credentials"),
|
||||
},
|
||||
{
|
||||
label: tEntities("groups"),
|
||||
label: t("items.users.items.groups"),
|
||||
icon: IconUsersGroup,
|
||||
href: "/manage/users/groups",
|
||||
},
|
||||
@@ -171,19 +170,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: tEntities("logs"),
|
||||
label: t("items.tools.items.logs"),
|
||||
icon: IconBrandTablerFilled,
|
||||
href: "/manage/tools/logs",
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
label: tEntities("certificates"),
|
||||
label: t("items.tools.items.certificates"),
|
||||
icon: IconCertificate,
|
||||
href: "/manage/tools/certificates",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: tEntities("tasks"),
|
||||
label: t("items.tools.items.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("media");
|
||||
const t = useI18n();
|
||||
|
||||
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("action.copy.label")} openDelay={500}>
|
||||
<Tooltip label={t("media.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("media");
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||
|
||||
const onClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("action.delete.label"),
|
||||
children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
|
||||
title: t("media.action.delete.label"),
|
||||
children: t.rich("media.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("action.delete.label")} openDelay={500}>
|
||||
<Tooltip label={t("media.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("media");
|
||||
const t = useI18n();
|
||||
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("action.upload.label")}
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</UploadMedia>
|
||||
|
||||
@@ -54,15 +54,14 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const tMedia = await getI18n("media");
|
||||
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
|
||||
const t = await getI18n();
|
||||
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={tEntities("media")}
|
||||
title={t("media.plural")}
|
||||
primaryAction={
|
||||
canUpload ? (
|
||||
<ManageMobilePrimaryAction>
|
||||
@@ -73,8 +72,8 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
toolbar={
|
||||
<Group>
|
||||
<SearchInput
|
||||
placeholder={`${tMedia("search")}...`}
|
||||
ariaLabel={tMedia("search")}
|
||||
placeholder={`${t("media.search")}...`}
|
||||
ariaLabel={t("media.search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
@@ -85,15 +84,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={tMedia("noResults.title")} />}
|
||||
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
|
||||
{medias.length > 0 && (
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh></TableTh>
|
||||
<TableTh>{tCommon("field.name")}</TableTh>
|
||||
<TableTh>{tMedia("field.size")}</TableTh>
|
||||
<TableTh>{tMedia("field.creator")}</TableTh>
|
||||
<TableTh>{t("media.field.name")}</TableTh>
|
||||
<TableTh>{t("media.field.size")}</TableTh>
|
||||
<TableTh>{t("media.field.creator")}</TableTh>
|
||||
<TableTh></TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
@@ -114,7 +113,7 @@ interface RowProps {
|
||||
|
||||
const Row = async ({ media }: RowProps) => {
|
||||
const session = await auth();
|
||||
const tMedia = await getI18n("media");
|
||||
const t = await getI18n();
|
||||
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
|
||||
|
||||
return (
|
||||
@@ -146,7 +145,7 @@ const Row = async ({ media }: RowProps) => {
|
||||
<TableTd w={64}>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<CopyMedia media={media} />
|
||||
<Tooltip label={tMedia("action.open.label")} openDelay={500}>
|
||||
<Tooltip label={t("media.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 { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getI18n("management.notFound");
|
||||
const t = await getScopedI18n("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 { getI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } 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 getI18n("management");
|
||||
const t = await getScopedI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
@@ -20,21 +20,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
export default async function ManagementPage() {
|
||||
const statistics = await api.home.getStats();
|
||||
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]);
|
||||
};
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,32 +28,29 @@ export default async function ManagementPage() {
|
||||
<HeroBanner />
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{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}
|
||||
{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}`)}
|
||||
</Text>
|
||||
<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" />
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||
</Group>
|
||||
</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 { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import type { TranslationFunction } 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: ScopedTranslationFunction<"common">) => string;
|
||||
submitButtonTranslation: (t: TranslationFunction) => string;
|
||||
initialValues?: FormType;
|
||||
handleSubmit: (values: FormType) => void;
|
||||
isPending: boolean;
|
||||
@@ -26,8 +26,7 @@ interface SearchEngineFormProps {
|
||||
|
||||
export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
||||
const t = useI18n("search.engine");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useI18n();
|
||||
|
||||
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
|
||||
|
||||
@@ -47,14 +46,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={tCommon("field.name")} />
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
|
||||
<TextInput
|
||||
{...form.getInputProps("short")}
|
||||
disabled={disableShort}
|
||||
withAsterisk
|
||||
label={t("field.short.label")}
|
||||
label={t("search.engine.field.short.label")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
@@ -63,12 +62,12 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("page.edit.configControl")}>
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
data={searchEngineTypes.map(
|
||||
(type) =>
|
||||
({
|
||||
label: t(`page.edit.searchEngineType.${type}` as never),
|
||||
label: t(`search.engine.page.edit.searchEngineType.${type}`),
|
||||
value: type,
|
||||
}) satisfies SegmentedControlItem,
|
||||
)}
|
||||
@@ -77,7 +76,11 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
|
||||
{form.values.type === "generic" && (
|
||||
<TextInput {...form.getInputProps("urlTemplate")} withAsterisk label={t("field.urlTemplate.label")} />
|
||||
<TextInput
|
||||
{...form.getInputProps("urlTemplate")}
|
||||
withAsterisk
|
||||
label={t("search.engine.field.urlTemplate.label")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.values.type === "fromIntegration" && (
|
||||
@@ -92,14 +95,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
)}
|
||||
</Fieldset>
|
||||
|
||||
<Textarea {...form.getInputProps("description")} label={t("field.description.label")} />
|
||||
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="default" component={Link} href="/manage/search-engines">
|
||||
{tCommon("action.backToOverview")}
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{submitButtonTranslation(tCommon)}
|
||||
{submitButtonTranslation(t)}
|
||||
</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 { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface SearchEngineDeleteButtonProps {
|
||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||
const t = useI18n("search.engine.page.delete");
|
||||
const actionT = useI18n("common.action");
|
||||
const t = useScopedI18n("search.engine.page.delete");
|
||||
const tList = useScopedI18n("search.engine.page.list.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={actionT("deleteNamed", { name: searchEngine.name })}
|
||||
aria-label={tList("delete", { name: searchEngine.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
+6
-7
@@ -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 { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
|
||||
import { SearchEngineForm } from "../../_form";
|
||||
@@ -19,14 +19,13 @@ interface SearchEngineEditFormProps {
|
||||
}
|
||||
|
||||
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
|
||||
const t = useI18n("search.engine.page.edit.notification");
|
||||
const tCommon = useI18n("common");
|
||||
const t = useScopedI18n("search.engine.page.edit.notification");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.update.success"),
|
||||
title: t("success.title"),
|
||||
message: t("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/search-engines").then(() => {
|
||||
@@ -35,7 +34,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.update.error"),
|
||||
title: t("error.title"),
|
||||
message: t("error.message"),
|
||||
});
|
||||
},
|
||||
@@ -51,7 +50,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
|
||||
[mutate, searchEngine.id],
|
||||
);
|
||||
|
||||
const submitButtonTranslation = useCallback((t: ScopedTranslationFunction<"common">) => t("action.save"), []);
|
||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.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("search.engine.page.edit");
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
<Title>{t("search.engine.page.edit.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 { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
|
||||
import { SearchEngineForm } from "../_form";
|
||||
|
||||
export const SearchEngineNewForm = () => {
|
||||
const t = useI18n("search.engine.page.create.notification");
|
||||
const t = useScopedI18n("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: ScopedTranslationFunction<"common">) => t("action.create"), []);
|
||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
||||
|
||||
return (
|
||||
<SearchEngineForm
|
||||
|
||||
Loaded 100 of 575 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user