Compare commits

...
Author SHA1 Message Date
Thomas 062fda642c Merge branch 'release/v2' into feat/widget-integration-dx 2026-08-30 18:31:24 +02:00
Bergasha 7d1d9062ed fix(calendar): keep date numbers aligned regardless of indicator dots (#6742) 2026-08-29 09:14:53 +02:00
Thomas 8d0b13b309 fix(board): correct widget scaling and edit layering (#6741) 2026-08-29 09:12:16 +02:00
Bergasha ea250d418d Revert "feat(header): add opt-in auto-hide-on-scroll for the header (#6738)" (#6740)
This reverts commit e58c229664.
2026-08-29 15:19:20 +10:00
Bergasha e58c229664 feat(header): add opt-in auto-hide-on-scroll for the header (#6738)
* feat(header): add opt-in auto-hide-on-scroll for the header

New per-user header preference (default off): when enabled, the
header slides away while scrolling down and reappears on scroll-up or
near the top of the page. Disabled while in board edit mode, and
skipped entirely while focus is inside the header. Only applies when
the header is set to visible - the existing hover-to-reveal floating
controls already cover the hidden-header case.

* chore(api): regenerate the checked-in OpenAPI schema

check:openapi was failing in CI - the schema on disk hadn't been
regenerated after the header preferences gained autoHideOnScroll.

Add scripts/generate-openapi-schema.mts (and a generate:openapi
package script) alongside the existing check script so this is a
one-command fix next time the schema drifts.

* docs(users): document the header studio's visibility and auto-hide options

Header customization (drag-to-arrange controls, hide/show, hover-reveal
and pin, auto-hide-on-scroll) had no documentation on the user general
settings page.
2026-08-29 14:56:02 +10:00
Thomas ee08beef59 fix(widgets): use readable onboarding sizes (#6739) 2026-08-29 06:55:31 +02:00
Bergasha 10ba298233 fix(board): beszel & calendar scale fix (#6737)
* fix(calendar): add cell spacing and scale day content with widget size

* fix(beszel): stop chart panels from leaving blank grid columns and size charts to fit the widget

* fix(widgets): address CodeRabbit review on calendar/beszel scaling PR

- beszel: account for the SimpleGrid row gap and the Stack gap between
  the controls row and the grid in the available-height budget, so a
  two-row layout no longer overflows the widget.
- calendar: memoize the day-cell scale lookup instead of recomputing
  it on every render.
- calendar: don't render the notification indicator row when none of
  a day's events have an indicatorColor, which was shifting the date
  number up with no visible dot to show for it.

Skipped one finding: the claim that .day's absolute inset:0 covers
monthCell's padding. It doesn't - the containing block for an
absolutely positioned element is the padding edge of its positioned
ancestor per the CSS spec, so the padding correctly insets .day. This
was also visually verified in the browser while building the fix.

* fix(beszel): don't treat availableHeight=0 as omitted in computeChartHeight

A falsy check on availableHeight also caught the legitimate value 0
(widget shorter than its own padding+controls), falling back to the
full 180px height instead of clamping to MIN_CHART_HEIGHT - the exact
overflow this height budget was meant to prevent.
2026-08-29 11:02:10 +10:00
Bergasha 7855278ed1 fix(board): give AppShell root a viewport-height floor (#6736)
* fix(board): give AppShell root a viewport-height floor when a background is set

The board canvas sizes itself to its content, and AppShell runs in
'static' mode so <main> doesn't force a 100dvh minimum either. That
means a board's background image only covers as much height as the
content needs - collapsing a container (or any board with short
content) leaves flat page background showing below it instead of the
background continuing to the bottom of the viewport, looking like a
big empty void.

Root-caused via a local repro: the grid/reflow system was already
shrinking the collapsed container's own space correctly (verified the
live-measured canvas height matched exactly, not the stale debug
attribute) - the void was purely a background-sizing issue on the
AppShell root, unrelated to the collapse/layout logic itself.

* docs(boards): document background minimum-height behavior
2026-08-29 09:26:15 +10:00
Thomas aa179f1ec0 fix: decouple responsive layout roles from breakpoints
* fix: rework responsive layout breakpoints

* fix: keep widget edit overlays visible

* fix: inset widget edit action

* fix: use valid widget inset token
2026-08-28 17:11:51 +02:00
Thomas b53042f055 Merge branch 'release/v2' into feat/widget-integration-dx 2026-08-28 14:08:34 +02:00
Bergasha f455e71be5 fix(board): remove per-widget refresh indicator (#6731)
The small refresh-circle overlay that appeared in a widget's top-right
corner during a background refetch wasn't adding useful signal and
just added visual noise. Drop the isWidgetRefreshing detection, the
indicator markup/CSS, and the now-dead query-key/runtime-query
plumbing that only existed to power it.

Updated the lazy-widgets e2e test to drop its assertions on the now
-removed [data-widget-refreshing] marker; the behavior it was really
verifying (cached content stays visible during a background refetch,
which completes cleanly) is still covered by the existing network
-level checks in the same test.
2026-08-28 17:36:41 +10:00
Thomas f221a5b4d3 fix: respect mobile header and widget interactions (#6730) 2026-08-28 08:31:41 +02:00
Bergasha c18fa8cf77 fix(widgets): replace system-resources chart tooltip with Mantine (#6729)
* fix(widgets): replace system-resources chart tooltip with Mantine Tooltip.Floating
2026-08-28 15:08:22 +10:00
Bergasha 5f572f1b92 fix(board): reserve and scale space for collapsible container header (#6727) 2026-08-28 10:59:48 +10:00
Bergasha a7aa89fafe feat(board+clock): fix item radius zoom, add clock day/night weather colour (#6726) 2026-08-28 10:22:48 +10:00
Thomas e723cbe6dd Merge pull request #6725 from homarr-labs/codex/fix-demo-read-only
fix(cli): make demo mode writable
2026-08-27 22:45:49 +02:00
ajnart 411e75e799 fix(cli): make demo mode writable 2026-08-27 22:41:23 +02:00
ajnart bf0eed7607 fix: preserve typed media translations 2026-08-27 14:50:06 +02:00
ajnart 8e29f7aa18 merge: sync release/v2 2026-08-27 14:47:01 +02:00
Thomas 0553d60a58 fix: prevent widget move registry crash (#6717) 2026-08-27 14:40:00 +02:00
Thomas 7da2e10064 fix: address release v2 review findings (#6708) 2026-08-27 13:59:42 +02:00
Thomas a002b91add feat: reduce disruptive UI confirmations (#6688)
* feat: reduce disruptive UI confirmations

* fix: keep destructive confirmations red
2026-08-27 12:01:36 +02:00
ajnart 5ac4a4cffc fix: address review feedback 2026-08-26 17:36:02 +02:00
ajnart ac7e3f2ef4 Merge remote-tracking branch 'origin/release/v2' into feat/pr-6691-hardening
# Conflicts:
#	apps/nextjs/src/app/[locale]/layout.tsx
#	packages/api/src/router/widgets/anchor-notes.ts
#	packages/api/src/router/widgets/archive-team-warrior.ts
#	packages/api/src/router/widgets/audio-stats.ts
#	packages/api/src/router/widgets/bazarr.ts
#	packages/api/src/router/widgets/coolify.ts
#	packages/api/src/router/widgets/firewall.ts
#	packages/api/src/router/widgets/immich.ts
#	packages/api/src/router/widgets/paperless-ngx.ts
#	packages/api/src/router/widgets/patchmon.ts
#	packages/api/src/router/widgets/speedtest-tracker.ts
#	packages/api/src/router/widgets/tracearr.ts
#	packages/api/src/router/widgets/traefik.ts
#	packages/api/src/router/widgets/umami.ts
#	packages/api/src/router/widgets/uptime-kuma.ts
#	packages/api/src/router/widgets/vpn.ts
#	packages/api/src/router/widgets/wud.ts
#	packages/definitions/src/widget-integration-map.ts
#	packages/widgets/src/anchor-note/index.ts
#	packages/widgets/src/archive-team-warrior/index.ts
#	packages/widgets/src/audio-stats/index.ts
#	packages/widgets/src/bazarr/index.ts
#	packages/widgets/src/coolify/index.ts
#	packages/widgets/src/firewall/index.ts
#	packages/widgets/src/immich/album-carousel/index.ts
#	packages/widgets/src/immich/server-stats/index.ts
#	packages/widgets/src/paperless-ngx/index.ts
#	packages/widgets/src/patchmon/index.ts
#	packages/widgets/src/speedtest-tracker/index.ts
#	packages/widgets/src/system-disks/index.ts
#	packages/widgets/src/system-resources/index.ts
#	packages/widgets/src/tracearr/index.ts
#	packages/widgets/src/traefik/index.ts
#	packages/widgets/src/umami/index.ts
#	packages/widgets/src/uptime-kuma/index.ts
#	packages/widgets/src/vpn/index.ts
#	packages/widgets/src/wud/index.ts
2026-08-26 15:12:49 +02:00
ajnart 47724f44eb fix: harden widget request caching 2026-08-26 14:57:53 +02:00
ajnart c81088d876 Merge remote-tracking branch 'origin/release/v2' into feat/widget-integration-dx
# Conflicts:
#	packages/api/src/router/board.ts
2026-08-25 10:09:03 +02:00
ajnart 9bf266935b refactor: address adversarial widget architecture review 2026-08-25 10:07:32 +02:00
Thomas Camlong 0ca62f1232 fix(request-handler): prevent shared-cache stampede while a refresh lock is held
Waiters that lost the refresh lock polled the shared cache for only 750ms
before falling through to the upstream, but the refresh lock lives for 15s.
On a cold cache with an upstream slower than 750ms, every waiting pod gave up
and stampeded the origin (each also writing L2). Extend the poll window to
match the lock TTL so waiters return the entry the holder publishes instead.

Adds a regression test that fails without the fix.
2026-08-24 15:26:30 +02:00
ajnart 4ca17d8e8b merge: sync release/v2 2026-08-24 09:53:48 +02:00
ajnart 24e6e4a47d refactor: tighten TypeScript boundaries 2026-08-24 09:38:30 +02:00
ajnart ad0120fe01 fix: guard service example timestamp 2026-08-24 03:15:23 +02:00
ajnart 17c47ac9d1 fix: harden custom widget AI authoring 2026-08-24 02:59:41 +02:00
ajnart 97e0adbb3a fix: separate timetable address families 2026-08-24 01:52:55 +02:00
ajnart addf1c7335 test: update Beszel Redis mocks 2026-08-24 01:36:29 +02:00
ajnart 8f9efd0074 refactor: simplify widget and integration architecture 2026-08-24 00:57:36 +02:00
ajnart c988c35b29 refactor: finish feature module tooling 2026-08-23 22:39:06 +02:00
ajnart 425cb653a1 refactor: streamline widget and integration architecture 2026-08-23 22:33:29 +02:00
369 changed files with 8401 additions and 5573 deletions

No files matched your search

+10 -6
View File
@@ -40,7 +40,7 @@ homarr/
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
│ ├── core/ # Env validation, DB/Redis driver factories, logging
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
│ ├── widgets/ # All 39 dashboard widgets (definitions + components)
│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry
│ ├── integrations/ # Integration classes (HTTP clients to external apps)
│ ├── redis/ # Redis pub/sub channels, caching abstractions
│ ├── translation/ # next-intl setup, locale configs, lang JSON files
@@ -118,11 +118,15 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic`
- Fetches data via tRPC client hooks (backed by cron job caches)
Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in
`packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and
`componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`.
Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal
module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so
`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while
keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected
entries so later loads can retry. Do not restore an eager root `widgetImports` map.
### Integration System
+13 -7
View File
@@ -320,8 +320,12 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [preview-amd64, preview-arm64]
needs: [container, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -375,9 +379,6 @@ jobs:
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: |
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
fi
docker buildx imagetools create "${tags[@]}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
@@ -386,9 +387,6 @@ jobs:
if: "!env.ACT"
run: |
tags=("${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=("pr-6545")
fi
for tag in "${tags[@]}"; do
image="${PREVIEW_IMAGE}:${tag}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
@@ -435,6 +433,14 @@ jobs:
core.info(`No package version found with tag "${tag}"`);
return;
}
const targetTags = target.metadata?.container?.tags ?? [];
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
if (extraTags.length > 0) {
core.setFailed(
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
@@ -0,0 +1,102 @@
---
title: Widgets and integrations
tags:
- Developer
- Technical documentation
- Widgets
- Integrations
sidebar_position: 2
---
Homarr keeps feature registration explicit and typed. Feature registration has no generator or generated registry.
A new feature is available when its typed metadata and lazy loader are registered.
## Add a widget
Keep the UI, options, helpers, and tests together in `packages/widgets/src/<widget>/`. The usual folder only needs an
`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed.
1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in
`packages/definitions/src/docs/widget-doc-slugs.ts`.
2. Export `definition` and `componentLoader` from `packages/widgets/src/<widget>/index.ts` with
`createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and
errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard.
3. For an integration-backed widget, add its integration kinds and selection rules once to
`packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local
definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without
duplicating widget metadata.
4. Register the widget in both explicit literal loader maps and the type-only module map in
`packages/widgets/src/registry.ts`, then add its icon to `packages/ui/src/widget-icons.ts`. The module and component
loaders must both use literal import paths. Keeping them separate lets a cold widget load request its definition
and component chunks in parallel; the type-only map preserves exact option and component inference. Keep all three
maps in the same order so drift is obvious in review.
5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit
lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in
`@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures.
6. Add canonical documentation in `apps/docs/docs/widgets/<slug>/`.
Weather is the small integration-free example. Downloads shows integration selection, polling, server caching,
mutations, partial upstream failures, and a larger options surface.
## Add an integration
Keep the HTTP client and its capability-specific types in `packages/integrations/src/<integration>/`.
1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives,
categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant.
2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the
implementation out of the initial server bundle and makes the registration easy to find.
3. Implement only the interfaces required by the widgets that use the service.
4. Add canonical documentation in `apps/docs/docs/integrations/<slug>/`. Its `index.ts` must satisfy
`IntegrationDefinition` so invalid metadata fails type checking without widening the object. Use a type-only import
and `satisfies IntegrationDefinition`; do not cast the object.
Beszel is a compact example with several widgets sharing one integration client.
## Write user documentation
Write for technically capable self-hosters. Keep each page short and operational.
- Start with one sentence describing what the integration or widget adds to Homarr.
- Include prerequisites, credentials, permissions, destructive actions, and non-obvious limits only when they matter.
- Use numbered steps only when order matters. Prefer the exact UI labels a user must select.
- Do not add marketing, implementation history, architecture internals, or explanations of basic self-hosting concepts.
- End when the user can configure and verify the feature; do not repeat the same information in a summary.
The usual page needs only an introduction and a `Configuration` section. Add `Notes` or `Troubleshooting` only for
real caveats.
## Data and errors
- TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching for bounded key
spaces. Public handlers whose keys come from arbitrary URLs, domains, symbols, or coordinates stay in the bounded
process-local cache.
- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys
include keyed credential fingerprints and the complete integration identity, and are invalidated after mutations.
The fingerprints change with credentials without exposing their values in Redis keys.
- Give upstream requests a finite deadline and pass the request handler's `AbortSignal` into clients that support it.
Admission is bounded per handler. Request deduplication and distributed lock ownership remain active until the
upstream settles or a bounded cleanup grace expires, while unresolved upstream work stays separately capped.
- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards.
- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log
secrets or full upstream payloads.
- Multi-integration reads should return successful providers when one service fails and surface a clear error when all
providers fail.
Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact
TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable.
## Registry imports
Use `@homarr/widgets/manifest` to load registered widgets. `loadWidgetDefinition` is the metadata-only path;
`loadWidgetComponent` loads only the UI module; and `loadWidgetResources` requests both paths in parallel. These
loaders cache in-flight and successful promises and remove rejected promises so a later request can retry.
The former root `widgetImports` and `loadWidgetDynamic` exports are intentionally not compatibility aliases.
`widgetImports` synchronously imported every widget module, and `loadWidgetDynamic` depended on that eager map.
Restoring them at the package root would defeat lazy definition chunks or add client-only runtime dependencies to
every root consumer. Migrate metadata consumers to `loadWidgetDefinition`, renderers to `loadWidgetResources`, and
type-only registry consumers from the removed internal `WidgetImportRecord` helper to the root `WidgetImports` type.
`@homarr/widgets/catalog` remains as a deprecated compatibility subpath for `widgetCatalogIcons`. New imports should
use `@homarr/ui/widget-icons`, which is the canonical server-safe icon catalog.
+3 -3
View File
@@ -1,9 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
import type { IntegrationDefinition } from "@site/src/types";
export const beszelIntegration: IntegrationDefinition = {
export const beszelIntegration = {
name: "Beszel",
description:
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
path: "../../integrations/beszel",
};
} satisfies IntegrationDefinition;
+8
View File
@@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
You can obtain API keys from the "Authentication" tab at the top of the page.
API keys will not expire and can be used indefinitely.
When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the
creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation
instead of opening a separate dialog.
### API Key Format
@@ -110,8 +113,13 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Creating, duplicating, or reconfiguring a widget validates its integration selection on the server. The caller needs
use access for each newly linked integration; unchanged links remain editable through normal board permissions.
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
+20 -12
View File
@@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo
## Create a new board
When creating a board, the availability of the board name will be checked for you.
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
![](./img/create-board-modal.png)
The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that
board, and create the copy without navigating to a separate page.
### Name
@@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the
same menu item. No separate confirmation dialog is opened.
## Board settings
You navigate to the board settings by clicking on these three dots:
@@ -128,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling.
- **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the widest layout and starts at `768px` on new boards.
- **Base** is the main layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
their breakpoints.
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
unused breakpoint above Mobile.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
available breakpoint, and you can then change the breakpoint, columns, and rails.
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
Duplicate breakpoints are normalized without discarding saved positions.
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
@@ -177,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
eligible layout with the highest breakpoint, regardless of its role.
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout.
@@ -266,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
#### Background image URL
Homarr can display a background image behind apps and widgets.
@@ -405,8 +414,7 @@ nor the integrations the user does not have access to.
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
Duplicating a board that contains Custom Widgets also requires an administrator.
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
![](./img/duplicate-board-modal.png)
The name form expands below that board card and can be cancelled without leaving the list.
## Setting boards as your home board
@@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`.
## Add a certificate
To add a certificate click on the "Add certificate" button.
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
![](./img/add-button.png)
Then select the certificate file you want to add.
![](./img/add-modal.png)
And click the "Add" button.
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
leaving the page.
## Delete a certificate
@@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
![](./img/card.png)
Then confirm the deletion.
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
## Managing it through file system
@@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
performs definitive validation again at install time.
@@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards.
You can set the default color scheme for users without their own color scheme.
## Branding
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
### Identity
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
- **Logo** is used in the header and other application branding.
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
### Colors and corners
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
### Sign-in page
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
## Culture
### Default language
+6 -4
View File
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
### Creating a group
Click on the button `New group` to create a new one.
Homarr will prompt you for a group name.
The group-name form expands above the group list and can be closed without leaving the page.
### Managing a group
@@ -259,7 +259,8 @@ An invitation link can be used indefinitely.
### Creating an invitation
Click on the button:
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
dialog or navigating to another page:
![](./img/create-invitation-button.png)
@@ -269,12 +270,12 @@ The default is 4 hours in the future.
![](./img/invitation-expiry-date.png)
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
![](./img/create-invitation-confirmation.png)
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
Clicking the `Copy & close` button will copy the URL to your clipboard.
Use **Copy** to copy the URL, then close the section when you are finished.
### Self-onboarding
@@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
The link will become invalid.
If the invitation expires, the link will also expire.
The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog.
+2
View File
@@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
### Configuration
<WidgetConfig configuration={iframeWidget.configuration} />
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} />
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} />
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+11 -7
View File
@@ -2,13 +2,17 @@ import type { LoadContext, Plugin } from "@docusaurus/types";
import fs from "node:fs";
import path from "node:path";
import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs";
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
const slugMaps = {
Integration: { slugs: integrationDocSlugs, folder: "integrations" },
Widget: { slugs: widgetDocSlugs, folder: "widgets" },
} as const;
const documentationEntries = [
{
label: "Integration",
folder: "integrations",
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
},
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
] as const;
export default function validateDocsCoveragePlugin(context: LoadContext): Plugin {
return {
@@ -17,8 +21,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin
const docsDir = path.resolve(context.siteDir, "docs");
const missing: string[] = [];
for (const [label, { slugs, folder }] of Object.entries(slugMaps)) {
for (const [kind, slug] of Object.entries(slugs)) {
for (const { label, folder, entries } of documentationEntries) {
for (const [kind, slug] of entries) {
if (!slug) continue;
if (!fs.existsSync(path.join(docsDir, folder, slug))) {
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`);
+2 -2
View File
@@ -1,5 +1,5 @@
import { TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
import type { TablerIcon } from "@tabler/icons-react";
import type { ReactNode } from "react";
export interface IntegrationDefinition {
name: string;
+1
View File
@@ -72,6 +72,7 @@
"@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-sync-storage-persister": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
@@ -1,20 +1,17 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersister,
createSessionQueryPersistence,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
queryPersistenceMaxDataBytes,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data, { updatedAt });
queryClient.setQueryData(queryKey, data);
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
@@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("widget query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
describe("dashboard query persistence", () => {
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
apiKey: "secret",
});
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
});
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
const userAKey = getQueryPersistenceStorageKey("user-a");
window.sessionStorage.setItem(userAKey, "a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
});
test("evicts every other scope so signing out cannot leave data behind", () => {
const userAKey = getQueryPersistenceStorageKey("user-a");
const anonymousKey = getQueryPersistenceStorageKey(null);
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(anonymousKey, "anonymous");
window.sessionStorage.setItem("unrelated", "kept");
createSessionQueryPersister("user-b");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
});
test("stops persisting once the provider tears the cache down", async () => {
const storage = createMemoryStorage();
const persister = createSessionQueryPersister("user-a", storage);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
persister.stop();
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
vi.useFakeTimers();
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
const storage = createMemoryStorage();
const userA = createSessionQueryPersistence("user-a", storage);
const userB = createSessionQueryPersistence("user-b", storage);
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
expect(shouldPersistDashboardQuery(query)).toBe(true);
const cachedQuery = queryClient.getQueryCache().find({
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
});
if (!cachedQuery) throw new Error("Expected cached query to be present");
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
queryClient.getMutationCache().build(
queryClient,
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
{
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isPaused: true,
status: "pending",
submittedAt: Date.now(),
variables: { apiKey: "secret" },
},
);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
clientState: dehydrate(queryClient, userA.dehydrateOptions),
};
persister.persistClient(persistedClient);
persister.flush();
const restored = await persister.restoreClient();
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(persistedClient.clientState.mutations).toHaveLength(0);
await userA.persister.persistClient(persistedClient);
await vi.advanceTimersByTimeAsync(1_000);
const restored = await userA.persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
expect(await userB.persister.restoreClient()).toBeUndefined();
window.sessionStorage.setItem(key, "not-valid-superjson");
expect(persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
await userA.persister.removeClient();
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
});
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ title: "safe" }],
);
const validQuery = dehydrate(queryClient).queries[0];
if (!validQuery) throw new Error("Expected a dehydrated query");
window.sessionStorage.setItem(
key,
stringify({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
{
...validQuery,
state: {
...validQuery.state,
error: new Error("private refresh error"),
fetchStatus: "fetching",
status: "error",
},
},
{
...validQuery,
queryHash: "unrelated",
queryKey: [["board", "getBoardByName"], { type: "query" }],
},
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
{
...validQuery,
queryHash: "missing-timestamp",
state: { ...validQuery.state, dataUpdatedAt: undefined },
},
],
},
}),
);
const restored = await createSessionQueryPersister(scope).restoreClient();
expect(restored?.clientState.mutations).toEqual([]);
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
});
test.each([
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const storage = createMemoryStorage();
const setItem = storage.setItem.bind(storage);
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
const client = parse<PersistedClient>(value);
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
setItem(key, value);
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient(persistedClient);
persister.flush();
expect(setItemSpy).toHaveBeenCalledTimes(2);
const restored = await persister.restoreClient();
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const setItem = vi.spyOn(storage, "setItem");
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
test("treats unavailable session storage as an empty cache", async () => {
const blockedStorage = createMemoryStorage();
blockedStorage.getItem = () => {
throw new DOMException("blocked", "SecurityError");
});
};
blockedStorage.removeItem = () => {
throw new DOMException("blocked", "SecurityError");
};
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
expect(await persistence.persister.restoreClient()).toBeUndefined();
expect(() => persistence.persister.removeClient()).not.toThrow();
});
});
@@ -1,13 +1,12 @@
import type { QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
export const queryPersistenceBuster = "v2-session-superjson";
export const queryPersistenceBuster = "v6-dashboard-data";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
@@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
interface PersistableQuery {
queryKey: QueryKey;
state: {
data: unknown;
dataUpdatedAt: number;
};
state: { data: unknown; status: string };
}
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
try {
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const fitsStorageBudget = (data: unknown) => {
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
const cached = budgetVerdictCache.get(data);
if (cached !== undefined) return cached;
const verdict = measureFitsBudget(data);
budgetVerdictCache.set(data, verdict);
return verdict;
};
const holdsDashboardData = (query: PersistableQuery) =>
query.state.data !== undefined &&
Number.isFinite(query.state.dataUpdatedAt) &&
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
return holdsDashboardData(value as unknown as PersistableQuery);
};
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
return {
timestamp: value.timestamp as number,
buster: value.buster,
const serializePersistedClient = (client: PersistedClient) =>
stringify({
...client,
clientState: {
mutations: [],
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
...client.clientState,
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
},
};
};
});
const getSessionStorage = (): Storage | undefined => {
const getSessionStorage = () => {
if (typeof window === "undefined") return undefined;
try {
@@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | undefined => {
}
};
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
return undefined;
}
};
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
try {
const foreignKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const storedKey = storage.key(index);
if (storedKey === null || storedKey === activeKey) continue;
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
}
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
} catch {
return undefined;
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
removeForeignScopes(storage, key);
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
if (candidate.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return;
}
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
const protectStorage = (storage: Storage | undefined) => {
if (!storage) return undefined;
return {
persistClient(client) {
if (!storage || stopped) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
restoreClient() {
getItem(key: string) {
try {
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return undefined;
}
return client;
return storage.getItem(key);
} catch {
return null;
}
},
setItem(key: string, value: string) {
storage.setItem(key, value);
},
removeItem(key: string) {
try {
storage.removeItem(key);
} catch {
removeStorageItem(storage, key);
return undefined;
}
},
removeClient() {
clearPersistTimer();
pendingClient = undefined;
removeStorageItem(storage, key);
},
flush,
stop() {
stopped = true;
clearPersistTimer();
pendingClient = undefined;
},
};
};
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
export const createSessionQueryPersistence = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersistence => ({
persister: createSyncStoragePersister({
storage: protectStorage(storage),
key: getQueryPersistenceStorageKey(scope),
serialize: serializePersistedClient,
deserialize: parse,
retry: removeOldestQuery,
}),
maxAge: queryCacheDefaultGcTimeMs,
buster: queryPersistenceBuster,
dehydrateOptions: {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: shouldPersistDashboardQuery,
},
});
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryClient } from "@tanstack/react-query";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
@@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
dashboardSupportingQueryPolicies,
isTrpcForbiddenError,
isWidgetDataQueryKey,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
@@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import {
createSessionQueryPersister,
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createSessionQueryPersistence } from "./query-persistence";
import type { SessionQueryPersistence } from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersister.removeClient();
void queryPersistence.persister.removeClient();
reloadPage();
}, [queryPersister]);
}, [queryPersistence]);
return (
<SessionQueryScopeGuard
@@ -87,7 +84,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({
children,
queryPersister,
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
queryPersistence,
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -114,6 +111,13 @@ const ScopedTRPCReactProvider = ({
);
const [queryClient] = useState(() => {
const client = new QueryClient({
queryCache: new QueryCache({
onError(error, query) {
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
void queryPersistence.persister.removeClient();
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
},
}),
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
@@ -141,10 +145,14 @@ const ScopedTRPCReactProvider = ({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
for (const queryDefaults of widgetQueryRefetchIntervals) {
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
if (typeof queryDefaults.intervalSeconds === "number") {
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
}
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
client.setQueryDefaults(queryDefaults.queryKey, policy);
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
@@ -152,15 +160,6 @@ const ScopedTRPCReactProvider = ({
return client;
});
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
queryPersister.stop();
};
}, [queryPersister]);
useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => {
@@ -211,15 +210,7 @@ const ScopedTRPCReactProvider = ({
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider>
@@ -1,15 +1,16 @@
"use client";
import { useCallback } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import { Box, Button, Group, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { boardRenameSchema } from "@homarr/validation/board";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
@@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const utils = clientApi.useUtils();
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: board.name,
},
});
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
const onRenameClick = useCallback(
() =>
openModal({
useEffect(() => {
if (isRenaming) renameInputRef.current?.focus();
}, [isRenaming]);
const handleRename = renameForm.onSubmit((values) => {
renameBoard(
{ id: board.id, name: values.name },
{
onSuccess() {
router.push(`/boards/${values.name}/settings`);
},
},
);
});
const cancelRename = () => {
renameForm.reset();
setIsRenaming(false);
};
const handleVisibilityChange = () => {
changeVisibility(
{
id: board.id,
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
visibility: visibility === "public" ? "private" : "public",
},
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
);
};
const handleDelete = () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
};
return (
<DangerZoneRoot>
@@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
action={
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
isRenaming ? (
<Box component="form" onSubmit={handleRename}>
<Group wrap="wrap" justify="end">
<TextInput
ref={renameInputRef}
aria-label={t("section.dangerZone.action.rename.label")}
size="sm"
{...renameForm.getInputProps("name")}
/>
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
{tCommonAction("cancel")}
</Button>
<Button type="submit" color="red" loading={isRenamePending}>
{tCommonAction("confirm")}
</Button>
</Group>
</Box>
) : (
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
{t("section.dangerZone.action.rename.button")}
</Button>
)
}
/>
{!hideVisibility && (
@@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isChangeVisibilityPending}
onConfirm={handleVisibilityChange}
confirmLabel={tCommonAction("confirm")}
>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button>
</InlineConfirmButton>
}
/>
)}
@@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isDeletePending}
onConfirm={handleDelete}
confirmLabel={tCommonAction("confirm")}
>
{tCommonAction("delete")}
</Button>
</InlineConfirmButton>
}
/>
</DangerZoneRoot>
@@ -9,7 +9,7 @@ import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import type { Board } from "../../_types";
import classes from "./_layout-preview.module.css";
@@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
@@ -40,13 +40,14 @@ interface Props {
saveSettingsAsync: () => Promise<FormValues | null>;
}
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
@@ -84,33 +85,27 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
}
};
const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: tBoard("setting.section.layout.reset.confirm.message"),
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
onConfirm() {
setResettingLayoutId(layout.id);
void (async () => {
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
const handleReset = async (layout: FormValues["layouts"][number]) => {
setResettingLayoutId(layout.id);
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
})();
},
});
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
};
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
@@ -144,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Button>
</Group>
{form.values.layouts.map((layout, index) => {
{displayedLayouts.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
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
.map((candidate) => candidate.breakpoint);
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
return (
<Fieldset
@@ -200,12 +191,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={
layout.role === "base"
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
: 1
}
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
/>
</Stack>
</Grid.Col>
@@ -236,16 +223,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<Button
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onClick={() => confirmReset(layout)}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</Button>
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
+8 -5
View File
@@ -114,12 +114,15 @@ export default async function Layout(props: {
})
: null,
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise<AssistantAvailability> => {
if (!session) return "unauthenticated";
return api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error");
try {
const availability = await api.assistant.getAvailability();
return availability.enabled ? "enabled" : "unconfigured";
} catch {
return "error";
}
});
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
@@ -1,13 +1,12 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -18,48 +17,41 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: app.name,
}),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
const onConfirm = useCallback(() => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
});
}, [app, mutate, t, tCommon, openConfirmModal]);
);
}, [app.id, mutate, t, tCommon]);
return (
<ActionIcon
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -8,10 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -25,9 +23,10 @@ interface BoardCardMenuDropdownProps {
RouterOutputs["board"]["getManageOverview"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
onDuplicate: () => void;
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
@@ -35,9 +34,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
@@ -56,20 +52,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
},
});
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tCommon("action.deleteNamed", { name: board.name }),
children: t("delete.confirm.description", {
name: board.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await deleteBoardMutation.mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
const handleDeletion = useCallback(
() => deleteBoardMutation.mutateAsync({ id: board.id }),
[board.id, deleteBoardMutation],
);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -79,15 +65,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -97,7 +74,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")}
</Menu.Item>
)}
@@ -117,14 +94,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<>
<Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<Menu.Item
<InlineConfirmMenuItem
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion}
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
disabled={deleteBoardMutation.isPending}
>
{tCommon("action.delete")}
</Menu.Item>
</InlineConfirmMenuItem>
</>
)}
</Menu.Dropdown>
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Collapse,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
import { DuplicateBoardForm } from "./duplicate-board-form";
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<>
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown
board={board}
onDuplicate={() => {
setDuplicateFormKey((value) => value + 1);
setIsDuplicateOpen(true);
}}
/>
</Menu>
)}
</Group>
</CardSection>
</Card>
<Collapse expanded={isDuplicateOpen}>
<Card mt="sm" withBorder>
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
</Card>
</Collapse>
</>
);
};
@@ -1,23 +1,55 @@
"use client";
import { Button } from "@mantine/core";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { BoardCreateForm } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n("management.page.board.action");
const { openModal } = useModalAction(AddBoardModal);
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsOpen(true);
}, [searchParams]);
const close = () => {
setIsOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
};
const toggle = () => {
if (isOpen) {
close();
return;
}
setFormKey((value) => value + 1);
setIsOpen(true);
};
return (
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("new.label")}
</Button>
</ManageMobilePrimaryAction>
<Stack>
<Group justify="end">
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
{t("new.label")}
</Button>
</Group>
<Collapse expanded={isOpen}>
<Card withBorder>
<BoardCreateForm key={formKey} onCancel={close} />
</Card>
</Collapse>
</Stack>
);
};
@@ -1,29 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardDuplicateSchema } from "@homarr/validation/board";
import { createModal } from "../../../modals/src/creator";
import { useBoardNameStatus } from "./add-board-modal";
interface InnerProps {
interface DuplicateBoardFormProps {
board: {
id: string;
name: string;
};
onCancel: () => void;
}
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
mode: "controlled",
initialValues: {
name: innerProps.board.name,
name: board.name,
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
@@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
return (
<form
onSubmit={form.onSubmit(async (values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
await mutateAsync(
{
...values,
id: innerProps.board.id,
id: board.id,
},
{
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.duplicate.notification.success.title"),
message: tBoard("action.duplicate.notification.success.message"),
});
onCancel();
router.refresh();
},
onError() {
showErrorNotification({
@@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
);
})}
>
<Stack>
<Stack maw={720}>
<Text size="sm" c="gray.6">
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
{tBoard("action.duplicate.message", { name: board.name })}
</Text>
<TextInput
label={tCommon("field.name")}
data-autofocus
autoFocus
{...form.getInputProps("name")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
description={<BoardNameAvailability status={boardNameStatus} />}
withAsterisk
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tBoard("action.duplicate.title")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("board.action.duplicate.title");
},
});
};
@@ -1,38 +1,15 @@
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Grid,
GridCol,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import {
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import { Grid, GridCol } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { BoardCard } from "./_components/board-card";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
@@ -42,24 +19,14 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
<ManagePageLayout
title={t("title")}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
<ManagePageLayout title={t("title")}>
{canCreateBoards && (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
)}
{boards.length === 0 && (
<NoResults
icon={IconLayoutDashboard}
title={t("noResults.title")}
description={t("noResults.description")}
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
/>
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
@@ -92,97 +59,3 @@ export default async function ManageBoardsPage() {
</ManagePageLayout>
);
}
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group>
</CardSection>
</Card>
);
};
@@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonBaseProps {
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
request?: string | null;
documentationUrl?: string | null;
getDraft: () => CustomWidgetAiDraft;
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
}
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({
rawResponse,
draft,
getDraft,
diagnostics,
getDiagnostics,
request,
documentationUrl,
@@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({
const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt(
undefined,
rawResponse,
readDraft(),
getDraft(),
request,
documentationUrl,
readDiagnostics(),
getDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
@@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({
const value = buildCustomWidgetAiPrompt(
undefined,
rawResponse,
readDraft(),
getDraft(),
request,
documentationUrl,
readDiagnostics(),
getDiagnostics(),
);
await navigator.clipboard.writeText(value);
showSuccessNotification({
@@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { downloadJson } from "~/components/custom-widgets/download";
@@ -40,7 +39,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
@@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
const handleDelete = () => {
openConfirmModal({
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
);
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
});
);
};
return (
@@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</>
)}
{!widget.migrationRequired && (
<Menu.Item
<InlineConfirmMenuItem
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</InlineConfirmMenuItem>
)}
</Menu.Dropdown>
</Menu>
@@ -50,7 +50,8 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
onChange={(next) => {
setDraft(next);
try {
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
const value: unknown = JSON.parse(next);
const parsed = customWidgetDefinitionSchema.safeParse(value);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
return;
@@ -76,7 +76,8 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
export function parseJson(value: string): unknown {
try {
return JSON.parse(value) as unknown;
const parsed: unknown = JSON.parse(value);
return parsed;
} catch {
return null;
}
@@ -129,7 +129,9 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => props.setMobilePane(value as MobilePane)}
onChange={(value) => {
if (value === "configure" || value === "preview") props.setMobilePane(value);
}}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
@@ -173,7 +173,9 @@ export function RequestBodyEditor({
onChange={(next) => {
setDraft(next);
try {
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
let parsed: unknown;
if (next.trim()) parsed = JSON.parse(next);
onChange(parsed);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
@@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
import { WorkshopReportForm } from "~/components/workshop/workshop-report-form";
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
import { useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -213,6 +213,13 @@ export function WorkshopDetail({ id }: { id: string }) {
</Accordion.Item>
</Accordion>
<WorkshopReportForm
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
<ManageStickyFooter
secondary={
<>
@@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) {
leftSection={<IconFlag size={16} />}
disabled={!session.user}
onClick={reportControls.open}
aria-expanded={reportOpened}
aria-controls="workshop-report-form"
>
{t("report")}
</Button>
@@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) {
</Button>
</ManageStickyFooter>
</Stack>
<WorkshopReportModal
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
</ManagePageLayout>
);
}
@@ -6,11 +6,12 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, getMantineColor } from "@homarr/common";
import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
interface CertificateErrorDetailsProps {
@@ -23,80 +24,71 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false);
const [showUploadForm, setShowUploadForm] = useState(false);
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } =
clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } =
clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = () => {
const handleTrustHostname = async () => {
const { hostname } = new URL(url);
openConfirmModal({
title: tError("certificate.hostnameMismatch.confirm.title"),
children: tError("certificate.hostnameMismatch.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
});
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
};
const handleTrustSelfSigned = () => {
const handleTrustSelfSigned = async () => {
const { hostname } = new URL(url);
openConfirmModal({
title: tError("certificate.selfSigned.confirm.title"),
children: tError("certificate.selfSigned.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
});
};
const description = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
let trustConfirmLabel = tError("certificate.selfSigned.confirm.title");
if (error.data.reason === "hostnameMismatch") {
trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title");
}
if (!isAdmin) {
return (
@@ -130,30 +122,36 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<Button
type="button"
<InlineConfirmButton
variant="default"
fullWidth
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
confirmLabel={trustConfirmLabel}
>
{tError("certificate.action.trust.label")}
</Button>
</InlineConfirmButton>
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
openUploadModal({
onSuccess() {
setShowRetryButton(true);
},
})
}
>
{tError("certificate.action.upload.label")}
</Button>
<Stack gap="sm">
{!showUploadForm && (
<Button variant="default" fullWidth onClick={() => setShowUploadForm(true)}>
{tError("certificate.action.upload.label")}
</Button>
)}
{showUploadForm && (
<Card withBorder>
<CertificateUploadForm
embedded
onCancel={() => setShowUploadForm(false)}
onSuccess={() => {
setShowRetryButton(true);
setShowUploadForm(false);
}}
/>
</Card>
)}
</Stack>
) : null}
</>
);
@@ -1,13 +1,12 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
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 { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string };
@@ -16,46 +15,42 @@ interface DeleteIntegrationActionButtonProps {
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useI18n("integration.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
const onConfirm = () =>
mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
return (
<ActionIcon
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={() => {
openConfirmModal({
title: t("title"),
children: t("message", integration),
onConfirm: () => {
void mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
},
});
}}
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -1,40 +1,41 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n("media");
const tCommon = useI18n("common");
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> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
},
});
const onConfirm = async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
};
return (
<Tooltip label={t("action.delete.label")} openDelay={500}>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<InlineConfirmActionIcon
color="red"
variant="subtle"
onConfirm={onConfirm}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.label")}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
</Tooltip>
);
};
@@ -1,15 +1,14 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
@@ -18,48 +17,41 @@ interface SearchEngineDeleteButtonProps {
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useI18n("search.engine.page.delete");
const actionT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: searchEngine.name,
}),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
const onConfirm = useCallback(() => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
});
}, [searchEngine, mutate, t, openConfirmModal]);
);
}, [searchEngine.id, mutate, t]);
return (
<ActionIcon
<InlineConfirmActionIcon
confirmLabel={actionT("confirm")}
confirmationAriaLabel={actionT("confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -1,34 +1,67 @@
"use client";
import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
import { useCallback, useMemo, useState } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
import { NewApiKeyAlert } from "./new-api-key-alert";
interface ApiKeysManagementProps {
apiKeys: RouterOutputs["apiKeys"]["getAll"];
onCreated: () => void;
}
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { openConfirmModal } = useConfirmModal();
type ApiKey = RouterOutputs["apiKeys"]["getAll"][number];
type ApiKeyCellProps = { row: MRT_Row<ApiKey> };
interface ApiKeysTableMeta {
handleDelete: (id: string) => Promise<void>;
confirmLabel: string;
deleteTitle: string;
isPendingDelete: boolean;
}
const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => (
<Group gap="xs">
<UserAvatar user={row.original.user} size="sm" />
<Text>{row.original.user.name}</Text>
</Group>
);
const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance<ApiKey> }) => {
const meta = table.options.meta as ApiKeysTableMeta;
return (
<Group gap="xs">
<InlineConfirmActionIcon
onConfirm={() => meta.handleDelete(row.original.id)}
confirmLabel={meta.confirmLabel}
confirmationAriaLabel={meta.confirmLabel}
loading={meta.isPendingDelete}
color="red"
variant="subtle"
aria-label={meta.deleteTitle}
>
<IconTrash size="1rem" />
</InlineConfirmActionIcon>
</Group>
);
};
export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => {
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({
apiKey: data.apiKey,
});
await revalidatePathActionAsync("/manage/tools/api");
onSuccess(data) {
setNewApiKey(data.apiKey);
onCreated();
},
});
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
@@ -38,21 +71,19 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
});
const t = useI18n("management.page.tool.api.tab.apiKey");
const tCommon = useI18n("common");
const handleDelete = useCallback(
(id: string) => {
openConfirmModal({
title: t("modal.delete.title"),
children: t("modal.delete.text"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateDeleteAsync({ apiKeyId: id });
},
});
async (id: string) => {
await mutateDeleteAsync({ apiKeyId: id });
},
[t, openConfirmModal, mutateDeleteAsync],
[mutateDeleteAsync],
);
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
() => [
{
accessorKey: "id",
@@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
Cell: ApiKeyCreatedByCell,
},
{
header: t("table.header.actions"),
Cell: ({ row }) => (
<Group gap="xs">
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
<IconTrash size="1rem" />
</ActionIcon>
</Group>
),
Cell: ApiKeyActionsCell,
},
],
[t, handleDelete, isPendingDelete],
[t],
);
const table = useMantineReactTable({
columns,
data: apiKeys,
meta: {
handleDelete,
confirmLabel: tCommon("action.confirm"),
deleteTitle: t("modal.delete.title"),
isPendingDelete,
},
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutateCreate();
}}
loading={isPendingCreate}
disabled={newApiKey !== null}
>
{t("button.createApiToken")}
</Button>
@@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
return (
<Stack>
<Title>{t("title")}</Title>
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<MantineReactTable table={table} />
</Stack>
);
@@ -5,6 +5,8 @@ import type { ReactNode } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { ApiKeysManagement } from "./api-keys";
import { McpInstructions } from "./mcp-instructions";
export interface McpToolGroup {
@@ -17,9 +19,8 @@ interface ApiPageTabsProps {
apiKeyLabel: string;
mcpLabel: string;
documentationPanel: ReactNode;
authenticationPanel: ReactNode;
apiKeys: RouterOutputs["apiKeys"]["getAll"];
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
@@ -28,12 +29,12 @@ export function ApiPageTabs({
apiKeyLabel,
mcpLabel,
documentationPanel,
authenticationPanel,
apiKeys,
baseUrl,
hasApiKeys,
toolGroups,
}: ApiPageTabsProps) {
const [activeTab, setActiveTab] = useState<string | null>("documentation");
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
@@ -48,10 +49,17 @@ export function ApiPageTabs({
{mcpLabel}
</TabsTab>
</TabsList>
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
<TabsPanel value="authentication">
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
</TabsPanel>
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
<TabsPanel value="mcp">
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
<McpInstructions
baseUrl={baseUrl}
hasApiKeys={hasApiKeys}
toolGroups={toolGroups}
onApiKeyCreated={() => setHasApiKeys(true)}
/>
</TabsPanel>
</Tabs>
);
@@ -1,34 +0,0 @@
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const [visible, { toggle }] = useDisclosure(false);
return (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});
@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import {
Accordion,
AccordionControl,
@@ -36,12 +37,11 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { CopyApiKeyModal } from "./copy-api-key-modal";
import type { McpToolGroup } from "./api-page-tabs";
import classes from "./mcp-tools-accordion.module.css";
import { NewApiKeyAlert } from "./new-api-key-alert";
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
query: { color: "blue", method: "GET" },
@@ -52,18 +52,23 @@ interface McpInstructionsProps {
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
onApiKeyCreated: () => void;
}
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) {
const t = useI18n("management.page.tool.api.tab.mcp");
const tCommon = useI18n("common");
const { openModal } = useModalAction(CopyApiKeyModal);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({ apiKey: data.apiKey });
await revalidatePathActionAsync("/manage/tools/api");
onSuccess(data) {
setNewApiKey(data.apiKey);
onApiKeyCreated();
},
});
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const mcpUrl = `${baseUrl}/api/mcp`;
const streamableHttpConfig = JSON.stringify(
@@ -114,7 +119,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
<Text size="sm">{t("description")}</Text>
</Alert>
{!hasApiKeys && (
{!hasApiKeys && !newApiKey && (
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
<Group justify="space-between" align="center">
<Text size="sm">{t("noApiKey")}</Text>
@@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
</Alert>
)}
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<div>
<Title order={5} mb="xs">
<Group gap="xs">
@@ -305,7 +312,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
<Stack gap={6}>
{group.tools.map((tool) => (
<div key={tool.name} className={classes.toolRow}>
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
<Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light">
{toolTypeDisplay[tool.type].method}
</Badge>
<div>
@@ -0,0 +1,50 @@
"use client";
import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface NewApiKeyAlertProps {
apiKey: string;
onDismiss: () => void | Promise<void>;
}
export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const tCommon = useI18n("common");
const [visible, { toggle }] = useDisclosure(false);
return (
<Alert color="yellow" title={t("title")}>
<Stack gap="sm">
<Text size="sm">{t("description")}</Text>
<PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly />
<Stack gap="xs" align="stretch">
<CopyButton value={apiKey} timeout={2_000}>
{({ copied, copy }) => {
let color: "green" | undefined;
let icon = <IconCopy size={16} />;
let label = tCommon("action.copy");
if (copied) {
color = "green";
icon = <IconCheck size={16} />;
label = tCommon("action.copied");
}
return (
<Button onClick={copy} variant="default" color={color} leftSection={icon}>
{label}
</Button>
);
}}
</CopyButton>
<Button onClick={onDismiss} variant="subtle">
{tCommon("action.close")}
</Button>
</Stack>
</Stack>
</Alert>
);
};
@@ -2,7 +2,6 @@ import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack } from "@mantine/core";
import { mcpRouter } from "@homarr/api/mcp";
import { openApiDocument } from "@homarr/api/open-api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -11,46 +10,29 @@ import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
import type { McpToolGroup } from "./components/api-page-tabs";
let cachedToolGroups: McpToolGroup[] | null = null;
function getMcpToolGroups(): McpToolGroup[] {
if (cachedToolGroups) return cachedToolGroups;
const tools = extractMcpTools();
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
const procedureTypeMap = new Map<string, "query" | "mutation">();
for (const [key, proc] of Object.entries(procedures)) {
const type = proc?._def?.type;
if (type === "query" || type === "mutation") {
procedureTypeMap.set(key, type);
}
}
const groups = new Map<string, McpToolGroup["tools"]>();
for (const tool of tools) {
const namespace = tool.pathInRouter[0] ?? "other";
if (!groups.has(namespace)) {
groups.set(namespace, []);
}
const procedureKey = tool.pathInRouter.join(".");
groups.get(namespace)?.push({
name: tool.name,
description: tool.description,
type: procedureTypeMap.get(procedureKey) ?? "query",
type: tool.type,
});
}
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
return Array.from(groups.entries()).map(([namespace, items]) => ({
namespace,
tools: items,
}));
return cachedToolGroups;
}
export async function generateMetadata() {
@@ -71,12 +53,14 @@ export default async function ApiPage() {
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const requestHeaders = await headers();
const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([
headers(),
api.apiKeys.getAll(),
getI18n("management.page.tool.api.tab"),
getMcpToolGroups(),
]);
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
const document = openApiDocument(baseUrl);
const apiKeys = await api.apiKeys.getAll();
const t = await getI18n("management.page.tool.api.tab");
const toolGroups = getMcpToolGroups();
return (
<>
@@ -87,9 +71,8 @@ export default async function ApiPage() {
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
apiKeys={apiKeys}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
@@ -1,23 +1,35 @@
"use client";
import { Button } from "@mantine/core";
import { Button, Card, Collapse, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
import { CertificateUploadForm } from "./certificate-upload-form";
export const AddCertificateSection = () => {
const [opened, { close, toggle }] = useDisclosure(false);
const t = useI18n("certificate");
const handleClick = () => {
openModal({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
};
return <Button onClick={handleClick}>{t("action.create.label")}</Button>;
return (
<Stack gap="sm">
<Button onClick={toggle} variant="default" aria-expanded={opened}>
{t("action.create.label")}
</Button>
<Collapse expanded={opened}>
<Card withBorder>
<CertificateUploadForm
onCancel={close}
onSuccess={async () => {
await revalidatePathActionAsync("/manage/tools/certificates");
close();
}}
/>
</Card>
</Collapse>
</Stack>
);
};
export { CertificateUploadForm };
@@ -0,0 +1,110 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { checkCertificateFile } from "@homarr/validation/certificates";
interface CertificateUploadFormProps {
embedded?: boolean;
onSuccess?: () => MaybePromise<void>;
onCancel?: () => void;
}
export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.file().check(checkCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation();
const handleSubmit = async () => {
const validation = form.validate();
if (validation.hasErrors || !form.values.file) return;
const formData = new FormData();
formData.set("file", form.values.file);
await mutateAsync(formData, {
onSuccess: async () => {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
form.reset();
await onSuccess?.();
},
onError: () => {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
};
const handleCancel = () => {
form.reset();
onCancel?.();
};
const submit = () => {
void handleSubmit().catch(() => undefined);
};
let submitButtonType: "button" | "submit" = "submit";
let handleSubmitClick: (() => void) | undefined;
if (embedded) {
submitButtonType = "button";
handleSubmitClick = submit;
}
const content = (
<>
<FileInput
label={t("certificate.action.create.label")}
leftSection={<IconCertificate size={16} />}
{...form.getInputProps("file")}
/>
<Group justify="end">
{onCancel && (
<Button onClick={handleCancel} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
)}
<Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}>
{t("common.action.add")}
</Button>
</Group>
</>
);
if (embedded) {
return <Stack>{content}</Stack>;
}
return (
<Stack
component="form"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{content}
</Stack>
);
};
@@ -1,57 +1,56 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
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 { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleClick = () => {
openConfirmModal({
title: t("action.remove.label"),
children: t("action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
},
);
const handleRemove = () =>
mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
},
});
};
);
return (
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.remove.label")}
>
<IconTrash color="red" size={16} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -1,13 +1,12 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
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 { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveHostnameActionIconProps {
hostname: string;
@@ -15,41 +14,41 @@ interface RemoveHostnameActionIconProps {
}
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({
const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
},
});
const { openConfirmModal } = useConfirmModal();
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleRemove = () => {
openConfirmModal({
title: t("action.removeHostname.label"),
children: t("action.removeHostname.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
});
},
const handleRemove = () =>
mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
});
},
});
};
return (
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.removeHostname.label")}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -13,7 +13,7 @@ import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateButton } from "./_components/add-certificate";
import { AddCertificateSection } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
@@ -69,10 +69,11 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
{t("page.list.toHostnames")}
</Button>
<AddCertificateButton />
</Group>
</Group>
<AddCertificateSection />
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} />
)}
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps {
@@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
{ contextId },
{
initialData: initialConfigMaps,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialConfigMaps),
);
const table = useTranslatedMantineReactTable({
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
@@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
{ contextId },
{
initialData: initialIngresses,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialIngresses),
);
const table = useTranslatedMantineReactTable({
@@ -0,0 +1,18 @@
interface QueryWithStatus {
state: {
status: string;
};
}
const getErrorRefetchInterval = (query: QueryWithStatus) => {
if (query.state.status === "error") return 30_000;
return false;
};
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
initialData,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: getErrorRefetchInterval,
});
@@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface NamespacesTableComponentProps {
@@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
{ contextId },
{
initialData: initialNamespaces,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialNamespaces),
);
const table = useTranslatedMantineReactTable({
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
@@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
{ contextId },
{
initialData: initialNodes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialNodes),
);
const table = useTranslatedMantineReactTable({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface PodsTableComponentProps {
@@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
{ contextId },
{
initialData: initialPods,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialPods),
);
const table = useTranslatedMantineReactTable({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface SecretsTableComponentProps {
@@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
{ contextId },
{
initialData: initialSecrets,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialSecrets),
);
const table = useTranslatedMantineReactTable({
@@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface ServicesTableComponentProps {
@@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
const { data } = clientApi.kubernetes.services.getServices.useQuery(
{ contextId },
{
initialData: initialServices,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialServices),
);
const table = useTranslatedMantineReactTable({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface VolumesTableComponentProps {
@@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
{ contextId },
{
initialData: initialVolumes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
createKubernetesResourceQueryOptions(initialVolumes),
);
const table = useTranslatedMantineReactTable({
@@ -2,20 +2,18 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const tUser = useI18n("user");
const tCommon = useI18n("common");
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
@@ -23,27 +21,14 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(
() =>
openConfirmModal({
title: tUser("action.delete.label"),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
children: tUser("action.delete.confirm", { username: user.name! }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync({
userId: user.id,
});
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, tUser],
() => mutateUserDeletionAsync({ userId: user.id }),
[mutateUserDeletionAsync, user.id],
);
return (
<Button onClick={handleDelete} variant="subtle" color="red">
<InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red">
{tCommon("action.delete")}
</Button>
</InlineConfirmButton>
);
};
@@ -8,10 +8,9 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
interface UserProfileAvatarForm {
@@ -19,7 +18,7 @@ interface UserProfileAvatarForm {
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate } = clientApi.user.setProfileImage.useMutation({
const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
@@ -27,7 +26,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
});
const [opened, { toggle }] = useDisclosure(false);
const isMobile = useIsMobile();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -68,32 +66,28 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
[mutate, user.id, tManageAvatar],
);
const handleRemoveAvatar = useCallback(() => {
openConfirmModal({
title: tManageAvatar("removeImage.label"),
children: tManageAvatar("removeImage.confirm"),
onConfirm() {
mutate(
{
userId: user.id,
image: null,
const handleRemoveAvatar = useCallback(
() =>
mutateAsync(
{
userId: user.id,
image: null,
},
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
);
},
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
},
),
[mutateAsync, user.id, tManageAvatar],
);
return (
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
@@ -124,9 +118,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
)}
</FileButton>
{user.image && (
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
<InlineConfirmMenuItem
color="red"
onConfirm={handleRemoveAvatar}
confirmLabel={tCommon("action.confirm")}
disabled={isPending}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
{tManageAvatar("removeImage.label")}
</Menu.Item>
</InlineConfirmMenuItem>
)}
</Menu.Dropdown>
</Menu>
@@ -12,7 +12,6 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
@@ -20,7 +19,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { GroupSelect } from "~/components/access/group-select";
import { StepperNavigationComponent } from "./stepper-navigation";
interface GroupWithPermissions {
@@ -224,23 +223,6 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
const tCommon = useI18n("common");
const tPermission = useI18n("permission");
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
@@ -256,14 +238,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
{tCreate("step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{tCommon("action.add")}
</Button>
<GroupSelect
presentGroupIds={groups.map((group) => group.id)}
withPermissions
onSelect={({ id, name, permissions }) => {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
}}
triggerProps={{
variant: "subtle",
color: "gray",
leftSection: <IconPlus size={16} stroke={1.5} />,
}}
/>
</Group>
<Table>
<Table.Thead>
@@ -2,13 +2,13 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button, useMatches } from "@mantine/core";
import { useMatches } from "@mantine/core";
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 { InlineConfirmButton } from "@homarr/ui";
interface DeleteGroupProps {
group: {
@@ -20,46 +20,38 @@ interface DeleteGroupProps {
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
const router = useRouter();
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
const { openConfirmModal } = useConfirmModal();
const tDelete = useI18n("group.action.delete");
const tCommon = useI18n("common");
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tDelete("label"),
children: tDelete("confirm", {
name: group.name,
}),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{
id: group.id,
const handleDeletion = useCallback(
() =>
mutateAsync(
{
id: group.id,
},
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
);
},
});
}, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]);
},
),
[tDelete, tCommon, group.id, group.name, mutateAsync, router],
);
const fullWidth = useMatches({
xs: true,
@@ -68,8 +60,14 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
});
return (
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
<InlineConfirmButton
variant="subtle"
color="red"
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
fullWidth={fullWidth}
>
{tDelete("label")}
</Button>
</InlineConfirmButton>
);
};
@@ -1,15 +1,14 @@
"use client";
import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core";
import { useState } from "react";
import { Button, Group, Stack, Text, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { UserSelect } from "~/components/access/user-select";
interface TransferGroupOwnershipProps {
group: {
@@ -19,75 +18,87 @@ interface TransferGroupOwnershipProps {
};
}
interface SelectedOwner {
id: string;
name: string;
}
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
const tTransfer = useI18n("group.action.transfer");
const tCommon = useI18n("common");
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
const { openModal } = useModalAction(UserSelectModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.group.transferOwnership.useMutation({
const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>();
const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({
async onSuccess() {
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
},
});
const handleTransfer = useCallback(() => {
openModal(
const handleTransfer = async () => {
if (!selectedOwner) return;
await mutateAsync(
{
confirmLabel: tCommon("action.continue"),
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
onSelect: ({ id, name }) => {
openConfirmModal({
title: tTransfer("label"),
children: tTransfer("confirm", {
name: group.name,
username: name,
groupId: group.id,
userId: selectedOwner.id,
},
{
onSuccess() {
setInnerOwnerId(selectedOwner.id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: selectedOwner.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync(
{
groupId: group.id,
userId: id,
},
{
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
);
},
});
setSelectedOwner(undefined);
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
{
title: tTransfer("label"),
},
);
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]);
};
const fullWidth = useMatches({
xs: true,
sm: true,
md: false,
});
const presentUserIds: string[] = [];
if (innerOwnerId) presentUserIds.push(innerOwnerId);
if (!selectedOwner) {
return (
<UserSelect
presentUserIds={presentUserIds}
onSelect={setSelectedOwner}
triggerLabel={tTransfer("label")}
triggerProps={{ variant: "subtle", color: "red", fullWidth }}
/>
);
}
return (
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
{tTransfer("label")}
</Button>
<Stack gap="xs" maw={420}>
<Text size="sm">
{tTransfer("confirm", {
name: group.name,
username: selectedOwner.name,
})}
</Text>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}>
{tCommon("action.cancel")}
</Button>
<Button color="red" onClick={() => void handleTransfer()} loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
);
};
@@ -5,11 +5,9 @@ import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { UserSelect } from "~/components/access/user-select";
interface AddGroupMemberProps {
groupId: string;
@@ -20,27 +18,23 @@ interface AddGroupMemberProps {
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
const tMembersAdd = useI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
const handleAddMember = useCallback(
async ({ id }: { id: string }) => {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
[groupId, mutateAsync],
);
const handleAddMember = useCallback(() => {
openModal(
{
// eslint-disable-next-line no-restricted-syntax
async onSelect({ id }) {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
allowedProviders,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
return (
<UserSelect
presentUserIds={presentUserIds}
allowedProviders={allowedProviders}
onSelect={handleAddMember}
triggerLabel={tMembersAdd("label")}
/>
);
};
@@ -1,12 +1,11 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface RemoveGroupMemberProps {
groupId: string;
@@ -15,30 +14,25 @@ interface RemoveGroupMemberProps {
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
const tCommon = useI18n("common");
const tRemoveMember = useI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
const { openConfirmModal } = useConfirmModal();
const handleRemove = useCallback(() => {
openConfirmModal({
title: tRemoveMember("label"),
children: tRemoveMember("confirm", {
user: user.name ?? "",
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({
groupId,
userId: user.id,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
const handleRemove = useCallback(async () => {
await mutateAsync({
groupId,
userId: user.id,
});
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
}, [mutateAsync, groupId, user.id]);
return (
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
<InlineConfirmButton
variant="subtle"
color="red.9"
size="compact-sm"
confirmLabel={tCommon("action.confirm")}
onConfirm={handleRemove}
>
{tCommon("action.remove")}
</Button>
</InlineConfirmButton>
);
};
@@ -1,15 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { TextInput } from "@mantine/core";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { GroupCreateForm } from "./_components/group-create-form";
import { GroupsTable } from "./_groups-table";
interface GroupsListProps {
@@ -18,6 +17,10 @@ interface GroupsListProps {
export const GroupsList = ({ groups }: GroupsListProps) => {
const [search, setSearch] = useState("");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const initialGroupIds = useMemo(
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
[groups],
@@ -31,8 +34,46 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
);
const tGroup = useI18n("group");
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<>
<Stack>
<Group justify="end">
<Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button>
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<GroupCreateForm
key={formKey}
onCancel={closeCreate}
onCreated={() => {
closeCreate();
router.refresh();
}}
/>
</Card>
</Collapse>
<TextInput
leftSection={<IconSearch size={20} stroke={1.5} />}
value={search}
@@ -40,13 +81,6 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
placeholder={`${tGroup("search")}...`}
/>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</>
</Stack>
);
};
export const AddGroupButton = () => {
const tGroup = useI18n("group");
const { openModal } = useModalAction(AddGroupModal);
return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>;
};
@@ -0,0 +1,61 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
interface GroupCreateFormProps {
onCancel: () => void;
onCreated: () => void;
}
export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => {
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation();
const handleSubmit = form.onSubmit(async (values) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
onCreated();
},
onError() {
showErrorNotification({
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
<Group justify="end">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AddGroupButton, GroupsList } from "./_client";
import { GroupsList } from "./_client";
import classes from "./groups.module.css";
export default async function GroupsListPage() {
@@ -26,7 +26,7 @@ export default async function GroupsListPage() {
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return (
<ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
<ManagePageLayout title={tCommon("entity.groups")}>
<Stack>
{dbEveryoneGroup && (
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
@@ -2,18 +2,15 @@
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
export const InviteCreateButton = () => {
export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => {
const t = useI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
return (
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}>
{t("action.new.title")}
</Button>
);
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
type FormType = {
expirationDate: string;
};
type CreatedInvite = RouterOutputs["invite"]["createInvite"];
interface InviteCreateFormProps {
onClose: () => void;
}
export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const [createdInvite, setCreatedInvite] = useState<CreatedInvite>();
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
const minDate = dayjs().add(1, "hour").toDate();
const maxDate = dayjs().add(6, "months").toDate();
const form = useForm<FormType>({
initialValues: {
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
},
});
if (createdInvite) {
return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />;
}
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
void utils.invite.getAll.invalidate();
setCreatedInvite(result);
},
},
);
})}
>
<Stack>
<Text>{tInvite("action.new.description")}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY HH:mm"
label={tInvite("field.expirationDate.label")}
variant="filled"
{...form.getInputProps("expirationDate")}
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const inviteUrl = createInviteUrl(invite);
return (
<Stack component="output" aria-live="polite">
<Text>
{tInvite.rich("action.copy.description", {
b: bold,
})}
</Text>
<Stack gap="xs">
<Text fw="bold">{tInvite("action.copy.link")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
<Anchor href={inviteUrl} target="_blank" rel="noreferrer">
{inviteUrl}
</Anchor>
</Mark>
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.id}
</Mark>
<Text fw="bold">{tInvite("field.token.label")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.token}
</Mark>
</Stack>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.close")}
</Button>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
onClose();
}}
>
{tInvite("action.copy.button")}
</Button>
)}
</CopyButton>
</Group>
</Stack>
);
};
const bold = (children: ReactNode) => <b>{children}</b>;
const createInviteUrl = ({ id, token }: CreatedInvite) => {
const path = `/auth/invite/${id}?token=${token}`;
return new URL(path, window.location.origin).toString();
};
@@ -1,7 +1,8 @@
"use client";
import { useCallback, useMemo } from "react";
import { ActionIcon } from "@mantine/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, Collapse, Group, Stack } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -11,9 +12,11 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { InviteCreateButton } from "./invite-create-button";
import { InviteCreateForm } from "./invite-create-form";
dayjs.extend(relativeTime);
@@ -23,6 +26,10 @@ interface InviteListComponentProps {
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
const t = useI18n("management.page.user.invite");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites,
refetchOnMount: false,
@@ -73,28 +80,65 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
},
});
return <MantineReactTable table={table} />;
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<Stack>
<Group justify="end">
<InviteCreateButton onClick={toggleCreate} />
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<InviteCreateForm key={formKey} onClose={closeCreate} />
</Card>
</Collapse>
<MantineReactTable table={table} />
</Stack>
);
};
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
const t = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(() => {
openConfirmModal({
title: t("action.delete.title"),
children: t("action.delete.description"),
onConfirm: () => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
},
});
}, [openConfirmModal, row.original.id, mutate, utils, t]);
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
}, [row.original.id, mutate, utils]);
return (
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
<InlineConfirmActionIcon
variant="subtle"
color="red"
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.title")}
>
<IconTrash color="red" size={20} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -5,9 +5,7 @@ import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getI18n } from "@homarr/translation/server";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { InviteCreateButton } from "./_components/invite-create-button";
import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() {
@@ -24,15 +22,7 @@ export default async function InvitesOverviewPage() {
const initialInvites = await api.invite.getAll();
return (
<ManagePageLayout
title={t("title")}
primaryAction={
<ManageMobilePrimaryAction>
<InviteCreateButton />
</ManageMobilePrimaryAction>
}
floatingPrimaryAction
>
<ManagePageLayout title={t("title")}>
<InviteListComponent initialInvites={initialInvites} />
</ManagePageLayout>
);
@@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";
import type { Dimensions } from "./_dimension-popover";
import { PreviewDimensionsPopover } from "./_dimension-popover";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const t = useI18n();
const utils = clientApi.useUtils();
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
@@ -98,13 +97,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
});
}, [editMode, t]);
const openDimensionsModal = useCallback(() => {
openPreviewDimensionsModal({
dimensions,
setDimensions,
});
}, [dimensions, openPreviewDimensionsModal]);
const updateOptions = useCallback(
({ newOptions }: { newOptions: Record<string, unknown> }) =>
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
@@ -162,15 +154,21 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
</ActionIcon>
</Affix>
<Affix bottom={12} right={72 + 120}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
aria-label={t("widgetPreview.dimensions.title")}
>
<IconDimensions size={24} />
</ActionIcon>
<PreviewDimensionsPopover
dimensions={dimensions}
setDimensions={setDimensions}
target={(onClick) => (
<ActionIcon
size={48}
variant="default"
radius="xl"
aria-label={t("widgetPreview.dimensions.title")}
onClick={onClick}
>
<IconDimensions size={24} />
</ActionIcon>
)}
/>
</Affix>
</>
);
@@ -1,50 +0,0 @@
"use client";
import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.dimensions,
});
const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values);
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("widgetPreview.dimensions.title"),
});
export interface Dimensions {
width: number;
height: number;
}
@@ -0,0 +1,78 @@
"use client";
import type { ReactNode } from "react";
import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
interface PreviewDimensionsPopoverProps {
target: (onClick: () => void) => ReactNode;
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => {
const t = useI18n();
const [opened, { close, open }] = useDisclosure(false);
const form = useForm({
initialValues: dimensions,
});
const handleSubmit = (values: Dimensions) => {
setDimensions(values);
close();
};
const handleClose = () => {
form.setValues(dimensions);
close();
};
const handleToggle = () => {
if (opened) {
handleClose();
return;
}
open();
};
return (
<Popover
opened={opened}
onChange={(nextOpened) => {
if (nextOpened) open();
if (!nextOpened) handleClose();
}}
position="top-end"
width="min(320px, calc(100vw - 2rem))"
withArrow
>
<Popover.Target>{target(handleToggle)}</Popover.Target>
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={handleClose}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
</Popover.Dropdown>
</Popover>
);
};
export interface Dimensions {
width: number;
height: number;
}
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react";
import { widgetKinds } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
@@ -327,20 +327,6 @@ const sumCompleteStepMetric = (
return (values as number[]).reduce((sum, value) => sum + value, 0);
};
const getProcedureTypeMap = () => {
const procedures = (
mcpRouter as unknown as {
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
}
)["_def"].procedures;
return new Map(
Object.entries(procedures).flatMap(([path, procedure]) => {
const type = procedure["_def"]?.type;
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
}),
);
};
const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
@@ -385,7 +371,8 @@ export async function POST(request: Request) {
const parsed = requestSchema.safeParse(
(() => {
try {
return JSON.parse(requestBody) as unknown;
const value: unknown = JSON.parse(requestBody);
return value;
} catch {
return null;
}
@@ -516,14 +503,13 @@ export async function POST(request: Request) {
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
const caller = mcpRouter.createCaller(context);
const procedureTypes = getProcedureTypeMap();
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
const homarrTools = Object.fromEntries(
mcpTools.map((mcpTool) => {
const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
const requiresApproval = mcpTool.type === "mutation";
return [
mcpTool.name,
tool({
@@ -571,11 +557,9 @@ export async function POST(request: Request) {
}),
) satisfies ToolSet;
const toolApproval = Object.fromEntries(
mcpTools.flatMap((mcpTool) =>
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
? [[mcpTool.name, "user-approval" as const]]
: [],
),
mcpTools
.filter((mcpTool) => mcpTool.type === "mutation")
.map((mcpTool) => [mcpTool.name, "user-approval" as const]),
);
const frontendTools = Object.fromEntries(
@@ -46,18 +46,21 @@ vi.mock("../_extract-tools", () => ({
{
name: "board_getAllBoards",
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
+5 -13
View File
@@ -80,18 +80,10 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
let toolsCache: McpTool[] | null = null;
function getTools() {
if (!toolsCache) {
toolsCache = extractMcpTools();
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
const getVisibleTools = (isAdmin: boolean) =>
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
if (isAdmin) return tools;
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
};
function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt(
@@ -336,7 +328,7 @@ export const handleMcpRequest = async (req: NextRequest) => {
const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({
caller,
tools: getVisibleTools(isAdmin),
tools: getVisibleTools(extractMcpTools(), isAdmin),
version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
@@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() });
const echoTool: McpTool = {
name: "echo",
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
import type { ButtonProps } from "@mantine/core";
export interface AccessPickerOption<T> {
value: string;
label: string;
keywords?: readonly string[];
content: ReactNode;
item: T;
}
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
interface AccessPickerProps<T> {
label: string;
searchPlaceholder: string;
emptyMessage: string;
triggerLabel: string;
options: AccessPickerOption<T>[];
isPending: boolean;
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
triggerProps?: AccessPickerTriggerProps;
}
export const AccessPicker = <T,>({
label,
searchPlaceholder,
emptyMessage,
triggerLabel,
options,
isPending,
onSelect,
triggerProps,
}: AccessPickerProps<T>) => {
const [search, setSearch] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
requestAnimationFrame(() => combobox.focusTarget());
},
onDropdownOpen: () => {
combobox.selectFirstOption();
requestAnimationFrame(() => combobox.focusSearchInput());
},
});
const normalizedSearch = search.trim().toLowerCase();
const filteredOptions = options.filter((option) => {
if (!normalizedSearch) return true;
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
return searchableText.includes(normalizedSearch);
});
const visibleOptions = filteredOptions.slice(0, 5);
const handleSelect = (value: string) => {
const option = options.find((item) => item.value === value);
if (!option || isSubmitting) return;
combobox.closeDropdown();
setIsSubmitting(true);
const submit = async () => {
try {
await onSelect(option);
} finally {
setIsSubmitting(false);
}
};
void submit().catch(() => undefined);
};
return (
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
<Combobox.Target targetType="button">
<Button
{...triggerProps}
onClick={() => combobox.toggleDropdown()}
rightSection={
<Group gap={4} wrap="nowrap">
{triggerProps?.rightSection}
<Combobox.Chevron />
</Group>
}
loading={isSubmitting}
disabled={isSubmitting || triggerProps?.disabled}
>
{triggerLabel}
</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
placeholder={searchPlaceholder}
aria-label={label}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Combobox.Options>
{isPending && (
<Center p="md">
<LoaderPlaceholder />
</Center>
)}
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
{!isPending &&
visibleOptions.map((option) => (
<Combobox.Option value={option.value} key={option.value}>
{option.content}
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
const LoaderPlaceholder = () => <Loader size="sm" />;
@@ -2,7 +2,6 @@ import { useState } from "react";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import { GroupSelect } from "./group-select";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
@@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
@@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<GroupSelect
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
onSelect={(group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveGroup")}
</Button>
@@ -1,73 +0,0 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={tGroup("label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tGroup("notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});
@@ -0,0 +1,66 @@
"use client";
import { Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
interface GroupSelectProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const GroupSelect = ({
withPermissions,
presentGroupIds,
onSelect,
triggerLabel,
triggerProps,
}: GroupSelectProps) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
value: group.id,
label: group.name,
keywords: getGroupPermissions(group) ?? [],
item: group,
content: <Text size="sm">{group.name}</Text>,
}));
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
onSelect({
id: group.id,
name: group.name,
permissions: getGroupPermissions(group),
});
return (
<AccessPicker
label={tGroup("label")}
searchPlaceholder={tGroup("label")}
emptyMessage={tGroup("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
const getGroupPermissions = (group: SelectableGroup) => {
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
return undefined;
};
@@ -2,7 +2,6 @@ import { useState } from "react";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
import { UserSelect } from "./user-select";
export interface FormProps<TPermission extends string> {
entity: {
@@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({
})),
},
});
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
if (entity.ownerId) presentUserIds.push(entity.ownerId);
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
@@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<UserSelect
presentUserIds={presentUserIds}
onSelect={(user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveUser")}
</Button>
@@ -1,103 +0,0 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
email: currentUser.email ?? null,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={tUser("label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={tUser("notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
@@ -0,0 +1,75 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableUser = RouterOutputs["user"]["selectable"][number];
interface UserSelectProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const UserSelect = ({
presentUserIds,
allowedProviders,
onSelect,
triggerLabel,
triggerProps,
}: UserSelectProps) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
value: user.id,
label: user.name ?? "",
keywords: [user.email ?? ""],
item: user,
content: (
<Group wrap="nowrap" gap="xs">
<UserAvatar user={user} size="xs" />
<Stack gap={0}>
<Text size="sm">{user.name}</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</Stack>
</Group>
),
}));
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
onSelect({
id: user.id,
name: user.name ?? "",
image: user.image ?? "",
email: user.email ?? null,
});
return (
<AccessPicker
label={tUser("label")}
searchPlaceholder={tUser("label")}
emptyMessage={tUser("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
@@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -2001,7 +2001,6 @@ const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
@@ -2054,23 +2053,15 @@ const ThreadListItem = () => {
}
};
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
};
if (renameOpened) {
@@ -2152,16 +2143,19 @@ const ThreadListItem = () => {
>
<IconFileExport size={14} />
</ActionIcon>
<ActionIcon
<InlineConfirmActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
onClick={deleteConversation}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
>
<IconTrash size={14} />
</ActionIcon>
</InlineConfirmActionIcon>
</Group>
)}
<ActionIcon
@@ -2,25 +2,32 @@ import { describe, expect, test } from "vitest";
import type { WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
const widgetDefinitions = await loadAllWidgetDefinitions();
const getWidgetDefinition = (kind: WidgetKind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
return definition;
};
describe("getAssistantWidgetConfiguration", () => {
test("keeps server-side integration support synchronized with widget definitions", () => {
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
const definition = widgetImport.definition;
for (const [kind, definition] of widgetDefinitions) {
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
const supportedIntegrations = widgetIntegrationSupport[kind];
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
expect(supportedIntegrations, kind).toEqual(expected);
}
});
test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.notebook.definition,
getWidgetDefinition("notebook"),
{
boardId: "board-1",
boardName: "Home",
@@ -42,7 +49,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.audioStats.definition,
getWidgetDefinition("audioStats"),
{
boardId: "board-1",
boardName: "Home",
@@ -90,13 +97,13 @@ describe("getAssistantWidgetConfiguration", () => {
test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration(
widgetImports.mediaServer.definition,
getWidgetDefinition("mediaServer"),
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings,
[],
);
const calendar = getAssistantWidgetConfiguration(
widgetImports.calendar.definition,
getWidgetDefinition("calendar"),
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings,
[],
@@ -4,23 +4,24 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
fitFullLayout?: boolean;
previewRowLimit?: number;
className?: string;
}
const maxThumbnailRows = 12;
const compactBoardLayoutThumbnailRows = 12;
export const maxBoardLayoutThumbnailRows = 48;
export const BoardLayoutThumbnail = ({
preview,
label,
fitFullLayout = false,
previewRowLimit = compactBoardLayoutThumbnailRows,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
@@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({
}
const elements = projectBoardLayout(preview, layout, layout);
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
const roots = preview.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
@@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({
}}
>
{laneElements.map((element) => {
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
const nestedItems =
element.type === "section"
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
? elements.filter(
(candidate) =>
candidate.type === "item" &&
candidate.sectionId === element.id &&
candidate.xOffset >= 0 &&
candidate.xOffset < renderedWidth &&
candidate.yOffset >= 0 &&
candidate.yOffset < renderedHeight &&
candidate.width > 0 &&
candidate.height > 0,
)
: [];
return (
<span
@@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({
aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
}}
>
{element.type === "section" ? (
<span
className={classes.containerContents}
style={{
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
}}
>
{nestedItems.map((nestedItem) => (
@@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({
style={{
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
nestedItem.width,
element.width - nestedItem.xOffset,
renderedWidth - nestedItem.xOffset,
)}`,
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
nestedItem.height,
element.height - nestedItem.yOffset,
renderedHeight - nestedItem.yOffset,
)}`,
}}
>
@@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
export interface BoardSwitcherControls {
open: () => void;
@@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
},
);
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
enabled: isOpen,
});
const switcherBoards = useMemo(
() => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
() => boards.filter((board) => board.id !== currentBoard?.id),
[boards, currentBoard?.id],
);
const filteredBoards = useMemo(() => {
@@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
const spotlightAction = useMemo(
() => ({
@@ -341,7 +336,7 @@ const getBoardSwitcherResults = ({
<BoardLayoutThumbnail
preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
fitFullLayout
previewRowLimit={boardSwitcherPreview.previewRowLimit}
/>
</Card.Section>
<Card.Section withBorder px="sm" py={6}>
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
const result = createItemCallback({ kind: "mediaMissing" })(board);
const result = createItemCallback({ kind: "downloads" })(board);
const createdItem = result.items.at(0);
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
@@ -63,14 +63,14 @@ describe("item actions create-item", () => {
{
layoutId,
height: 1,
width: 1,
width: 2,
...emptyPosition,
sectionId: firstSectionId,
},
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
});
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
expect(createdItem).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
}),
);
expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount,
undefined,
{ height: 1, width: 1 },
{ height: 1, width: 2 },
);
});
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
]);
});
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 3,
height: 2,
xOffset: 0,
yOffset: 1,
}),
@@ -1,5 +1,5 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation {
widgetKind: WidgetKind;
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => {
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
if (!capability) return [];
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
if (!supportedIntegrations) return [];
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
widgetKind,
integrationKind,
isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
},
];
})
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -1,21 +1,7 @@
.refreshIndicator {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
pointer-events: none;
}
.settingsButton {
position: absolute;
top: -34px;
right: 14px;
top: calc(var(--mantine-spacing-sm) / 2);
right: calc(var(--mantine-spacing-sm) / 2);
z-index: 201;
opacity: 0;
pointer-events: none;
@@ -66,7 +52,6 @@
@media (max-width: 768px) {
.settingsButton {
right: 4px;
opacity: 1;
pointer-events: auto;
}
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary";
@@ -19,7 +19,6 @@ import { WidgetError } from "@homarr/widgets/errors";
import {
createWidgetRuntimeState,
getWidgetQueryKeys,
getWidgetRuntimeQueries,
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
@@ -40,7 +39,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false,
@@ -158,24 +156,6 @@ const LoadedBoardItemContent = ({
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
[definition, item, settings],
);
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
query.queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options: menuItem.options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
}) > 0;
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
@@ -343,11 +323,6 @@ const LoadedBoardItemContent = ({
/>
</ErrorBoundary>
</Box>
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
<Loader size="xs" />
</Box>
)}
</WidgetCardShell>
);
@@ -361,6 +336,7 @@ const LoadedBoardItemContent = ({
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
disabled={isAdvanced}
>
<Box
ref={sourceRef}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -8,20 +8,21 @@ import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { useOpenItemMoveModal } from "./item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number };
previewDimensions: WidgetPreviewDimensions;
resetErrorBoundary?: () => void;
}
@@ -142,11 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onConfirm={() => removeItem({ itemId: item.id })}
/>
>
{tItem("action.remove")}
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
@@ -30,6 +31,7 @@ interface InnerProps extends OpenItemMoveModalInput {
board: Board;
currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
}
interface MoveTarget {
@@ -57,8 +59,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tLandmark = useI18n("board.landmark");
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
@@ -235,6 +236,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
export const useOpenItemMoveModal = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const registeredGridEditors = useRegisteredGridEditors();
const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal);
@@ -245,9 +247,10 @@ export const useOpenItemMoveModal = () => {
board,
currentLayoutId,
commitSectionGrids,
registeredGridEditors,
});
},
[board, commitSectionGrids, currentLayoutId, openModal],
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
);
};
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff. Show more