Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8d12c19e | ||
|
|
10ba298233 | ||
|
|
7855278ed1 | ||
|
|
aa179f1ec0 | ||
|
|
f455e71be5 | ||
|
|
f221a5b4d3 | ||
|
|
c18fa8cf77 | ||
|
|
5f572f1b92 | ||
|
|
a7aa89fafe | ||
|
|
e723cbe6dd | ||
|
|
411e75e799 | ||
|
|
0553d60a58 | ||
|
|
7da2e10064 | ||
|
|
a002b91add |
No files matched your search
@@ -320,8 +320,12 @@ jobs:
|
|||||||
|
|
||||||
publish-preview:
|
publish-preview:
|
||||||
name: Publish multi-platform preview image
|
name: Publish multi-platform preview image
|
||||||
needs: [preview-amd64, preview-arm64]
|
needs: [container, preview-amd64, preview-arm64]
|
||||||
if: |
|
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 == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||||
@@ -375,9 +379,6 @@ jobs:
|
|||||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||||
run: |
|
run: |
|
||||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
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[@]}" \
|
docker buildx imagetools create "${tags[@]}" \
|
||||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||||
@@ -386,9 +387,6 @@ jobs:
|
|||||||
if: "!env.ACT"
|
if: "!env.ACT"
|
||||||
run: |
|
run: |
|
||||||
tags=("${PREVIEW_TAG}")
|
tags=("${PREVIEW_TAG}")
|
||||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
|
||||||
tags+=("pr-6545")
|
|
||||||
fi
|
|
||||||
for tag in "${tags[@]}"; do
|
for tag in "${tags[@]}"; do
|
||||||
image="${PREVIEW_IMAGE}:${tag}"
|
image="${PREVIEW_IMAGE}:${tag}"
|
||||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||||
@@ -435,6 +433,14 @@ jobs:
|
|||||||
core.info(`No package version found with tag "${tag}"`);
|
core.info(`No package version found with tag "${tag}"`);
|
||||||
return;
|
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({
|
await github.rest.packages.deletePackageVersionForOrg({
|
||||||
package_type: "container",
|
package_type: "container",
|
||||||
package_name: packageName,
|
package_name: packageName,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
|
|||||||
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
|
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.
|
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.
|
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
|
### API Key Format
|
||||||
|
|
||||||
@@ -110,7 +113,9 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
|||||||
- Set desktop and mobile home boards for the current user
|
- Set desktop and mobile home boards for the current user
|
||||||
- Configure global board defaults such as public home boards and status behavior
|
- 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`, 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.
|
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.
|
||||||
|
|
||||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo
|
|||||||
|
|
||||||
## Create a new board
|
## Create a new board
|
||||||
|
|
||||||
When creating a board, the availability of the board name will be checked for you.
|
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
|
||||||
|
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
|
||||||
|
|
||||||

|
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
|
### Name
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log
|
|||||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
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.
|
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
|
## Board settings
|
||||||
|
|
||||||
You navigate to the board settings by clicking on these three dots:
|
You navigate to the board settings by clicking on these three dots:
|
||||||
@@ -128,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
|
|||||||
The complete canvas always fits the available width and never adds horizontal board scrolling.
|
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.
|
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
- **Base** is the main layout and starts at `768px` on new boards.
|
||||||
|
|
||||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
|
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
|
||||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
|
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
|
||||||
their breakpoints.
|
unused breakpoint above Mobile.
|
||||||
|
|
||||||
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
|
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
|
||||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
|
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
|
||||||
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
|
available breakpoint, and you can then change the breakpoint, columns, and rails.
|
||||||
|
|
||||||
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
|
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
|
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
|
||||||
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
|
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
|
||||||
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
|
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
|
||||||
|
Duplicate breakpoints are normalized without discarding saved positions.
|
||||||
|
|
||||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||||
|
|
||||||
@@ -177,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
|
|||||||
#### Breakpoint
|
#### Breakpoint
|
||||||
|
|
||||||
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
|
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
|
||||||
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
|
||||||
|
eligible layout with the highest breakpoint, regardless of its role.
|
||||||
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
||||||
fallback to select a layout.
|
fallback to select a layout.
|
||||||
|
|
||||||
@@ -266,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
|
|||||||
|
|
||||||
### Background
|
### 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
|
#### Background image URL
|
||||||
|
|
||||||
Homarr can display a background image behind apps and widgets.
|
Homarr can display a background image behind apps and widgets.
|
||||||
@@ -405,8 +414,7 @@ nor the integrations the user does not have access to.
|
|||||||
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
|
Duplicating a board 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.
|
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".
|
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
|
## Setting boards as your home board
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`.
|
|||||||
|
|
||||||
## Add a certificate
|
## Add a certificate
|
||||||
|
|
||||||
To add a certificate click on the "Add certificate" button.
|
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Then select the certificate file you want to add.
|
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.
|
||||||

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

|

|
||||||
|
|
||||||
Then confirm the deletion.
|
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
|
||||||
|
|
||||||
## Managing it through file system
|
## Managing it through file system
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t
|
|||||||
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
|
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
|
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
|
||||||
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
|
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
|
||||||
|
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
|
||||||
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
|
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
|
||||||
performs definitive validation again at install time.
|
performs definitive validation again at install time.
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards.
|
|||||||
|
|
||||||
You can set the default color scheme for users without their own color scheme.
|
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
|
## Culture
|
||||||
|
|
||||||
### Default language
|
### Default language
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
|
|||||||
### Creating a group
|
### Creating a group
|
||||||
|
|
||||||
Click on the button `New group` to create a new one.
|
Click on the button `New group` to create a new one.
|
||||||
Homarr will prompt you for a group name.
|
The group-name form expands above the group list and can be closed without leaving the page.
|
||||||
|
|
||||||
### Managing a group
|
### Managing a group
|
||||||
|
|
||||||
@@ -259,7 +259,8 @@ An invitation link can be used indefinitely.
|
|||||||
|
|
||||||
### Creating an invitation
|
### Creating an invitation
|
||||||
|
|
||||||
Click on the button:
|
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
|
||||||
|
dialog or navigating to another page:
|
||||||
|
|
||||||

|

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

|

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

|

|
||||||
|
|
||||||
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.
|
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
|
||||||
Clicking the `Copy & close` button will copy the URL to your clipboard.
|
Use **Copy** to copy the URL, then close the section when you are finished.
|
||||||
|
|
||||||
### Self-onboarding
|
### Self-onboarding
|
||||||
|
|
||||||
@@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
|
|||||||
|
|
||||||
The link will become invalid.
|
The link will become invalid.
|
||||||
If the invitation expires, the link will also expire.
|
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.
|
||||||
@@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
|
|||||||
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
|
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
|
||||||
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
|
- **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
|
### Configuration
|
||||||
|
|
||||||
<WidgetConfig configuration={iframeWidget.configuration} />
|
<WidgetConfig configuration={iframeWidget.configuration} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
|
|||||||
import { mediaRequestListWidget } from ".";
|
import { mediaRequestListWidget } from ".";
|
||||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||||
|
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||||
|
|
||||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
|
|||||||
{
|
{
|
||||||
integration: overseerrIntegration,
|
integration: overseerrIntegration,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
integration: seerrIntegration,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
|
|||||||
|
|
||||||
- Approve / decline a request by clicking on the thumbs up or down icon.
|
- 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.
|
- 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.
|
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
|
||||||
|
|
||||||
<WidgetInteractionGuide advancedView={false} />
|
<WidgetInteractionGuide advancedView={false} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
|
|||||||
import { mediaRequestStatsWidget } from ".";
|
import { mediaRequestStatsWidget } from ".";
|
||||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||||
|
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||||
|
|
||||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
|
|||||||
{
|
{
|
||||||
integration: overseerrIntegration,
|
integration: overseerrIntegration,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
integration: seerrIntegration,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
|
|||||||
### Interactions
|
### Interactions
|
||||||
|
|
||||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
- 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.
|
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
|
||||||
|
|
||||||
<WidgetInteractionGuide advancedView={false} />
|
<WidgetInteractionGuide advancedView={false} />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
|||||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
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.
|
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||||
|
|
||||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
|
||||||
|
|
||||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"@rexa-developer/tiks": "catalog:",
|
"@rexa-developer/tiks": "catalog:",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
|
"@tanstack/query-sync-storage-persister": "catalog:",
|
||||||
"@tanstack/react-devtools": "catalog:",
|
"@tanstack/react-devtools": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
||||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
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 { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createSessionQueryPersister,
|
createSessionQueryPersistence,
|
||||||
getQueryPersistenceStorageKey,
|
getQueryPersistenceStorageKey,
|
||||||
queryPersistenceBuster,
|
queryPersistenceBuster,
|
||||||
queryPersistenceMaxDataBytes,
|
|
||||||
shouldPersistDashboardQuery,
|
shouldPersistDashboardQuery,
|
||||||
} from "./query-persistence";
|
} from "./query-persistence";
|
||||||
|
|
||||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
queryClient.setQueryData(queryKey, data, { updatedAt });
|
queryClient.setQueryData(queryKey, data);
|
||||||
const query = queryClient.getQueryCache().find({ queryKey });
|
const query = queryClient.getQueryCache().find({ queryKey });
|
||||||
if (!query) throw new Error("Expected query to be present");
|
if (!query) throw new Error("Expected query to be present");
|
||||||
return { query, queryClient };
|
return { query, queryClient };
|
||||||
@@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
window.sessionStorage.clear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("widget query persistence", () => {
|
describe("dashboard query persistence", () => {
|
||||||
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
|
||||||
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
|
||||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||||
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
|
||||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||||
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
|
||||||
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
|
||||||
|
apiKey: "secret",
|
||||||
});
|
});
|
||||||
|
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
||||||
const pendingClient = new QueryClient();
|
const pendingClient = new QueryClient();
|
||||||
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||||
queryFn: () => Promise.resolve([]),
|
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(widget.query)).toBe(true);
|
||||||
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
||||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
|
||||||
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
|
|
||||||
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
|
|
||||||
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
|
|
||||||
|
|
||||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
|
||||||
window.sessionStorage.setItem(userAKey, "a");
|
|
||||||
createSessionQueryPersister("user-a").removeClient();
|
|
||||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("evicts every other scope so signing out cannot leave data behind", () => {
|
|
||||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
|
||||||
const anonymousKey = getQueryPersistenceStorageKey(null);
|
|
||||||
window.sessionStorage.setItem(userAKey, "a");
|
|
||||||
window.sessionStorage.setItem(anonymousKey, "anonymous");
|
|
||||||
window.sessionStorage.setItem("unrelated", "kept");
|
|
||||||
|
|
||||||
createSessionQueryPersister("user-b");
|
|
||||||
|
|
||||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
|
||||||
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
|
|
||||||
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never restores another session scope", async () => {
|
|
||||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
|
||||||
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
|
|
||||||
const userAPersister = createSessionQueryPersister("user-a");
|
|
||||||
userAPersister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient),
|
|
||||||
});
|
|
||||||
userAPersister.flush();
|
|
||||||
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
|
|
||||||
|
|
||||||
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stops persisting once the provider tears the cache down", async () => {
|
|
||||||
const storage = createMemoryStorage();
|
|
||||||
const persister = createSessionQueryPersister("user-a", storage);
|
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
|
||||||
temp: 21,
|
|
||||||
});
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient),
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
persister.stop();
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: { mutations: [], queries: [] },
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes the cache instead of storing an empty one", () => {
|
|
||||||
const scope = "user-a";
|
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
|
||||||
const persister = createSessionQueryPersister(scope);
|
|
||||||
window.sessionStorage.setItem(key, "stale");
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: { mutations: [], queries: [] },
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const scope = "user-a";
|
const storage = createMemoryStorage();
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
const userA = createSessionQueryPersistence("user-a", storage);
|
||||||
const persister = createSessionQueryPersister(scope);
|
const userB = createSessionQueryPersistence("user-b", storage);
|
||||||
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||||
});
|
});
|
||||||
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
const cachedQuery = queryClient.getQueryCache().find({
|
||||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||||
|
});
|
||||||
|
if (!cachedQuery) throw new Error("Expected cached query to be present");
|
||||||
|
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
|
||||||
|
queryClient.getMutationCache().build(
|
||||||
|
queryClient,
|
||||||
|
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
|
||||||
|
{
|
||||||
|
context: undefined,
|
||||||
|
data: undefined,
|
||||||
|
error: null,
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
isPaused: true,
|
||||||
|
status: "pending",
|
||||||
|
submittedAt: Date.now(),
|
||||||
|
variables: { apiKey: "secret" },
|
||||||
|
},
|
||||||
|
);
|
||||||
const persistedClient: PersistedClient = {
|
const persistedClient: PersistedClient = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
buster: queryPersistenceBuster,
|
buster: queryPersistenceBuster,
|
||||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
clientState: dehydrate(queryClient, userA.dehydrateOptions),
|
||||||
};
|
};
|
||||||
|
|
||||||
persister.persistClient(persistedClient);
|
expect(persistedClient.clientState.mutations).toHaveLength(0);
|
||||||
persister.flush();
|
|
||||||
const restored = await persister.restoreClient();
|
await userA.persister.persistClient(persistedClient);
|
||||||
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
|
||||||
|
const restored = await userA.persister.restoreClient();
|
||||||
|
expect(restored?.clientState.queries[0]?.state.data).toEqual({
|
||||||
|
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||||
|
});
|
||||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
expect(await userB.persister.restoreClient()).toBeUndefined();
|
||||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
|
||||||
|
|
||||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
await userA.persister.removeClient();
|
||||||
expect(persister.restoreClient()).toBeUndefined();
|
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
|
||||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
|
test("treats unavailable session storage as an empty cache", async () => {
|
||||||
const scope = "user-a";
|
const blockedStorage = createMemoryStorage();
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
blockedStorage.getItem = () => {
|
||||||
const { queryClient } = createSuccessfulQuery(
|
|
||||||
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
|
||||||
[{ title: "safe" }],
|
|
||||||
);
|
|
||||||
const validQuery = dehydrate(queryClient).queries[0];
|
|
||||||
if (!validQuery) throw new Error("Expected a dehydrated query");
|
|
||||||
|
|
||||||
window.sessionStorage.setItem(
|
|
||||||
key,
|
|
||||||
stringify({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: {
|
|
||||||
mutations: [{ state: { status: "pending" } }],
|
|
||||||
queries: [
|
|
||||||
{
|
|
||||||
...validQuery,
|
|
||||||
state: {
|
|
||||||
...validQuery.state,
|
|
||||||
error: new Error("private refresh error"),
|
|
||||||
fetchStatus: "fetching",
|
|
||||||
status: "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...validQuery,
|
|
||||||
queryHash: "unrelated",
|
|
||||||
queryKey: [["board", "getBoardByName"], { type: "query" }],
|
|
||||||
},
|
|
||||||
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
|
|
||||||
{
|
|
||||||
...validQuery,
|
|
||||||
queryHash: "missing-timestamp",
|
|
||||||
state: { ...validQuery.state, dataUpdatedAt: undefined },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const restored = await createSessionQueryPersister(scope).restoreClient();
|
|
||||||
expect(restored?.clientState.mutations).toEqual([]);
|
|
||||||
expect(restored?.clientState.queries).toHaveLength(1);
|
|
||||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
|
||||||
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
|
|
||||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["expired", 1, queryPersistenceBuster],
|
|
||||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
|
||||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
|
||||||
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
|
||||||
const scope = "user-a";
|
|
||||||
const persister = createSessionQueryPersister(scope);
|
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
|
||||||
temp: 21,
|
|
||||||
});
|
|
||||||
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
const restoredClient = new QueryClient();
|
|
||||||
await persistQueryClientRestore({
|
|
||||||
queryClient: restoredClient,
|
|
||||||
persister,
|
|
||||||
maxAge: 5 * 60 * 1_000,
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
|
||||||
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quota retry writes the cache after evicting the oldest query", async () => {
|
|
||||||
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
|
|
||||||
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
|
|
||||||
const storage = createMemoryStorage();
|
|
||||||
const setItem = storage.setItem.bind(storage);
|
|
||||||
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
|
|
||||||
const client = parse<PersistedClient>(value);
|
|
||||||
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
|
|
||||||
setItem(key, value);
|
|
||||||
});
|
|
||||||
const persistedClient: PersistedClient = {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: {
|
|
||||||
mutations: [],
|
|
||||||
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const persister = createSessionQueryPersister("user-a", storage);
|
|
||||||
|
|
||||||
persister.persistClient(persistedClient);
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
|
||||||
const restored = await persister.restoreClient();
|
|
||||||
expect(restored?.clientState.queries).toHaveLength(1);
|
|
||||||
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const storage = createMemoryStorage();
|
|
||||||
const setItem = vi.spyOn(storage, "setItem");
|
|
||||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
|
||||||
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
|
|
||||||
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
|
|
||||||
const persister = createSessionQueryPersister("user-a", storage);
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(first.queryClient),
|
|
||||||
});
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(latest.queryClient),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setItem).not.toHaveBeenCalled();
|
|
||||||
persister.flush();
|
|
||||||
expect(setItem).toHaveBeenCalledOnce();
|
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
|
||||||
expect(setItem).toHaveBeenCalledOnce();
|
|
||||||
|
|
||||||
const restored = await persister.restoreClient();
|
|
||||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats blocked storage operations as an empty cache", async () => {
|
|
||||||
const blockedStorage = {
|
|
||||||
getItem: () => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
},
|
|
||||||
removeItem: () => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
},
|
|
||||||
setItem: () => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
},
|
|
||||||
} as unknown as Storage;
|
|
||||||
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
|
||||||
|
|
||||||
expect(persister.restoreClient()).toBeUndefined();
|
|
||||||
expect(() => {
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient),
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
}).not.toThrow();
|
|
||||||
expect(() => persister.removeClient()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats an unavailable session storage getter as an empty cache", async () => {
|
|
||||||
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
throw new DOMException("blocked", "SecurityError");
|
||||||
});
|
};
|
||||||
|
blockedStorage.removeItem = () => {
|
||||||
|
throw new DOMException("blocked", "SecurityError");
|
||||||
|
};
|
||||||
|
|
||||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
|
||||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
expect(await persistence.persister.restoreClient()).toBeUndefined();
|
||||||
|
expect(() => persistence.persister.removeClient()).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||||
|
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
|
||||||
import { parse, stringify } from "superjson";
|
import { parse, stringify } from "superjson";
|
||||||
|
|
||||||
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
export const queryPersistenceBuster = "v6-dashboard-data";
|
||||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
|
||||||
export const queryPersistenceBuster = "v2-session-superjson";
|
|
||||||
|
|
||||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||||
|
|
||||||
@@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
|||||||
|
|
||||||
interface PersistableQuery {
|
interface PersistableQuery {
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
state: {
|
state: { data: unknown; status: string };
|
||||||
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) =>
|
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
|
||||||
|
|
||||||
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
const serializePersistedClient = (client: PersistedClient) =>
|
||||||
|
stringify({
|
||||||
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
...client,
|
||||||
...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: {
|
clientState: {
|
||||||
mutations: [],
|
...client.clientState,
|
||||||
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
queries: client.clientState.queries.map((query) => ({
|
||||||
|
...query,
|
||||||
|
state: {
|
||||||
|
...query.state,
|
||||||
|
error: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
fetchFailureCount: 0,
|
||||||
|
fetchFailureReason: null,
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const getSessionStorage = (): Storage | undefined => {
|
const getSessionStorage = () => {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | undefined => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
const protectStorage = (storage: Storage | undefined) => {
|
||||||
try {
|
if (!storage) return undefined;
|
||||||
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 {
|
return {
|
||||||
persistClient(client) {
|
getItem(key: string) {
|
||||||
if (!storage || stopped) return;
|
|
||||||
pendingClient = client;
|
|
||||||
if (persistTimer !== undefined) return;
|
|
||||||
persistTimer = setTimeout(flush, 0);
|
|
||||||
},
|
|
||||||
restoreClient() {
|
|
||||||
try {
|
try {
|
||||||
const storedValue = storage?.getItem(key);
|
return storage.getItem(key);
|
||||||
if (!storedValue) return undefined;
|
} catch {
|
||||||
|
return null;
|
||||||
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
}
|
||||||
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
},
|
||||||
removeStorageItem(storage, key);
|
setItem(key: string, value: string) {
|
||||||
return undefined;
|
storage.setItem(key, value);
|
||||||
}
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
return client;
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
} catch {
|
} catch {
|
||||||
removeStorageItem(storage, key);
|
|
||||||
return undefined;
|
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 type { PropsWithChildren } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import {
|
import {
|
||||||
dashboardSupportingQueryPolicies,
|
dashboardSupportingQueryPolicies,
|
||||||
|
isTrpcForbiddenError,
|
||||||
|
isWidgetDataQueryKey,
|
||||||
queryCacheDefaultGcTimeMs,
|
queryCacheDefaultGcTimeMs,
|
||||||
queryCacheDefaultRefetchIntervalMs,
|
queryCacheDefaultRefetchIntervalMs,
|
||||||
queryCacheDefaultStaleTimeMs,
|
queryCacheDefaultStaleTimeMs,
|
||||||
@@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications";
|
|||||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||||
|
|
||||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||||
import {
|
import { createSessionQueryPersistence } from "./query-persistence";
|
||||||
createSessionQueryPersister,
|
import type { SessionQueryPersistence } from "./query-persistence";
|
||||||
queryPersistenceBuster,
|
|
||||||
queryPersistenceMaxAgeMs,
|
|
||||||
shouldPersistDashboardQuery,
|
|
||||||
} from "./query-persistence";
|
|
||||||
import type { SessionQueryPersister } from "./query-persistence";
|
|
||||||
import { createQueryRetry } from "./query-retry";
|
import { createQueryRetry } from "./query-retry";
|
||||||
|
|
||||||
const DevelopmentTools =
|
const DevelopmentTools =
|
||||||
@@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const sessionQueryScope = getSessionQueryScope(session);
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
|
||||||
const handleScopeChange = useCallback(() => {
|
const handleScopeChange = useCallback(() => {
|
||||||
void queryPersister.removeClient();
|
void queryPersistence.persister.removeClient();
|
||||||
reloadPage();
|
reloadPage();
|
||||||
}, [queryPersister]);
|
}, [queryPersistence]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionQueryScopeGuard
|
<SessionQueryScopeGuard
|
||||||
@@ -87,7 +84,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
currentScope={sessionQueryScope}
|
currentScope={sessionQueryScope}
|
||||||
onScopeChange={handleScopeChange}
|
onScopeChange={handleScopeChange}
|
||||||
>
|
>
|
||||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
|
||||||
</SessionQueryScopeGuard>
|
</SessionQueryScopeGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload();
|
|||||||
|
|
||||||
const ScopedTRPCReactProvider = ({
|
const ScopedTRPCReactProvider = ({
|
||||||
children,
|
children,
|
||||||
queryPersister,
|
queryPersistence,
|
||||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
|
||||||
const wsClient = useMemo(
|
const wsClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createWSClient({
|
createWSClient({
|
||||||
@@ -114,6 +111,13 @@ const ScopedTRPCReactProvider = ({
|
|||||||
);
|
);
|
||||||
const [queryClient] = useState(() => {
|
const [queryClient] = useState(() => {
|
||||||
const client = new QueryClient({
|
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: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
staleTime: queryCacheDefaultStaleTimeMs,
|
||||||
@@ -152,15 +156,6 @@ const ScopedTRPCReactProvider = ({
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const flush = () => queryPersister.flush();
|
|
||||||
window.addEventListener("pagehide", flush);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pagehide", flush);
|
|
||||||
flush();
|
|
||||||
queryPersister.stop();
|
|
||||||
};
|
|
||||||
}, [queryPersister]);
|
|
||||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
const trpcClient = useMemo(() => {
|
const trpcClient = useMemo(() => {
|
||||||
@@ -211,15 +206,7 @@ const ScopedTRPCReactProvider = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<PersistQueryClientProvider
|
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{
|
|
||||||
persister: queryPersister,
|
|
||||||
maxAge: queryPersistenceMaxAgeMs,
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@mantine/core";
|
import { Box, Button, Group, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
|
|
||||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||||
@@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
|||||||
const t = useI18n("board.setting");
|
const t = useI18n("board.setting");
|
||||||
const tCommonAction = useI18n("common.action");
|
const tCommonAction = useI18n("common.action");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const utils = clientApi.useUtils();
|
||||||
const { openModal } = useModalAction(BoardRenameModal);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
|
||||||
|
initialValues: {
|
||||||
|
name: board.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
|
||||||
|
onSettled() {
|
||||||
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
|
void utils.board.getHomeBoard.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
||||||
clientApi.board.changeBoardVisibility.useMutation();
|
clientApi.board.changeBoardVisibility.useMutation();
|
||||||
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
|
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const visibility = board.isPublic ? "public" : "private";
|
const visibility = board.isPublic ? "public" : "private";
|
||||||
|
|
||||||
const onRenameClick = useCallback(
|
useEffect(() => {
|
||||||
() =>
|
if (isRenaming) renameInputRef.current?.focus();
|
||||||
openModal({
|
}, [isRenaming]);
|
||||||
|
|
||||||
|
const handleRename = renameForm.onSubmit((values) => {
|
||||||
|
renameBoard(
|
||||||
|
{ id: board.id, name: values.name },
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
router.push(`/boards/${values.name}/settings`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelRename = () => {
|
||||||
|
renameForm.reset();
|
||||||
|
setIsRenaming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
changeVisibility(
|
||||||
|
{
|
||||||
id: board.id,
|
id: board.id,
|
||||||
previousName: board.name,
|
visibility: visibility === "public" ? "private" : "public",
|
||||||
onSuccess: (name) => router.push(`/boards/${name}/settings`),
|
|
||||||
}),
|
|
||||||
[board.id, board.name, router, openModal],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onVisibilityClick = useCallback(() => {
|
|
||||||
openConfirmModal({
|
|
||||||
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
|
|
||||||
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
|
|
||||||
onConfirm: () => {
|
|
||||||
changeVisibility(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
visibility: visibility === "public" ? "private" : "public",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSettled() {
|
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
|
||||||
void utils.board.getHomeBoard.invalidate();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
}, [
|
onSettled() {
|
||||||
board.id,
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
board.name,
|
void utils.board.getHomeBoard.invalidate();
|
||||||
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]);
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteBoard(
|
||||||
|
{ id: board.id },
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
@@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
|||||||
label={t("section.dangerZone.action.rename.label")}
|
label={t("section.dangerZone.action.rename.label")}
|
||||||
description={t("section.dangerZone.action.rename.description")}
|
description={t("section.dangerZone.action.rename.description")}
|
||||||
action={
|
action={
|
||||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
isRenaming ? (
|
||||||
{t("section.dangerZone.action.rename.button")}
|
<Box component="form" onSubmit={handleRename}>
|
||||||
</Button>
|
<Group wrap="wrap" justify="end">
|
||||||
|
<TextInput
|
||||||
|
ref={renameInputRef}
|
||||||
|
aria-label={t("section.dangerZone.action.rename.label")}
|
||||||
|
size="sm"
|
||||||
|
{...renameForm.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
|
||||||
|
{tCommonAction("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" color="red" loading={isRenamePending}>
|
||||||
|
{tCommonAction("confirm")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
|
||||||
|
{t("section.dangerZone.action.rename.button")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!hideVisibility && (
|
{!hideVisibility && (
|
||||||
@@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
|||||||
label={t("section.dangerZone.action.visibility.label")}
|
label={t("section.dangerZone.action.visibility.label")}
|
||||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||||
action={
|
action={
|
||||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
<InlineConfirmButton
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
loading={isChangeVisibilityPending}
|
||||||
|
onConfirm={handleVisibilityChange}
|
||||||
|
confirmLabel={tCommonAction("confirm")}
|
||||||
|
>
|
||||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
|||||||
label={tCommonAction("delete")}
|
label={tCommonAction("delete")}
|
||||||
description={t("section.dangerZone.action.delete.description")}
|
description={t("section.dangerZone.action.delete.description")}
|
||||||
action={
|
action={
|
||||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
<InlineConfirmButton
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
loading={isDeletePending}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
confirmLabel={tCommonAction("confirm")}
|
||||||
|
>
|
||||||
{tCommonAction("delete")}
|
{tCommonAction("delete")}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</DangerZoneRoot>
|
</DangerZoneRoot>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmButton } from "@homarr/ui";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
@@ -40,13 +40,14 @@ interface Props {
|
|||||||
saveSettingsAsync: () => Promise<FormValues | null>;
|
saveSettingsAsync: () => Promise<FormValues | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
|
||||||
|
|
||||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tLayout = useI18n("layout");
|
const tLayout = useI18n("layout");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
|
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
|
||||||
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
|
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
|
||||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
||||||
@@ -84,33 +85,27 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmReset = (layout: FormValues["layouts"][number]) => {
|
const handleReset = async (layout: FormValues["layouts"][number]) => {
|
||||||
openConfirmModal({
|
setResettingLayoutId(layout.id);
|
||||||
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
try {
|
||||||
children: tBoard("setting.section.layout.reset.confirm.message"),
|
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||||
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
|
if (!canonicalLayout) return;
|
||||||
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 resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
// The mutation callback displays the error notification.
|
// The mutation callback displays the error notification.
|
||||||
} finally {
|
} finally {
|
||||||
setResettingLayoutId(null);
|
setResettingLayoutId(null);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||||
|
const displayedLayouts = form.values.layouts
|
||||||
|
.map((layout, index) => ({ layout, index }))
|
||||||
|
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||||
@@ -144,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{form.values.layouts.map((layout, index) => {
|
{displayedLayouts.map(({ layout, index }) => {
|
||||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||||
const customBreakpoints = form.values.layouts
|
|
||||||
.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 (
|
return (
|
||||||
<Fieldset
|
<Fieldset
|
||||||
@@ -200,12 +191,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
}
|
}
|
||||||
disabled={layout.role === "mobile"}
|
disabled={layout.role === "mobile"}
|
||||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||||
min={
|
min={layout.role === "mobile" ? 0 : 1}
|
||||||
layout.role === "base"
|
max={32767}
|
||||||
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
|
||||||
: 1
|
|
||||||
}
|
|
||||||
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -236,16 +223,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{layout.role !== "base" && persistedLayout && (
|
{layout.role !== "base" && persistedLayout && (
|
||||||
<Button
|
<InlineConfirmButton
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||||
loading={resettingLayoutId === layout.id}
|
loading={resettingLayoutId === layout.id}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
onClick={() => confirmReset(layout)}
|
onConfirm={() => handleReset(layout)}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
>
|
>
|
||||||
{tBoard("setting.section.layout.reset.action")}
|
{tBoard("setting.section.layout.reset.action")}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
)}
|
)}
|
||||||
{layout.role === "custom" && (
|
{layout.role === "custom" && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -18,48 +17,41 @@ interface AppDeleteButtonProps {
|
|||||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||||
const t = useI18n("app.page.delete");
|
const t = useI18n("app.page.delete");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onConfirm = useCallback(() => {
|
||||||
openConfirmModal({
|
mutate(
|
||||||
title: t("title"),
|
{ id: app.id },
|
||||||
children: t("message", {
|
{
|
||||||
name: app.name,
|
onSuccess: () => {
|
||||||
}),
|
showSuccessNotification({
|
||||||
onConfirm: () => {
|
title: tCommon("notification.delete.success"),
|
||||||
mutate(
|
message: t("notification.success.message"),
|
||||||
{ id: app.id },
|
});
|
||||||
{
|
void revalidatePathActionAsync("/manage/apps");
|
||||||
onSuccess: () => {
|
},
|
||||||
showSuccessNotification({
|
onError: () => {
|
||||||
title: tCommon("notification.delete.success"),
|
showErrorNotification({
|
||||||
message: t("notification.success.message"),
|
title: tCommon("notification.delete.error"),
|
||||||
});
|
message: t("notification.error.message"),
|
||||||
void revalidatePathActionAsync("/manage/apps");
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: tCommon("notification.delete.error"),
|
|
||||||
message: t("notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
}, [app.id, mutate, t, tCommon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<InlineConfirmActionIcon
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
confirmationAriaLabel={tCommon("action.confirm")}
|
||||||
|
onConfirm={onConfirm}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
size={44}
|
size={44}
|
||||||
onClick={onClick}
|
|
||||||
aria-label={tCommon("action.deleteNamed", { name: app.name })}
|
aria-label={tCommon("action.deleteNamed", { name: app.name })}
|
||||||
>
|
>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+12
-34
@@ -8,10 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|
||||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
|
||||||
@@ -25,9 +23,10 @@ interface BoardCardMenuDropdownProps {
|
|||||||
RouterOutputs["board"]["getManageOverview"][number],
|
RouterOutputs["board"]["getManageOverview"][number],
|
||||||
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||||
>;
|
>;
|
||||||
|
onDuplicate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
|
||||||
const t = useI18n("management.page.board.action");
|
const t = useI18n("management.page.board.action");
|
||||||
const tRoot = useI18n();
|
const tRoot = useI18n();
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
@@ -35,9 +34,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
|
||||||
|
|
||||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||||
@@ -56,20 +52,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeletion = useCallback(() => {
|
const handleDeletion = useCallback(
|
||||||
openConfirmModal({
|
() => deleteBoardMutation.mutateAsync({ id: board.id }),
|
||||||
title: tCommon("action.deleteNamed", { name: board.name }),
|
[board.id, deleteBoardMutation],
|
||||||
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 () => {
|
const handleSetHomeBoard = useCallback(async () => {
|
||||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||||
@@ -79,15 +65,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
||||||
}, [board.id, setMobileHomeBoardMutation]);
|
}, [board.id, setMobileHomeBoardMutation]);
|
||||||
|
|
||||||
const handleDuplicateBoard = useCallback(() => {
|
|
||||||
openDuplicateModal({
|
|
||||||
board: {
|
|
||||||
id: board.id,
|
|
||||||
name: board.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [board.id, board.name, openDuplicateModal]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||||
@@ -97,7 +74,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
{t("setMobileHomeBoard.label")}
|
{t("setMobileHomeBoard.label")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{session?.user.permissions.includes("board-create") && (
|
{session?.user.permissions.includes("board-create") && (
|
||||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
|
||||||
{tRoot("board.action.duplicate.title")}
|
{tRoot("board.action.duplicate.title")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
@@ -117,14 +94,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||||
<Menu.Item
|
<InlineConfirmMenuItem
|
||||||
c="red.7"
|
c="red.7"
|
||||||
leftSection={<IconTrash {...iconProps} />}
|
leftSection={<IconTrash {...iconProps} />}
|
||||||
onClick={handleDeletion}
|
onConfirm={handleDeletion}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
disabled={deleteBoardMutation.isPending}
|
disabled={deleteBoardMutation.isPending}
|
||||||
>
|
>
|
||||||
{tCommon("action.delete")}
|
{tCommon("action.delete")}
|
||||||
</Menu.Item>
|
</InlineConfirmMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardSection,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
MenuTarget,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||||
|
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
|
||||||
|
import { DuplicateBoardForm } from "./duplicate-board-form";
|
||||||
|
|
||||||
|
interface BoardCardProps {
|
||||||
|
board: RouterOutputs["board"]["getManageOverview"][number];
|
||||||
|
isMenuVisible: boolean;
|
||||||
|
labels: {
|
||||||
|
visibility: string;
|
||||||
|
preview: string;
|
||||||
|
unknownCreator: string;
|
||||||
|
home: string;
|
||||||
|
homeTooltip: string;
|
||||||
|
mobileHome: string;
|
||||||
|
mobileHomeTooltip: string;
|
||||||
|
open: string;
|
||||||
|
settings: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||||
|
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
|
||||||
|
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
|
||||||
|
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card padding={0} withBorder>
|
||||||
|
<CardSection withBorder>
|
||||||
|
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||||
|
</CardSection>
|
||||||
|
<CardSection p="sm" withBorder>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||||
|
<Tooltip label={labels.visibility}>
|
||||||
|
<VisibilityIcon size={20} stroke={1.5} />
|
||||||
|
</Tooltip>
|
||||||
|
<Text fw={700} truncate>
|
||||||
|
{board.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||||
|
{board.isHome && (
|
||||||
|
<Tooltip label={labels.homeTooltip}>
|
||||||
|
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||||
|
{labels.home}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{board.isMobileHome && (
|
||||||
|
<Tooltip label={labels.mobileHomeTooltip}>
|
||||||
|
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||||
|
{labels.mobileHome}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{board.creator && (
|
||||||
|
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||||
|
<UserAvatar user={board.creator} size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</CardSection>
|
||||||
|
|
||||||
|
<CardSection>
|
||||||
|
<Group gap={0} wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
style={{ border: "none", borderRadius: 0 }}
|
||||||
|
component={Link}
|
||||||
|
href={`/boards/${board.name}`}
|
||||||
|
variant="default"
|
||||||
|
h={44}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{labels.open}
|
||||||
|
</Button>
|
||||||
|
{isMenuVisible && (
|
||||||
|
<Menu position="bottom-end">
|
||||||
|
<MenuTarget>
|
||||||
|
<ActionIcon
|
||||||
|
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||||
|
variant="default"
|
||||||
|
size={44}
|
||||||
|
aria-label={labels.settings}
|
||||||
|
>
|
||||||
|
<IconDotsVertical size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
</MenuTarget>
|
||||||
|
<BoardCardMenuDropdown
|
||||||
|
board={board}
|
||||||
|
onDuplicate={() => {
|
||||||
|
setDuplicateFormKey((value) => value + 1);
|
||||||
|
setIsDuplicateOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</CardSection>
|
||||||
|
</Card>
|
||||||
|
<Collapse expanded={isDuplicateOpen}>
|
||||||
|
<Card mt="sm" withBorder>
|
||||||
|
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,55 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@mantine/core";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
|
||||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { BoardCreateForm } from "@homarr/forms-collection";
|
||||||
import { AddBoardModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
|
||||||
|
|
||||||
export const CreateBoardButton = () => {
|
export const CreateBoardButton = () => {
|
||||||
const t = useI18n("management.page.board.action");
|
const t = useI18n("management.page.board.action");
|
||||||
const { openModal } = useModalAction(AddBoardModal);
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
|
||||||
|
const [formKey, setFormKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("create") !== "true") return;
|
||||||
|
|
||||||
|
setFormKey((value) => value + 1);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setFormKey((value) => value + 1);
|
||||||
|
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormKey((value) => value + 1);
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageMobilePrimaryAction>
|
<Stack>
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
<Group justify="end">
|
||||||
{t("new.label")}
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
|
||||||
</Button>
|
{t("new.label")}
|
||||||
</ManageMobilePrimaryAction>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Collapse expanded={isOpen}>
|
||||||
|
<Card withBorder>
|
||||||
|
<BoardCreateForm key={formKey} onCancel={close} />
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+22
-31
@@ -1,29 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { boardDuplicateSchema } from "@homarr/validation/board";
|
import { boardDuplicateSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
import { createModal } from "../../../modals/src/creator";
|
interface DuplicateBoardFormProps {
|
||||||
import { useBoardNameStatus } from "./add-board-modal";
|
|
||||||
|
|
||||||
interface InnerProps {
|
|
||||||
board: {
|
board: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
|
const router = useRouter();
|
||||||
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
|
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
|
||||||
mode: "controlled",
|
mode: "controlled",
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: innerProps.board.name,
|
name: board.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const boardNameStatus = useBoardNameStatus(form.values.name);
|
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||||
@@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit(async (values) => {
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
// Prevent submit before name availability check
|
|
||||||
if (!boardNameStatus.canSubmit) return;
|
if (!boardNameStatus.canSubmit) return;
|
||||||
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
...values,
|
...values,
|
||||||
id: innerProps.board.id,
|
id: board.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
actions.closeModal();
|
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: tBoard("action.duplicate.notification.success.title"),
|
title: tBoard("action.duplicate.notification.success.title"),
|
||||||
message: tBoard("action.duplicate.notification.success.message"),
|
message: tBoard("action.duplicate.notification.success.message"),
|
||||||
});
|
});
|
||||||
|
onCancel();
|
||||||
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack maw={720}>
|
||||||
<Text size="sm" c="gray.6">
|
<Text size="sm" c="gray.6">
|
||||||
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
|
{tBoard("action.duplicate.message", { name: board.name })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={tCommon("field.name")}
|
label={tCommon("field.name")}
|
||||||
data-autofocus
|
autoFocus
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
description={
|
description={<BoardNameAvailability status={boardNameStatus} />}
|
||||||
boardNameStatus.description ? (
|
|
||||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
|
||||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
|
||||||
<span>{boardNameStatus.description.label}</span>
|
|
||||||
</Group>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
<Group justify="end" wrap="wrap">
|
||||||
<Group justify="end">
|
<Button variant="default" onClick={onCancel}>
|
||||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
|
||||||
{tCommon("action.cancel")}
|
{tCommon("action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
|
||||||
{tCommon("action.create")}
|
{tBoard("action.duplicate.title")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
};
|
||||||
defaultTitle(t) {
|
|
||||||
return t("board.action.duplicate.title");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,38 +1,15 @@
|
|||||||
import {
|
import { Grid, GridCol } from "@mantine/core";
|
||||||
ActionIcon,
|
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||||
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 { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
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 { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCard } from "./_components/board-card";
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
|
|
||||||
export default async function ManageBoardsPage() {
|
export default async function ManageBoardsPage() {
|
||||||
@@ -42,24 +19,14 @@ export default async function ManageBoardsPage() {
|
|||||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManagePageLayout title={t("title")}>
|
||||||
title={t("title")}
|
{canCreateBoards && (
|
||||||
primaryAction={
|
<TourTarget id="manage-boards-create">
|
||||||
canCreateBoards ? (
|
<CreateBoardButton />
|
||||||
<TourTarget id="manage-boards-create">
|
</TourTarget>
|
||||||
<CreateBoardButton />
|
)}
|
||||||
</TourTarget>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
floatingPrimaryAction={canCreateBoards}
|
|
||||||
>
|
|
||||||
{boards.length === 0 && (
|
{boards.length === 0 && (
|
||||||
<NoResults
|
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||||
icon={IconLayoutDashboard}
|
|
||||||
title={t("noResults.title")}
|
|
||||||
description={t("noResults.description")}
|
|
||||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{boards.length > 0 && (
|
{boards.length > 0 && (
|
||||||
<TourTarget id="manage-boards-list">
|
<TourTarget id="manage-boards-list">
|
||||||
@@ -92,97 +59,3 @@ export default async function ManageBoardsPage() {
|
|||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoardCardProps {
|
|
||||||
board: RouterOutputs["board"]["getManageOverview"][number];
|
|
||||||
isMenuVisible: boolean;
|
|
||||||
labels: {
|
|
||||||
visibility: string;
|
|
||||||
preview: string;
|
|
||||||
unknownCreator: string;
|
|
||||||
home: string;
|
|
||||||
homeTooltip: string;
|
|
||||||
mobileHome: string;
|
|
||||||
mobileHomeTooltip: string;
|
|
||||||
open: string;
|
|
||||||
settings: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
|
||||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card padding={0} withBorder>
|
|
||||||
<CardSection withBorder>
|
|
||||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
|
||||||
</CardSection>
|
|
||||||
<CardSection p="sm" withBorder>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
|
||||||
<Tooltip label={labels.visibility}>
|
|
||||||
<VisibilityIcon size={20} stroke={1.5} />
|
|
||||||
</Tooltip>
|
|
||||||
<Text fw={700} truncate>
|
|
||||||
{board.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
|
||||||
{board.isHome && (
|
|
||||||
<Tooltip label={labels.homeTooltip}>
|
|
||||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
|
||||||
{labels.home}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{board.isMobileHome && (
|
|
||||||
<Tooltip label={labels.mobileHomeTooltip}>
|
|
||||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
|
||||||
{labels.mobileHome}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{board.creator && (
|
|
||||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
|
||||||
<UserAvatar user={board.creator} size="sm" />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</CardSection>
|
|
||||||
|
|
||||||
<CardSection>
|
|
||||||
<Group gap={0} wrap="nowrap">
|
|
||||||
<Button
|
|
||||||
style={{ border: "none", borderRadius: 0 }}
|
|
||||||
component={Link}
|
|
||||||
href={`/boards/${board.name}`}
|
|
||||||
variant="default"
|
|
||||||
h={44}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{labels.open}
|
|
||||||
</Button>
|
|
||||||
{isMenuVisible && (
|
|
||||||
<Menu position="bottom-end">
|
|
||||||
<MenuTarget>
|
|
||||||
<ActionIcon
|
|
||||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
|
||||||
variant="default"
|
|
||||||
size={44}
|
|
||||||
aria-label={labels.settings}
|
|
||||||
>
|
|
||||||
<IconDotsVertical size={16} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</MenuTarget>
|
|
||||||
<BoardCardMenuDropdown board={board} />
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</CardSection>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||||
|
|
||||||
interface CopyAiPromptButtonBaseProps {
|
interface CopyAiPromptButtonProps {
|
||||||
rawResponse?: string | null;
|
rawResponse?: string | null;
|
||||||
request?: string | null;
|
request?: string | null;
|
||||||
documentationUrl?: 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 = ({
|
export const CopyAiPromptButton = ({
|
||||||
rawResponse,
|
rawResponse,
|
||||||
draft,
|
|
||||||
getDraft,
|
getDraft,
|
||||||
diagnostics,
|
|
||||||
getDiagnostics,
|
getDiagnostics,
|
||||||
request,
|
request,
|
||||||
documentationUrl,
|
documentationUrl,
|
||||||
@@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({
|
|||||||
const t = useI18n("customWidget");
|
const t = useI18n("customWidget");
|
||||||
const assistant = useOptionalHomarrAssistant();
|
const assistant = useOptionalHomarrAssistant();
|
||||||
|
|
||||||
const readDraft = () => {
|
|
||||||
if (getDraft) return getDraft();
|
|
||||||
return draft;
|
|
||||||
};
|
|
||||||
const readDiagnostics = () => {
|
|
||||||
if (getDiagnostics) return getDiagnostics();
|
|
||||||
return diagnostics;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssistant = () => {
|
const handleAssistant = () => {
|
||||||
const prompt = buildCustomWidgetAssistantPrompt(
|
const prompt = buildCustomWidgetAssistantPrompt(
|
||||||
undefined,
|
undefined,
|
||||||
rawResponse,
|
rawResponse,
|
||||||
readDraft(),
|
getDraft(),
|
||||||
request,
|
request,
|
||||||
documentationUrl,
|
documentationUrl,
|
||||||
readDiagnostics(),
|
getDiagnostics(),
|
||||||
);
|
);
|
||||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||||
@@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({
|
|||||||
const value = buildCustomWidgetAiPrompt(
|
const value = buildCustomWidgetAiPrompt(
|
||||||
undefined,
|
undefined,
|
||||||
rawResponse,
|
rawResponse,
|
||||||
readDraft(),
|
getDraft(),
|
||||||
request,
|
request,
|
||||||
documentationUrl,
|
documentationUrl,
|
||||||
readDiagnostics(),
|
getDiagnostics(),
|
||||||
);
|
);
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
|
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
|
||||||
import type { HomarrCustomWidgetV2 } 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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||||
import { downloadJson } from "~/components/custom-widgets/download";
|
import { downloadJson } from "~/components/custom-widgets/download";
|
||||||
@@ -40,7 +39,7 @@ interface WidgetRef {
|
|||||||
|
|
||||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||||
const t = useI18n("customWidget");
|
const t = useI18n("customWidget");
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const tCommon = useI18n("common");
|
||||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||||
@@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
openConfirmModal({
|
deleteMutation.mutate(
|
||||||
title: t("action.delete"),
|
{ id: widget.id },
|
||||||
children: t("action.deleteConfirm", { name: widget.name }),
|
{
|
||||||
onConfirm: () => {
|
onSuccess: () => {
|
||||||
deleteMutation.mutate(
|
showSuccessNotification({
|
||||||
{ id: widget.id },
|
title: t("action.delete"),
|
||||||
{
|
message: t("notification.deleted", { name: widget.name }),
|
||||||
onSuccess: () => {
|
});
|
||||||
showSuccessNotification({
|
void utils.customWidget.list.invalidate();
|
||||||
title: t("action.delete"),
|
void utils.customWidget.available.invalidate();
|
||||||
message: t("notification.deleted", { name: widget.name }),
|
void utils.widget.customApi.getData.invalidate();
|
||||||
});
|
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||||
void utils.customWidget.list.invalidate();
|
},
|
||||||
void utils.customWidget.available.invalidate();
|
onError: () => {
|
||||||
void utils.widget.customApi.getData.invalidate();
|
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
},
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!widget.migrationRequired && (
|
{!widget.migrationRequired && (
|
||||||
<Menu.Item
|
<InlineConfirmMenuItem
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={<IconTrash {...iconProps} />}
|
leftSection={<IconTrash {...iconProps} />}
|
||||||
onClick={handleDelete}
|
onConfirm={handleDelete}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
{t("action.delete")}
|
{t("action.delete")}
|
||||||
</Menu.Item>
|
</InlineConfirmMenuItem>
|
||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
+10
-8
@@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
|
|||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
|
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
|
||||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||||
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
|
import { WorkshopReportForm } from "~/components/workshop/workshop-report-form";
|
||||||
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
|
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
|
||||||
import { useWorkshopSession } from "~/components/workshop/workshop-session";
|
import { useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||||
@@ -213,6 +213,13 @@ export function WorkshopDetail({ id }: { id: string }) {
|
|||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<WorkshopReportForm
|
||||||
|
client={session.client}
|
||||||
|
submissionId={submission.id}
|
||||||
|
opened={reportOpened}
|
||||||
|
onClose={reportControls.close}
|
||||||
|
/>
|
||||||
|
|
||||||
<ManageStickyFooter
|
<ManageStickyFooter
|
||||||
secondary={
|
secondary={
|
||||||
<>
|
<>
|
||||||
@@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) {
|
|||||||
leftSection={<IconFlag size={16} />}
|
leftSection={<IconFlag size={16} />}
|
||||||
disabled={!session.user}
|
disabled={!session.user}
|
||||||
onClick={reportControls.open}
|
onClick={reportControls.open}
|
||||||
|
aria-expanded={reportOpened}
|
||||||
|
aria-controls="workshop-report-form"
|
||||||
>
|
>
|
||||||
{t("report")}
|
{t("report")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</ManageStickyFooter>
|
</ManageStickyFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<WorkshopReportModal
|
|
||||||
client={session.client}
|
|
||||||
submissionId={submission.id}
|
|
||||||
opened={reportOpened}
|
|
||||||
onClose={reportControls.close}
|
|
||||||
/>
|
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-79
@@ -6,11 +6,12 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { createId, getMantineColor } from "@homarr/common";
|
import { createId, getMantineColor } from "@homarr/common";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
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";
|
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
|
||||||
|
|
||||||
interface CertificateErrorDetailsProps {
|
interface CertificateErrorDetailsProps {
|
||||||
@@ -23,80 +24,71 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||||
const [showRetryButton, setShowRetryButton] = useState(false);
|
const [showRetryButton, setShowRetryButton] = useState(false);
|
||||||
|
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||||
|
|
||||||
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
|
const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } =
|
||||||
const { openConfirmModal } = useConfirmModal();
|
clientApi.certificates.trustHostnameMismatch.useMutation();
|
||||||
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
|
const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } =
|
||||||
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
|
clientApi.certificates.addCertificate.useMutation();
|
||||||
|
|
||||||
const rootCertificate = getHeighestCertificate(error.data.certificate);
|
const rootCertificate = getHeighestCertificate(error.data.certificate);
|
||||||
|
|
||||||
const handleTrustHostname = () => {
|
const handleTrustHostname = async () => {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
openConfirmModal({
|
await trustHostnameAsync(
|
||||||
title: tError("certificate.hostnameMismatch.confirm.title"),
|
{
|
||||||
children: tError("certificate.hostnameMismatch.confirm.message"),
|
hostname,
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
certificate: error.data.certificate.pem,
|
||||||
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 = () => {
|
const handleTrustSelfSigned = async () => {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
openConfirmModal({
|
const formData = new FormData();
|
||||||
title: tError("certificate.selfSigned.confirm.title"),
|
formData.append(
|
||||||
children: tError("certificate.selfSigned.confirm.message"),
|
"file",
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
|
||||||
async onConfirm() {
|
type: "application/x-x509-ca-cert",
|
||||||
const formData = new FormData();
|
}),
|
||||||
formData.append(
|
);
|
||||||
"file",
|
await addCertificateAsync(formData, {
|
||||||
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
|
onSuccess() {
|
||||||
type: "application/x-x509-ca-cert",
|
showSuccessNotification({
|
||||||
}),
|
title: tError("certificate.selfSigned.notification.success.title"),
|
||||||
);
|
message: tError("certificate.selfSigned.notification.success.message"),
|
||||||
await addCertificateAsync(formData, {
|
});
|
||||||
onSuccess() {
|
setShowRetryButton(true);
|
||||||
showSuccessNotification({
|
},
|
||||||
title: tError("certificate.selfSigned.notification.success.title"),
|
onError() {
|
||||||
message: tError("certificate.selfSigned.notification.success.message"),
|
showErrorNotification({
|
||||||
});
|
title: tError("certificate.selfSigned.notification.error.title"),
|
||||||
setShowRetryButton(true);
|
message: tError("certificate.selfSigned.notification.error.message"),
|
||||||
},
|
|
||||||
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>;
|
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) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -130,30 +122,36 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
|||||||
|
|
||||||
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
||||||
error.data.reason === "hostnameMismatch" ? (
|
error.data.reason === "hostnameMismatch" ? (
|
||||||
<Button
|
<InlineConfirmButton
|
||||||
type="button"
|
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
|
||||||
|
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||||
|
confirmLabel={trustConfirmLabel}
|
||||||
>
|
>
|
||||||
{tError("certificate.action.trust.label")}
|
{tError("certificate.action.trust.label")}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
) : null}
|
) : null}
|
||||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||||
<Button
|
<Stack gap="sm">
|
||||||
type="button"
|
{!showUploadForm && (
|
||||||
variant="default"
|
<Button variant="default" fullWidth onClick={() => setShowUploadForm(true)}>
|
||||||
fullWidth
|
{tError("certificate.action.upload.label")}
|
||||||
onClick={() =>
|
</Button>
|
||||||
openUploadModal({
|
)}
|
||||||
onSuccess() {
|
{showUploadForm && (
|
||||||
setShowRetryButton(true);
|
<Card withBorder>
|
||||||
},
|
<CertificateUploadForm
|
||||||
})
|
embedded
|
||||||
}
|
onCancel={() => setShowUploadForm(false)}
|
||||||
>
|
onSuccess={() => {
|
||||||
{tError("certificate.action.upload.label")}
|
setShowRetryButton(true);
|
||||||
</Button>
|
setShowUploadForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface DeleteIntegrationActionButtonProps {
|
interface DeleteIntegrationActionButtonProps {
|
||||||
integration: { id: string; name: string };
|
integration: { id: string; name: string };
|
||||||
@@ -16,46 +15,42 @@ interface DeleteIntegrationActionButtonProps {
|
|||||||
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
||||||
const t = useI18n("integration.page.delete");
|
const t = useI18n("integration.page.delete");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ActionIcon
|
<InlineConfirmActionIcon
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
confirmationAriaLabel={tCommon("action.confirm")}
|
||||||
|
onConfirm={onConfirm}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
size={44}
|
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 })}
|
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
|
||||||
>
|
>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface DeleteMediaProps {
|
interface DeleteMediaProps {
|
||||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const t = useI18n("media");
|
const t = useI18n("media");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||||
|
|
||||||
const onClick = () => {
|
const onConfirm = async () => {
|
||||||
openConfirmModal({
|
await mutateAsync({ id: media.id });
|
||||||
title: t("action.delete.label"),
|
await revalidatePathActionAsync("/manage/medias");
|
||||||
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 (
|
return (
|
||||||
<Tooltip label={t("action.delete.label")} openDelay={500}>
|
<Tooltip label={t("action.delete.label")} openDelay={500}>
|
||||||
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
<InlineConfirmActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
confirmationAriaLabel={tCommon("action.confirm")}
|
||||||
|
loading={isPending}
|
||||||
|
aria-label={t("action.delete.label")}
|
||||||
|
>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+25
-33
@@ -1,15 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface SearchEngineDeleteButtonProps {
|
interface SearchEngineDeleteButtonProps {
|
||||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||||
@@ -18,48 +17,41 @@ interface SearchEngineDeleteButtonProps {
|
|||||||
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||||
const t = useI18n("search.engine.page.delete");
|
const t = useI18n("search.engine.page.delete");
|
||||||
const actionT = useI18n("common.action");
|
const actionT = useI18n("common.action");
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onConfirm = useCallback(() => {
|
||||||
openConfirmModal({
|
mutate(
|
||||||
title: t("title"),
|
{ id: searchEngine.id },
|
||||||
children: t("message", {
|
{
|
||||||
name: searchEngine.name,
|
onSuccess: () => {
|
||||||
}),
|
showSuccessNotification({
|
||||||
onConfirm: () => {
|
title: t("notification.success.title"),
|
||||||
mutate(
|
message: t("notification.success.message"),
|
||||||
{ id: searchEngine.id },
|
});
|
||||||
{
|
void revalidatePathActionAsync("/manage/search-engines");
|
||||||
onSuccess: () => {
|
},
|
||||||
showSuccessNotification({
|
onError: () => {
|
||||||
title: t("notification.success.title"),
|
showErrorNotification({
|
||||||
message: t("notification.success.message"),
|
title: t("notification.error.title"),
|
||||||
});
|
message: t("notification.error.message"),
|
||||||
void revalidatePathActionAsync("/manage/search-engines");
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("notification.error.title"),
|
|
||||||
message: t("notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}, [searchEngine, mutate, t, openConfirmModal]);
|
}, [searchEngine.id, mutate, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<InlineConfirmActionIcon
|
||||||
|
confirmLabel={actionT("confirm")}
|
||||||
|
confirmationAriaLabel={actionT("confirm")}
|
||||||
|
onConfirm={onConfirm}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
size={44}
|
size={44}
|
||||||
onClick={onClick}
|
|
||||||
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
|
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
|
||||||
>
|
>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,34 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
|
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table";
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
|
import { NewApiKeyAlert } from "./new-api-key-alert";
|
||||||
|
|
||||||
interface ApiKeysManagementProps {
|
interface ApiKeysManagementProps {
|
||||||
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
||||||
|
onCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
type ApiKey = RouterOutputs["apiKeys"]["getAll"][number];
|
||||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
type ApiKeyCellProps = { row: MRT_Row<ApiKey> };
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
|
interface ApiKeysTableMeta {
|
||||||
|
handleDelete: (id: string) => Promise<void>;
|
||||||
|
confirmLabel: string;
|
||||||
|
deleteTitle: string;
|
||||||
|
isPendingDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<UserAvatar user={row.original.user} size="sm" />
|
||||||
|
<Text>{row.original.user.name}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance<ApiKey> }) => {
|
||||||
|
const meta = table.options.meta as ApiKeysTableMeta;
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<InlineConfirmActionIcon
|
||||||
|
onConfirm={() => meta.handleDelete(row.original.id)}
|
||||||
|
confirmLabel={meta.confirmLabel}
|
||||||
|
confirmationAriaLabel={meta.confirmLabel}
|
||||||
|
loading={meta.isPendingDelete}
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
aria-label={meta.deleteTitle}
|
||||||
|
>
|
||||||
|
<IconTrash size="1rem" />
|
||||||
|
</InlineConfirmActionIcon>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => {
|
||||||
|
const [newApiKey, setNewApiKey] = useState<string | null>(null);
|
||||||
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
|
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
|
||||||
async onSuccess(data) {
|
onSuccess(data) {
|
||||||
openModal({
|
setNewApiKey(data.apiKey);
|
||||||
apiKey: data.apiKey,
|
onCreated();
|
||||||
});
|
|
||||||
await revalidatePathActionAsync("/manage/tools/api");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
|
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
|
||||||
@@ -38,21 +71,19 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const t = useI18n("management.page.tool.api.tab.apiKey");
|
const t = useI18n("management.page.tool.api.tab.apiKey");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
(id: string) => {
|
async (id: string) => {
|
||||||
openConfirmModal({
|
await mutateDeleteAsync({ apiKeyId: id });
|
||||||
title: t("modal.delete.title"),
|
|
||||||
children: t("modal.delete.text"),
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
async onConfirm() {
|
|
||||||
await mutateDeleteAsync({ apiKeyId: id });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[t, openConfirmModal, mutateDeleteAsync],
|
[mutateDeleteAsync],
|
||||||
);
|
);
|
||||||
|
const handleDismissNewApiKey = useCallback(async () => {
|
||||||
|
setNewApiKey(null);
|
||||||
|
await revalidatePathActionAsync("/manage/tools/api");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
@@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
{
|
{
|
||||||
accessorKey: "user",
|
accessorKey: "user",
|
||||||
header: t("table.header.createdBy"),
|
header: t("table.header.createdBy"),
|
||||||
Cell: ({ row }) => (
|
Cell: ApiKeyCreatedByCell,
|
||||||
<Group gap={"xs"}>
|
|
||||||
<UserAvatar user={row.original.user} size={"sm"} />
|
|
||||||
<Text>{row.original.user.name}</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t("table.header.actions"),
|
header: t("table.header.actions"),
|
||||||
Cell: ({ row }) => (
|
Cell: ApiKeyActionsCell,
|
||||||
<Group gap="xs">
|
|
||||||
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
|
|
||||||
<IconTrash size="1rem" />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, handleDelete, isPendingDelete],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useMantineReactTable({
|
const table = useMantineReactTable({
|
||||||
columns,
|
columns,
|
||||||
data: apiKeys,
|
data: apiKeys,
|
||||||
|
meta: {
|
||||||
|
handleDelete,
|
||||||
|
confirmLabel: tCommon("action.confirm"),
|
||||||
|
deleteTitle: t("modal.delete.title"),
|
||||||
|
isPendingDelete,
|
||||||
|
},
|
||||||
renderTopToolbarCustomActions: () => (
|
renderTopToolbarCustomActions: () => (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateCreate();
|
mutateCreate();
|
||||||
}}
|
}}
|
||||||
loading={isPendingCreate}
|
loading={isPendingCreate}
|
||||||
|
disabled={newApiKey !== null}
|
||||||
>
|
>
|
||||||
{t("button.createApiToken")}
|
{t("button.createApiToken")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("title")}</Title>
|
<Title>{t("title")}</Title>
|
||||||
|
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
|
||||||
<MantineReactTable table={table} />
|
<MantineReactTable table={table} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { ReactNode } from "react";
|
|||||||
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||||
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
|
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { ApiKeysManagement } from "./api-keys";
|
||||||
import { McpInstructions } from "./mcp-instructions";
|
import { McpInstructions } from "./mcp-instructions";
|
||||||
|
|
||||||
export interface McpToolGroup {
|
export interface McpToolGroup {
|
||||||
@@ -17,9 +19,8 @@ interface ApiPageTabsProps {
|
|||||||
apiKeyLabel: string;
|
apiKeyLabel: string;
|
||||||
mcpLabel: string;
|
mcpLabel: string;
|
||||||
documentationPanel: ReactNode;
|
documentationPanel: ReactNode;
|
||||||
authenticationPanel: ReactNode;
|
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
hasApiKeys: boolean;
|
|
||||||
toolGroups: McpToolGroup[];
|
toolGroups: McpToolGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +29,12 @@ export function ApiPageTabs({
|
|||||||
apiKeyLabel,
|
apiKeyLabel,
|
||||||
mcpLabel,
|
mcpLabel,
|
||||||
documentationPanel,
|
documentationPanel,
|
||||||
authenticationPanel,
|
apiKeys,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
hasApiKeys,
|
|
||||||
toolGroups,
|
toolGroups,
|
||||||
}: ApiPageTabsProps) {
|
}: ApiPageTabsProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("documentation");
|
const [activeTab, setActiveTab] = useState<string | null>("documentation");
|
||||||
|
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
@@ -48,10 +49,17 @@ export function ApiPageTabs({
|
|||||||
{mcpLabel}
|
{mcpLabel}
|
||||||
</TabsTab>
|
</TabsTab>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
|
<TabsPanel value="authentication">
|
||||||
|
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
|
||||||
|
</TabsPanel>
|
||||||
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
|
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
|
||||||
<TabsPanel value="mcp">
|
<TabsPanel value="mcp">
|
||||||
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
|
<McpInstructions
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
hasApiKeys={hasApiKeys}
|
||||||
|
toolGroups={toolGroups}
|
||||||
|
onApiKeyCreated={() => setHasApiKeys(true)}
|
||||||
|
/>
|
||||||
</TabsPanel>
|
</TabsPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
|
|
||||||
const t = useI18n("management.page.tool.api.modal.createApiToken");
|
|
||||||
const [visible, { toggle }] = useDisclosure(false);
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Text>{t("description")}</Text>
|
|
||||||
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
|
|
||||||
<CopyButton value={innerProps.apiKey}>
|
|
||||||
{({ copy }) => (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
copy();
|
|
||||||
actions.closeModal();
|
|
||||||
}}
|
|
||||||
variant="default"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t("button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle(t) {
|
|
||||||
return t("management.page.tool.api.modal.createApiToken.title");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
@@ -36,12 +37,11 @@ import {
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { CopyApiKeyModal } from "./copy-api-key-modal";
|
|
||||||
import type { McpToolGroup } from "./api-page-tabs";
|
import type { McpToolGroup } from "./api-page-tabs";
|
||||||
import classes from "./mcp-tools-accordion.module.css";
|
import classes from "./mcp-tools-accordion.module.css";
|
||||||
|
import { NewApiKeyAlert } from "./new-api-key-alert";
|
||||||
|
|
||||||
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
|
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
|
||||||
query: { color: "blue", method: "GET" },
|
query: { color: "blue", method: "GET" },
|
||||||
@@ -52,18 +52,23 @@ interface McpInstructionsProps {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
hasApiKeys: boolean;
|
hasApiKeys: boolean;
|
||||||
toolGroups: McpToolGroup[];
|
toolGroups: McpToolGroup[];
|
||||||
|
onApiKeyCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
|
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) {
|
||||||
const t = useI18n("management.page.tool.api.tab.mcp");
|
const t = useI18n("management.page.tool.api.tab.mcp");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
const [newApiKey, setNewApiKey] = useState<string | null>(null);
|
||||||
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
|
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
|
||||||
async onSuccess(data) {
|
onSuccess(data) {
|
||||||
openModal({ apiKey: data.apiKey });
|
setNewApiKey(data.apiKey);
|
||||||
await revalidatePathActionAsync("/manage/tools/api");
|
onApiKeyCreated();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const handleDismissNewApiKey = useCallback(async () => {
|
||||||
|
setNewApiKey(null);
|
||||||
|
await revalidatePathActionAsync("/manage/tools/api");
|
||||||
|
}, []);
|
||||||
const mcpUrl = `${baseUrl}/api/mcp`;
|
const mcpUrl = `${baseUrl}/api/mcp`;
|
||||||
|
|
||||||
const streamableHttpConfig = JSON.stringify(
|
const streamableHttpConfig = JSON.stringify(
|
||||||
@@ -114,7 +119,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
|
|||||||
<Text size="sm">{t("description")}</Text>
|
<Text size="sm">{t("description")}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{!hasApiKeys && (
|
{!hasApiKeys && !newApiKey && (
|
||||||
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
|
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text size="sm">{t("noApiKey")}</Text>
|
<Text size="sm">{t("noApiKey")}</Text>
|
||||||
@@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Title order={5} mb="xs">
|
<Title order={5} mb="xs">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -305,7 +312,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
|
|||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{group.tools.map((tool) => (
|
{group.tools.map((tool) => (
|
||||||
<div key={tool.name} className={classes.toolRow}>
|
<div key={tool.name} className={classes.toolRow}>
|
||||||
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
|
<Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light">
|
||||||
{toolTypeDisplay[tool.type].method}
|
{toolTypeDisplay[tool.type].method}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface NewApiKeyAlertProps {
|
||||||
|
apiKey: string;
|
||||||
|
onDismiss: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => {
|
||||||
|
const t = useI18n("management.page.tool.api.modal.createApiToken");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const [visible, { toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert color="yellow" title={t("title")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{t("description")}</Text>
|
||||||
|
<PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly />
|
||||||
|
<Stack gap="xs" align="stretch">
|
||||||
|
<CopyButton value={apiKey} timeout={2_000}>
|
||||||
|
{({ copied, copy }) => {
|
||||||
|
let color: "green" | undefined;
|
||||||
|
let icon = <IconCopy size={16} />;
|
||||||
|
let label = tCommon("action.copy");
|
||||||
|
if (copied) {
|
||||||
|
color = "green";
|
||||||
|
icon = <IconCheck size={16} />;
|
||||||
|
label = tCommon("action.copied");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={copy} variant="default" color={color} leftSection={icon}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</CopyButton>
|
||||||
|
<Button onClick={onDismiss} variant="subtle">
|
||||||
|
{tCommon("action.close")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,7 +11,6 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||||
import { ApiKeysManagement } from "./components/api-keys";
|
|
||||||
import { ApiPageTabs } from "./components/api-page-tabs";
|
import { ApiPageTabs } from "./components/api-page-tabs";
|
||||||
import { ScalarApiReference } from "./components/scalar-api-reference";
|
import { ScalarApiReference } from "./components/scalar-api-reference";
|
||||||
|
|
||||||
@@ -87,9 +86,8 @@ export default async function ApiPage() {
|
|||||||
apiKeyLabel={t("apiKey.label")}
|
apiKeyLabel={t("apiKey.label")}
|
||||||
mcpLabel={t("mcp.label")}
|
mcpLabel={t("mcp.label")}
|
||||||
documentationPanel={<ScalarApiReference document={document} />}
|
documentationPanel={<ScalarApiReference document={document} />}
|
||||||
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
|
apiKeys={apiKeys}
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
hasApiKeys={apiKeys.length > 0}
|
|
||||||
toolGroups={toolGroups}
|
toolGroups={toolGroups}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+26
-14
@@ -1,23 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@mantine/core";
|
import { Button, Card, Collapse, Stack } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
export const AddCertificateButton = () => {
|
import { CertificateUploadForm } from "./certificate-upload-form";
|
||||||
const { openModal } = useModalAction(AddCertificateModal);
|
|
||||||
|
export const AddCertificateSection = () => {
|
||||||
|
const [opened, { close, toggle }] = useDisclosure(false);
|
||||||
const t = useI18n("certificate");
|
const t = useI18n("certificate");
|
||||||
|
|
||||||
const handleClick = () => {
|
return (
|
||||||
openModal({
|
<Stack gap="sm">
|
||||||
async onSuccess() {
|
<Button onClick={toggle} variant="default" aria-expanded={opened}>
|
||||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
{t("action.create.label")}
|
||||||
},
|
</Button>
|
||||||
});
|
<Collapse expanded={opened}>
|
||||||
};
|
<Card withBorder>
|
||||||
|
<CertificateUploadForm
|
||||||
return <Button onClick={handleClick}>{t("action.create.label")}</Button>;
|
onCancel={close}
|
||||||
|
onSuccess={async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { CertificateUploadForm };
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||||
|
import { IconCertificate } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { checkCertificateFile } from "@homarr/validation/certificates";
|
||||||
|
|
||||||
|
interface CertificateUploadFormProps {
|
||||||
|
embedded?: boolean;
|
||||||
|
onSuccess?: () => MaybePromise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const form = useZodForm(
|
||||||
|
z.object({
|
||||||
|
file: z.file().check(checkCertificateFile),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialValues: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
file: null!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const validation = form.validate();
|
||||||
|
if (validation.hasErrors || !form.values.file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", form.values.file);
|
||||||
|
await mutateAsync(formData, {
|
||||||
|
onSuccess: async () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("certificate.action.create.notification.success.title"),
|
||||||
|
message: t("certificate.action.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
await onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("certificate.action.create.notification.error.title"),
|
||||||
|
message: t("certificate.action.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.reset();
|
||||||
|
onCancel?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
void handleSubmit().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
let submitButtonType: "button" | "submit" = "submit";
|
||||||
|
let handleSubmitClick: (() => void) | undefined;
|
||||||
|
if (embedded) {
|
||||||
|
submitButtonType = "button";
|
||||||
|
handleSubmitClick = submit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<FileInput
|
||||||
|
label={t("certificate.action.create.label")}
|
||||||
|
leftSection={<IconCertificate size={16} />}
|
||||||
|
{...form.getInputProps("file")}
|
||||||
|
/>
|
||||||
|
<Group justify="end">
|
||||||
|
{onCancel && (
|
||||||
|
<Button onClick={handleCancel} variant="subtle" color="gray">
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}>
|
||||||
|
{t("common.action.add")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Stack>{content}</Stack>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
component="form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
+30
-31
@@ -1,57 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface RemoveCertificateProps {
|
interface RemoveCertificateProps {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
|
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({
|
||||||
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
|
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const t = useI18n("certificate");
|
const t = useI18n("certificate");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleRemove = () =>
|
||||||
openConfirmModal({
|
mutateAsync(
|
||||||
title: t("action.remove.label"),
|
{ fileName },
|
||||||
children: t("action.remove.confirm"),
|
{
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
onSuccess() {
|
||||||
async onConfirm() {
|
showSuccessNotification({
|
||||||
await mutateAsync(
|
title: t("action.remove.notification.success.title"),
|
||||||
{ fileName },
|
message: t("action.remove.notification.success.message"),
|
||||||
{
|
});
|
||||||
onSuccess() {
|
},
|
||||||
showSuccessNotification({
|
onError() {
|
||||||
title: t("action.remove.notification.success.title"),
|
showErrorNotification({
|
||||||
message: t("action.remove.notification.success.message"),
|
title: t("action.remove.notification.error.title"),
|
||||||
});
|
message: t("action.remove.notification.error.message"),
|
||||||
},
|
});
|
||||||
onError() {
|
},
|
||||||
showErrorNotification({
|
|
||||||
title: t("action.remove.notification.error.title"),
|
|
||||||
message: t("action.remove.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon onClick={handleClick} color="red" variant="subtle">
|
<InlineConfirmActionIcon
|
||||||
|
onConfirm={handleRemove}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
confirmationAriaLabel={tCommon("action.confirm")}
|
||||||
|
loading={isPending}
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
aria-label={t("action.remove.label")}
|
||||||
|
>
|
||||||
<IconTrash color="red" size={16} />
|
<IconTrash color="red" size={16} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+25
-26
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface RemoveHostnameActionIconProps {
|
interface RemoveHostnameActionIconProps {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@@ -15,41 +14,41 @@ interface RemoveHostnameActionIconProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
|
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
|
||||||
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({
|
const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
|
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const t = useI18n("certificate");
|
const t = useI18n("certificate");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleRemove = () =>
|
||||||
openConfirmModal({
|
mutateAsync(input, {
|
||||||
title: t("action.removeHostname.label"),
|
onSuccess() {
|
||||||
children: t("action.removeHostname.confirm"),
|
showSuccessNotification({
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
title: t("action.removeHostname.notification.success.title"),
|
||||||
async onConfirm() {
|
message: t("action.removeHostname.notification.success.message"),
|
||||||
await mutateAsync(input, {
|
});
|
||||||
onSuccess() {
|
},
|
||||||
showSuccessNotification({
|
onError() {
|
||||||
title: t("action.removeHostname.notification.success.title"),
|
showErrorNotification({
|
||||||
message: t("action.removeHostname.notification.success.message"),
|
title: t("action.removeHostname.notification.error.title"),
|
||||||
});
|
message: t("action.removeHostname.notification.error.message"),
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("action.removeHostname.notification.error.title"),
|
|
||||||
message: t("action.removeHostname.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
|
<InlineConfirmActionIcon
|
||||||
|
onConfirm={handleRemove}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
confirmationAriaLabel={tCommon("action.confirm")}
|
||||||
|
loading={isPending}
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
aria-label={t("action.removeHostname.label")}
|
||||||
|
>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -13,7 +13,7 @@ import { Link } from "@homarr/ui";
|
|||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { AddCertificateButton } from "./_components/add-certificate";
|
import { AddCertificateSection } from "./_components/add-certificate";
|
||||||
import { RemoveCertificate } from "./_components/remove-certificate";
|
import { RemoveCertificate } from "./_components/remove-certificate";
|
||||||
|
|
||||||
interface CertificatesPageProps {
|
interface CertificatesPageProps {
|
||||||
@@ -69,10 +69,11 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
|||||||
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
|
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
|
||||||
{t("page.list.toHostnames")}
|
{t("page.list.toHostnames")}
|
||||||
</Button>
|
</Button>
|
||||||
<AddCertificateButton />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<AddCertificateSection />
|
||||||
|
|
||||||
{x509Certificates.length === 0 && (
|
{x509Certificates.length === 0 && (
|
||||||
<NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} />
|
<NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+3
-7
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface ConfigMapsTableComponentProps {
|
interface ConfigMapsTableComponentProps {
|
||||||
@@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
|
|||||||
|
|
||||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialConfigMaps),
|
||||||
initialData: initialConfigMaps,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
|
|||||||
|
|
||||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialIngresses),
|
||||||
initialData: initialIngresses,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface QueryWithStatus {
|
||||||
|
state: {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getErrorRefetchInterval = (query: QueryWithStatus) => {
|
||||||
|
if (query.state.status === "error") return 30_000;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
|
||||||
|
initialData,
|
||||||
|
refetchOnMount: "always" as const,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchInterval: getErrorRefetchInterval,
|
||||||
|
});
|
||||||
+3
-7
@@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface NamespacesTableComponentProps {
|
interface NamespacesTableComponentProps {
|
||||||
@@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
|
|||||||
|
|
||||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialNamespaces),
|
||||||
initialData: initialNamespaces,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
|
|||||||
|
|
||||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialNodes),
|
||||||
initialData: initialNodes,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface PodsTableComponentProps {
|
interface PodsTableComponentProps {
|
||||||
@@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
|||||||
|
|
||||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialPods),
|
||||||
initialData: initialPods,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface SecretsTableComponentProps {
|
interface SecretsTableComponentProps {
|
||||||
@@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
|
|||||||
|
|
||||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialSecrets),
|
||||||
initialData: initialSecrets,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface ServicesTableComponentProps {
|
interface ServicesTableComponentProps {
|
||||||
@@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
|
|||||||
|
|
||||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialServices),
|
||||||
initialData: initialServices,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface VolumesTableComponentProps {
|
interface VolumesTableComponentProps {
|
||||||
@@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
|
|||||||
|
|
||||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||||
{ contextId },
|
{ contextId },
|
||||||
{
|
createKubernetesResourceQueryOptions(initialVolumes),
|
||||||
initialData: initialVolumes,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
|
|||||||
+5
-20
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmButton } from "@homarr/ui";
|
||||||
|
|
||||||
interface DeleteUserButtonProps {
|
interface DeleteUserButtonProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
|
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
|
||||||
const tUser = useI18n("user");
|
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
|
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
|
||||||
@@ -23,27 +21,14 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
|
|||||||
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
|
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
() =>
|
() => mutateUserDeletionAsync({ userId: user.id }),
|
||||||
openConfirmModal({
|
[mutateUserDeletionAsync, user.id],
|
||||||
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 (
|
return (
|
||||||
<Button onClick={handleDelete} variant="subtle" color="red">
|
<InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red">
|
||||||
{tCommon("action.delete")}
|
{tCommon("action.delete")}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+30
-30
@@ -8,10 +8,9 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui";
|
||||||
import { useIsMobile } from "@homarr/ui/hooks";
|
import { useIsMobile } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
interface UserProfileAvatarForm {
|
interface UserProfileAvatarForm {
|
||||||
@@ -19,7 +18,7 @@ interface UserProfileAvatarForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||||
const { mutate } = clientApi.user.setProfileImage.useMutation({
|
const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
// Revalidate all as the avatar is used in multiple places
|
// Revalidate all as the avatar is used in multiple places
|
||||||
await revalidatePathActionAsync("/");
|
await revalidatePathActionAsync("/");
|
||||||
@@ -27,7 +26,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
});
|
});
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tManageAvatar = useI18n("user.action.manageAvatar");
|
const tManageAvatar = useI18n("user.action.manageAvatar");
|
||||||
|
|
||||||
@@ -68,32 +66,28 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
[mutate, user.id, tManageAvatar],
|
[mutate, user.id, tManageAvatar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveAvatar = useCallback(() => {
|
const handleRemoveAvatar = useCallback(
|
||||||
openConfirmModal({
|
() =>
|
||||||
title: tManageAvatar("removeImage.label"),
|
mutateAsync(
|
||||||
children: tManageAvatar("removeImage.confirm"),
|
{
|
||||||
onConfirm() {
|
userId: user.id,
|
||||||
mutate(
|
image: null,
|
||||||
{
|
},
|
||||||
userId: user.id,
|
{
|
||||||
image: null,
|
onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: tManageAvatar("removeImage.notification.success.message"),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{
|
onError() {
|
||||||
onSuccess() {
|
showErrorNotification({
|
||||||
showSuccessNotification({
|
message: tManageAvatar("removeImage.notification.error.message"),
|
||||||
message: tManageAvatar("removeImage.notification.success.message"),
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: tManageAvatar("removeImage.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
});
|
[mutateAsync, user.id, tManageAvatar],
|
||||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
||||||
@@ -124,9 +118,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
{user.image && (
|
{user.image && (
|
||||||
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
|
<InlineConfirmMenuItem
|
||||||
|
color="red"
|
||||||
|
onConfirm={handleRemoveAvatar}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
disabled={isPending}
|
||||||
|
leftSection={<IconPhotoX size={16} stroke={1.5} />}
|
||||||
|
>
|
||||||
{tManageAvatar("removeImage.label")}
|
{tManageAvatar("removeImage.label")}
|
||||||
</Menu.Item>
|
</InlineConfirmMenuItem>
|
||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
+18
-27
@@ -12,7 +12,6 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
|||||||
import type { IsValid } from "@homarr/form";
|
import type { IsValid } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
@@ -20,7 +19,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
|||||||
import { optionalEmailSchema } from "@homarr/validation/email";
|
import { optionalEmailSchema } from "@homarr/validation/email";
|
||||||
import { userPasswordSchema } from "@homarr/validation/user";
|
import { userPasswordSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
import { GroupSelectModal } from "~/components/access/group-select-modal";
|
import { GroupSelect } from "~/components/access/group-select";
|
||||||
import { StepperNavigationComponent } from "./stepper-navigation";
|
import { StepperNavigationComponent } from "./stepper-navigation";
|
||||||
|
|
||||||
interface GroupWithPermissions {
|
interface GroupWithPermissions {
|
||||||
@@ -224,23 +223,6 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
|
|||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tPermission = useI18n("permission");
|
const tPermission = useI18n("permission");
|
||||||
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
|
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) => {
|
const handleGroupRemove = (id: string) => {
|
||||||
filter((group) => group.id !== id);
|
filter((group) => group.id !== id);
|
||||||
removeGroup(id);
|
removeGroup(id);
|
||||||
@@ -256,14 +238,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
|
|||||||
{tCreate("step.groups.description", { everyoneGroup })}
|
{tCreate("step.groups.description", { everyoneGroup })}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
<GroupSelect
|
||||||
variant="subtle"
|
presentGroupIds={groups.map((group) => group.id)}
|
||||||
color="gray"
|
withPermissions
|
||||||
leftSection={<IconPlus size={16} stroke={1.5} />}
|
onSelect={({ id, name, permissions }) => {
|
||||||
onClick={handleAddClick}
|
if (!permissions) return;
|
||||||
>
|
|
||||||
{tCommon("action.add")}
|
startTransition(() => {
|
||||||
</Button>
|
addGroup(id);
|
||||||
|
append({ id, name, permissions });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "subtle",
|
||||||
|
color: "gray",
|
||||||
|
leftSection: <IconPlus size={16} stroke={1.5} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Table>
|
<Table>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, useMatches } from "@mantine/core";
|
import { useMatches } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmButton } from "@homarr/ui";
|
||||||
|
|
||||||
interface DeleteGroupProps {
|
interface DeleteGroupProps {
|
||||||
group: {
|
group: {
|
||||||
@@ -20,46 +20,38 @@ interface DeleteGroupProps {
|
|||||||
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
|
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
|
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const tDelete = useI18n("group.action.delete");
|
const tDelete = useI18n("group.action.delete");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
|
|
||||||
const handleDeletion = useCallback(() => {
|
const handleDeletion = useCallback(
|
||||||
openConfirmModal({
|
() =>
|
||||||
title: tDelete("label"),
|
mutateAsync(
|
||||||
children: tDelete("confirm", {
|
{
|
||||||
name: group.name,
|
id: group.id,
|
||||||
}),
|
},
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
{
|
||||||
async onConfirm() {
|
onSuccess() {
|
||||||
await mutateAsync(
|
void revalidatePathActionAsync("/manage/users/groups");
|
||||||
{
|
router.push("/manage/users/groups");
|
||||||
id: group.id,
|
showSuccessNotification({
|
||||||
|
title: tCommon("notification.delete.success"),
|
||||||
|
message: tDelete("notification.success.message", {
|
||||||
|
name: group.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{
|
onError() {
|
||||||
onSuccess() {
|
showErrorNotification({
|
||||||
void revalidatePathActionAsync("/manage/users/groups");
|
title: tCommon("notification.delete.error"),
|
||||||
router.push("/manage/users/groups");
|
message: tDelete("notification.error.message", {
|
||||||
showSuccessNotification({
|
name: group.name,
|
||||||
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({
|
const fullWidth = useMatches({
|
||||||
xs: true,
|
xs: true,
|
||||||
@@ -68,8 +60,14 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
|
<InlineConfirmButton
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onConfirm={handleDeletion}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
>
|
||||||
{tDelete("label")}
|
{tDelete("label")}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+62
-51
@@ -1,15 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, useMatches } from "@mantine/core";
|
import { Button, Group, Stack, Text, useMatches } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UserSelectModal } from "~/components/access/user-select-modal";
|
import { UserSelect } from "~/components/access/user-select";
|
||||||
|
|
||||||
interface TransferGroupOwnershipProps {
|
interface TransferGroupOwnershipProps {
|
||||||
group: {
|
group: {
|
||||||
@@ -19,75 +18,87 @@ interface TransferGroupOwnershipProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectedOwner {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
|
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
|
||||||
const tTransfer = useI18n("group.action.transfer");
|
const tTransfer = useI18n("group.action.transfer");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
|
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({
|
||||||
const { mutateAsync } = clientApi.group.transferOwnership.useMutation({
|
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
|
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleTransfer = useCallback(() => {
|
const handleTransfer = async () => {
|
||||||
openModal(
|
if (!selectedOwner) return;
|
||||||
|
|
||||||
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
confirmLabel: tCommon("action.continue"),
|
groupId: group.id,
|
||||||
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
|
userId: selectedOwner.id,
|
||||||
onSelect: ({ id, name }) => {
|
},
|
||||||
openConfirmModal({
|
{
|
||||||
title: tTransfer("label"),
|
onSuccess() {
|
||||||
children: tTransfer("confirm", {
|
setInnerOwnerId(selectedOwner.id);
|
||||||
name: group.name,
|
showSuccessNotification({
|
||||||
username: name,
|
title: tCommon("notification.transfer.success"),
|
||||||
|
message: tTransfer("notification.success.message", {
|
||||||
|
group: group.name,
|
||||||
|
user: selectedOwner.name,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
});
|
||||||
onConfirm: async () => {
|
setSelectedOwner(undefined);
|
||||||
await mutateAsync(
|
},
|
||||||
{
|
onError() {
|
||||||
groupId: group.id,
|
showErrorNotification({
|
||||||
userId: id,
|
title: tCommon("notification.transfer.error"),
|
||||||
},
|
message: tTransfer("notification.error.message"),
|
||||||
{
|
|
||||||
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"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: tTransfer("label"),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]);
|
};
|
||||||
|
|
||||||
const fullWidth = useMatches({
|
const fullWidth = useMatches({
|
||||||
xs: true,
|
xs: true,
|
||||||
sm: true,
|
sm: true,
|
||||||
md: false,
|
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 (
|
return (
|
||||||
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
|
<Stack gap="xs" maw={420}>
|
||||||
{tTransfer("label")}
|
<Text size="sm">
|
||||||
</Button>
|
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+19
-25
@@ -5,11 +5,9 @@ import { useCallback } from "react";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UserSelectModal } from "~/components/access/user-select-modal";
|
import { UserSelect } from "~/components/access/user-select";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
|
|
||||||
interface AddGroupMemberProps {
|
interface AddGroupMemberProps {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
@@ -20,27 +18,23 @@ interface AddGroupMemberProps {
|
|||||||
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
|
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
|
||||||
const tMembersAdd = useI18n("group.action.addMember");
|
const tMembersAdd = useI18n("group.action.addMember");
|
||||||
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
const handleAddMember = useCallback(
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
await mutateAsync({
|
||||||
|
userId: id,
|
||||||
|
groupId,
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
|
||||||
|
},
|
||||||
|
[groupId, mutateAsync],
|
||||||
|
);
|
||||||
|
|
||||||
const handleAddMember = useCallback(() => {
|
return (
|
||||||
openModal(
|
<UserSelect
|
||||||
{
|
presentUserIds={presentUserIds}
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
allowedProviders={allowedProviders}
|
||||||
async onSelect({ id }) {
|
onSelect={handleAddMember}
|
||||||
await mutateAsync({
|
triggerLabel={tMembersAdd("label")}
|
||||||
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>;
|
|
||||||
};
|
};
|
||||||
+15
-21
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmButton } from "@homarr/ui";
|
||||||
|
|
||||||
interface RemoveGroupMemberProps {
|
interface RemoveGroupMemberProps {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
@@ -15,30 +14,25 @@ interface RemoveGroupMemberProps {
|
|||||||
|
|
||||||
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
|
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tRemoveMember = useI18n("group.action.removeMember");
|
|
||||||
const { mutateAsync } = clientApi.group.removeMember.useMutation();
|
const { mutateAsync } = clientApi.group.removeMember.useMutation();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(async () => {
|
||||||
openConfirmModal({
|
await mutateAsync({
|
||||||
title: tRemoveMember("label"),
|
groupId,
|
||||||
children: tRemoveMember("confirm", {
|
userId: user.id,
|
||||||
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`);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
|
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
|
||||||
|
}, [mutateAsync, groupId, user.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
|
<InlineConfirmButton
|
||||||
|
variant="subtle"
|
||||||
|
color="red.9"
|
||||||
|
size="compact-sm"
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
onConfirm={handleRemove}
|
||||||
|
>
|
||||||
{tCommon("action.remove")}
|
{tCommon("action.remove")}
|
||||||
</Button>
|
</InlineConfirmButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TextInput } from "@mantine/core";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { AddGroupModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { GroupCreateForm } from "./_components/group-create-form";
|
||||||
import { GroupsTable } from "./_groups-table";
|
import { GroupsTable } from "./_groups-table";
|
||||||
|
|
||||||
interface GroupsListProps {
|
interface GroupsListProps {
|
||||||
@@ -18,6 +17,10 @@ interface GroupsListProps {
|
|||||||
|
|
||||||
export const GroupsList = ({ groups }: GroupsListProps) => {
|
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||||
const [search, setSearch] = useState("");
|
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(
|
const initialGroupIds = useMemo(
|
||||||
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||||
[groups],
|
[groups],
|
||||||
@@ -31,8 +34,46 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
|
|||||||
);
|
);
|
||||||
const tGroup = useI18n("group");
|
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 (
|
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
|
<TextInput
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
value={search}
|
value={search}
|
||||||
@@ -40,13 +81,6 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
|
|||||||
placeholder={`${tGroup("search")}...`}
|
placeholder={`${tGroup("search")}...`}
|
||||||
/>
|
/>
|
||||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||||
</>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddGroupButton = () => {
|
|
||||||
const tGroup = useI18n("group");
|
|
||||||
const { openModal } = useModalAction(AddGroupModal);
|
|
||||||
|
|
||||||
return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { groupCreateSchema } from "@homarr/validation/group";
|
||||||
|
|
||||||
|
interface GroupCreateFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => {
|
||||||
|
const tGroup = useI18n("group");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const form = useZodForm(groupCreateSchema, {
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
await mutateAsync(values, {
|
||||||
|
async onSuccess() {
|
||||||
|
await revalidatePathActionAsync("/manage/users/groups");
|
||||||
|
showSuccessNotification({
|
||||||
|
title: tCommon("notification.create.success"),
|
||||||
|
message: tGroup("action.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
onCreated();
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: tCommon("notification.create.error"),
|
||||||
|
message: tGroup("action.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
|
||||||
|
<Group justify="end">
|
||||||
|
<Button variant="default" onClick={onCancel}>
|
||||||
|
{tCommon("action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button loading={isPending} type="submit">
|
||||||
|
{tCommon("action.create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { AddGroupButton, GroupsList } from "./_client";
|
import { GroupsList } from "./_client";
|
||||||
import classes from "./groups.module.css";
|
import classes from "./groups.module.css";
|
||||||
|
|
||||||
export default async function GroupsListPage() {
|
export default async function GroupsListPage() {
|
||||||
@@ -26,7 +26,7 @@ export default async function GroupsListPage() {
|
|||||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
|
<ManagePageLayout title={tCommon("entity.groups")}>
|
||||||
<Stack>
|
<Stack>
|
||||||
{dbEveryoneGroup && (
|
{dbEveryoneGroup && (
|
||||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||||
|
|||||||
+2
-5
@@ -2,18 +2,15 @@
|
|||||||
|
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { InviteCreateModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
|
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
|
||||||
|
|
||||||
export const InviteCreateButton = () => {
|
export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => {
|
||||||
const t = useI18n("management.page.user.invite");
|
const t = useI18n("management.page.user.invite");
|
||||||
const { openModal } = useModalAction(InviteCreateModal);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
|
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}>
|
||||||
{t("action.new.title")}
|
{t("action.new.title")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core";
|
||||||
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
type FormType = {
|
||||||
|
expirationDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreatedInvite = RouterOutputs["invite"]["createInvite"];
|
||||||
|
|
||||||
|
interface InviteCreateFormProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
|
||||||
|
const tInvite = useI18n("management.page.user.invite");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const [createdInvite, setCreatedInvite] = useState<CreatedInvite>();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
|
||||||
|
const minDate = dayjs().add(1, "hour").toDate();
|
||||||
|
const maxDate = dayjs().add(6, "months").toDate();
|
||||||
|
const form = useForm<FormType>({
|
||||||
|
initialValues: {
|
||||||
|
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createdInvite) {
|
||||||
|
return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
expirationDate: new Date(values.expirationDate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
void utils.invite.getAll.invalidate();
|
||||||
|
setCreatedInvite(result);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>{tInvite("action.new.description")}</Text>
|
||||||
|
<DateTimePicker
|
||||||
|
popoverProps={{ withinPortal: true }}
|
||||||
|
minDate={minDate}
|
||||||
|
maxDate={maxDate}
|
||||||
|
withAsterisk
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
|
label={tInvite("field.expirationDate.label")}
|
||||||
|
variant="filled"
|
||||||
|
{...form.getInputProps("expirationDate")}
|
||||||
|
/>
|
||||||
|
<Group justify="end" wrap="wrap">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{tCommon("action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{tCommon("action.create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => {
|
||||||
|
const tInvite = useI18n("management.page.user.invite");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const inviteUrl = createInviteUrl(invite);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack component="output" aria-live="polite">
|
||||||
|
<Text>
|
||||||
|
{tInvite.rich("action.copy.description", {
|
||||||
|
b: bold,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw="bold">{tInvite("action.copy.link")}:</Text>
|
||||||
|
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
|
||||||
|
<Anchor href={inviteUrl} target="_blank" rel="noreferrer">
|
||||||
|
{inviteUrl}
|
||||||
|
</Anchor>
|
||||||
|
</Mark>
|
||||||
|
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
|
||||||
|
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
|
||||||
|
{invite.id}
|
||||||
|
</Mark>
|
||||||
|
<Text fw="bold">{tInvite("field.token.label")}:</Text>
|
||||||
|
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
|
||||||
|
{invite.token}
|
||||||
|
</Mark>
|
||||||
|
</Stack>
|
||||||
|
<Group justify="end" wrap="wrap">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{tCommon("action.close")}
|
||||||
|
</Button>
|
||||||
|
<CopyButton value={inviteUrl}>
|
||||||
|
{({ copy }) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
copy();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tInvite("action.copy.button")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bold = (children: ReactNode) => <b>{children}</b>;
|
||||||
|
|
||||||
|
const createInviteUrl = ({ id, token }: CreatedInvite) => {
|
||||||
|
const path = `/auth/invite/${id}?token=${token}`;
|
||||||
|
return new URL(path, window.location.origin).toString();
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Card, Collapse, Group, Stack } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
@@ -11,9 +12,11 @@ import { MantineReactTable } from "mantine-react-table";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
import { InviteCreateButton } from "./invite-create-button";
|
||||||
|
import { InviteCreateForm } from "./invite-create-form";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -23,6 +26,10 @@ interface InviteListComponentProps {
|
|||||||
|
|
||||||
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
|
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
|
||||||
const t = useI18n("management.page.user.invite");
|
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, {
|
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
|
||||||
initialData: initialInvites,
|
initialData: initialInvites,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
@@ -73,28 +80,65 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MantineReactTable table={table} />;
|
useEffect(() => {
|
||||||
|
if (searchParams.get("create") !== "true") return;
|
||||||
|
|
||||||
|
setFormKey((value) => value + 1);
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const closeCreate = () => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setFormKey((value) => value + 1);
|
||||||
|
if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCreate = () => {
|
||||||
|
if (isCreateOpen) {
|
||||||
|
closeCreate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormKey((value) => value + 1);
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="end">
|
||||||
|
<InviteCreateButton onClick={toggleCreate} />
|
||||||
|
</Group>
|
||||||
|
<Collapse expanded={isCreateOpen}>
|
||||||
|
<Card withBorder>
|
||||||
|
<InviteCreateForm key={formKey} onClose={closeCreate} />
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
<MantineReactTable table={table} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
||||||
const t = useI18n("management.page.user.invite");
|
const t = useI18n("management.page.user.invite");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
|
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
openConfirmModal({
|
mutate({ id: row.original.id });
|
||||||
title: t("action.delete.title"),
|
void utils.invite.getAll.invalidate();
|
||||||
children: t("action.delete.description"),
|
}, [row.original.id, mutate, utils]);
|
||||||
onConfirm: () => {
|
|
||||||
mutate({ id: row.original.id });
|
|
||||||
void utils.invite.getAll.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [openConfirmModal, row.original.id, mutate, utils, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
|
<InlineConfirmActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
confirmLabel={tCommon("action.confirm")}
|
||||||
|
confirmationAriaLabel={tCommon("action.confirm")}
|
||||||
|
loading={isPending}
|
||||||
|
aria-label={t("action.delete.title")}
|
||||||
|
>
|
||||||
<IconTrash color="red" size={20} stroke={1.5} />
|
<IconTrash color="red" size={20} stroke={1.5} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -5,9 +5,7 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getI18n } from "@homarr/translation/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 { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { InviteCreateButton } from "./_components/invite-create-button";
|
|
||||||
import { InviteListComponent } from "./_components/invite-list";
|
import { InviteListComponent } from "./_components/invite-list";
|
||||||
|
|
||||||
export default async function InvitesOverviewPage() {
|
export default async function InvitesOverviewPage() {
|
||||||
@@ -24,15 +22,7 @@ export default async function InvitesOverviewPage() {
|
|||||||
const initialInvites = await api.invite.getAll();
|
const initialInvites = await api.invite.getAll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManagePageLayout title={t("title")}>
|
||||||
title={t("title")}
|
|
||||||
primaryAction={
|
|
||||||
<ManageMobilePrimaryAction>
|
|
||||||
<InviteCreateButton />
|
|
||||||
</ManageMobilePrimaryAction>
|
|
||||||
}
|
|
||||||
floatingPrimaryAction
|
|
||||||
>
|
|
||||||
<InviteListComponent initialInvites={initialInvites} />
|
<InviteListComponent initialInvites={initialInvites} />
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors";
|
|||||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
|
||||||
import type { Dimensions } from "./_dimension-modal";
|
import type { Dimensions } from "./_dimension-popover";
|
||||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
import { PreviewDimensionsPopover } from "./_dimension-popover";
|
||||||
|
|
||||||
interface WidgetPreviewPageContentProps {
|
interface WidgetPreviewPageContentProps {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
|
||||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||||
@@ -98,13 +97,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
});
|
});
|
||||||
}, [editMode, t]);
|
}, [editMode, t]);
|
||||||
|
|
||||||
const openDimensionsModal = useCallback(() => {
|
|
||||||
openPreviewDimensionsModal({
|
|
||||||
dimensions,
|
|
||||||
setDimensions,
|
|
||||||
});
|
|
||||||
}, [dimensions, openPreviewDimensionsModal]);
|
|
||||||
|
|
||||||
const updateOptions = useCallback(
|
const updateOptions = useCallback(
|
||||||
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
||||||
@@ -162,15 +154,21 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
<Affix bottom={12} right={72 + 120}>
|
<Affix bottom={12} right={72 + 120}>
|
||||||
<ActionIcon
|
<PreviewDimensionsPopover
|
||||||
size={48}
|
dimensions={dimensions}
|
||||||
variant="default"
|
setDimensions={setDimensions}
|
||||||
radius="xl"
|
target={(onClick) => (
|
||||||
onClick={openDimensionsModal}
|
<ActionIcon
|
||||||
aria-label={t("widgetPreview.dimensions.title")}
|
size={48}
|
||||||
>
|
variant="default"
|
||||||
<IconDimensions size={24} />
|
radius="xl"
|
||||||
</ActionIcon>
|
aria-label={t("widgetPreview.dimensions.title")}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<IconDimensions size={24} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Affix>
|
</Affix>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useForm } from "@homarr/form";
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
interface InnerProps {
|
|
||||||
dimensions: Dimensions;
|
|
||||||
setDimensions: (dimensions: Dimensions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: innerProps.dimensions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: Dimensions) => {
|
|
||||||
innerProps.setDimensions(values);
|
|
||||||
actions.closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
<InputWrapper label={t("item.moveResize.field.width.label")}>
|
|
||||||
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
|
|
||||||
</InputWrapper>
|
|
||||||
<InputWrapper label={t("item.moveResize.field.height.label")}>
|
|
||||||
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
|
|
||||||
</InputWrapper>
|
|
||||||
<Group justify="end">
|
|
||||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
|
||||||
{t("common.action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{t("common.action.confirm")}</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("widgetPreview.dimensions.title"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface Dimensions {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface PreviewDimensionsPopoverProps {
|
||||||
|
target: (onClick: () => void) => ReactNode;
|
||||||
|
dimensions: Dimensions;
|
||||||
|
setDimensions: (dimensions: Dimensions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [opened, { close, open }] = useDisclosure(false);
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: dimensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: Dimensions) => {
|
||||||
|
setDimensions(values);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.setValues(dimensions);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (opened) {
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={(nextOpened) => {
|
||||||
|
if (nextOpened) open();
|
||||||
|
if (!nextOpened) handleClose();
|
||||||
|
}}
|
||||||
|
position="top-end"
|
||||||
|
width="min(320px, calc(100vw - 2rem))"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Popover.Target>{target(handleToggle)}</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<InputWrapper label={t("item.moveResize.field.width.label")}>
|
||||||
|
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
|
||||||
|
</InputWrapper>
|
||||||
|
<InputWrapper label={t("item.moveResize.field.height.label")}>
|
||||||
|
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
|
||||||
|
</InputWrapper>
|
||||||
|
<Group justify="end">
|
||||||
|
<Button variant="subtle" color="gray" onClick={handleClose}>
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{t("common.action.confirm")}</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Dimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
|
||||||
|
import type { ButtonProps } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface AccessPickerOption<T> {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
keywords?: readonly string[];
|
||||||
|
content: ReactNode;
|
||||||
|
item: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
|
||||||
|
|
||||||
|
interface AccessPickerProps<T> {
|
||||||
|
label: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
triggerLabel: string;
|
||||||
|
options: AccessPickerOption<T>[];
|
||||||
|
isPending: boolean;
|
||||||
|
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
|
||||||
|
triggerProps?: AccessPickerTriggerProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessPicker = <T,>({
|
||||||
|
label,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
|
triggerLabel,
|
||||||
|
options,
|
||||||
|
isPending,
|
||||||
|
onSelect,
|
||||||
|
triggerProps,
|
||||||
|
}: AccessPickerProps<T>) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => {
|
||||||
|
combobox.resetSelectedOption();
|
||||||
|
setSearch("");
|
||||||
|
requestAnimationFrame(() => combobox.focusTarget());
|
||||||
|
},
|
||||||
|
onDropdownOpen: () => {
|
||||||
|
combobox.selectFirstOption();
|
||||||
|
requestAnimationFrame(() => combobox.focusSearchInput());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
const filteredOptions = options.filter((option) => {
|
||||||
|
if (!normalizedSearch) return true;
|
||||||
|
|
||||||
|
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
|
||||||
|
return searchableText.includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
const visibleOptions = filteredOptions.slice(0, 5);
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
const option = options.find((item) => item.value === value);
|
||||||
|
if (!option || isSubmitting) return;
|
||||||
|
|
||||||
|
combobox.closeDropdown();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await onSelect(option);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void submit().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
|
||||||
|
<Combobox.Target targetType="button">
|
||||||
|
<Button
|
||||||
|
{...triggerProps}
|
||||||
|
onClick={() => combobox.toggleDropdown()}
|
||||||
|
rightSection={
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{triggerProps?.rightSection}
|
||||||
|
<Combobox.Chevron />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting || triggerProps?.disabled}
|
||||||
|
>
|
||||||
|
{triggerLabel}
|
||||||
|
</Button>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Search
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
combobox.updateSelectedOptionIndex();
|
||||||
|
}}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<ScrollArea.Autosize mah={300} type="auto">
|
||||||
|
<Combobox.Options>
|
||||||
|
{isPending && (
|
||||||
|
<Center p="md">
|
||||||
|
<LoaderPlaceholder />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
|
||||||
|
{!isPending &&
|
||||||
|
visibleOptions.map((option) => (
|
||||||
|
<Combobox.Option value={option.value} key={option.value}>
|
||||||
|
{option.content}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoaderPlaceholder = () => <Loader size="sm" />;
|
||||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows";
|
|||||||
import { useAccessContext } from "./context";
|
import { useAccessContext } from "./context";
|
||||||
import type { AccessFormType } from "./form";
|
import type { AccessFormType } from "./form";
|
||||||
import { FormProvider, useForm } from "./form";
|
import { FormProvider, useForm } from "./form";
|
||||||
import { GroupSelectModal } from "./group-select-modal";
|
import { GroupSelect } from "./group-select";
|
||||||
import type { FormProps } from "./user-access-form";
|
import type { FormProps } from "./user-access-form";
|
||||||
|
|
||||||
export const GroupAccessForm = <TPermission extends string>({
|
export const GroupAccessForm = <TPermission extends string>({
|
||||||
@@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({
|
|||||||
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
|
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
|
||||||
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
|
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
|
||||||
);
|
);
|
||||||
const { openModal } = useModalAction(GroupSelectModal);
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const tPermissions = useI18n("permission");
|
const tPermissions = useI18n("permission");
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddUser = () => {
|
|
||||||
openModal({
|
|
||||||
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
|
|
||||||
onSelect: (group) => {
|
|
||||||
setGroups((prev) => new Map(prev).set(group.id, group));
|
|
||||||
form.setFieldValue("items", [
|
|
||||||
{
|
|
||||||
principalId: group.id,
|
|
||||||
permission: defaultPermission,
|
|
||||||
},
|
|
||||||
...form.values.items,
|
|
||||||
]);
|
|
||||||
handleCountChange((prev) => prev + 1);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
||||||
<FormProvider form={form}>
|
<FormProvider form={form}>
|
||||||
@@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
<GroupSelect
|
||||||
{tCommon("action.add")}
|
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
|
||||||
</Button>
|
onSelect={(group) => {
|
||||||
|
setGroups((prev) => new Map(prev).set(group.id, group));
|
||||||
|
form.setFieldValue("items", [
|
||||||
|
{
|
||||||
|
principalId: group.id,
|
||||||
|
permission: defaultPermission,
|
||||||
|
},
|
||||||
|
...form.values.items,
|
||||||
|
]);
|
||||||
|
handleCountChange((prev) => prev + 1);
|
||||||
|
}}
|
||||||
|
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
|
||||||
|
/>
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{tPermissions("action.saveGroup")}
|
{tPermissions("action.saveGroup")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
|
||||||
import { useForm } from "@homarr/form";
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
interface InnerProps {
|
|
||||||
withPermissions?: boolean;
|
|
||||||
presentGroupIds: string[];
|
|
||||||
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
|
|
||||||
confirmLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupSelectFormType {
|
|
||||||
groupId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
|
||||||
const tGroup = useI18n("group.action.select");
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
|
|
||||||
withPermissions: innerProps.withPermissions,
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const form = useForm<GroupSelectFormType>();
|
|
||||||
const handleSubmitAsync = async (values: GroupSelectFormType) => {
|
|
||||||
const currentGroup = groups?.find((group) => group.id === values.groupId);
|
|
||||||
if (!currentGroup) return;
|
|
||||||
setLoading(true);
|
|
||||||
await innerProps.onSelect({
|
|
||||||
id: currentGroup.id,
|
|
||||||
name: currentGroup.name,
|
|
||||||
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
actions.closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
|
||||||
<Stack>
|
|
||||||
<Select
|
|
||||||
{...form.getInputProps("groupId")}
|
|
||||||
label={tGroup("label")}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
leftSection={isPending ? <Loader size="xs" /> : undefined}
|
|
||||||
nothingFoundMessage={tGroup("notFound")}
|
|
||||||
limit={5}
|
|
||||||
data={groups
|
|
||||||
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
|
|
||||||
.map((group) => ({ value: group.id, label: group.name }))}
|
|
||||||
/>
|
|
||||||
<Group justify="end">
|
|
||||||
<Button variant="default" onClick={actions.closeModal}>
|
|
||||||
{tCommon("action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={loading}>
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("permission.groupSelect.title"),
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
|
||||||
|
import { AccessPicker } from "./access-picker";
|
||||||
|
|
||||||
|
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
|
||||||
|
|
||||||
|
interface GroupSelectProps {
|
||||||
|
withPermissions?: boolean;
|
||||||
|
presentGroupIds: string[];
|
||||||
|
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerProps?: AccessPickerTriggerProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupSelect = ({
|
||||||
|
withPermissions,
|
||||||
|
presentGroupIds,
|
||||||
|
onSelect,
|
||||||
|
triggerLabel,
|
||||||
|
triggerProps,
|
||||||
|
}: GroupSelectProps) => {
|
||||||
|
const tGroup = useI18n("group.action.select");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
|
||||||
|
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
|
||||||
|
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name,
|
||||||
|
keywords: getGroupPermissions(group) ?? [],
|
||||||
|
item: group,
|
||||||
|
content: <Text size="sm">{group.name}</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
|
||||||
|
onSelect({
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
permissions: getGroupPermissions(group),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessPicker
|
||||||
|
label={tGroup("label")}
|
||||||
|
searchPlaceholder={tGroup("label")}
|
||||||
|
emptyMessage={tGroup("notFound")}
|
||||||
|
triggerLabel={triggerLabel ?? tCommon("action.add")}
|
||||||
|
triggerProps={triggerProps}
|
||||||
|
options={options}
|
||||||
|
isPending={isPending}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupPermissions = (group: SelectableGroup) => {
|
||||||
|
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
|
|||||||
import { useAccessContext } from "./context";
|
import { useAccessContext } from "./context";
|
||||||
import type { AccessFormType, HandleCountChange } from "./form";
|
import type { AccessFormType, HandleCountChange } from "./form";
|
||||||
import { FormProvider, useForm } from "./form";
|
import { FormProvider, useForm } from "./form";
|
||||||
import { UserSelectModal } from "./user-select-modal";
|
import { UserSelect } from "./user-select";
|
||||||
|
|
||||||
export interface FormProps<TPermission extends string> {
|
export interface FormProps<TPermission extends string> {
|
||||||
entity: {
|
entity: {
|
||||||
@@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({
|
|||||||
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
|
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
|
||||||
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
|
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
|
||||||
);
|
);
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const tPermissions = useI18n("permission");
|
const tPermissions = useI18n("permission");
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
|
||||||
const handleAddUser = () => {
|
if (entity.ownerId) presentUserIds.push(entity.ownerId);
|
||||||
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 (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
||||||
@@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
<UserSelect
|
||||||
{tCommon("action.add")}
|
presentUserIds={presentUserIds}
|
||||||
</Button>
|
onSelect={(user) => {
|
||||||
|
setUsers((prev) => new Map(prev).set(user.id, user));
|
||||||
|
form.setFieldValue("items", [
|
||||||
|
{
|
||||||
|
principalId: user.id,
|
||||||
|
permission: defaultPermission,
|
||||||
|
},
|
||||||
|
...form.values.items,
|
||||||
|
]);
|
||||||
|
handleCountChange((prev) => prev + 1);
|
||||||
|
}}
|
||||||
|
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
|
||||||
|
/>
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{tPermissions("action.saveUser")}
|
{tPermissions("action.saveUser")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import type { SelectProps } from "@mantine/core";
|
|
||||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
|
||||||
import { useForm } from "@homarr/form";
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { UserAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
interface InnerProps {
|
|
||||||
presentUserIds: string[];
|
|
||||||
allowedProviders?: SupportedAuthProvider[];
|
|
||||||
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
|
|
||||||
confirmLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserSelectFormType {
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
|
||||||
const tUser = useI18n("user.action.select");
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const { data: users, isPending } = clientApi.user.selectable.useQuery({
|
|
||||||
providers: innerProps.allowedProviders,
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const form = useForm<UserSelectFormType>();
|
|
||||||
const handleSubmitAsync = async (values: UserSelectFormType) => {
|
|
||||||
const currentUser = users?.find((user) => user.id === values.userId);
|
|
||||||
if (!currentUser) return;
|
|
||||||
setLoading(true);
|
|
||||||
await innerProps.onSelect({
|
|
||||||
id: currentUser.id,
|
|
||||||
name: currentUser.name ?? "",
|
|
||||||
image: currentUser.image ?? "",
|
|
||||||
email: currentUser.email ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
actions.closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
|
|
||||||
const currentUser = users?.find((user) => user.id === form.values.userId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
|
||||||
<Stack>
|
|
||||||
<Select
|
|
||||||
{...form.getInputProps("userId")}
|
|
||||||
label={tUser("label")}
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
leftSection={
|
|
||||||
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
|
|
||||||
}
|
|
||||||
nothingFoundMessage={tUser("notFound")}
|
|
||||||
renderOption={createRenderOption(users ?? [])}
|
|
||||||
limit={5}
|
|
||||||
data={users
|
|
||||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
|
||||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
|
||||||
/>
|
|
||||||
<Group justify="end">
|
|
||||||
<Button variant="default" onClick={actions.closeModal}>
|
|
||||||
{tCommon("action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={loading}>
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("permission.userSelect.title"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconProps = {
|
|
||||||
stroke: 1.5,
|
|
||||||
color: "currentColor",
|
|
||||||
opacity: 0.6,
|
|
||||||
size: "1rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
|
|
||||||
function InnerRenderRoot({ option, checked }) {
|
|
||||||
const user = users.find((user) => user.id === option.value);
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group flex="1" gap="xs">
|
|
||||||
<UserAvatar user={user} size="xs" />
|
|
||||||
{option.label}
|
|
||||||
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
|
||||||
|
import { AccessPicker } from "./access-picker";
|
||||||
|
|
||||||
|
type SelectableUser = RouterOutputs["user"]["selectable"][number];
|
||||||
|
|
||||||
|
interface UserSelectProps {
|
||||||
|
presentUserIds: string[];
|
||||||
|
allowedProviders?: SupportedAuthProvider[];
|
||||||
|
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerProps?: AccessPickerTriggerProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserSelect = ({
|
||||||
|
presentUserIds,
|
||||||
|
allowedProviders,
|
||||||
|
onSelect,
|
||||||
|
triggerLabel,
|
||||||
|
triggerProps,
|
||||||
|
}: UserSelectProps) => {
|
||||||
|
const tUser = useI18n("user.action.select");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
|
||||||
|
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
|
||||||
|
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name ?? "",
|
||||||
|
keywords: [user.email ?? ""],
|
||||||
|
item: user,
|
||||||
|
content: (
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<UserAvatar user={user} size="xs" />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm">{user.name}</Text>
|
||||||
|
{user.email && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
|
||||||
|
onSelect({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name ?? "",
|
||||||
|
image: user.image ?? "",
|
||||||
|
email: user.email ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessPicker
|
||||||
|
label={tUser("label")}
|
||||||
|
searchPlaceholder={tUser("label")}
|
||||||
|
emptyMessage={tUser("notFound")}
|
||||||
|
triggerLabel={triggerLabel ?? tCommon("action.add")}
|
||||||
|
triggerProps={triggerProps}
|
||||||
|
options={options}
|
||||||
|
isPending={isPending}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common";
|
|||||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||||
import type { AssistantProvider } from "@homarr/definitions";
|
import type { AssistantProvider } from "@homarr/definitions";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import classes from "./assistant-panel.module.css";
|
import classes from "./assistant-panel.module.css";
|
||||||
import { getAssistantActivityState } from "./assistant-activity-state";
|
import { getAssistantActivityState } from "./assistant-activity-state";
|
||||||
@@ -2001,7 +2001,6 @@ const ThreadListItem = () => {
|
|||||||
const t = useI18n("assistant");
|
const t = useI18n("assistant");
|
||||||
const actionT = useI18n("common.action");
|
const actionT = useI18n("common.action");
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const onSelect = useContext(HistorySelectContext);
|
const onSelect = useContext(HistorySelectContext);
|
||||||
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
||||||
const title = useAuiState((state) => state.threadListItem.title);
|
const title = useAuiState((state) => state.threadListItem.title);
|
||||||
@@ -2054,23 +2053,15 @@ const ThreadListItem = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteConversation = () => {
|
const deleteConversation = async () => {
|
||||||
openConfirmModal({
|
try {
|
||||||
title: t("deleteConversation.title"),
|
await aui.threadListItem().delete();
|
||||||
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
|
} catch {
|
||||||
confirmProps: { color: "red" },
|
showErrorNotification({
|
||||||
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
|
title: t("deleteConversation.failedTitle"),
|
||||||
onConfirm: async () => {
|
message: t("deleteConversation.failedDescription"),
|
||||||
try {
|
});
|
||||||
await aui.threadListItem().delete();
|
}
|
||||||
} catch {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("deleteConversation.failedTitle"),
|
|
||||||
message: t("deleteConversation.failedDescription"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (renameOpened) {
|
if (renameOpened) {
|
||||||
@@ -2152,16 +2143,19 @@ const ThreadListItem = () => {
|
|||||||
>
|
>
|
||||||
<IconFileExport size={14} />
|
<IconFileExport size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<InlineConfirmActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
size="sm"
|
size="sm"
|
||||||
title={actionT("delete")}
|
title={actionT("delete")}
|
||||||
aria-label={actionT("delete")}
|
aria-label={actionT("delete")}
|
||||||
onClick={deleteConversation}
|
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
|
||||||
|
confirmationAriaLabel={t("deleteConversation.title")}
|
||||||
|
confirmationChildren={<IconCheck size={14} />}
|
||||||
|
onConfirm={deleteConversation}
|
||||||
>
|
>
|
||||||
<IconTrash size={14} />
|
<IconTrash size={14} />
|
||||||
</ActionIcon>
|
</InlineConfirmActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css";
|
|||||||
interface BoardLayoutThumbnailProps {
|
interface BoardLayoutThumbnailProps {
|
||||||
preview: BoardPreviewData | null;
|
preview: BoardPreviewData | null;
|
||||||
label: string;
|
label: string;
|
||||||
fitFullLayout?: boolean;
|
previewRowLimit?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxThumbnailRows = 12;
|
const compactBoardLayoutThumbnailRows = 12;
|
||||||
|
export const maxBoardLayoutThumbnailRows = 48;
|
||||||
|
|
||||||
export const BoardLayoutThumbnail = ({
|
export const BoardLayoutThumbnail = ({
|
||||||
preview,
|
preview,
|
||||||
label,
|
label,
|
||||||
fitFullLayout = false,
|
previewRowLimit = compactBoardLayoutThumbnailRows,
|
||||||
className,
|
className,
|
||||||
}: BoardLayoutThumbnailProps) => {
|
}: BoardLayoutThumbnailProps) => {
|
||||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||||
@@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elements = projectBoardLayout(preview, layout, layout);
|
const elements = projectBoardLayout(preview, layout, layout);
|
||||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
|
||||||
const roots = preview.sections
|
const roots = preview.sections
|
||||||
.filter((section) => section.kind === "empty")
|
.filter((section) => section.kind === "empty")
|
||||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||||
@@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{laneElements.map((element) => {
|
{laneElements.map((element) => {
|
||||||
|
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
|
||||||
|
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
|
||||||
const nestedItems =
|
const nestedItems =
|
||||||
element.type === "section"
|
element.type === "section"
|
||||||
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
|
? elements.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.type === "item" &&
|
||||||
|
candidate.sectionId === element.id &&
|
||||||
|
candidate.xOffset >= 0 &&
|
||||||
|
candidate.xOffset < renderedWidth &&
|
||||||
|
candidate.yOffset >= 0 &&
|
||||||
|
candidate.yOffset < renderedHeight &&
|
||||||
|
candidate.width > 0 &&
|
||||||
|
candidate.height > 0,
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
||||||
style={{
|
style={{
|
||||||
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
|
||||||
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{element.type === "section" ? (
|
{element.type === "section" ? (
|
||||||
<span
|
<span
|
||||||
className={classes.containerContents}
|
className={classes.containerContents}
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
|
||||||
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
|
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nestedItems.map((nestedItem) => (
|
{nestedItems.map((nestedItem) => (
|
||||||
@@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({
|
|||||||
style={{
|
style={{
|
||||||
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
||||||
nestedItem.width,
|
nestedItem.width,
|
||||||
element.width - nestedItem.xOffset,
|
renderedWidth - nestedItem.xOffset,
|
||||||
)}`,
|
)}`,
|
||||||
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
||||||
nestedItem.height,
|
nestedItem.height,
|
||||||
element.height - nestedItem.yOffset,
|
renderedHeight - nestedItem.yOffset,
|
||||||
)}`,
|
)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
|
||||||
|
|
||||||
import classes from "./board-switcher.module.css";
|
import classes from "./board-switcher.module.css";
|
||||||
|
|
||||||
export const boardSwitcherHotkey = "shift+c";
|
export const boardSwitcherHotkey = "shift+c";
|
||||||
|
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
|
||||||
|
|
||||||
export interface BoardSwitcherControls {
|
export interface BoardSwitcherControls {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
@@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
data: boards = [],
|
data: boards = [],
|
||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
} = clientApi.board.getManageOverview.useQuery(
|
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
|
||||||
{ fullPreview: true },
|
enabled: isOpen,
|
||||||
{
|
});
|
||||||
enabled: isOpen,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const switcherBoards = useMemo(
|
const switcherBoards = useMemo(
|
||||||
() => [
|
() => boards.filter((board) => board.id !== currentBoard?.id),
|
||||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
|
||||||
...boards.filter((board) => board.id === currentBoard?.id),
|
|
||||||
],
|
|
||||||
[boards, currentBoard?.id],
|
[boards, currentBoard?.id],
|
||||||
);
|
);
|
||||||
const filteredBoards = useMemo(() => {
|
const filteredBoards = useMemo(() => {
|
||||||
@@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
}, []);
|
}, []);
|
||||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
|
||||||
|
|
||||||
const spotlightAction = useMemo(
|
const spotlightAction = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -341,7 +336,7 @@ const getBoardSwitcherResults = ({
|
|||||||
<BoardLayoutThumbnail
|
<BoardLayoutThumbnail
|
||||||
preview={board.preview}
|
preview={board.preview}
|
||||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||||
fitFullLayout
|
previewRowLimit={boardSwitcherPreview.previewRowLimit}
|
||||||
/>
|
/>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section withBorder px="sm" py={6}>
|
<Card.Section withBorder px="sm" py={6}>
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
.refreshIndicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.25rem;
|
|
||||||
right: 0.25rem;
|
|
||||||
z-index: 3;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -34px;
|
top: calc(var(--mantine-spacing-sm) / 2);
|
||||||
right: 14px;
|
right: calc(var(--mantine-spacing-sm) / 2);
|
||||||
z-index: 201;
|
z-index: 201;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -66,7 +52,6 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
right: 4px;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
@@ -19,7 +19,6 @@ import { WidgetError } from "@homarr/widgets/errors";
|
|||||||
import {
|
import {
|
||||||
createWidgetRuntimeState,
|
createWidgetRuntimeState,
|
||||||
getWidgetQueryKeys,
|
getWidgetQueryKeys,
|
||||||
getWidgetRuntimeQueries,
|
|
||||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||||
} from "@homarr/widgets/definition";
|
} from "@homarr/widgets/definition";
|
||||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||||
@@ -40,7 +39,6 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
|
||||||
|
|
||||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -158,24 +156,6 @@ const LoadedBoardItemContent = ({
|
|||||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||||
[definition, item, settings],
|
[definition, item, settings],
|
||||||
);
|
);
|
||||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
|
||||||
const isWidgetRefreshing =
|
|
||||||
useIsFetching({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.state.data !== undefined &&
|
|
||||||
matchesWidgetItemQuery(
|
|
||||||
query.queryKey,
|
|
||||||
widgetQueryKeys,
|
|
||||||
{
|
|
||||||
itemId: item.id,
|
|
||||||
boardId: board.id,
|
|
||||||
integrationIds: item.integrationIds,
|
|
||||||
options: menuItem.options,
|
|
||||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
|
||||||
},
|
|
||||||
definition.queryMatcher,
|
|
||||||
),
|
|
||||||
}) > 0;
|
|
||||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||||
@@ -343,11 +323,6 @@ const LoadedBoardItemContent = ({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
{isWidgetRefreshing && (
|
|
||||||
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
|
||||||
<Loader size="xs" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</WidgetCardShell>
|
</WidgetCardShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -361,6 +336,7 @@ const LoadedBoardItemContent = ({
|
|||||||
widgetStateRef={widgetStateRef}
|
widgetStateRef={widgetStateRef}
|
||||||
widgetRuntimeRef={widgetRuntimeRef}
|
widgetRuntimeRef={widgetRuntimeRef}
|
||||||
sourceRef={sourceRef}
|
sourceRef={sourceRef}
|
||||||
|
disabled={isAdvanced}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
ref={sourceRef}
|
ref={sourceRef}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
@@ -8,20 +8,21 @@ import { getWidgetName } from "@homarr/definitions";
|
|||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmMenuItem } from "@homarr/ui";
|
||||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
|
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
|
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
|
||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
|
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||||
|
|
||||||
interface BoardItemMenuProps {
|
interface BoardItemMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
previewDimensions: { width: number; height: number; scale?: number };
|
previewDimensions: WidgetPreviewDimensions;
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<BoardRemoveConfirmationMenuItem
|
<InlineConfirmMenuItem
|
||||||
label={tItem("action.remove")}
|
color="red"
|
||||||
confirmationLabel={tItem("remove.message")}
|
confirmLabel={tItem("remove.message")}
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
onConfirm={() => removeItem({ itemId: item.id })}
|
onConfirm={() => removeItem({ itemId: item.id })}
|
||||||
/>
|
>
|
||||||
|
{tItem("action.remove")}
|
||||||
|
</InlineConfirmMenuItem>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { getLayoutRowCount } from "../layout";
|
import { getLayoutRowCount } from "../layout";
|
||||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||||
|
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
|
||||||
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
||||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||||
@@ -30,6 +31,7 @@ interface InnerProps extends OpenItemMoveModalInput {
|
|||||||
board: Board;
|
board: Board;
|
||||||
currentLayoutId: string;
|
currentLayoutId: string;
|
||||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||||
|
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MoveTarget {
|
interface MoveTarget {
|
||||||
@@ -57,8 +59,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
const tLandmark = useI18n("board.landmark");
|
const tLandmark = useI18n("board.landmark");
|
||||||
const tContainer = useI18n("section.container");
|
const tContainer = useI18n("section.container");
|
||||||
const tMoveResize = useI18n("item.moveResize");
|
const tMoveResize = useI18n("item.moveResize");
|
||||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
|
||||||
const registeredGridEditors = useRegisteredGridEditors();
|
|
||||||
const minimumSize = useMemo(
|
const minimumSize = useMemo(
|
||||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||||
[board, currentLayoutId, entry],
|
[board, currentLayoutId, entry],
|
||||||
@@ -235,6 +236,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
export const useOpenItemMoveModal = () => {
|
export const useOpenItemMoveModal = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
|
const registeredGridEditors = useRegisteredGridEditors();
|
||||||
const { commitSectionGrids } = useGridLayoutActions();
|
const { commitSectionGrids } = useGridLayoutActions();
|
||||||
const { openModal } = useModalAction(ItemMoveModal);
|
const { openModal } = useModalAction(ItemMoveModal);
|
||||||
|
|
||||||
@@ -245,9 +247,10 @@ export const useOpenItemMoveModal = () => {
|
|||||||
board,
|
board,
|
||||||
currentLayoutId,
|
currentLayoutId,
|
||||||
commitSectionGrids,
|
commitSectionGrids,
|
||||||
|
registeredGridEditors,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit
|
|||||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
||||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
|
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
|
||||||
|
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||||
@@ -39,10 +40,11 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool
|
|||||||
interface WidgetContextMenuProps {
|
interface WidgetContextMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
previewDimensions: { width: number; height: number; scale?: number };
|
previewDimensions: WidgetPreviewDimensions;
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||||
widgetRuntimeRef: WidgetRuntimeRef;
|
widgetRuntimeRef: WidgetRuntimeRef;
|
||||||
sourceRef: RefObject<HTMLElement | null>;
|
sourceRef: RefObject<HTMLElement | null>;
|
||||||
|
disabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export const WidgetContextMenu = ({
|
|||||||
previewDimensions,
|
previewDimensions,
|
||||||
widgetRuntimeRef,
|
widgetRuntimeRef,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
|
disabled = false,
|
||||||
children,
|
children,
|
||||||
}: WidgetContextMenuProps) => {
|
}: WidgetContextMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -161,6 +164,7 @@ export const WidgetContextMenu = ({
|
|||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
const openEditModal = useCallback(() => {
|
const openEditModal = useCallback(() => {
|
||||||
|
setMenuOpened(false);
|
||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
@@ -229,6 +233,7 @@ export const WidgetContextMenu = ({
|
|||||||
|
|
||||||
const handleOpenAdvancedFocus = useCallback(() => {
|
const handleOpenAdvancedFocus = useCallback(() => {
|
||||||
if (!sourceRef.current) return;
|
if (!sourceRef.current) return;
|
||||||
|
setMenuOpened(false);
|
||||||
openAdvancedFocus(item.id, sourceRef.current, {
|
openAdvancedFocus(item.id, sourceRef.current, {
|
||||||
restoreFocusTarget:
|
restoreFocusTarget:
|
||||||
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
|
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
|
||||||
@@ -255,7 +260,7 @@ export const WidgetContextMenu = ({
|
|||||||
opened={menuOpened}
|
opened={menuOpened}
|
||||||
onChange={setMenuOpened}
|
onChange={setMenuOpened}
|
||||||
>
|
>
|
||||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
<Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
|
||||||
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
|
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
|
||||||
{canOpenAdvancedFocus && (
|
{canOpenAdvancedFocus && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -49,12 +49,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widgetIcon {
|
.widgetIcon {
|
||||||
|
width: var(--mantine-font-size-sm);
|
||||||
|
height: var(--mantine-font-size-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-size: 11px;
|
font-size: var(--mantine-font-size-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background-color: var(--chip-background);
|
background-color: var(--chip-background);
|
||||||
font-size: 10px;
|
font-size: var(--mantine-font-size-xxs);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow-x: clip;
|
||||||
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport[data-board-hydrated="false"] {
|
.viewport[data-board-hydrated="false"] {
|
||||||
@@ -45,4 +46,13 @@
|
|||||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
|
||||||
|
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
|
||||||
|
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
|
||||||
|
* above, just never ported over to radius. */
|
||||||
|
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
}
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
|
||||||
import type { z } from "zod/v4";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { boardRenameSchema } from "@homarr/validation/board";
|
|
||||||
|
|
||||||
interface InnerProps {
|
|
||||||
id: string;
|
|
||||||
previousName: string;
|
|
||||||
onSuccess?: (name: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
|
|
||||||
onSettled() {
|
|
||||||
void utils.board.getBoardByName.invalidate({
|
|
||||||
name: innerProps.previousName,
|
|
||||||
});
|
|
||||||
void utils.board.getHomeBoard.invalidate();
|
|
||||||
void utils.board.getAllBoards.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
|
|
||||||
initialValues: {
|
|
||||||
name: innerProps.previousName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate(
|
|
||||||
{
|
|
||||||
id: innerProps.id,
|
|
||||||
name: values.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
actions.closeModal();
|
|
||||||
innerProps.onSuccess?.(values.name);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
<TextInput label={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus />
|
|
||||||
<Group justify="end">
|
|
||||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
|
||||||
{tCommon("action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{tCommon("action.confirm")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">;
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { InlineConfirmMenuItem } from "@homarr/ui";
|
||||||
|
|
||||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
|
|
||||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||||
import { useSectionContext } from "../section-context";
|
import { useSectionContext } from "../section-context";
|
||||||
import { useContainerActions } from "./container-actions";
|
import { useContainerActions } from "./container-actions";
|
||||||
@@ -59,11 +59,14 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
|||||||
{tItem("action.moveResize")}
|
{tItem("action.moveResize")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<BoardRemoveConfirmationMenuItem
|
<InlineConfirmMenuItem
|
||||||
label={tContainer("action.remove")}
|
color="red"
|
||||||
confirmationLabel={tContainer("remove.message")}
|
confirmLabel={tContainer("remove.message")}
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
onConfirm={() => removeContainer({ id: section.id })}
|
onConfirm={() => removeContainer({ id: section.id })}
|
||||||
/>
|
>
|
||||||
|
{tContainer("action.remove")}
|
||||||
|
</InlineConfirmMenuItem>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.board-grid-entry:has([data-board-widget-header]):hover,
|
.board-grid-entry:has([data-board-widget-header]):hover,
|
||||||
.board-grid-entry:has([data-board-widget-header]):focus-within {
|
.board-grid-entry:has([data-board-widget-header]):focus-within {
|
||||||
z-index: 10;
|
z-index: var(--homarr-z-index-widget-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-entry[data-grid-preview="true"] {
|
.board-grid-entry[data-grid-preview="true"] {
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
||||||
|
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
||||||
|
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
||||||
|
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
||||||
|
while growing during a drag happening anywhere else on the board - any container (not just
|
||||||
|
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
|
||||||
|
inside that section's DOM tree, so the stray value cascades straight through and silently
|
||||||
|
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
|
||||||
|
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
|
||||||
|
unset, not merely inherited. Reset it here so every container can never inherit a stray
|
||||||
|
value from anywhere above it, no matter what any ancestor's own drag state is doing.
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||||
|
.viewport[data-section-kind="container"] {
|
||||||
|
--board-grid-drag-height: initial;
|
||||||
|
}
|
||||||
|
|
||||||
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
|
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
|
||||||
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
|
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
|
||||||
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
|
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
|
||||||
@@ -11,17 +27,14 @@
|
|||||||
.viewport.scrollableViewport {
|
.viewport.scrollableViewport {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
/* The grid content inside has a fixed pixel width matching this viewport's full
|
||||||
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
outer width (needed for the drag/resize math), so there's no room to reserve
|
||||||
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
for a scrollbar without it eating into - or overlapping - the content on
|
||||||
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
whichever side it renders. Hiding the native scrollbar track entirely (wheel/
|
||||||
while growing during a drag happening anywhere else on the board - a nested scrollable
|
touch/drag scrolling still works) avoids that instead of trying to reserve
|
||||||
container sits inside that section's DOM tree, so the stray value cascades straight
|
space for it. */
|
||||||
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in
|
scrollbar-width: none;
|
||||||
section-grid.tsx, which only kicks in when the property is entirely unset, not merely
|
-ms-overflow-style: none;
|
||||||
inherited. Reset it here so this element can never inherit a stray value from anywhere
|
|
||||||
above it, no matter what any ancestor's own drag state is doing.
|
|
||||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
|
||||||
--board-grid-drag-height: initial;
|
--board-grid-drag-height: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,8 +148,25 @@ export const SectionGrid = ({
|
|||||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||||
|
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||||
|
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||||
|
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||||
|
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
|
||||||
|
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
|
||||||
|
// not like the grid's own un-compensated logical-pixel math, so it needs the same
|
||||||
|
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
|
||||||
|
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
|
||||||
|
// all, the header bar covers the first row of the container's content instead of sitting
|
||||||
|
// above it. Only the vertical axis is affected - the toggle spans the full width already.
|
||||||
|
const collapsibleHeaderInset =
|
||||||
|
section.kind === "container" && section.options.collapsible
|
||||||
|
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
|
||||||
|
: 0;
|
||||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||||
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
|
const viewportHeight = Math.max(
|
||||||
|
1,
|
||||||
|
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
|
||||||
|
);
|
||||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||||
@@ -283,6 +300,7 @@ export const SectionGrid = ({
|
|||||||
{
|
{
|
||||||
width: logicalWidth,
|
width: logicalWidth,
|
||||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||||
|
marginTop: collapsibleHeaderInset || undefined,
|
||||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
@@ -317,6 +335,9 @@ export const SectionGrid = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Matches the collapsible container toggle's h={24} in container-section.tsx.
|
||||||
|
const CONTAINER_HEADER_HEIGHT = 24;
|
||||||
|
|
||||||
const INTERACTIVE_GRID_SELECTOR =
|
const INTERACTIVE_GRID_SELECTOR =
|
||||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header[data-desktop-visible="false"] {
|
.header[data-visible="false"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,39 +73,38 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.userMenu {
|
||||||
|
display: flex;
|
||||||
|
padding-inline: rem(6px);
|
||||||
|
}
|
||||||
|
|
||||||
.mobileContent {
|
.mobileContent {
|
||||||
display: none;
|
display: none;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--mantine-spacing-xs);
|
gap: var(--mantine-spacing-xs);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-inline: var(--mantine-spacing-xs);
|
padding-inline: var(--mantine-spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileIdentity,
|
.mobileZone {
|
||||||
.mobileActions {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileIdentity {
|
.mobileZone::-webkit-scrollbar {
|
||||||
flex: 1 1 auto;
|
display: none;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileIdentity > :last-child {
|
.mobileZone[data-zone="left"] {
|
||||||
flex: 1 1 auto;
|
justify-content: flex-start;
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileIdentity h2 {
|
.mobileZone[data-zone="right"] {
|
||||||
overflow: hidden;
|
justify-content: flex-end;
|
||||||
text-overflow: ellipsis;
|
max-width: 72vw;
|
||||||
}
|
|
||||||
|
|
||||||
.mobileActions {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.floatingControls {
|
.floatingControls {
|
||||||
@@ -155,21 +154,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
.header[data-desktop-visible="false"] {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktopContent {
|
.desktopContent {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileContent {
|
.mobileContent {
|
||||||
display: flex;
|
display: grid;
|
||||||
}
|
|
||||||
|
|
||||||
.floatingControls,
|
|
||||||
.floatingControlsCorner {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
|
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
|
||||||
import {
|
import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
|
||||||
IconBrandDocker,
|
|
||||||
IconHome,
|
|
||||||
IconLayoutDashboard,
|
|
||||||
IconReplace,
|
|
||||||
IconRobot,
|
|
||||||
IconSettings,
|
|
||||||
IconSunMoon,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -81,7 +73,10 @@ export const ConfigurableHeader = ({
|
|||||||
return (
|
return (
|
||||||
<BoardSwitcher>
|
<BoardSwitcher>
|
||||||
{(boardSwitcher) => {
|
{(boardSwitcher) => {
|
||||||
const renderItems = (items: HeaderItem[]) =>
|
const renderItems = (
|
||||||
|
items: HeaderItem[],
|
||||||
|
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
|
||||||
|
) =>
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<HeaderItem
|
<HeaderItem
|
||||||
key={getHeaderItemKey(item)}
|
key={getHeaderItemKey(item)}
|
||||||
@@ -95,8 +90,8 @@ export const ConfigurableHeader = ({
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
isDockerEnabled={isDockerEnabled}
|
isDockerEnabled={isDockerEnabled}
|
||||||
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||||
searchDisplay={headerPreferences.searchDisplay}
|
searchDisplay={display.search}
|
||||||
logoDisplay={headerPreferences.logoDisplay}
|
logoDisplay={display.logo}
|
||||||
boardSwitcher={boardSwitcher}
|
boardSwitcher={boardSwitcher}
|
||||||
assistant={assistant}
|
assistant={assistant}
|
||||||
openDockerModal={openDockerModal}
|
openDockerModal={openDockerModal}
|
||||||
@@ -106,13 +101,19 @@ export const ConfigurableHeader = ({
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const desktopDisplay = {
|
||||||
|
search: headerPreferences.searchDisplay,
|
||||||
|
logo: headerPreferences.logoDisplay,
|
||||||
|
};
|
||||||
|
const mobileDisplay = { search: "icon", logo: "logo" } as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShellHeader
|
<AppShellHeader
|
||||||
maw="100vw"
|
maw="100vw"
|
||||||
zIndex="var(--homarr-z-index-board-header)"
|
zIndex="var(--homarr-z-index-board-header)"
|
||||||
className={classes.header}
|
className={classes.header}
|
||||||
data-desktop-visible={headerPreferences.visible}
|
data-visible={headerPreferences.visible}
|
||||||
data-advanced-focus-background
|
data-advanced-focus-background
|
||||||
data-app-shell-header
|
data-app-shell-header
|
||||||
>
|
>
|
||||||
@@ -120,37 +121,26 @@ export const ConfigurableHeader = ({
|
|||||||
<div className={classes.content}>
|
<div className={classes.content}>
|
||||||
<div className={classes.zone} data-zone="left">
|
<div className={classes.zone} data-zone="left">
|
||||||
{hasNavigation ? <ClientBurger /> : null}
|
{hasNavigation ? <ClientBurger /> : null}
|
||||||
{renderItems(headerPreferences.zones.left)}
|
{renderItems(headerPreferences.zones.left, desktopDisplay)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.zone} data-zone="center">
|
<div className={classes.zone} data-zone="center">
|
||||||
{renderItems(headerPreferences.zones.center)}
|
{renderItems(headerPreferences.zones.center, desktopDisplay)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.zone} data-zone="right">
|
<div className={classes.zone} data-zone="right">
|
||||||
{renderItems(headerPreferences.zones.right)}
|
{renderItems(headerPreferences.zones.right, desktopDisplay)}
|
||||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.mobileContent}>
|
<div className={classes.mobileContent}>
|
||||||
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
|
<Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
|
||||||
{hasNavigation ? <ClientBurger /> : null}
|
{hasNavigation ? <ClientBurger /> : null}
|
||||||
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
|
{renderItems(headerPreferences.zones.left, mobileDisplay)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
|
<Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
|
||||||
{actions}
|
{renderItems(headerPreferences.zones.center, mobileDisplay)}
|
||||||
{boardEditAction}
|
{renderItems(headerPreferences.zones.right, mobileDisplay)}
|
||||||
{boardSettingsAction}
|
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||||
<TourTarget id="board-search">
|
|
||||||
<MobileSearchButton alwaysVisible />
|
|
||||||
</TourTarget>
|
|
||||||
<TourTarget id="board-user-menu">
|
|
||||||
<UserButtonClient
|
|
||||||
avatar={avatar}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
isDockerEnabled={isDockerEnabled}
|
|
||||||
boardSwitcher={boardSwitcher}
|
|
||||||
/>
|
|
||||||
</TourTarget>
|
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</AppShellHeader>
|
</AppShellHeader>
|
||||||
@@ -298,7 +288,9 @@ const HeaderItem = ({
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={boardUnavailableLabel}>
|
<Tooltip label={boardUnavailableLabel}>
|
||||||
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
||||||
<IconLayoutDashboard size={20} stroke={1.5} />
|
<Avatar size={22} radius="sm">
|
||||||
|
?
|
||||||
|
</Avatar>
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -416,14 +408,19 @@ const HeaderItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TourTarget id="board-user-menu">
|
<TourTarget id="board-user-menu">
|
||||||
<UserButtonClient
|
<div className={classes.userMenu}>
|
||||||
avatar={avatar}
|
<UserButtonClient
|
||||||
isAdmin={isAdmin}
|
avatar={avatar}
|
||||||
isDockerEnabled={isDockerEnabled}
|
isAdmin={isAdmin}
|
||||||
boardSwitcher={boardSwitcher}
|
isDockerEnabled={isDockerEnabled}
|
||||||
/>
|
boardSwitcher={boardSwitcher}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</TourTarget>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
|
const getBoardInitial = (name: string) => {
|
||||||
|
const firstCharacter = Array.from(name.trim())[0] ?? "?";
|
||||||
|
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
|
||||||
|
};
|
||||||
@@ -54,11 +54,13 @@ export const LazySpotlight = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return isMounted || isPreloaded ? (
|
if (!isMounted && !isPreloaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||||
<Spotlight />
|
{isMounted && <Spotlight />}
|
||||||
</>
|
</>
|
||||||
) : null;
|
);
|
||||||
};
|
};
|
||||||
@@ -22,11 +22,17 @@ export const ClientShell = ({
|
|||||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||||
const backgroundProps = useOptionalBackgroundProps();
|
const backgroundProps = useOptionalBackgroundProps();
|
||||||
const { headerPreferences } = useSettings();
|
const { headerPreferences } = useSettings();
|
||||||
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
|
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
{...backgroundProps}
|
{...backgroundProps}
|
||||||
|
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
|
||||||
|
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
|
||||||
|
// background image only covers as much height as the content needs, so collapsing a
|
||||||
|
// container short enough leaves flat page background showing below it instead of the
|
||||||
|
// background continuing to the bottom of the viewport.
|
||||||
|
mih={backgroundProps.bg ? "100dvh" : undefined}
|
||||||
header={hasHeader ? { height: headerHeight } : undefined}
|
header={hasHeader ? { height: headerHeight } : undefined}
|
||||||
navbar={
|
navbar={
|
||||||
hasNavigation
|
hasNavigation
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
IconHome,
|
IconHome,
|
||||||
|
IconLayoutDashboard,
|
||||||
IconLogin,
|
IconLogin,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconReplace,
|
IconReplace,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
|
import { useOptionalBoard } from "@homarr/boards/context";
|
||||||
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
|
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -46,6 +49,8 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
|||||||
const t = useI18n("common.userAvatar.menu");
|
const t = useI18n("common.userAvatar.menu");
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const board = useOptionalBoard();
|
||||||
|
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
|
||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||||
@@ -67,6 +72,15 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
|||||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||||
{t("homeBoard")}
|
{t("homeBoard")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{board && boardPermissions?.hasChangeAccess && (
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href={`/boards/${encodeURIComponent(board.name)}/settings`}
|
||||||
|
leftSection={<IconLayoutDashboard size="1rem" />}
|
||||||
|
>
|
||||||
|
{tBoard("action.settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconReplace size="1rem" />}
|
leftSection={<IconReplace size="1rem" />}
|
||||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core";
|
||||||
|
|
||||||
|
import { showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { WorkshopBackend } from "@homarr/workshop/backend";
|
||||||
|
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
|
||||||
|
import type { WorkshopReport } from "@homarr/workshop/schema";
|
||||||
|
|
||||||
|
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
|
||||||
|
|
||||||
|
interface WorkshopReportFormProps {
|
||||||
|
client: WorkshopBackend;
|
||||||
|
submissionId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) {
|
||||||
|
const t = useI18n("workshop");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
|
||||||
|
const [explanation, setExplanation] = useState("");
|
||||||
|
const report = useWorkshopReportMutation(client);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setCategory("other");
|
||||||
|
setExplanation("");
|
||||||
|
report.reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () =>
|
||||||
|
report.mutate(
|
||||||
|
{ submission: submissionId, category, explanation },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("reportSent"),
|
||||||
|
message: t("reportSentDescription"),
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapse id="workshop-report-form" expanded={opened}>
|
||||||
|
<Paper p="md">
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Box>
|
||||||
|
<Text fw={600}>{t("reportTitle")}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("reportExplanation")}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<CloseButton aria-label={tCommon("action.close")} onClick={close} />
|
||||||
|
</Group>
|
||||||
|
<Select
|
||||||
|
label={t("reportReason")}
|
||||||
|
value={category}
|
||||||
|
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
|
||||||
|
data={categories.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: t(`reportCategory.${value}`),
|
||||||
|
}))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label={t("reportExplanation")}
|
||||||
|
minRows={4}
|
||||||
|
autosize
|
||||||
|
value={explanation}
|
||||||
|
onChange={(event) => setExplanation(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{report.error && <Alert color="red">{report.error.message}</Alert>}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={close}>
|
||||||
|
{tCommon("action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}>
|
||||||
|
{t("reportSend")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
|
|
||||||
|
|
||||||
import { showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import type { WorkshopBackend } from "@homarr/workshop/backend";
|
|
||||||
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
|
|
||||||
import type { WorkshopReport } from "@homarr/workshop/schema";
|
|
||||||
|
|
||||||
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
|
|
||||||
|
|
||||||
interface WorkshopReportModalProps {
|
|
||||||
client: WorkshopBackend;
|
|
||||||
submissionId: string;
|
|
||||||
opened: boolean;
|
|
||||||
onClose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
|
|
||||||
const t = useI18n("workshop");
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
|
|
||||||
const [explanation, setExplanation] = useState("");
|
|
||||||
const report = useWorkshopReportMutation(client);
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
setExplanation("");
|
|
||||||
report.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = () =>
|
|
||||||
report.mutate(
|
|
||||||
{ submission: submissionId, category, explanation },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("reportSent"),
|
|
||||||
message: t("reportSentDescription"),
|
|
||||||
});
|
|
||||||
close();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
|
|
||||||
<Stack>
|
|
||||||
<Select
|
|
||||||
label={t("reportReason")}
|
|
||||||
value={category}
|
|
||||||
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
|
|
||||||
data={categories.map((value) => ({
|
|
||||||
value,
|
|
||||||
label: t(`reportCategory.${value}`),
|
|
||||||
}))}
|
|
||||||
allowDeselect={false}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label={t("reportExplanation")}
|
|
||||||
minRows={4}
|
|
||||||
autosize
|
|
||||||
value={explanation}
|
|
||||||
onChange={(event) => setExplanation(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{report.error && <Alert color="red">{report.error.message}</Alert>}
|
|
||||||
<Group justify="flex-end" gap="sm">
|
|
||||||
<Button variant="default" onClick={close}>
|
|
||||||
{tCommon("action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
|
|
||||||
{t("reportSend")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -163,7 +163,6 @@ describe("lazy widget application graph", () => {
|
|||||||
});
|
});
|
||||||
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||||
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
|
||||||
|
|
||||||
const refreshedDownloadsResponse = page.waitForResponse(
|
const refreshedDownloadsResponse = page.waitForResponse(
|
||||||
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||||
@@ -171,7 +170,6 @@ describe("lazy widget application graph", () => {
|
|||||||
releaseRefresh();
|
releaseRefresh();
|
||||||
releaseRefresh = undefined;
|
releaseRefresh = undefined;
|
||||||
await refreshedDownloadsResponse;
|
await refreshedDownloadsResponse;
|
||||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
|
||||||
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
expect(hydrationErrors).toEqual([]);
|
expect(hydrationErrors).toEqual([]);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"lint": "turbo lint --continue",
|
"lint": "turbo lint --continue",
|
||||||
"lint:fix": "turbo lint --continue -- --fix",
|
"lint:fix": "turbo lint --continue -- --fix",
|
||||||
"lint:ws": "pnpm dlx sherif@latest",
|
"lint:ws": "pnpm dlx sherif@latest",
|
||||||
|
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
|
||||||
"release": "semantic-release",
|
"release": "semantic-release",
|
||||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
interface ForbiddenTrpcError {
|
||||||
|
data: { code: "FORBIDDEN" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => {
|
||||||
|
if (!isRecord(error) || !isRecord(error.data)) return false;
|
||||||
|
return error.data.code === "FORBIDDEN";
|
||||||
|
};
|
||||||
|
|
||||||
// RSC-prefetched data must survive the hydration pass without an immediate
|
// RSC-prefetched data must survive the hydration pass without an immediate
|
||||||
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
||||||
export const queryCacheDefaultStaleTimeMs = 30_000;
|
export const queryCacheDefaultStaleTimeMs = 30_000;
|
||||||
@@ -8,6 +19,7 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
|||||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
|
|
||||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||||
|
const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]);
|
||||||
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||||
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||||
@@ -66,3 +78,11 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
|
|||||||
|
|
||||||
return isWidgetDataTrpcPath(path.join("."));
|
return isWidgetDataTrpcPath(path.join("."));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => {
|
||||||
|
const path = getTrpcPathFromQueryKey(queryKey);
|
||||||
|
if (!path) return false;
|
||||||
|
|
||||||
|
const joinedPath = path.join(".");
|
||||||
|
return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath);
|
||||||
|
};
|
||||||
@@ -86,9 +86,14 @@ interface BoardItemPlacementRectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||||
const maxManageOverviewPreviewRows = 12;
|
const defaultManageOverviewPreviewRows = 12;
|
||||||
|
const maxManageOverviewPreviewRows = 48;
|
||||||
const manageOverviewInputSchema = z
|
const manageOverviewInputSchema = z
|
||||||
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
.object({
|
||||||
|
fullPreview: z.boolean().default(false),
|
||||||
|
previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||||
@@ -311,6 +316,8 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||||
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
|
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
|
||||||
|
let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows;
|
||||||
|
if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows;
|
||||||
|
|
||||||
const dbBoards = await ctx.db.query.boards.findMany({
|
const dbBoards = await ctx.db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
@@ -388,7 +395,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
lt(itemLayouts.yOffset, previewRowLimit),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -413,7 +420,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||||
gte(sectionLayouts.xOffset, 0),
|
gte(sectionLayouts.xOffset, 0),
|
||||||
gte(sectionLayouts.yOffset, 0),
|
gte(sectionLayouts.yOffset, 0),
|
||||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
lt(sectionLayouts.yOffset, previewRowLimit),
|
||||||
gt(sectionLayouts.width, 0),
|
gt(sectionLayouts.width, 0),
|
||||||
gt(sectionLayouts.height, 0),
|
gt(sectionLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -448,7 +455,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
lt(itemLayouts.yOffset, previewRowLimit),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -2769,14 +2776,11 @@ export const boardLayoutsNeedRepair = (
|
|||||||
) => {
|
) => {
|
||||||
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
|
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
|
||||||
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
|
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
|
||||||
const baseLayout = baseLayouts.at(0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
mobileLayouts.length !== 1 ||
|
mobileLayouts.length !== 1 ||
|
||||||
baseLayouts.length !== 1 ||
|
baseLayouts.length !== 1 ||
|
||||||
mobileLayouts.at(0)?.breakpoint !== 0 ||
|
mobileLayouts.at(0)?.breakpoint !== 0 ||
|
||||||
!baseLayout ||
|
|
||||||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
|
|
||||||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
|
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user