Compare commits

..
173 changed files with 3554 additions and 4569 deletions

No files matched your search

+7 -13
View File
@@ -320,12 +320,8 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [container, preview-amd64, preview-arm64]
needs: [preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -379,6 +375,9 @@ jobs:
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: |
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
fi
docker buildx imagetools create "${tags[@]}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
@@ -387,6 +386,9 @@ jobs:
if: "!env.ACT"
run: |
tags=("${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=("pr-6545")
fi
for tag in "${tags[@]}"; do
image="${PREVIEW_IMAGE}:${tag}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
@@ -433,14 +435,6 @@ jobs:
core.info(`No package version found with tag "${tag}"`);
return;
}
const targetTags = target.metadata?.container?.tags ?? [];
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
if (extraTags.length > 0) {
core.setFailed(
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
+1 -6
View File
@@ -29,9 +29,6 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
You can obtain API keys from the "Authentication" tab at the top of the page.
API keys will not expire and can be used indefinitely.
When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the
creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation
instead of opening a separate dialog.
### API Key Format
@@ -113,9 +110,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
+12 -20
View File
@@ -34,11 +34,9 @@ arrow keys to move through the results and press `Enter` to open the selected bo
## Create a new board
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
When creating a board, the availability of the board name will be checked for you.
The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that
board, and create the copy without navigating to a separate page.
![](./img/create-board-modal.png)
### Name
@@ -58,9 +56,6 @@ This will enable public access for everyone - regardless of whether they are log
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the
same menu item. No separate confirmation dialog is opened.
## Board settings
You navigate to the board settings by clicking on these three dots:
@@ -133,21 +128,20 @@ On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling.
- **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the main layout and starts at `768px` on new boards.
- **Base** is the widest layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
unused breakpoint above Mobile.
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
their breakpoints.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
available breakpoint, and you can then change the breakpoint, columns, and rails.
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
Duplicate breakpoints are normalized without discarding saved positions.
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
@@ -183,8 +177,7 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
eligible layout with the highest breakpoint, regardless of its role.
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout.
@@ -273,8 +266,6 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
#### Background image URL
Homarr can display a background image behind apps and widgets.
@@ -414,7 +405,8 @@ nor the integrations the user does not have access to.
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
Duplicating a board that contains Custom Widgets also requires an administrator.
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
The name form expands below that board card and can be cancelled without leaving the list.
![](./img/duplicate-board-modal.png)
## Setting boards as your home board
@@ -30,12 +30,15 @@ Navigate to `Management` > `Tools` > `Certificates`.
## Add a certificate
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
To add a certificate click on the "Add certificate" button.
![](./img/add-button.png)
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
leaving the page.
Then select the certificate file you want to add.
![](./img/add-modal.png)
And click the "Add" button.
## Delete a certificate
@@ -43,7 +46,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
![](./img/card.png)
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
Then confirm the deletion.
## Managing it through file system
@@ -114,8 +114,7 @@ The install flow always links to the corresponding public Workshop entry under t
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
performs definitive validation again at install time.
@@ -128,26 +128,6 @@ You can decide if app status is disabled on all boards.
You can set the default color scheme for users without their own color scheme.
## Branding
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
### Identity
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
- **Logo** is used in the header and other application branding.
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
### Colors and corners
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
### Sign-in page
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
## Culture
### Default language
+4 -6
View File
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
### Creating a group
Click on the button `New group` to create a new one.
The group-name form expands above the group list and can be closed without leaving the page.
Homarr will prompt you for a group name.
### Managing a group
@@ -259,8 +259,7 @@ An invitation link can be used indefinitely.
### Creating an invitation
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
dialog or navigating to another page:
Click on the button:
![](./img/create-invitation-button.png)
@@ -270,12 +269,12 @@ The default is 4 hours in the future.
![](./img/invitation-expiry-date.png)
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
![](./img/create-invitation-confirmation.png)
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
Use **Copy** to copy the URL, then close the section when you are finished.
Clicking the `Copy & close` button will copy the URL to your clipboard.
### Self-onboarding
@@ -294,4 +293,3 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
The link will become invalid.
If the invitation expires, the link will also expire.
The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog.
-2
View File
@@ -67,8 +67,6 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
### Configuration
<WidgetConfig configuration={iframeWidget.configuration} />
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -50,7 +46,6 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} />
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -47,7 +43,6 @@ import mediaRequestStats from "./img/stats.png";
### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} />
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar 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.
-1
View File
@@ -72,7 +72,6 @@
"@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-sync-storage-persister": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
@@ -1,17 +1,20 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersistence,
createSessionQueryPersister,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
queryPersistenceMaxDataBytes,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data);
queryClient.setQueryData(queryKey, data, { updatedAt });
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
@@ -34,98 +37,314 @@ const createMemoryStorage = (): Storage => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("dashboard query persistence", () => {
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
describe("widget query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
apiKey: "secret",
});
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
});
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
});
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
vi.useFakeTimers();
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
const userAKey = getQueryPersistenceStorageKey("user-a");
window.sessionStorage.setItem(userAKey, "a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
});
test("evicts every other scope so signing out cannot leave data behind", () => {
const userAKey = getQueryPersistenceStorageKey("user-a");
const anonymousKey = getQueryPersistenceStorageKey(null);
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(anonymousKey, "anonymous");
window.sessionStorage.setItem("unrelated", "kept");
createSessionQueryPersister("user-b");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
});
test("stops persisting once the provider tears the cache down", async () => {
const storage = createMemoryStorage();
const userA = createSessionQueryPersistence("user-a", storage);
const userB = createSessionQueryPersistence("user-b", storage);
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
const persister = createSessionQueryPersister("user-a", storage);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
persister.stop();
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
const cachedQuery = queryClient.getQueryCache().find({
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
});
if (!cachedQuery) throw new Error("Expected cached query to be present");
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
queryClient.getMutationCache().build(
queryClient,
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
{
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isPaused: true,
status: "pending",
submittedAt: Date.now(),
variables: { apiKey: "secret" },
},
);
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
expect(shouldPersistDashboardQuery(query)).toBe(true);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, userA.dehydrateOptions),
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
expect(persistedClient.clientState.mutations).toHaveLength(0);
await userA.persister.persistClient(persistedClient);
await vi.advanceTimersByTimeAsync(1_000);
const restored = await userA.persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
persister.persistClient(persistedClient);
persister.flush();
const restored = await persister.restoreClient();
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(await userB.persister.restoreClient()).toBeUndefined();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
await userA.persister.removeClient();
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
window.sessionStorage.setItem(key, "not-valid-superjson");
expect(persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("treats unavailable session storage as an empty cache", async () => {
const blockedStorage = createMemoryStorage();
blockedStorage.getItem = () => {
throw new DOMException("blocked", "SecurityError");
};
blockedStorage.removeItem = () => {
throw new DOMException("blocked", "SecurityError");
};
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ title: "safe" }],
);
const validQuery = dehydrate(queryClient).queries[0];
if (!validQuery) throw new Error("Expected a dehydrated query");
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
expect(await persistence.persister.restoreClient()).toBeUndefined();
expect(() => persistence.persister.removeClient()).not.toThrow();
window.sessionStorage.setItem(
key,
stringify({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
{
...validQuery,
state: {
...validQuery.state,
error: new Error("private refresh error"),
fetchStatus: "fetching",
status: "error",
},
},
{
...validQuery,
queryHash: "unrelated",
queryKey: [["board", "getBoardByName"], { type: "query" }],
},
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
{
...validQuery,
queryHash: "missing-timestamp",
state: { ...validQuery.state, dataUpdatedAt: undefined },
},
],
},
}),
);
const restored = await createSessionQueryPersister(scope).restoreClient();
expect(restored?.clientState.mutations).toEqual([]);
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
});
test.each([
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const storage = createMemoryStorage();
const setItem = storage.setItem.bind(storage);
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
const client = parse<PersistedClient>(value);
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
setItem(key, value);
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient(persistedClient);
persister.flush();
expect(setItemSpy).toHaveBeenCalledTimes(2);
const restored = await persister.restoreClient();
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const setItem = vi.spyOn(storage, "setItem");
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
});
});
@@ -1,12 +1,13 @@
import type { QueryKey } from "@tanstack/react-query";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceBuster = "v6-dashboard-data";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
export const queryPersistenceBuster = "v2-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
@@ -15,33 +16,95 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
interface PersistableQuery {
queryKey: QueryKey;
state: { data: unknown; status: string };
state: {
data: unknown;
dataUpdatedAt: number;
};
}
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
try {
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const fitsStorageBudget = (data: unknown) => {
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
const cached = budgetVerdictCache.get(data);
if (cached !== undefined) return cached;
const verdict = measureFitsBudget(data);
budgetVerdictCache.set(data, verdict);
return verdict;
};
const holdsDashboardData = (query: PersistableQuery) =>
query.state.data !== undefined &&
Number.isFinite(query.state.dataUpdatedAt) &&
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
const serializePersistedClient = (client: PersistedClient) =>
stringify({
...client,
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
return holdsDashboardData(value as unknown as PersistableQuery);
};
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
return {
timestamp: value.timestamp as number,
buster: value.buster,
clientState: {
...client.clientState,
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
mutations: [],
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
},
});
};
};
const getSessionStorage = () => {
const getSessionStorage = (): Storage | undefined => {
if (typeof window === "undefined") return undefined;
try {
@@ -51,47 +114,113 @@ const getSessionStorage = () => {
}
};
const protectStorage = (storage: Storage | undefined) => {
if (!storage) return undefined;
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
return undefined;
}
};
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
try {
const foreignKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const storedKey = storage.key(index);
if (storedKey === null || storedKey === activeKey) continue;
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
}
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
} catch {
return undefined;
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
removeForeignScopes(storage, key);
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
if (candidate.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return;
}
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
return {
getItem(key: string) {
try {
return storage.getItem(key);
} catch {
return null;
}
persistClient(client) {
if (!storage || stopped) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
setItem(key: string, value: string) {
storage.setItem(key, value);
},
removeItem(key: string) {
restoreClient() {
try {
storage.removeItem(key);
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return undefined;
}
return client;
} catch {
removeStorageItem(storage, key);
return undefined;
}
},
removeClient() {
clearPersistTimer();
pendingClient = undefined;
removeStorageItem(storage, key);
},
flush,
stop() {
stopped = true;
clearPersistTimer();
pendingClient = undefined;
},
};
};
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
export const createSessionQueryPersistence = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersistence => ({
persister: createSyncStoragePersister({
storage: protectStorage(storage),
key: getQueryPersistenceStorageKey(scope),
serialize: serializePersistedClient,
deserialize: parse,
retry: removeOldestQuery,
}),
maxAge: queryCacheDefaultGcTimeMs,
buster: queryPersistenceBuster,
dehydrateOptions: {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: shouldPersistDashboardQuery,
},
});
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
@@ -25,8 +25,6 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
dashboardSupportingQueryPolicies,
isTrpcForbiddenError,
isWidgetDataQueryKey,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
@@ -38,8 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import { createSessionQueryPersistence } from "./query-persistence";
import type { SessionQueryPersistence } from "./query-persistence";
import {
createSessionQueryPersister,
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -72,11 +75,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersistence.persister.removeClient();
void queryPersister.removeClient();
reloadPage();
}, [queryPersistence]);
}, [queryPersister]);
return (
<SessionQueryScopeGuard
@@ -84,7 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -93,8 +96,8 @@ const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({
children,
queryPersistence,
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
queryPersister,
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -111,13 +114,6 @@ const ScopedTRPCReactProvider = ({
);
const [queryClient] = useState(() => {
const client = new QueryClient({
queryCache: new QueryCache({
onError(error, query) {
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
void queryPersistence.persister.removeClient();
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
},
}),
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
@@ -156,6 +152,15 @@ const ScopedTRPCReactProvider = ({
return client;
});
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
queryPersister.stop();
};
}, [queryPersister]);
useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => {
@@ -206,7 +211,15 @@ const ScopedTRPCReactProvider = ({
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider>
@@ -1,16 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Box, Button, Group, TextInput } from "@mantine/core";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useZodForm } from "@homarr/form";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { boardRenameSchema } from "@homarr/validation/board";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
@@ -18,70 +17,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const utils = clientApi.useUtils();
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: board.name,
},
});
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
useEffect(() => {
if (isRenaming) renameInputRef.current?.focus();
}, [isRenaming]);
const handleRename = renameForm.onSubmit((values) => {
renameBoard(
{ id: board.id, name: values.name },
{
onSuccess() {
router.push(`/boards/${values.name}/settings`);
},
},
);
});
const cancelRename = () => {
renameForm.reset();
setIsRenaming(false);
};
const handleVisibilityChange = () => {
changeVisibility(
{
const onRenameClick = useCallback(
() =>
openModal({
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
};
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const handleDelete = () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
},
);
};
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
return (
<DangerZoneRoot>
@@ -89,28 +88,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
action={
isRenaming ? (
<Box component="form" onSubmit={handleRename}>
<Group wrap="wrap" justify="end">
<TextInput
ref={renameInputRef}
aria-label={t("section.dangerZone.action.rename.label")}
size="sm"
{...renameForm.getInputProps("name")}
/>
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
{tCommonAction("cancel")}
</Button>
<Button type="submit" color="red" loading={isRenamePending}>
{tCommonAction("confirm")}
</Button>
</Group>
</Box>
) : (
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
{t("section.dangerZone.action.rename.button")}
</Button>
)
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
}
/>
{!hideVisibility && (
@@ -118,15 +98,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<InlineConfirmButton
variant="subtle"
color="red"
loading={isChangeVisibilityPending}
onConfirm={handleVisibilityChange}
confirmLabel={tCommonAction("confirm")}
>
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</InlineConfirmButton>
</Button>
}
/>
)}
@@ -134,15 +108,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<InlineConfirmButton
variant="subtle"
color="red"
loading={isDeletePending}
onConfirm={handleDelete}
confirmLabel={tCommonAction("confirm")}
>
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{tCommonAction("delete")}
</InlineConfirmButton>
</Button>
}
/>
</DangerZoneRoot>
@@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
@@ -40,14 +40,13 @@ interface Props {
saveSettingsAsync: () => Promise<FormValues | null>;
}
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
@@ -85,27 +84,33 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
}
};
const handleReset = async (layout: FormValues["layouts"][number]) => {
setResettingLayoutId(layout.id);
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: tBoard("setting.section.layout.reset.confirm.message"),
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
onConfirm() {
setResettingLayoutId(layout.id);
void (async () => {
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
})();
},
});
};
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
@@ -139,9 +144,13 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Button>
</Group>
{displayedLayouts.map(({ layout, index }) => {
{form.values.layouts.map((layout, index) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
.map((candidate) => candidate.breakpoint);
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
return (
<Fieldset
@@ -191,8 +200,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
min={
layout.role === "base"
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
: 1
}
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
/>
</Stack>
</Grid.Col>
@@ -223,17 +236,16 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
<Button
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
onClick={() => confirmReset(layout)}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
</Button>
)}
{layout.role === "custom" && (
<Button
@@ -111,7 +111,7 @@ export const createBoardLayout = <TParams extends Params>({
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
@@ -1,12 +1,13 @@
"use client";
import { useCallback } from "react";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -17,41 +18,48 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onConfirm = useCallback(() => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: app.name,
}),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
},
);
}, [app.id, mutate, t, tCommon]);
});
}, [app, mutate, t, tCommon, openConfirmModal]);
return (
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -8,8 +8,10 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -23,10 +25,9 @@ interface BoardCardMenuDropdownProps {
RouterOutputs["board"]["getManageOverview"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
onDuplicate: () => void;
}
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
@@ -34,6 +35,9 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
@@ -52,10 +56,20 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
},
});
const handleDeletion = useCallback(
() => deleteBoardMutation.mutateAsync({ id: board.id }),
[board.id, deleteBoardMutation],
);
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tCommon("action.deleteNamed", { name: board.name }),
children: t("delete.confirm.description", {
name: board.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await deleteBoardMutation.mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -65,6 +79,15 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -74,7 +97,7 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")}
</Menu.Item>
)}
@@ -94,15 +117,14 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
<>
<Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<InlineConfirmMenuItem
<Menu.Item
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
onClick={handleDeletion}
disabled={deleteBoardMutation.isPending}
>
{tCommon("action.delete")}
</InlineConfirmMenuItem>
</Menu.Item>
</>
)}
</Menu.Dropdown>
@@ -1,133 +0,0 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Collapse,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
import { DuplicateBoardForm } from "./duplicate-board-form";
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<>
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown
board={board}
onDuplicate={() => {
setDuplicateFormKey((value) => value + 1);
setIsDuplicateOpen(true);
}}
/>
</Menu>
)}
</Group>
</CardSection>
</Card>
<Collapse expanded={isDuplicateOpen}>
<Card mt="sm" withBorder>
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
</Card>
</Collapse>
</>
);
};
@@ -1,55 +1,23 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
import { Button } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { BoardCreateForm } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n("management.page.board.action");
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsOpen(true);
}, [searchParams]);
const close = () => {
setIsOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
};
const toggle = () => {
if (isOpen) {
close();
return;
}
setFormKey((value) => value + 1);
setIsOpen(true);
};
const { openModal } = useModalAction(AddBoardModal);
return (
<Stack>
<Group justify="end">
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
{t("new.label")}
</Button>
</Group>
<Collapse expanded={isOpen}>
<Card withBorder>
<BoardCreateForm key={formKey} onCancel={close} />
</Card>
</Collapse>
</Stack>
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("new.label")}
</Button>
</ManageMobilePrimaryAction>
);
};
@@ -1,15 +1,38 @@
import { Grid, GridCol } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Grid,
GridCol,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import {
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCard } from "./_components/board-card";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
@@ -19,14 +42,24 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
<ManagePageLayout title={t("title")}>
{canCreateBoards && (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
)}
<ManagePageLayout
title={t("title")}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && (
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
<NoResults
icon={IconLayoutDashboard}
title={t("noResults.title")}
description={t("noResults.description")}
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
/>
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
@@ -59,3 +92,97 @@ export default async function ManageBoardsPage() {
</ManagePageLayout>
);
}
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group>
</CardSection>
</Card>
);
};
@@ -10,17 +10,24 @@ import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonProps {
interface CopyAiPromptButtonBaseProps {
rawResponse?: string | null;
request?: string | null;
documentationUrl?: string | null;
getDraft: () => CustomWidgetAiDraft;
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
}
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({
rawResponse,
draft,
getDraft,
diagnostics,
getDiagnostics,
request,
documentationUrl,
@@ -28,14 +35,23 @@ export const CopyAiPromptButton = ({
const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt(
undefined,
rawResponse,
getDraft(),
readDraft(),
request,
documentationUrl,
getDiagnostics(),
readDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
@@ -46,10 +62,10 @@ export const CopyAiPromptButton = ({
const value = buildCustomWidgetAiPrompt(
undefined,
rawResponse,
getDraft(),
readDraft(),
request,
documentationUrl,
getDiagnostics(),
readDiagnostics(),
);
await navigator.clipboard.writeText(value);
showSuccessNotification({
@@ -20,9 +20,10 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { downloadJson } from "~/components/custom-widgets/download";
@@ -39,7 +40,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
@@ -172,24 +173,30 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
const handleDelete = () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
openConfirmModal({
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
);
},
);
});
};
return (
@@ -256,15 +263,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</>
)}
{!widget.migrationRequired && (
<InlineConfirmMenuItem
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</InlineConfirmMenuItem>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
@@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopReportForm } from "~/components/workshop/workshop-report-form";
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
import { useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -213,13 +213,6 @@ export function WorkshopDetail({ id }: { id: string }) {
</Accordion.Item>
</Accordion>
<WorkshopReportForm
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
<ManageStickyFooter
secondary={
<>
@@ -248,8 +241,6 @@ export function WorkshopDetail({ id }: { id: string }) {
leftSection={<IconFlag size={16} />}
disabled={!session.user}
onClick={reportControls.open}
aria-expanded={reportOpened}
aria-controls="workshop-report-form"
>
{t("report")}
</Button>
@@ -268,6 +259,13 @@ export function WorkshopDetail({ id }: { id: string }) {
</Button>
</ManageStickyFooter>
</Stack>
<WorkshopReportModal
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
</ManagePageLayout>
);
}
@@ -6,12 +6,11 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, getMantineColor } from "@homarr/common";
import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
interface CertificateErrorDetailsProps {
@@ -24,71 +23,80 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false);
const [showUploadForm, setShowUploadForm] = useState(false);
const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } =
clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } =
clientApi.certificates.addCertificate.useMutation();
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = async () => {
const handleTrustHostname = () => {
const { hostname } = new URL(url);
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
openConfirmModal({
title: tError("certificate.hostnameMismatch.confirm.title"),
children: tError("certificate.hostnameMismatch.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
});
};
const handleTrustSelfSigned = async () => {
const handleTrustSelfSigned = () => {
const { hostname } = new URL(url);
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
openConfirmModal({
title: tError("certificate.selfSigned.confirm.title"),
children: tError("certificate.selfSigned.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
});
},
});
};
const description = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
let trustConfirmLabel = tError("certificate.selfSigned.confirm.title");
if (error.data.reason === "hostnameMismatch") {
trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title");
}
if (!isAdmin) {
return (
@@ -122,36 +130,30 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<InlineConfirmButton
<Button
type="button"
variant="default"
fullWidth
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
confirmLabel={trustConfirmLabel}
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
>
{tError("certificate.action.trust.label")}
</InlineConfirmButton>
</Button>
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Stack gap="sm">
{!showUploadForm && (
<Button variant="default" fullWidth onClick={() => setShowUploadForm(true)}>
{tError("certificate.action.upload.label")}
</Button>
)}
{showUploadForm && (
<Card withBorder>
<CertificateUploadForm
embedded
onCancel={() => setShowUploadForm(false)}
onSuccess={() => {
setShowRetryButton(true);
setShowUploadForm(false);
}}
/>
</Card>
)}
</Stack>
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
openUploadModal({
onSuccess() {
setShowRetryButton(true);
},
})
}
>
{tError("certificate.action.upload.label")}
</Button>
) : null}
</>
);
@@ -1,12 +1,13 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string };
@@ -15,42 +16,46 @@ interface DeleteIntegrationActionButtonProps {
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useI18n("integration.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
const onConfirm = () =>
mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
return (
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={() => {
openConfirmModal({
title: t("title"),
children: t("message", integration),
onConfirm: () => {
void mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
},
});
}}
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -1,41 +1,40 @@
"use client";
import { Tooltip } from "@mantine/core";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n("media");
const tCommon = useI18n("common");
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onConfirm = async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
const onClick = () => {
openConfirmModal({
title: t("action.delete.label"),
children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
},
});
};
return (
<Tooltip label={t("action.delete.label")} openDelay={500}>
<InlineConfirmActionIcon
color="red"
variant="subtle"
onConfirm={onConfirm}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.label")}
>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
</Tooltip>
);
};
@@ -1,14 +1,15 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
@@ -17,41 +18,48 @@ interface SearchEngineDeleteButtonProps {
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useI18n("search.engine.page.delete");
const actionT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onConfirm = useCallback(() => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: searchEngine.name,
}),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
);
}, [searchEngine.id, mutate, t]);
});
}, [searchEngine, mutate, t, openConfirmModal]);
return (
<InlineConfirmActionIcon
confirmLabel={actionT("confirm")}
confirmationAriaLabel={actionT("confirm")}
onConfirm={onConfirm}
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -1,67 +1,34 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { NewApiKeyAlert } from "./new-api-key-alert";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
interface ApiKeysManagementProps {
apiKeys: RouterOutputs["apiKeys"]["getAll"];
onCreated: () => void;
}
type ApiKey = RouterOutputs["apiKeys"]["getAll"][number];
type ApiKeyCellProps = { row: MRT_Row<ApiKey> };
interface ApiKeysTableMeta {
handleDelete: (id: string) => Promise<void>;
confirmLabel: string;
deleteTitle: string;
isPendingDelete: boolean;
}
const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => (
<Group gap="xs">
<UserAvatar user={row.original.user} size="sm" />
<Text>{row.original.user.name}</Text>
</Group>
);
const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance<ApiKey> }) => {
const meta = table.options.meta as ApiKeysTableMeta;
return (
<Group gap="xs">
<InlineConfirmActionIcon
onConfirm={() => meta.handleDelete(row.original.id)}
confirmLabel={meta.confirmLabel}
confirmationAriaLabel={meta.confirmLabel}
loading={meta.isPendingDelete}
color="red"
variant="subtle"
aria-label={meta.deleteTitle}
>
<IconTrash size="1rem" />
</InlineConfirmActionIcon>
</Group>
);
};
export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => {
const [newApiKey, setNewApiKey] = useState<string | null>(null);
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { openConfirmModal } = useConfirmModal();
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
onSuccess(data) {
setNewApiKey(data.apiKey);
onCreated();
async onSuccess(data) {
openModal({
apiKey: data.apiKey,
});
await revalidatePathActionAsync("/manage/tools/api");
},
});
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
@@ -71,19 +38,21 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
});
const t = useI18n("management.page.tool.api.tab.apiKey");
const tCommon = useI18n("common");
const handleDelete = useCallback(
async (id: string) => {
await mutateDeleteAsync({ apiKeyId: id });
(id: string) => {
openConfirmModal({
title: t("modal.delete.title"),
children: t("modal.delete.text"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateDeleteAsync({ apiKeyId: id });
},
});
},
[mutateDeleteAsync],
[t, openConfirmModal, mutateDeleteAsync],
);
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
() => [
{
accessorKey: "id",
@@ -92,32 +61,36 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ApiKeyCreatedByCell,
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
},
{
header: t("table.header.actions"),
Cell: ApiKeyActionsCell,
Cell: ({ row }) => (
<Group gap="xs">
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
<IconTrash size="1rem" />
</ActionIcon>
</Group>
),
},
],
[t],
[t, handleDelete, isPendingDelete],
);
const table = useMantineReactTable({
columns,
data: apiKeys,
meta: {
handleDelete,
confirmLabel: tCommon("action.confirm"),
deleteTitle: t("modal.delete.title"),
isPendingDelete,
},
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutateCreate();
}}
loading={isPendingCreate}
disabled={newApiKey !== null}
>
{t("button.createApiToken")}
</Button>
@@ -131,7 +104,6 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
return (
<Stack>
<Title>{t("title")}</Title>
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<MantineReactTable table={table} />
</Stack>
);
@@ -5,8 +5,6 @@ import type { ReactNode } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { ApiKeysManagement } from "./api-keys";
import { McpInstructions } from "./mcp-instructions";
export interface McpToolGroup {
@@ -19,8 +17,9 @@ interface ApiPageTabsProps {
apiKeyLabel: string;
mcpLabel: string;
documentationPanel: ReactNode;
apiKeys: RouterOutputs["apiKeys"]["getAll"];
authenticationPanel: ReactNode;
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
@@ -29,12 +28,12 @@ export function ApiPageTabs({
apiKeyLabel,
mcpLabel,
documentationPanel,
apiKeys,
authenticationPanel,
baseUrl,
hasApiKeys,
toolGroups,
}: ApiPageTabsProps) {
const [activeTab, setActiveTab] = useState<string | null>("documentation");
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
@@ -49,17 +48,10 @@ export function ApiPageTabs({
{mcpLabel}
</TabsTab>
</TabsList>
<TabsPanel value="authentication">
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
</TabsPanel>
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
<TabsPanel value="mcp">
<McpInstructions
baseUrl={baseUrl}
hasApiKeys={hasApiKeys}
toolGroups={toolGroups}
onApiKeyCreated={() => setHasApiKeys(true)}
/>
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
</TabsPanel>
</Tabs>
);
@@ -0,0 +1,34 @@
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const [visible, { toggle }] = useDisclosure(false);
return (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});
@@ -1,6 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import {
Accordion,
AccordionControl,
@@ -37,11 +36,12 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { CopyApiKeyModal } from "./copy-api-key-modal";
import type { McpToolGroup } from "./api-page-tabs";
import classes from "./mcp-tools-accordion.module.css";
import { NewApiKeyAlert } from "./new-api-key-alert";
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
query: { color: "blue", method: "GET" },
@@ -52,23 +52,18 @@ interface McpInstructionsProps {
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
onApiKeyCreated: () => void;
}
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) {
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
const t = useI18n("management.page.tool.api.tab.mcp");
const tCommon = useI18n("common");
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const { openModal } = useModalAction(CopyApiKeyModal);
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
onSuccess(data) {
setNewApiKey(data.apiKey);
onApiKeyCreated();
async onSuccess(data) {
openModal({ apiKey: data.apiKey });
await revalidatePathActionAsync("/manage/tools/api");
},
});
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const mcpUrl = `${baseUrl}/api/mcp`;
const streamableHttpConfig = JSON.stringify(
@@ -119,7 +114,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
<Text size="sm">{t("description")}</Text>
</Alert>
{!hasApiKeys && !newApiKey && (
{!hasApiKeys && (
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
<Group justify="space-between" align="center">
<Text size="sm">{t("noApiKey")}</Text>
@@ -136,8 +131,6 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
</Alert>
)}
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<div>
<Title order={5} mb="xs">
<Group gap="xs">
@@ -312,7 +305,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
<Stack gap={6}>
{group.tools.map((tool) => (
<div key={tool.name} className={classes.toolRow}>
<Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light">
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
{toolTypeDisplay[tool.type].method}
</Badge>
<div>
@@ -1,50 +0,0 @@
"use client";
import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface NewApiKeyAlertProps {
apiKey: string;
onDismiss: () => void | Promise<void>;
}
export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const tCommon = useI18n("common");
const [visible, { toggle }] = useDisclosure(false);
return (
<Alert color="yellow" title={t("title")}>
<Stack gap="sm">
<Text size="sm">{t("description")}</Text>
<PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly />
<Stack gap="xs" align="stretch">
<CopyButton value={apiKey} timeout={2_000}>
{({ copied, copy }) => {
let color: "green" | undefined;
let icon = <IconCopy size={16} />;
let label = tCommon("action.copy");
if (copied) {
color = "green";
icon = <IconCheck size={16} />;
label = tCommon("action.copied");
}
return (
<Button onClick={copy} variant="default" color={color} leftSection={icon}>
{label}
</Button>
);
}}
</CopyButton>
<Button onClick={onDismiss} variant="subtle">
{tCommon("action.close")}
</Button>
</Stack>
</Stack>
</Alert>
);
};
@@ -11,6 +11,7 @@ import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
@@ -86,8 +87,9 @@ export default async function ApiPage() {
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
apiKeys={apiKeys}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
@@ -1,35 +1,23 @@
"use client";
import { Button, Card, Collapse, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Button } from "@mantine/core";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { CertificateUploadForm } from "./certificate-upload-form";
export const AddCertificateSection = () => {
const [opened, { close, toggle }] = useDisclosure(false);
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
const t = useI18n("certificate");
return (
<Stack gap="sm">
<Button onClick={toggle} variant="default" aria-expanded={opened}>
{t("action.create.label")}
</Button>
<Collapse expanded={opened}>
<Card withBorder>
<CertificateUploadForm
onCancel={close}
onSuccess={async () => {
await revalidatePathActionAsync("/manage/tools/certificates");
close();
}}
/>
</Card>
</Collapse>
</Stack>
);
};
const handleClick = () => {
openModal({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
};
export { CertificateUploadForm };
return <Button onClick={handleClick}>{t("action.create.label")}</Button>;
};
@@ -1,110 +0,0 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { checkCertificateFile } from "@homarr/validation/certificates";
interface CertificateUploadFormProps {
embedded?: boolean;
onSuccess?: () => MaybePromise<void>;
onCancel?: () => void;
}
export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.file().check(checkCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation();
const handleSubmit = async () => {
const validation = form.validate();
if (validation.hasErrors || !form.values.file) return;
const formData = new FormData();
formData.set("file", form.values.file);
await mutateAsync(formData, {
onSuccess: async () => {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
form.reset();
await onSuccess?.();
},
onError: () => {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
};
const handleCancel = () => {
form.reset();
onCancel?.();
};
const submit = () => {
void handleSubmit().catch(() => undefined);
};
let submitButtonType: "button" | "submit" = "submit";
let handleSubmitClick: (() => void) | undefined;
if (embedded) {
submitButtonType = "button";
handleSubmitClick = submit;
}
const content = (
<>
<FileInput
label={t("certificate.action.create.label")}
leftSection={<IconCertificate size={16} />}
{...form.getInputProps("file")}
/>
<Group justify="end">
{onCancel && (
<Button onClick={handleCancel} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
)}
<Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}>
{t("common.action.add")}
</Button>
</Group>
</>
);
if (embedded) {
return <Stack>{content}</Stack>;
}
return (
<Stack
component="form"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{content}
</Stack>
);
};
@@ -1,56 +1,57 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleRemove = () =>
mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
const handleClick = () => {
openConfirmModal({
title: t("action.remove.label"),
children: t("action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
},
);
},
);
});
};
return (
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.remove.label")}
>
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<IconTrash color="red" size={16} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -1,12 +1,13 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveHostnameActionIconProps {
hostname: string;
@@ -14,41 +15,41 @@ interface RemoveHostnameActionIconProps {
}
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
},
});
const { openConfirmModal } = useConfirmModal();
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleRemove = () =>
mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
const handleRemove = () => {
openConfirmModal({
title: t("action.removeHostname.label"),
children: t("action.removeHostname.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
});
},
});
},
});
};
return (
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.removeHostname.label")}
>
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -13,7 +13,7 @@ import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateSection } from "./_components/add-certificate";
import { AddCertificateButton } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
@@ -69,11 +69,10 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
{t("page.list.toHostnames")}
</Button>
<AddCertificateButton />
</Group>
</Group>
<AddCertificateSection />
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} />
)}
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps {
@@ -49,7 +47,13 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialConfigMaps),
{
initialData: initialConfigMaps,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -17,7 +17,6 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
@@ -85,7 +84,13 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialIngresses),
{
initialData: initialIngresses,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -1,18 +0,0 @@
interface QueryWithStatus {
state: {
status: string;
};
}
const getErrorRefetchInterval = (query: QueryWithStatus) => {
if (query.state.status === "error") return 30_000;
return false;
};
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
initialData,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: getErrorRefetchInterval,
});
@@ -15,8 +15,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface NamespacesTableComponentProps {
@@ -68,7 +66,13 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialNamespaces),
{
initialData: initialNamespaces,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -17,7 +17,6 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
@@ -98,7 +97,13 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialNodes),
{
initialData: initialNodes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface PodsTableComponentProps {
@@ -59,7 +57,13 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialPods),
{
initialData: initialPods,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface SecretsTableComponentProps {
@@ -54,7 +52,13 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialSecrets),
{
initialData: initialSecrets,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -14,8 +14,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface ServicesTableComponentProps {
@@ -73,7 +71,13 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
const { data } = clientApi.kubernetes.services.getServices.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialServices),
{
initialData: initialServices,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface VolumesTableComponentProps {
@@ -78,7 +76,13 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialVolumes),
{
initialData: initialVolumes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -2,18 +2,20 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const tUser = useI18n("user");
const tCommon = useI18n("common");
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
@@ -21,14 +23,27 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(
() => mutateUserDeletionAsync({ userId: user.id }),
[mutateUserDeletionAsync, user.id],
() =>
openConfirmModal({
title: tUser("action.delete.label"),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
children: tUser("action.delete.confirm", { username: user.name! }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync({
userId: user.id,
});
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, tUser],
);
return (
<InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red">
<Button onClick={handleDelete} variant="subtle" color="red">
{tCommon("action.delete")}
</InlineConfirmButton>
</Button>
);
};
@@ -2,30 +2,31 @@
import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
import { UserAvatar } from "@homarr/ui";
interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({
const { mutate } = clientApi.user.setProfileImage.useMutation({
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
},
});
const [opened, { toggle }] = useDisclosure(false);
const isMobile = useIsMobile();
const isMobile = useMediaQuery("(max-width: 48em)");
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -66,28 +67,32 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
[mutate, user.id, tManageAvatar],
);
const handleRemoveAvatar = useCallback(
() =>
mutateAsync(
{
userId: user.id,
image: null,
},
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
const handleRemoveAvatar = useCallback(() => {
openConfirmModal({
title: tManageAvatar("removeImage.label"),
children: tManageAvatar("removeImage.confirm"),
onConfirm() {
mutate(
{
userId: user.id,
image: null,
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
},
},
),
[mutateAsync, user.id, tManageAvatar],
);
);
},
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
return (
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
@@ -118,15 +123,9 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
)}
</FileButton>
{user.image && (
<InlineConfirmMenuItem
color="red"
onConfirm={handleRemoveAvatar}
confirmLabel={tCommon("action.confirm")}
disabled={isPending}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
{tManageAvatar("removeImage.label")}
</InlineConfirmMenuItem>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
@@ -12,6 +12,7 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
@@ -19,7 +20,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelect } from "~/components/access/group-select";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { StepperNavigationComponent } from "./stepper-navigation";
interface GroupWithPermissions {
@@ -223,6 +224,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
const tCommon = useI18n("common");
const tPermission = useI18n("permission");
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
@@ -238,23 +256,14 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
{tCreate("step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<GroupSelect
presentGroupIds={groups.map((group) => group.id)}
withPermissions
onSelect={({ id, name, permissions }) => {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
}}
triggerProps={{
variant: "subtle",
color: "gray",
leftSection: <IconPlus size={16} stroke={1.5} />,
}}
/>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{tCommon("action.add")}
</Button>
</Group>
<Table>
<Table.Thead>
@@ -2,13 +2,13 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { useMatches } from "@mantine/core";
import { Button, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteGroupProps {
group: {
@@ -20,38 +20,46 @@ interface DeleteGroupProps {
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
const router = useRouter();
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
const { openConfirmModal } = useConfirmModal();
const tDelete = useI18n("group.action.delete");
const tCommon = useI18n("common");
const handleDeletion = useCallback(
() =>
mutateAsync(
{
id: group.id,
},
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tDelete("label"),
children: tDelete("confirm", {
name: group.name,
}),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{
id: group.id,
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
},
},
),
[tDelete, tCommon, group.id, group.name, mutateAsync, router],
);
);
},
});
}, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]);
const fullWidth = useMatches({
xs: true,
@@ -60,14 +68,8 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
});
return (
<InlineConfirmButton
variant="subtle"
color="red"
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
fullWidth={fullWidth}
>
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
{tDelete("label")}
</InlineConfirmButton>
</Button>
);
};
@@ -1,14 +1,15 @@
"use client";
import { useState } from "react";
import { Button, Group, Stack, Text, useMatches } from "@mantine/core";
import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserSelect } from "~/components/access/user-select";
import { UserSelectModal } from "~/components/access/user-select-modal";
interface TransferGroupOwnershipProps {
group: {
@@ -18,87 +19,75 @@ interface TransferGroupOwnershipProps {
};
}
interface SelectedOwner {
id: string;
name: string;
}
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
const tTransfer = useI18n("group.action.transfer");
const tCommon = useI18n("common");
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>();
const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({
const { openModal } = useModalAction(UserSelectModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.group.transferOwnership.useMutation({
async onSuccess() {
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
},
});
const handleTransfer = async () => {
if (!selectedOwner) return;
await mutateAsync(
const handleTransfer = useCallback(() => {
openModal(
{
groupId: group.id,
userId: selectedOwner.id,
confirmLabel: tCommon("action.continue"),
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
onSelect: ({ id, name }) => {
openConfirmModal({
title: tTransfer("label"),
children: tTransfer("confirm", {
name: group.name,
username: name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync(
{
groupId: group.id,
userId: id,
},
{
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
);
},
});
},
},
{
onSuccess() {
setInnerOwnerId(selectedOwner.id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: selectedOwner.name,
}),
});
setSelectedOwner(undefined);
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
title: tTransfer("label"),
},
);
};
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]);
const fullWidth = useMatches({
xs: true,
sm: true,
md: false,
});
const presentUserIds: string[] = [];
if (innerOwnerId) presentUserIds.push(innerOwnerId);
if (!selectedOwner) {
return (
<UserSelect
presentUserIds={presentUserIds}
onSelect={setSelectedOwner}
triggerLabel={tTransfer("label")}
triggerProps={{ variant: "subtle", color: "red", fullWidth }}
/>
);
}
return (
<Stack gap="xs" maw={420}>
<Text size="sm">
{tTransfer("confirm", {
name: group.name,
username: selectedOwner.name,
})}
</Text>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}>
{tCommon("action.cancel")}
</Button>
<Button color="red" onClick={() => void handleTransfer()} loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
{tTransfer("label")}
</Button>
);
};
@@ -5,9 +5,11 @@ import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserSelect } from "~/components/access/user-select";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface AddGroupMemberProps {
groupId: string;
@@ -18,23 +20,27 @@ interface AddGroupMemberProps {
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
const tMembersAdd = useI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const handleAddMember = useCallback(
async ({ id }: { id: string }) => {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
[groupId, mutateAsync],
);
const { openModal } = useModalAction(UserSelectModal);
return (
<UserSelect
presentUserIds={presentUserIds}
allowedProviders={allowedProviders}
onSelect={handleAddMember}
triggerLabel={tMembersAdd("label")}
/>
);
const handleAddMember = useCallback(() => {
openModal(
{
// eslint-disable-next-line no-restricted-syntax
async onSelect({ id }) {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
allowedProviders,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
};
@@ -1,11 +1,12 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface RemoveGroupMemberProps {
groupId: string;
@@ -14,25 +15,30 @@ interface RemoveGroupMemberProps {
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
const tCommon = useI18n("common");
const tRemoveMember = useI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
const { openConfirmModal } = useConfirmModal();
const handleRemove = useCallback(async () => {
await mutateAsync({
groupId,
userId: user.id,
const handleRemove = useCallback(() => {
openConfirmModal({
title: tRemoveMember("label"),
children: tRemoveMember("confirm", {
user: user.name ?? "",
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({
groupId,
userId: user.id,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
}, [mutateAsync, groupId, user.id]);
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
return (
<InlineConfirmButton
variant="subtle"
color="red.9"
size="compact-sm"
confirmLabel={tCommon("action.confirm")}
onConfirm={handleRemove}
>
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
{tCommon("action.remove")}
</InlineConfirmButton>
</Button>
);
};
@@ -1,14 +1,15 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core";
import { useMemo, useState } from "react";
import { TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { GroupCreateForm } from "./_components/group-create-form";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { GroupsTable } from "./_groups-table";
interface GroupsListProps {
@@ -17,10 +18,6 @@ interface GroupsListProps {
export const GroupsList = ({ groups }: GroupsListProps) => {
const [search, setSearch] = useState("");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const initialGroupIds = useMemo(
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
[groups],
@@ -34,46 +31,8 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
);
const tGroup = useI18n("group");
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<Stack>
<Group justify="end">
<Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button>
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<GroupCreateForm
key={formKey}
onCancel={closeCreate}
onCreated={() => {
closeCreate();
router.refresh();
}}
/>
</Card>
</Collapse>
<>
<TextInput
leftSection={<IconSearch size={20} stroke={1.5} />}
value={search}
@@ -81,6 +40,13 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
placeholder={`${tGroup("search")}...`}
/>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</Stack>
</>
);
};
export const AddGroupButton = () => {
const tGroup = useI18n("group");
const { openModal } = useModalAction(AddGroupModal);
return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>;
};
@@ -1,61 +0,0 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
interface GroupCreateFormProps {
onCancel: () => void;
onCreated: () => void;
}
export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => {
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation();
const handleSubmit = form.onSubmit(async (values) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
onCreated();
},
onError() {
showErrorNotification({
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
<Group justify="end">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { GroupsList } from "./_client";
import { AddGroupButton, GroupsList } from "./_client";
import classes from "./groups.module.css";
export default async function GroupsListPage() {
@@ -26,7 +26,7 @@ export default async function GroupsListPage() {
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return (
<ManagePageLayout title={tCommon("entity.groups")}>
<ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
<Stack>
{dbEveryoneGroup && (
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
@@ -2,15 +2,18 @@
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => {
export const InviteCreateButton = () => {
const t = useI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
return (
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}>
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
{t("action.new.title")}
</Button>
);
@@ -1,138 +0,0 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
type FormType = {
expirationDate: string;
};
type CreatedInvite = RouterOutputs["invite"]["createInvite"];
interface InviteCreateFormProps {
onClose: () => void;
}
export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const [createdInvite, setCreatedInvite] = useState<CreatedInvite>();
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
const minDate = dayjs().add(1, "hour").toDate();
const maxDate = dayjs().add(6, "months").toDate();
const form = useForm<FormType>({
initialValues: {
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
},
});
if (createdInvite) {
return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />;
}
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
void utils.invite.getAll.invalidate();
setCreatedInvite(result);
},
},
);
})}
>
<Stack>
<Text>{tInvite("action.new.description")}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY HH:mm"
label={tInvite("field.expirationDate.label")}
variant="filled"
{...form.getInputProps("expirationDate")}
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const inviteUrl = createInviteUrl(invite);
return (
<Stack component="output" aria-live="polite">
<Text>
{tInvite.rich("action.copy.description", {
b: bold,
})}
</Text>
<Stack gap="xs">
<Text fw="bold">{tInvite("action.copy.link")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
<Anchor href={inviteUrl} target="_blank" rel="noreferrer">
{inviteUrl}
</Anchor>
</Mark>
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.id}
</Mark>
<Text fw="bold">{tInvite("field.token.label")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.token}
</Mark>
</Stack>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.close")}
</Button>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
onClose();
}}
>
{tInvite("action.copy.button")}
</Button>
)}
</CopyButton>
</Group>
</Stack>
);
};
const bold = (children: ReactNode) => <b>{children}</b>;
const createInviteUrl = ({ id, token }: CreatedInvite) => {
const path = `/auth/invite/${id}?token=${token}`;
return new URL(path, window.location.origin).toString();
};
@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, Collapse, Group, Stack } from "@mantine/core";
import { useCallback, useMemo } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -12,11 +11,9 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { InviteCreateButton } from "./invite-create-button";
import { InviteCreateForm } from "./invite-create-form";
dayjs.extend(relativeTime);
@@ -26,10 +23,6 @@ interface InviteListComponentProps {
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
const t = useI18n("management.page.user.invite");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites,
refetchOnMount: false,
@@ -80,65 +73,28 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
},
});
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<Stack>
<Group justify="end">
<InviteCreateButton onClick={toggleCreate} />
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<InviteCreateForm key={formKey} onClose={closeCreate} />
</Card>
</Collapse>
<MantineReactTable table={table} />
</Stack>
);
return <MantineReactTable table={table} />;
};
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
const t = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(() => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
}, [row.original.id, mutate, utils]);
openConfirmModal({
title: t("action.delete.title"),
children: t("action.delete.description"),
onConfirm: () => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
},
});
}, [openConfirmModal, row.original.id, mutate, utils, t]);
return (
<InlineConfirmActionIcon
variant="subtle"
color="red"
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.title")}
>
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
<IconTrash color="red" size={20} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -5,7 +5,9 @@ import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getI18n } from "@homarr/translation/server";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { InviteCreateButton } from "./_components/invite-create-button";
import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() {
@@ -22,7 +24,15 @@ export default async function InvitesOverviewPage() {
const initialInvites = await api.invite.getAll();
return (
<ManagePageLayout title={t("title")}>
<ManagePageLayout
title={t("title")}
primaryAction={
<ManageMobilePrimaryAction>
<InviteCreateButton />
</ManageMobilePrimaryAction>
}
floatingPrimaryAction
>
<InviteListComponent initialInvites={initialInvites} />
</ManagePageLayout>
);
@@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-popover";
import { PreviewDimensionsPopover } from "./_dimension-popover";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
@@ -32,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const t = useI18n();
const utils = clientApi.useUtils();
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
@@ -97,6 +98,13 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
});
}, [editMode, t]);
const openDimensionsModal = useCallback(() => {
openPreviewDimensionsModal({
dimensions,
setDimensions,
});
}, [dimensions, openPreviewDimensionsModal]);
const updateOptions = useCallback(
({ newOptions }: { newOptions: Record<string, unknown> }) =>
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
@@ -154,21 +162,15 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
</ActionIcon>
</Affix>
<Affix bottom={12} right={72 + 120}>
<PreviewDimensionsPopover
dimensions={dimensions}
setDimensions={setDimensions}
target={(onClick) => (
<ActionIcon
size={48}
variant="default"
radius="xl"
aria-label={t("widgetPreview.dimensions.title")}
onClick={onClick}
>
<IconDimensions size={24} />
</ActionIcon>
)}
/>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
aria-label={t("widgetPreview.dimensions.title")}
>
<IconDimensions size={24} />
</ActionIcon>
</Affix>
</>
);
@@ -0,0 +1,50 @@
"use client";
import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.dimensions,
});
const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values);
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("widgetPreview.dimensions.title"),
});
export interface Dimensions {
width: number;
height: number;
}
@@ -1,78 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
interface PreviewDimensionsPopoverProps {
target: (onClick: () => void) => ReactNode;
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => {
const t = useI18n();
const [opened, { close, open }] = useDisclosure(false);
const form = useForm({
initialValues: dimensions,
});
const handleSubmit = (values: Dimensions) => {
setDimensions(values);
close();
};
const handleClose = () => {
form.setValues(dimensions);
close();
};
const handleToggle = () => {
if (opened) {
handleClose();
return;
}
open();
};
return (
<Popover
opened={opened}
onChange={(nextOpened) => {
if (nextOpened) open();
if (!nextOpened) handleClose();
}}
position="top-end"
width="min(320px, calc(100vw - 2rem))"
withArrow
>
<Popover.Target>{target(handleToggle)}</Popover.Target>
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={handleClose}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
</Popover.Dropdown>
</Popover>
);
};
export interface Dimensions {
width: number;
height: number;
}
@@ -1,130 +0,0 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
import type { ButtonProps } from "@mantine/core";
export interface AccessPickerOption<T> {
value: string;
label: string;
keywords?: readonly string[];
content: ReactNode;
item: T;
}
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
interface AccessPickerProps<T> {
label: string;
searchPlaceholder: string;
emptyMessage: string;
triggerLabel: string;
options: AccessPickerOption<T>[];
isPending: boolean;
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
triggerProps?: AccessPickerTriggerProps;
}
export const AccessPicker = <T,>({
label,
searchPlaceholder,
emptyMessage,
triggerLabel,
options,
isPending,
onSelect,
triggerProps,
}: AccessPickerProps<T>) => {
const [search, setSearch] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
requestAnimationFrame(() => combobox.focusTarget());
},
onDropdownOpen: () => {
combobox.selectFirstOption();
requestAnimationFrame(() => combobox.focusSearchInput());
},
});
const normalizedSearch = search.trim().toLowerCase();
const filteredOptions = options.filter((option) => {
if (!normalizedSearch) return true;
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
return searchableText.includes(normalizedSearch);
});
const visibleOptions = filteredOptions.slice(0, 5);
const handleSelect = (value: string) => {
const option = options.find((item) => item.value === value);
if (!option || isSubmitting) return;
combobox.closeDropdown();
setIsSubmitting(true);
const submit = async () => {
try {
await onSelect(option);
} finally {
setIsSubmitting(false);
}
};
void submit().catch(() => undefined);
};
return (
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
<Combobox.Target targetType="button">
<Button
{...triggerProps}
onClick={() => combobox.toggleDropdown()}
rightSection={
<Group gap={4} wrap="nowrap">
{triggerProps?.rightSection}
<Combobox.Chevron />
</Group>
}
loading={isSubmitting}
disabled={isSubmitting || triggerProps?.disabled}
>
{triggerLabel}
</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
placeholder={searchPlaceholder}
aria-label={label}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Combobox.Options>
{isPending && (
<Center p="md">
<LoaderPlaceholder />
</Center>
)}
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
{!isPending &&
visibleOptions.map((option) => (
<Combobox.Option value={option.value} key={option.value}>
{option.content}
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
const LoaderPlaceholder = () => <Loader size="sm" />;
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -10,7 +11,7 @@ import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelect } from "./group-select";
import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
@@ -23,6 +24,8 @@ export const GroupAccessForm = <TPermission extends string>({
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -33,6 +36,23 @@ export const GroupAccessForm = <TPermission extends string>({
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
@@ -59,21 +79,9 @@ export const GroupAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<GroupSelect
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
onSelect={(group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveGroup")}
</Button>
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={tGroup("label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tGroup("notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});
@@ -1,66 +0,0 @@
"use client";
import { Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
interface GroupSelectProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const GroupSelect = ({
withPermissions,
presentGroupIds,
onSelect,
triggerLabel,
triggerProps,
}: GroupSelectProps) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
value: group.id,
label: group.name,
keywords: getGroupPermissions(group) ?? [],
item: group,
content: <Text size="sm">{group.name}</Text>,
}));
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
onSelect({
id: group.id,
name: group.name,
permissions: getGroupPermissions(group),
});
return (
<AccessPicker
label={tGroup("label")}
searchPlaceholder={tGroup("label")}
emptyMessage={tGroup("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
const getGroupPermissions = (group: SelectableGroup) => {
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
return undefined;
};
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -10,7 +11,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelect } from "./user-select";
import { UserSelectModal } from "./user-select-modal";
export interface FormProps<TPermission extends string> {
entity: {
@@ -40,6 +41,8 @@ export const UsersAccessForm = <TPermission extends string>({
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -49,8 +52,25 @@ export const UsersAccessForm = <TPermission extends string>({
})),
},
});
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
if (entity.ownerId) presentUserIds.push(entity.ownerId);
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
@@ -81,21 +101,9 @@ export const UsersAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<UserSelect
presentUserIds={presentUserIds}
onSelect={(user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveUser")}
</Button>
@@ -0,0 +1,103 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
email: currentUser.email ?? null,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={tUser("label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={tUser("notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
@@ -1,75 +0,0 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableUser = RouterOutputs["user"]["selectable"][number];
interface UserSelectProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const UserSelect = ({
presentUserIds,
allowedProviders,
onSelect,
triggerLabel,
triggerProps,
}: UserSelectProps) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
value: user.id,
label: user.name ?? "",
keywords: [user.email ?? ""],
item: user,
content: (
<Group wrap="nowrap" gap="xs">
<UserAvatar user={user} size="xs" />
<Stack gap={0}>
<Text size="sm">{user.name}</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</Stack>
</Group>
),
}));
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
onSelect({
id: user.id,
name: user.name ?? "",
image: user.image ?? "",
email: user.email ?? null,
});
return (
<AccessPicker
label={tUser("label")}
searchPlaceholder={tUser("label")}
emptyMessage={tUser("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
@@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -2001,6 +2001,7 @@ const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
@@ -2053,15 +2054,23 @@ const ThreadListItem = () => {
}
};
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
};
if (renameOpened) {
@@ -2143,19 +2152,16 @@ const ThreadListItem = () => {
>
<IconFileExport size={14} />
</ActionIcon>
<InlineConfirmActionIcon
<ActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
onClick={deleteConversation}
>
<IconTrash size={14} />
</InlineConfirmActionIcon>
</ActionIcon>
</Group>
)}
<ActionIcon
@@ -11,17 +11,16 @@ import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
previewRowLimit?: number;
fitFullLayout?: boolean;
className?: string;
}
const compactBoardLayoutThumbnailRows = 12;
export const maxBoardLayoutThumbnailRows = 48;
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({
preview,
label,
previewRowLimit = compactBoardLayoutThumbnailRows,
fitFullLayout = false,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
@@ -35,7 +34,7 @@ export const BoardLayoutThumbnail = ({
}
const elements = projectBoardLayout(preview, layout, layout);
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
const roots = preview.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
@@ -95,21 +94,9 @@ export const BoardLayoutThumbnail = ({
}}
>
{laneElements.map((element) => {
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
const nestedItems =
element.type === "section"
? elements.filter(
(candidate) =>
candidate.type === "item" &&
candidate.sectionId === element.id &&
candidate.xOffset >= 0 &&
candidate.xOffset < renderedWidth &&
candidate.yOffset >= 0 &&
candidate.yOffset < renderedHeight &&
candidate.width > 0 &&
candidate.height > 0,
)
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
: [];
return (
<span
@@ -117,16 +104,16 @@ export const BoardLayoutThumbnail = ({
aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
}}
>
{element.type === "section" ? (
<span
className={classes.containerContents}
style={{
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
}}
>
{nestedItems.map((nestedItem) => (
@@ -136,11 +123,11 @@ export const BoardLayoutThumbnail = ({
style={{
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
nestedItem.width,
renderedWidth - nestedItem.xOffset,
element.width - nestedItem.xOffset,
)}`,
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
nestedItem.height,
renderedHeight - nestedItem.yOffset,
element.height - nestedItem.yOffset,
)}`,
}}
>
@@ -15,10 +15,6 @@
outline: none;
}
.results {
min-height: 0;
}
.searchInput {
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
@@ -48,19 +44,3 @@
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-md);
}
@media (max-width: $mantine-breakpoint-sm) {
.modalContent {
height: calc(100dvh - var(--modal-y-offset) * 2);
}
.modalBody,
.content {
min-height: 0;
height: 100%;
}
.results {
flex: 1;
}
}
@@ -30,12 +30,11 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
export interface BoardSwitcherControls {
open: () => void;
@@ -62,12 +61,18 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
enabled: isOpen,
});
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
},
);
const switcherBoards = useMemo(
() => boards.filter((board) => board.id !== currentBoard?.id),
() => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
[boards, currentBoard?.id],
);
const filteredBoards = useMemo(() => {
@@ -86,7 +91,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const spotlightAction = useMemo(
() => ({
@@ -223,13 +228,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
radius="xl"
classNames={{ input: classes.searchInput }}
/>
<ScrollArea.Autosize
mah="min(80vh, 42rem)"
type="never"
scrollbars="y"
overscrollBehavior="contain"
className={classes.results}
>
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
{results}
</ScrollArea.Autosize>
</Stack>
@@ -336,7 +335,7 @@ const getBoardSwitcherResults = ({
<BoardLayoutThumbnail
preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
previewRowLimit={boardSwitcherPreview.previewRowLimit}
fitFullLayout
/>
</Card.Section>
<Card.Section withBorder px="sm" py={6}>
@@ -1,7 +1,21 @@
.refreshIndicator {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
pointer-events: none;
}
.settingsButton {
position: absolute;
top: calc(var(--mantine-spacing-sm) / 2);
right: calc(var(--mantine-spacing-sm) / 2);
top: -34px;
right: 14px;
z-index: 201;
opacity: 0;
pointer-events: none;
@@ -52,6 +66,7 @@
@media (max-width: 768px) {
.settingsButton {
right: 4px;
opacity: 1;
pointer-events: auto;
}
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary";
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
import {
createWidgetRuntimeState,
getWidgetQueryKeys,
getWidgetRuntimeQueries,
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false,
@@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
[definition, item, settings],
);
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
query.queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options: menuItem.options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
}) > 0;
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
@@ -323,6 +343,11 @@ const LoadedBoardItemContent = ({
/>
</ErrorBoundary>
</Box>
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
<Loader size="xs" />
</Box>
)}
</WidgetCardShell>
);
@@ -336,7 +361,6 @@ const LoadedBoardItemContent = ({
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
disabled={isAdvanced}
>
<Box
ref={sourceRef}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -8,21 +8,20 @@ import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: WidgetPreviewDimensions;
previewDimensions: { width: number; height: number; scale?: number };
resetErrorBoundary?: () => void;
}
@@ -143,14 +142,11 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
onConfirm={() => removeItem({ itemId: item.id })}
>
{tItem("action.remove")}
</InlineConfirmMenuItem>
/>
</Menu.Dropdown>
</Menu>
);
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
@@ -31,7 +30,6 @@ interface InnerProps extends OpenItemMoveModalInput {
board: Board;
currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
}
interface MoveTarget {
@@ -59,7 +57,8 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tLandmark = useI18n("board.landmark");
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
@@ -236,7 +235,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
export const useOpenItemMoveModal = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const registeredGridEditors = useRegisteredGridEditors();
const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal);
@@ -247,10 +245,9 @@ export const useOpenItemMoveModal = () => {
board,
currentLayoutId,
commitSectionGrids,
registeredGridEditors,
});
},
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
[board, commitSectionGrids, currentLayoutId, openModal],
);
};
@@ -1,14 +0,0 @@
.mobileDrawerContent {
height: min(80dvh, rem(640px));
max-height: 80dvh;
overscroll-behavior: contain;
}
.mobileDrawerBody {
padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom));
}
.mobileActionList {
display: flex;
flex-direction: column;
}
@@ -2,7 +2,7 @@
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -20,12 +20,10 @@ import { getWidgetName } from "@homarr/definitions";
import { translateIfNecessary } from "@homarr/translation";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
@@ -33,18 +31,14 @@ import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
import classes from "./widget-context-menu.module.css";
type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }];
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: WidgetPreviewDimensions;
previewDimensions: { width: number; height: number; scale?: number };
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
disabled?: boolean;
children: ReactNode;
}
@@ -54,7 +48,6 @@ export const WidgetContextMenu = ({
previewDimensions,
widgetRuntimeRef,
sourceRef,
disabled = false,
children,
}: WidgetContextMenuProps) => {
const { data: session } = useSession();
@@ -141,12 +134,11 @@ export const WidgetContextMenu = ({
[item.id, persistBoard],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, ToggleOption[1]>;
return Object.entries(rawOptions).filter(
([, option]) => option.type === "switch" && !option.skipContextMenu,
) as ToggleOption[];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
}, [canConfigureWidget, definition, settings]);
const widgetContextActions =
@@ -164,7 +156,6 @@ export const WidgetContextMenu = ({
}) ?? [];
const openEditModal = useCallback(() => {
setMenuOpened(false);
openModal(
{
kind: item.kind,
@@ -231,19 +222,6 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return;
setMenuOpened(false);
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}, [item.id, openAdvancedFocus, sourceRef]);
const handleCloseMenu = useCallback(() => {
setMenuOpened(false);
}, []);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
@@ -260,11 +238,22 @@ export const WidgetContextMenu = ({
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item closeMenuOnClick leftSection={<IconMaximize size={16} />} onClick={handleOpenAdvancedFocus}>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
@@ -330,39 +319,11 @@ export const WidgetContextMenu = ({
>
{tMenu("settings")}
</Menu.Item>
</WidgetContextMenuDropdown>
</Menu.Dropdown>
</Menu>
);
};
interface WidgetContextMenuDropdownProps {
opened: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => {
const isMobile = useIsMobile();
if (!isMobile) return <Menu.Dropdown>{children}</Menu.Dropdown>;
return (
<Drawer
opened={opened}
onClose={onClose}
position="bottom"
size="min(80dvh, 40rem)"
title={title}
classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }}
>
<div className={classes.mobileActionList} role="menu" aria-orientation="vertical" data-menu-dropdown>
{children}
</div>
</Drawer>
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
matchesQuery: (queryKey: QueryKey) => boolean;
@@ -49,14 +49,12 @@
}
.widgetIcon {
width: var(--mantine-font-size-sm);
height: var(--mantine-font-size-sm);
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: var(--mantine-font-size-xs);
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
@@ -96,7 +94,7 @@
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: var(--mantine-font-size-xxs);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
@@ -2,8 +2,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: clip;
overflow-y: visible;
overflow: clip;
}
.viewport[data-board-hydrated="false"] {
@@ -46,13 +45,4 @@
--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-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));
}
@@ -0,0 +1,70 @@
"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 { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
@@ -59,14 +59,11 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<InlineConfirmMenuItem
color="red"
confirmLabel={tContainer("remove.message")}
leftSection={<IconTrash size={16} />}
<BoardRemoveConfirmationMenuItem
label={tContainer("action.remove")}
confirmationLabel={tContainer("remove.message")}
onConfirm={() => removeContainer({ id: section.id })}
>
{tContainer("action.remove")}
</InlineConfirmMenuItem>
/>
</Menu.Dropdown>
</Menu>
);
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: var(--homarr-z-index-widget-focus);
z-index: 10;
}
.board-grid-entry[data-grid-preview="true"] {
@@ -3,22 +3,6 @@
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
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,
@@ -27,14 +11,17 @@
.viewport.scrollableViewport {
overflow-x: hidden;
overflow-y: auto;
/* The grid content inside has a fixed pixel width matching this viewport's full
outer width (needed for the drag/resize math), so there's no room to reserve
for a scrollbar without it eating into - or overlapping - the content on
whichever side it renders. Hiding the native scrollbar track entirely (wheel/
touch/drag scrolling still works) avoids that instead of trying to reserve
space for it. */
scrollbar-width: none;
-ms-overflow-style: none;
/* --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 - a nested scrollable
container 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, which only kicks in when the property is entirely unset, not merely
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;
}
@@ -148,25 +148,8 @@ export const SectionGrid = ({
// 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 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 viewportHeight = Math.max(
1,
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
);
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
// 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
// deliberately smaller by outerCardInset to match the container's actual visible card size.
@@ -300,7 +283,6 @@ export const SectionGrid = ({
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
@@ -335,9 +317,6 @@ export const SectionGrid = ({
);
};
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR =
'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"]);
+1 -2
View File
@@ -27,12 +27,11 @@ export const MainHeader = async ({
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const resolvedLogoWithTitle = logoWithTitle ?? <HomarrLogoWithTitle size="md" />;
return (
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={resolvedLogoWithTitle}
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
@@ -2,15 +2,6 @@
overflow: hidden;
}
.header[data-visible="false"] {
display: none;
}
.desktopContent {
width: 100%;
height: 100%;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -73,40 +64,6 @@
flex: 0 0 auto;
}
.userMenu {
display: flex;
padding-inline: rem(6px);
}
.mobileContent {
display: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--mantine-spacing-xs);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-xs);
}
.mobileZone {
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.mobileZone::-webkit-scrollbar {
display: none;
}
.mobileZone[data-zone="left"] {
justify-content: flex-start;
}
.mobileZone[data-zone="right"] {
justify-content: flex-end;
max-width: 72vw;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
@@ -154,12 +111,18 @@
}
@media (max-width: $mantine-breakpoint-sm) {
.desktopContent {
display: none;
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
}
.mobileContent {
display: grid;
.zone {
gap: var(--mantine-spacing-xs);
}
.search {
width: auto;
flex: 0 0 auto;
}
}
@@ -2,8 +2,24 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -73,10 +89,7 @@ export const ConfigurableHeader = ({
return (
<BoardSwitcher>
{(boardSwitcher) => {
const renderItems = (
items: HeaderItem[],
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
) =>
const renderItems = (items: HeaderItem[]) =>
items.map((item) => (
<HeaderItem
key={getHeaderItemKey(item)}
@@ -90,8 +103,8 @@ export const ConfigurableHeader = ({
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={display.search}
logoDisplay={display.logo}
searchDisplay={headerPreferences.searchDisplay}
logoDisplay={headerPreferences.logoDisplay}
boardSwitcher={boardSwitcher}
assistant={assistant}
openDockerModal={openDockerModal}
@@ -101,50 +114,31 @@ export const ConfigurableHeader = ({
/>
));
const desktopDisplay = {
search: headerPreferences.searchDisplay,
logo: headerPreferences.logoDisplay,
};
const mobileDisplay = { search: "icon", logo: "logo" } as const;
return (
<>
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-visible={headerPreferences.visible}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.desktopContent}>
{headerPreferences.visible ? (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.content}>
<div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left, desktopDisplay)}
{renderItems(headerPreferences.zones.left)}
</div>
<div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center, desktopDisplay)}
{renderItems(headerPreferences.zones.center)}
</div>
<div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right, desktopDisplay)}
{renderItems(headerPreferences.zones.right)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</div>
<div className={classes.mobileContent}>
<Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left, mobileDisplay)}
</Group>
<Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
{renderItems(headerPreferences.zones.center, mobileDisplay)}
{renderItems(headerPreferences.zones.right, mobileDisplay)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</Group>
</div>
</AppShellHeader>
{!headerPreferences.visible ? (
</AppShellHeader>
) : (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
@@ -154,7 +148,7 @@ export const ConfigurableHeader = ({
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
) : null}
)}
<LazySpotlight />
</>
);
@@ -288,9 +282,7 @@ const HeaderItem = ({
return (
<Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled>
<Avatar size={22} radius="sm">
?
</Avatar>
<IconLayoutDashboard size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
@@ -299,7 +291,7 @@ const HeaderItem = ({
<Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm">
{getBoardInitial(board.name)}
<IconLayoutDashboard size={17} stroke={1.5} />
</Avatar>
</HeaderButton>
</Tooltip>
@@ -307,7 +299,14 @@ const HeaderItem = ({
}
if (item.id === "logo") {
return <HeaderLogo display={logoDisplay} logo={logo} logoWithTitle={logoWithTitle} label={label("logo")} />;
return (
<HeaderLogo
display={logoDisplay}
logo={logo}
logoWithTitle={logoWithTitle}
label={label("logo")}
/>
);
}
if (item.id === "boardEdit") return boardEditAction;
@@ -408,19 +407,12 @@ const HeaderItem = ({
return (
<TourTarget id="board-user-menu">
<div className={classes.userMenu}>
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</div>
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
);
};
const getBoardInitial = (name: string) => {
const firstCharacter = Array.from(name.trim())[0] ?? "?";
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
};
@@ -54,13 +54,11 @@ export const LazySpotlight = () => {
};
}, []);
if (!isMounted && !isPreloaded) return null;
return (
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
{isMounted && <Spotlight />}
<Spotlight />
</>
);
) : null;
};
+2 -8
View File
@@ -22,18 +22,12 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
const isHeaderVisible = hasHeader && headerPreferences.visible;
return (
<AppShell
{...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={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
navbar={
hasNavigation
? {
@@ -4,6 +4,7 @@ import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
import {
IconAffiliateFilled,
IconAppsFilled,
@@ -15,7 +16,6 @@ import {
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
@@ -48,7 +48,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useI18n("onboardingTour.manage");
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const isMobile = useIsMobile();
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
@@ -6,7 +6,6 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
import {
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconLogin,
IconLogout,
IconReplace,
@@ -17,8 +16,6 @@ import {
import type { RouterOutputs } from "@homarr/api";
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 { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
@@ -49,8 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board");
const session = useSession();
const board = useOptionalBoard();
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
const { logoutUrl } = useAuthContext();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
@@ -72,15 +67,6 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</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
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
@@ -1,99 +0,0 @@
"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>
);
}
@@ -0,0 +1,80 @@
"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>
);
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more