Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e420bc087 | ||
|
|
322d4a7fc4 | ||
|
|
63e1bc7f6c |
No files matched your search
@@ -320,12 +320,8 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [container, preview-amd64, preview-arm64]
|
||||
needs: [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') ||
|
||||
@@ -379,6 +375,9 @@ 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}"
|
||||
@@ -387,6 +386,9 @@ 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)"
|
||||
@@ -433,14 +435,6 @@ 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,
|
||||
|
||||
@@ -29,9 +29,6 @@ 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
|
||||
|
||||
@@ -113,9 +110,7 @@ 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
|
||||
|
||||
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`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. 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.
|
||||
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.
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
|
||||
@@ -34,11 +34,9 @@ arrow keys to move through the results and press `Enter` to open the selected bo
|
||||
|
||||
## Create a new board
|
||||
|
||||
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.
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||
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
|
||||
|
||||
@@ -58,9 +56,6 @@ 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:
|
||||
@@ -133,21 +128,20 @@ 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 main layout and starts at `768px` on new boards.
|
||||
- **Base** is the widest 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 at any
|
||||
unused breakpoint above Mobile.
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
|
||||
their breakpoints.
|
||||
|
||||
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 an
|
||||
available breakpoint, 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 a
|
||||
free breakpoint between Mobile and Base, 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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -183,8 +177,7 @@ 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`. 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.
|
||||
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
||||
fallback to select a layout.
|
||||
|
||||
@@ -273,8 +266,6 @@ 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.
|
||||
@@ -414,7 +405,8 @@ 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".
|
||||
The name form expands below that board card and can be cancelled without leaving the list.
|
||||
|
||||

|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
|
||||
@@ -30,12 +30,15 @@ Navigate to `Management` > `Tools` > `Certificates`.
|
||||
|
||||
## Add a certificate
|
||||
|
||||
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
|
||||
To add a certificate click on the "Add certificate" 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.
|
||||
Then select the certificate file you want to add.
|
||||
|
||||

|
||||
|
||||
And click the "Add" button.
|
||||
|
||||
## Delete a certificate
|
||||
|
||||
@@ -43,7 +46,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
|
||||
|
||||

|
||||
|
||||
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
|
||||
Then confirm the deletion.
|
||||
|
||||
## Managing it through file system
|
||||
|
||||
|
||||
@@ -114,8 +114,7 @@ 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. 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.
|
||||
delete submissions. 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,26 +128,6 @@ 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
|
||||
|
||||
@@ -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.
|
||||
The group-name form expands above the group list and can be closed without leaving the page.
|
||||
Homarr will prompt you for a group name.
|
||||
|
||||
### Managing a group
|
||||
|
||||
@@ -259,8 +259,7 @@ An invitation link can be used indefinitely.
|
||||
|
||||
### Creating an invitation
|
||||
|
||||
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
|
||||
dialog or navigating to another page:
|
||||
Click on the button:
|
||||
|
||||

|
||||
|
||||
@@ -270,12 +269,12 @@ The default is 4 hours in the future.
|
||||
|
||||

|
||||
|
||||
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
|
||||
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
Use **Copy** to copy the URL, then close the section when you are finished.
|
||||
Clicking the `Copy & close` button will copy the URL to your clipboard.
|
||||
|
||||
### Self-onboarding
|
||||
|
||||
@@ -294,4 +293,3 @@ 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.
|
||||
@@ -11,7 +11,7 @@ import { appWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -23,8 +23,6 @@ This widget adapts its display based on the linked integration:
|
||||
|
||||
Only options relevant to your linked integration are shown in the configuration panel.
|
||||
|
||||
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
|
||||
|
||||
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
|
||||
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
|
||||
|
||||
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
|
||||
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding.
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
|
||||
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height. Bulk actions keep successful results visible and report the number of failures.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
|
||||
:::info
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
|
||||
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
### Features
|
||||
|
||||
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
|
||||
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
|
||||
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
|
||||
@@ -72,10 +72,11 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
:::note
|
||||
|
||||
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
|
||||
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
|
||||
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
|
||||
the filter panel available from the footer.
|
||||
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
|
||||
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
|
||||
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
|
||||
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
|
||||
from the footer.
|
||||
|
||||
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
|
||||
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
|
||||
|
||||
@@ -16,8 +16,6 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
|
||||
|
||||
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
|
||||
|
||||
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
|
||||
|
||||
### Screenshots
|
||||
|
||||

|
||||
|
||||
@@ -67,8 +67,6 @@ 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} />
|
||||
|
||||
@@ -15,8 +15,6 @@ import { immichServerStatsWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
|
||||
|
||||
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import screen from "./img/screen.png";
|
||||
|
||||
@@ -14,8 +14,6 @@ import { indexerManagerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
|
||||
|
||||
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import bigItem from "./img/big.png";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
|
||||
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
|
||||
|
||||
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
|
||||
Provides a list of newly added media or upcoming releases from your media server.
|
||||
|
||||
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
|
||||
|
||||
|
||||
@@ -16,11 +16,10 @@ 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"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -50,7 +46,6 @@ 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,11 +16,10 @@ 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"]} />
|
||||
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -47,7 +43,6 @@ 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} />
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
|
||||
|
||||
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
|
||||
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
|
||||
|
||||
Shows connected users and connected guest devices on wired or wireless networks.
|
||||
|
||||
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
|
||||
|
||||
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
|
||||
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
|
||||
|
||||
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
|
||||
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards.
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
|
||||
|
||||
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
|
||||
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
|
||||
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release.
|
||||
It also supports importing releases from your local docker containers.
|
||||
|
||||
### Screenshots
|
||||
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
|
||||
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
|
||||
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
|
||||
|
||||
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
|
||||
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
|
||||
|
||||
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
|
||||
|
||||
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
|
||||
The entity state enables you to display the current state of an entity.
|
||||
This works with most entities and enables you display almost any data.
|
||||
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
|
||||
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 the responsive aggregate overview 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.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
|
||||
|
||||
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
|
||||
The Timetable widget provides real-time departure times for a selected station.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
|
||||
|
||||
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
|
||||
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
|
||||
|
||||
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning 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.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
|
||||
|
||||
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
|
||||
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
|
||||
|
||||
When multiple Traefik integrations are selected, data from healthy sources remains 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.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami";
|
||||
|
||||
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
|
||||
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
|
||||
|
||||
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
|
||||
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height.
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
|
||||
|
||||
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"@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,17 +1,20 @@
|
||||
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 {
|
||||
createSessionQueryPersistence,
|
||||
createSessionQueryPersister,
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxDataBytes,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||
const query = queryClient.getQueryCache().find({ queryKey });
|
||||
if (!query) throw new Error("Expected query to be present");
|
||||
return { query, queryClient };
|
||||
@@ -34,98 +37,314 @@ const createMemoryStorage = (): Storage => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
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 });
|
||||
describe("widget query persistence", () => {
|
||||
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
||||
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||
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 ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
||||
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
||||
});
|
||||
const pendingClient = new QueryClient();
|
||||
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
});
|
||||
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(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
|
||||
});
|
||||
|
||||
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
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 userA = createSessionQueryPersistence("user-a", storage);
|
||||
const userB = createSessionQueryPersistence("user-b", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||
temp: 21,
|
||||
});
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
persister.stop();
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: { mutations: [], queries: [] },
|
||||
});
|
||||
persister.flush();
|
||||
|
||||
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("removes the cache instead of storing an empty one", () => {
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
window.sessionStorage.setItem(key, "stale");
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: { mutations: [], queries: [] },
|
||||
});
|
||||
persister.flush();
|
||||
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
});
|
||||
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" },
|
||||
},
|
||||
);
|
||||
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient, userA.dehydrateOptions),
|
||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||
};
|
||||
|
||||
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"),
|
||||
});
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
const restored = await persister.restoreClient();
|
||||
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
||||
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
expect(await userB.persister.restoreClient()).toBeUndefined();
|
||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||
|
||||
await userA.persister.removeClient();
|
||||
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
|
||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
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");
|
||||
};
|
||||
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");
|
||||
|
||||
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
|
||||
expect(await persistence.persister.restoreClient()).toBeUndefined();
|
||||
expect(() => persistence.persister.removeClient()).not.toThrow();
|
||||
window.sessionStorage.setItem(
|
||||
key,
|
||||
stringify({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [{ state: { status: "pending" } }],
|
||||
queries: [
|
||||
{
|
||||
...validQuery,
|
||||
state: {
|
||||
...validQuery.state,
|
||||
error: new Error("private refresh error"),
|
||||
fetchStatus: "fetching",
|
||||
status: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
...validQuery,
|
||||
queryHash: "unrelated",
|
||||
queryKey: [["board", "getBoardByName"], { type: "query" }],
|
||||
},
|
||||
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
|
||||
{
|
||||
...validQuery,
|
||||
queryHash: "missing-timestamp",
|
||||
state: { ...validQuery.state, dataUpdatedAt: undefined },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = await createSessionQueryPersister(scope).restoreClient();
|
||||
expect(restored?.clientState.mutations).toEqual([]);
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["expired", 1, queryPersistenceBuster],
|
||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
||||
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
||||
const scope = "user-a";
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||
temp: 21,
|
||||
});
|
||||
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
|
||||
persister.flush();
|
||||
|
||||
const restoredClient = new QueryClient();
|
||||
await persistQueryClientRestore({
|
||||
queryClient: restoredClient,
|
||||
persister,
|
||||
maxAge: 5 * 60 * 1_000,
|
||||
buster: queryPersistenceBuster,
|
||||
});
|
||||
|
||||
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
|
||||
});
|
||||
|
||||
test("quota retry writes the cache after evicting the oldest query", async () => {
|
||||
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
|
||||
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
|
||||
const storage = createMemoryStorage();
|
||||
const setItem = storage.setItem.bind(storage);
|
||||
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
|
||||
const client = parse<PersistedClient>(value);
|
||||
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
|
||||
setItem(key, value);
|
||||
});
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
||||
},
|
||||
};
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
||||
});
|
||||
|
||||
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const storage = createMemoryStorage();
|
||||
const setItem = vi.spyOn(storage, "setItem");
|
||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
|
||||
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(first.queryClient),
|
||||
});
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(latest.queryClient),
|
||||
});
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
persister.flush();
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
||||
});
|
||||
|
||||
test("treats blocked storage operations as an empty cache", async () => {
|
||||
const blockedStorage = {
|
||||
getItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
||||
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(() => {
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
}).not.toThrow();
|
||||
expect(() => persister.removeClient()).not.toThrow();
|
||||
});
|
||||
|
||||
test("treats an unavailable session storage getter as an empty cache", async () => {
|
||||
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
});
|
||||
|
||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
|
||||
export const queryPersistenceBuster = "v6-dashboard-data";
|
||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||
export const queryPersistenceBuster = "v2-session-superjson";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
|
||||
@@ -15,33 +16,95 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||
|
||||
interface PersistableQuery {
|
||||
queryKey: QueryKey;
|
||||
state: { data: unknown; status: string };
|
||||
state: {
|
||||
data: unknown;
|
||||
dataUpdatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const budgetVerdictCache = new WeakMap<object, boolean>();
|
||||
|
||||
const measureFitsBudget = (data: unknown) => {
|
||||
try {
|
||||
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fitsStorageBudget = (data: unknown) => {
|
||||
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
||||
|
||||
const cached = budgetVerdictCache.get(data);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const verdict = measureFitsBudget(data);
|
||||
budgetVerdictCache.set(data, verdict);
|
||||
return verdict;
|
||||
};
|
||||
|
||||
const holdsDashboardData = (query: PersistableQuery) =>
|
||||
query.state.data !== undefined &&
|
||||
Number.isFinite(query.state.dataUpdatedAt) &&
|
||||
query.state.dataUpdatedAt > 0 &&
|
||||
isWidgetDataQueryKey(query.queryKey);
|
||||
|
||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
|
||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||
|
||||
const serializePersistedClient = (client: PersistedClient) =>
|
||||
stringify({
|
||||
...client,
|
||||
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||
|
||||
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
fetchStatus: "idle",
|
||||
isInvalidated: true,
|
||||
status: "success",
|
||||
},
|
||||
});
|
||||
|
||||
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||
...client,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
||||
},
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
||||
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
||||
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
||||
|
||||
return holdsDashboardData(value as unknown as PersistableQuery);
|
||||
};
|
||||
|
||||
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
||||
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
||||
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
||||
|
||||
return {
|
||||
timestamp: value.timestamp as number,
|
||||
buster: value.buster,
|
||||
clientState: {
|
||||
...client.clientState,
|
||||
queries: client.clientState.queries.map((query) => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
status: "success",
|
||||
},
|
||||
})),
|
||||
mutations: [],
|
||||
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionStorage = () => {
|
||||
const getSessionStorage = (): Storage | undefined => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
try {
|
||||
@@ -51,47 +114,113 @@ const getSessionStorage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const protectStorage = (storage: Storage | undefined) => {
|
||||
if (!storage) return undefined;
|
||||
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||
try {
|
||||
storage?.removeItem(key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
const foreignKeys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const storedKey = storage.key(index);
|
||||
if (storedKey === null || storedKey === activeKey) continue;
|
||||
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
||||
}
|
||||
|
||||
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSessionQueryPersister = (
|
||||
scope: string | null,
|
||||
storage: Storage | undefined = getSessionStorage(),
|
||||
): SessionQueryPersister => {
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
removeForeignScopes(storage, key);
|
||||
let pendingClient: PersistedClient | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
|
||||
const persistNow = (client: PersistedClient) => {
|
||||
if (!storage) return;
|
||||
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
||||
let errorCount = 0;
|
||||
|
||||
if (candidate.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return;
|
||||
}
|
||||
|
||||
while (candidate) {
|
||||
try {
|
||||
storage.setItem(key, stringify(candidate));
|
||||
return;
|
||||
} catch (error) {
|
||||
errorCount += 1;
|
||||
candidate = removeOldestQuery({
|
||||
persistedClient: candidate,
|
||||
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
||||
errorCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearPersistTimer = () => {
|
||||
if (persistTimer === undefined) return;
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
clearPersistTimer();
|
||||
const client = pendingClient;
|
||||
pendingClient = undefined;
|
||||
if (client) persistNow(client);
|
||||
};
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
persistClient(client) {
|
||||
if (!storage || stopped) return;
|
||||
pendingClient = client;
|
||||
if (persistTimer !== undefined) return;
|
||||
persistTimer = setTimeout(flush, 0);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
storage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
restoreClient() {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
const storedValue = storage?.getItem(key);
|
||||
if (!storedValue) return undefined;
|
||||
|
||||
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
||||
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return client;
|
||||
} catch {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
removeClient() {
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
removeStorageItem(storage, key);
|
||||
},
|
||||
flush,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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 { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
@@ -25,8 +25,6 @@ import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
dashboardSupportingQueryPolicies,
|
||||
isTrpcForbiddenError,
|
||||
isWidgetDataQueryKey,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
@@ -38,8 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createSessionQueryPersistence } from "./query-persistence";
|
||||
import type { SessionQueryPersistence } from "./query-persistence";
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxAgeMs,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
import type { SessionQueryPersister } from "./query-persistence";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -72,11 +75,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
|
||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersistence.persister.removeClient();
|
||||
void queryPersister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersistence]);
|
||||
}, [queryPersister]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
@@ -84,7 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
@@ -93,8 +96,8 @@ const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersistence,
|
||||
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -111,13 +114,6 @@ 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,
|
||||
@@ -156,6 +152,15 @@ 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(() => {
|
||||
@@ -206,7 +211,15 @@ const ScopedTRPCReactProvider = ({
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: queryPersistenceMaxAgeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Button, Group, TextInput } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
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 }) => {
|
||||
@@ -18,70 +17,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
const t = useI18n("board.setting");
|
||||
const tCommonAction = useI18n("common.action");
|
||||
const router = useRouter();
|
||||
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 { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
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";
|
||||
|
||||
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(
|
||||
{
|
||||
const onRenameClick = useCallback(
|
||||
() =>
|
||||
openModal({
|
||||
id: board.id,
|
||||
visibility: visibility === "public" ? "private" : "public",
|
||||
},
|
||||
{
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => router.push(`/boards/${name}/settings`),
|
||||
}),
|
||||
[board.id, board.name, router, openModal],
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteBoard(
|
||||
{ id: board.id },
|
||||
{
|
||||
onSettled: () => {
|
||||
router.push("/");
|
||||
},
|
||||
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();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
});
|
||||
}, [
|
||||
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("/");
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<DangerZoneRoot>
|
||||
@@ -89,28 +88,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
action={
|
||||
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>
|
||||
)
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!hideVisibility && (
|
||||
@@ -118,15 +98,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
action={
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isChangeVisibilityPending}
|
||||
onConfirm={handleVisibilityChange}
|
||||
confirmLabel={tCommonAction("confirm")}
|
||||
>
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -134,15 +108,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={tCommonAction("delete")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isDeletePending}
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommonAction("confirm")}
|
||||
>
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{tCommonAction("delete")}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DangerZoneRoot>
|
||||
|
||||
@@ -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,14 +40,13 @@ 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({
|
||||
@@ -85,27 +84,33 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (layout: FormValues["layouts"][number]) => {
|
||||
setResettingLayoutId(layout.id);
|
||||
try {
|
||||
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
if (!canonicalLayout) return;
|
||||
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;
|
||||
|
||||
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")}>
|
||||
@@ -139,9 +144,13 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{displayedLayouts.map(({ layout, index }) => {
|
||||
{form.values.layouts.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
|
||||
@@ -191,8 +200,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
min={layout.role === "mobile" ? 0 : 1}
|
||||
max={32767}
|
||||
min={
|
||||
layout.role === "base"
|
||||
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
||||
: 1
|
||||
}
|
||||
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
@@ -223,17 +236,16 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
{layout.role !== "base" && persistedLayout && (
|
||||
<InlineConfirmButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={resettingLayoutId === layout.id}
|
||||
disabled={isSaving}
|
||||
onConfirm={() => handleReset(layout)}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
onClick={() => confirmReset(layout)}
|
||||
>
|
||||
{tBoard("setting.section.layout.reset.action")}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
<Button
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||
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";
|
||||
|
||||
@@ -17,41 +18,48 @@ 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 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"),
|
||||
});
|
||||
},
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}, [app.id, mutate, t, tCommon]);
|
||||
});
|
||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
onConfirm={onConfirm}
|
||||
<ActionIcon
|
||||
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} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
+34
-12
@@ -8,8 +8,10 @@ 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 { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
|
||||
@@ -23,10 +25,9 @@ interface BoardCardMenuDropdownProps {
|
||||
RouterOutputs["board"]["getManageOverview"][number],
|
||||
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||
>;
|
||||
onDuplicate: () => void;
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useI18n("management.page.board.action");
|
||||
const tRoot = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
@@ -34,6 +35,9 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
|
||||
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
|
||||
@@ -52,10 +56,20 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeletion = useCallback(
|
||||
() => deleteBoardMutation.mutateAsync({ id: board.id }),
|
||||
[board.id, deleteBoardMutation],
|
||||
);
|
||||
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 handleSetHomeBoard = useCallback(async () => {
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
@@ -65,6 +79,15 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
|
||||
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} />}>
|
||||
@@ -74,7 +97,7 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{tRoot("board.action.duplicate.title")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -94,15 +117,14 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||
<InlineConfirmMenuItem
|
||||
<Menu.Item
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onConfirm={handleDeletion}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
onClick={handleDeletion}
|
||||
disabled={deleteBoardMutation.isPending}
|
||||
>
|
||||
{tCommon("action.delete")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"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,55 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
|
||||
import { BoardCreateForm } from "@homarr/forms-collection";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-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 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);
|
||||
};
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,38 @@
|
||||
import { Grid, GridCol } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
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 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 { BoardCard } from "./_components/board-card";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
@@ -19,14 +42,24 @@ export default async function ManageBoardsPage() {
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
{canCreateBoards && (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
)}
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
canCreateBoards ? (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && (
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
<NoResults
|
||||
icon={IconLayoutDashboard}
|
||||
title={t("noResults.title")}
|
||||
description={t("noResults.description")}
|
||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
||||
/>
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
@@ -59,3 +92,97 @@ 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,17 +10,24 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
interface CopyAiPromptButtonBaseProps {
|
||||
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,
|
||||
@@ -28,14 +35,23 @@ 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,
|
||||
getDraft(),
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
getDiagnostics(),
|
||||
readDiagnostics(),
|
||||
);
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
@@ -46,10 +62,10 @@ export const CopyAiPromptButton = ({
|
||||
const value = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
getDraft(),
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
getDiagnostics(),
|
||||
readDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
|
||||
@@ -20,9 +20,10 @@ 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 { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { downloadJson } from "~/components/custom-widgets/download";
|
||||
@@ -39,7 +40,7 @@ interface WidgetRef {
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
@@ -172,24 +173,30 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
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") });
|
||||
},
|
||||
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") });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -256,15 +263,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
</>
|
||||
)}
|
||||
{!widget.migrationRequired && (
|
||||
<InlineConfirmMenuItem
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
+8
-10
@@ -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 { WorkshopReportForm } from "~/components/workshop/workshop-report-form";
|
||||
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
|
||||
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
|
||||
import { useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||
@@ -213,13 +213,6 @@ export function WorkshopDetail({ id }: { id: string }) {
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<WorkshopReportForm
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
opened={reportOpened}
|
||||
onClose={reportControls.close}
|
||||
/>
|
||||
|
||||
<ManageStickyFooter
|
||||
secondary={
|
||||
<>
|
||||
@@ -248,8 +241,6 @@ 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>
|
||||
@@ -268,6 +259,13 @@ export function WorkshopDetail({ id }: { id: string }) {
|
||||
</Button>
|
||||
</ManageStickyFooter>
|
||||
</Stack>
|
||||
|
||||
<WorkshopReportModal
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
opened={reportOpened}
|
||||
onClose={reportControls.close}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+79
-77
@@ -6,12 +6,11 @@ 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, useModalAction } from "@homarr/modals";
|
||||
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { InlineConfirmButton } from "@homarr/ui";
|
||||
|
||||
import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form";
|
||||
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
|
||||
|
||||
interface CertificateErrorDetailsProps {
|
||||
@@ -24,71 +23,80 @@ 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 { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } =
|
||||
clientApi.certificates.trustHostnameMismatch.useMutation();
|
||||
const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } =
|
||||
clientApi.certificates.addCertificate.useMutation();
|
||||
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
|
||||
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
|
||||
|
||||
const rootCertificate = getHeighestCertificate(error.data.certificate);
|
||||
|
||||
const handleTrustHostname = async () => {
|
||||
const handleTrustHostname = () => {
|
||||
const { hostname } = new URL(url);
|
||||
await trustHostnameAsync(
|
||||
{
|
||||
hostname,
|
||||
certificate: error.data.certificate.pem,
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
{
|
||||
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 = async () => {
|
||||
const handleTrustSelfSigned = () => {
|
||||
const { hostname } = new URL(url);
|
||||
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"),
|
||||
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 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 (
|
||||
@@ -122,36 +130,30 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
|
||||
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
||||
error.data.reason === "hostnameMismatch" ? (
|
||||
<InlineConfirmButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
|
||||
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
confirmLabel={trustConfirmLabel}
|
||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
>
|
||||
{tError("certificate.action.trust.label")}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
) : null}
|
||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
openUploadModal({
|
||||
onSuccess() {
|
||||
setShowRetryButton(true);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{tError("certificate.action.upload.label")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"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 };
|
||||
@@ -15,42 +16,46 @@ 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 (
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
onConfirm={onConfirm}
|
||||
<ActionIcon
|
||||
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} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, 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 onConfirm = async () => {
|
||||
await mutateAsync({ id: media.id });
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
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");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.delete.label")} openDelay={500}>
|
||||
<InlineConfirmActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
loading={isPending}
|
||||
aria-label={t("action.delete.label")}
|
||||
>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+33
-25
@@ -1,14 +1,15 @@
|
||||
"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];
|
||||
@@ -17,41 +18,48 @@ 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 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"),
|
||||
});
|
||||
},
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}, [searchEngine.id, mutate, t]);
|
||||
});
|
||||
}, [searchEngine, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={actionT("confirm")}
|
||||
confirmationAriaLabel={actionT("confirm")}
|
||||
onConfirm={onConfirm}
|
||||
<ActionIcon
|
||||
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} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table";
|
||||
import type { MRT_ColumnDef } 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 { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewApiKeyAlert } from "./new-api-key-alert";
|
||||
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
|
||||
|
||||
interface ApiKeysManagementProps {
|
||||
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
|
||||
onSuccess(data) {
|
||||
setNewApiKey(data.apiKey);
|
||||
onCreated();
|
||||
async onSuccess(data) {
|
||||
openModal({
|
||||
apiKey: data.apiKey,
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
},
|
||||
});
|
||||
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
|
||||
@@ -71,19 +38,21 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
|
||||
});
|
||||
|
||||
const t = useI18n("management.page.tool.api.tab.apiKey");
|
||||
const tCommon = useI18n("common");
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
await mutateDeleteAsync({ apiKeyId: id });
|
||||
(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 });
|
||||
},
|
||||
});
|
||||
},
|
||||
[mutateDeleteAsync],
|
||||
[t, openConfirmModal, mutateDeleteAsync],
|
||||
);
|
||||
const handleDismissNewApiKey = useCallback(async () => {
|
||||
setNewApiKey(null);
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
}, []);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
|
||||
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "id",
|
||||
@@ -92,32 +61,36 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("table.header.createdBy"),
|
||||
Cell: ApiKeyCreatedByCell,
|
||||
Cell: ({ row }) => (
|
||||
<Group gap={"xs"}>
|
||||
<UserAvatar user={row.original.user} size={"sm"} />
|
||||
<Text>{row.original.user.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("table.header.actions"),
|
||||
Cell: ApiKeyActionsCell,
|
||||
Cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
|
||||
<IconTrash size="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, handleDelete, isPendingDelete],
|
||||
);
|
||||
|
||||
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>
|
||||
@@ -131,7 +104,6 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
|
||||
<MantineReactTable table={table} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,6 @@ 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 {
|
||||
@@ -19,8 +17,9 @@ interface ApiPageTabsProps {
|
||||
apiKeyLabel: string;
|
||||
mcpLabel: string;
|
||||
documentationPanel: ReactNode;
|
||||
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
||||
authenticationPanel: ReactNode;
|
||||
baseUrl: string;
|
||||
hasApiKeys: boolean;
|
||||
toolGroups: McpToolGroup[];
|
||||
}
|
||||
|
||||
@@ -29,12 +28,12 @@ export function ApiPageTabs({
|
||||
apiKeyLabel,
|
||||
mcpLabel,
|
||||
documentationPanel,
|
||||
apiKeys,
|
||||
authenticationPanel,
|
||||
baseUrl,
|
||||
hasApiKeys,
|
||||
toolGroups,
|
||||
}: ApiPageTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<string | null>("documentation");
|
||||
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
@@ -49,17 +48,10 @@ export function ApiPageTabs({
|
||||
{mcpLabel}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="authentication">
|
||||
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
|
||||
</TabsPanel>
|
||||
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
|
||||
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
|
||||
<TabsPanel value="mcp">
|
||||
<McpInstructions
|
||||
baseUrl={baseUrl}
|
||||
hasApiKeys={hasApiKeys}
|
||||
toolGroups={toolGroups}
|
||||
onApiKeyCreated={() => setHasApiKeys(true)}
|
||||
/>
|
||||
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControl,
|
||||
@@ -37,11 +36,12 @@ 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,23 +52,18 @@ interface McpInstructionsProps {
|
||||
baseUrl: string;
|
||||
hasApiKeys: boolean;
|
||||
toolGroups: McpToolGroup[];
|
||||
onApiKeyCreated: () => void;
|
||||
}
|
||||
|
||||
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) {
|
||||
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
|
||||
const t = useI18n("management.page.tool.api.tab.mcp");
|
||||
const tCommon = useI18n("common");
|
||||
const [newApiKey, setNewApiKey] = useState<string | null>(null);
|
||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
|
||||
onSuccess(data) {
|
||||
setNewApiKey(data.apiKey);
|
||||
onApiKeyCreated();
|
||||
async onSuccess(data) {
|
||||
openModal({ apiKey: data.apiKey });
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
},
|
||||
});
|
||||
const handleDismissNewApiKey = useCallback(async () => {
|
||||
setNewApiKey(null);
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
}, []);
|
||||
const mcpUrl = `${baseUrl}/api/mcp`;
|
||||
|
||||
const streamableHttpConfig = JSON.stringify(
|
||||
@@ -119,7 +114,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
|
||||
<Text size="sm">{t("description")}</Text>
|
||||
</Alert>
|
||||
|
||||
{!hasApiKeys && !newApiKey && (
|
||||
{!hasApiKeys && (
|
||||
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">{t("noApiKey")}</Text>
|
||||
@@ -136,8 +131,6 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="xs">
|
||||
<Group gap="xs">
|
||||
@@ -312,7 +305,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
|
||||
<Stack gap={6}>
|
||||
{group.tools.map((tool) => (
|
||||
<div key={tool.name} className={classes.toolRow}>
|
||||
<Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light">
|
||||
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
|
||||
{toolTypeDisplay[tool.type].method}
|
||||
</Badge>
|
||||
<div>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
|
||||
@@ -86,8 +87,9 @@ export default async function ApiPage() {
|
||||
apiKeyLabel={t("apiKey.label")}
|
||||
mcpLabel={t("mcp.label")}
|
||||
documentationPanel={<ScalarApiReference document={document} />}
|
||||
apiKeys={apiKeys}
|
||||
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
|
||||
baseUrl={baseUrl}
|
||||
hasApiKeys={apiKeys.length > 0}
|
||||
toolGroups={toolGroups}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
+14
-26
@@ -1,35 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Collapse, Stack } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CertificateUploadForm } from "./certificate-upload-form";
|
||||
|
||||
export const AddCertificateSection = () => {
|
||||
const [opened, { close, toggle }] = useDisclosure(false);
|
||||
export const AddCertificateButton = () => {
|
||||
const { openModal } = useModalAction(AddCertificateModal);
|
||||
const t = useI18n("certificate");
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { CertificateUploadForm };
|
||||
return <Button onClick={handleClick}>{t("action.create.label")}</Button>;
|
||||
};
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
+31
-30
@@ -1,56 +1,57 @@
|
||||
"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 { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||
},
|
||||
});
|
||||
const t = useI18n("certificate");
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
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"),
|
||||
});
|
||||
},
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<InlineConfirmActionIcon
|
||||
onConfirm={handleRemove}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
loading={isPending}
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label={t("action.remove.label")}
|
||||
>
|
||||
<ActionIcon onClick={handleClick} color="red" variant="subtle">
|
||||
<IconTrash color="red" size={16} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
+26
-25
@@ -1,12 +1,13 @@
|
||||
"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;
|
||||
@@ -14,41 +15,41 @@ interface RemoveHostnameActionIconProps {
|
||||
}
|
||||
|
||||
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
|
||||
const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({
|
||||
const { mutateAsync } = 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 = () =>
|
||||
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 = () => {
|
||||
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"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<InlineConfirmActionIcon
|
||||
onConfirm={handleRemove}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
loading={isPending}
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label={t("action.removeHostname.label")}
|
||||
>
|
||||
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AddCertificateSection } from "./_components/add-certificate";
|
||||
import { AddCertificateButton } from "./_components/add-certificate";
|
||||
import { RemoveCertificate } from "./_components/remove-certificate";
|
||||
|
||||
interface CertificatesPageProps {
|
||||
@@ -69,11 +69,10 @@ 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")} />
|
||||
)}
|
||||
|
||||
+7
-3
@@ -13,8 +13,6 @@ 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 {
|
||||
@@ -49,7 +47,13 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialConfigMaps),
|
||||
{
|
||||
initialData: initialConfigMaps,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,7 +17,6 @@ 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);
|
||||
|
||||
@@ -85,7 +84,13 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialIngresses),
|
||||
{
|
||||
initialData: initialIngresses,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
});
|
||||
+7
-3
@@ -15,8 +15,6 @@ 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 {
|
||||
@@ -68,7 +66,13 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialNamespaces),
|
||||
{
|
||||
initialData: initialNamespaces,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,7 +17,6 @@ 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);
|
||||
|
||||
@@ -98,7 +97,13 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialNodes),
|
||||
{
|
||||
initialData: initialNodes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,8 +13,6 @@ 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 {
|
||||
@@ -59,7 +57,13 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
||||
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialPods),
|
||||
{
|
||||
initialData: initialPods,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,8 +13,6 @@ 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 {
|
||||
@@ -54,7 +52,13 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialSecrets),
|
||||
{
|
||||
initialData: initialSecrets,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -14,8 +14,6 @@ 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 {
|
||||
@@ -73,7 +71,13 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
|
||||
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialServices),
|
||||
{
|
||||
initialData: initialServices,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,8 +13,6 @@ 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 {
|
||||
@@ -78,7 +76,13 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialVolumes),
|
||||
{
|
||||
initialData: initialVolumes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
+20
-5
@@ -2,18 +2,20 @@
|
||||
|
||||
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({
|
||||
@@ -21,14 +23,27 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
|
||||
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
|
||||
},
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
() => mutateUserDeletionAsync({ userId: user.id }),
|
||||
[mutateUserDeletionAsync, user.id],
|
||||
() =>
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red">
|
||||
<Button onClick={handleDelete} variant="subtle" color="red">
|
||||
{tCommon("action.delete")}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+30
-30
@@ -8,9 +8,10 @@ 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 { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
import { useIsMobile } from "@homarr/ui/hooks";
|
||||
|
||||
interface UserProfileAvatarForm {
|
||||
@@ -18,7 +19,7 @@ interface UserProfileAvatarForm {
|
||||
}
|
||||
|
||||
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({
|
||||
const { mutate } = clientApi.user.setProfileImage.useMutation({
|
||||
async onSuccess() {
|
||||
// Revalidate all as the avatar is used in multiple places
|
||||
await revalidatePathActionAsync("/");
|
||||
@@ -26,6 +27,7 @@ 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");
|
||||
|
||||
@@ -66,28 +68,32 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
[mutate, user.id, tManageAvatar],
|
||||
);
|
||||
|
||||
const handleRemoveAvatar = useCallback(
|
||||
() =>
|
||||
mutateAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: tManageAvatar("removeImage.notification.success.message"),
|
||||
});
|
||||
const handleRemoveAvatar = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: tManageAvatar("removeImage.label"),
|
||||
children: tManageAvatar("removeImage.confirm"),
|
||||
onConfirm() {
|
||||
mutate(
|
||||
{
|
||||
userId: user.id,
|
||||
image: null,
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tManageAvatar("removeImage.notification.error.message"),
|
||||
});
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: tManageAvatar("removeImage.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tManageAvatar("removeImage.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
[mutateAsync, user.id, tManageAvatar],
|
||||
);
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||
|
||||
return (
|
||||
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
||||
@@ -118,15 +124,9 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
)}
|
||||
</FileButton>
|
||||
{user.image && (
|
||||
<InlineConfirmMenuItem
|
||||
color="red"
|
||||
onConfirm={handleRemoveAvatar}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
disabled={isPending}
|
||||
leftSection={<IconPhotoX size={16} stroke={1.5} />}
|
||||
>
|
||||
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
|
||||
{tManageAvatar("removeImage.label")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
+27
-18
@@ -12,6 +12,7 @@ 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";
|
||||
@@ -19,7 +20,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||
import { optionalEmailSchema } from "@homarr/validation/email";
|
||||
import { userPasswordSchema } from "@homarr/validation/user";
|
||||
|
||||
import { GroupSelect } from "~/components/access/group-select";
|
||||
import { GroupSelectModal } from "~/components/access/group-select-modal";
|
||||
import { StepperNavigationComponent } from "./stepper-navigation";
|
||||
|
||||
interface GroupWithPermissions {
|
||||
@@ -223,6 +224,23 @@ 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);
|
||||
@@ -238,23 +256,14 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
|
||||
{tCreate("step.groups.description", { everyoneGroup })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<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} />,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={16} stroke={1.5} />}
|
||||
onClick={handleAddClick}
|
||||
>
|
||||
{tCommon("action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMatches } from "@mantine/core";
|
||||
import { Button, 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,38 +20,46 @@ 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(
|
||||
() =>
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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,
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: tDelete("notification.error.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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
[tDelete, tCommon, group.id, group.name, mutateAsync, router],
|
||||
);
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]);
|
||||
|
||||
const fullWidth = useMatches({
|
||||
xs: true,
|
||||
@@ -60,14 +68,8 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onConfirm={handleDeletion}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
|
||||
{tDelete("label")}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+52
-63
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Stack, Text, useMatches } from "@mantine/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, 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 { UserSelect } from "~/components/access/user-select";
|
||||
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||
|
||||
interface TransferGroupOwnershipProps {
|
||||
group: {
|
||||
@@ -18,87 +19,75 @@ 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 [selectedOwner, setSelectedOwner] = useState<SelectedOwner>();
|
||||
const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync } = clientApi.group.transferOwnership.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!selectedOwner) return;
|
||||
|
||||
await mutateAsync(
|
||||
const handleTransfer = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
groupId: group.id,
|
||||
userId: selectedOwner.id,
|
||||
confirmLabel: tCommon("action.continue"),
|
||||
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
|
||||
onSelect: ({ id, name }) => {
|
||||
openConfirmModal({
|
||||
title: tTransfer("label"),
|
||||
children: tTransfer("confirm", {
|
||||
name: group.name,
|
||||
username: 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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
setInnerOwnerId(selectedOwner.id);
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.transfer.success"),
|
||||
message: tTransfer("notification.success.message", {
|
||||
group: group.name,
|
||||
user: selectedOwner.name,
|
||||
}),
|
||||
});
|
||||
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 (
|
||||
<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>
|
||||
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
|
||||
{tTransfer("label")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+25
-19
@@ -5,9 +5,11 @@ 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 { UserSelect } from "~/components/access/user-select";
|
||||
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
interface AddGroupMemberProps {
|
||||
groupId: string;
|
||||
@@ -18,23 +20,27 @@ interface AddGroupMemberProps {
|
||||
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
|
||||
const tMembersAdd = useI18n("group.action.addMember");
|
||||
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
||||
const handleAddMember = useCallback(
|
||||
async ({ id }: { id: string }) => {
|
||||
await mutateAsync({
|
||||
userId: id,
|
||||
groupId,
|
||||
});
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
|
||||
},
|
||||
[groupId, mutateAsync],
|
||||
);
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
|
||||
return (
|
||||
<UserSelect
|
||||
presentUserIds={presentUserIds}
|
||||
allowedProviders={allowedProviders}
|
||||
onSelect={handleAddMember}
|
||||
triggerLabel={tMembersAdd("label")}
|
||||
/>
|
||||
);
|
||||
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>;
|
||||
};
|
||||
+21
-15
@@ -1,11 +1,12 @@
|
||||
"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;
|
||||
@@ -14,25 +15,30 @@ 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(async () => {
|
||||
await mutateAsync({
|
||||
groupId,
|
||||
userId: user.id,
|
||||
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`);
|
||||
},
|
||||
});
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
|
||||
}, [mutateAsync, groupId, user.id]);
|
||||
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
|
||||
|
||||
return (
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red.9"
|
||||
size="compact-sm"
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
onConfirm={handleRemove}
|
||||
>
|
||||
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
|
||||
{tCommon("action.remove")}
|
||||
</InlineConfirmButton>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { 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 { GroupCreateForm } from "./_components/group-create-form";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { GroupsTable } from "./_groups-table";
|
||||
|
||||
interface GroupsListProps {
|
||||
@@ -17,10 +18,6 @@ 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],
|
||||
@@ -34,46 +31,8 @@ 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}
|
||||
@@ -81,6 +40,13 @@ 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>;
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
"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 { GroupsList } from "./_client";
|
||||
import { AddGroupButton, 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")}>
|
||||
<ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
|
||||
<Stack>
|
||||
{dbEveryoneGroup && (
|
||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||
|
||||
+5
-2
@@ -2,15 +2,18 @@
|
||||
|
||||
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 = ({ onClick }: { onClick: () => void }) => {
|
||||
export const InviteCreateButton = () => {
|
||||
const t = useI18n("management.page.user.invite");
|
||||
const { openModal } = useModalAction(InviteCreateModal);
|
||||
|
||||
return (
|
||||
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}>
|
||||
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
|
||||
{t("action.new.title")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"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,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Card, Collapse, Group, Stack } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
@@ -12,11 +11,9 @@ 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);
|
||||
|
||||
@@ -26,10 +23,6 @@ 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,
|
||||
@@ -80,65 +73,28 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
return <MantineReactTable table={table} />;
|
||||
};
|
||||
|
||||
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(() => {
|
||||
mutate({ id: row.original.id });
|
||||
void utils.invite.getAll.invalidate();
|
||||
}, [row.original.id, mutate, utils]);
|
||||
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]);
|
||||
|
||||
return (
|
||||
<InlineConfirmActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
loading={isPending}
|
||||
aria-label={t("action.delete.title")}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
|
||||
<IconTrash color="red" size={20} stroke={1.5} />
|
||||
</InlineConfirmActionIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,9 @@ 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() {
|
||||
@@ -22,7 +24,15 @@ export default async function InvitesOverviewPage() {
|
||||
const initialInvites = await api.invite.getAll();
|
||||
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
<ManageMobilePrimaryAction>
|
||||
<InviteCreateButton />
|
||||
</ManageMobilePrimaryAction>
|
||||
}
|
||||
floatingPrimaryAction
|
||||
>
|
||||
<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-popover";
|
||||
import { PreviewDimensionsPopover } from "./_dimension-popover";
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||
|
||||
interface WidgetPreviewPageContentProps {
|
||||
kind: WidgetKind;
|
||||
@@ -32,6 +32,7 @@ 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);
|
||||
@@ -97,6 +98,13 @@ 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 } })),
|
||||
@@ -154,21 +162,15 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
<Affix bottom={12} right={72 + 120}>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={openDimensionsModal}
|
||||
aria-label={t("widgetPreview.dimensions.title")}
|
||||
>
|
||||
<IconDimensions size={24} />
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"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,130 +0,0 @@
|
||||
"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,6 +2,7 @@ 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";
|
||||
|
||||
@@ -10,7 +11,7 @@ import { AccessSelectRow } from "./access-table-rows";
|
||||
import { useAccessContext } from "./context";
|
||||
import type { AccessFormType } from "./form";
|
||||
import { FormProvider, useForm } from "./form";
|
||||
import { GroupSelect } from "./group-select";
|
||||
import { GroupSelectModal } from "./group-select-modal";
|
||||
import type { FormProps } from "./user-access-form";
|
||||
|
||||
export const GroupAccessForm = <TPermission extends string>({
|
||||
@@ -23,6 +24,8 @@ 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: {
|
||||
@@ -33,6 +36,23 @@ 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}>
|
||||
@@ -59,21 +79,9 @@ export const GroupAccessForm = <TPermission extends string>({
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<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 rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||
{tCommon("action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tPermissions("action.saveGroup")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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"),
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
"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,6 +2,7 @@ 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";
|
||||
|
||||
@@ -10,7 +11,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
|
||||
import { useAccessContext } from "./context";
|
||||
import type { AccessFormType, HandleCountChange } from "./form";
|
||||
import { FormProvider, useForm } from "./form";
|
||||
import { UserSelect } from "./user-select";
|
||||
import { UserSelectModal } from "./user-select-modal";
|
||||
|
||||
export interface FormProps<TPermission extends string> {
|
||||
entity: {
|
||||
@@ -40,6 +41,8 @@ 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: {
|
||||
@@ -49,8 +52,25 @@ export const UsersAccessForm = <TPermission extends string>({
|
||||
})),
|
||||
},
|
||||
});
|
||||
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
|
||||
if (entity.ownerId) presentUserIds.push(entity.ownerId);
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
||||
@@ -81,21 +101,9 @@ export const UsersAccessForm = <TPermission extends string>({
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<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 rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||
{tCommon("action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tPermissions("action.saveUser")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user