Compare commits

..
134 changed files with 3116 additions and 3918 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,
-5
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
@@ -115,8 +112,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
+4 -8
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:
@@ -410,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} />
-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";
@@ -46,6 +46,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
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({
@@ -83,20 +84,29 @@ 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);
@@ -226,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,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,14 +31,11 @@ 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>;
@@ -139,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 =
@@ -228,18 +222,6 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return;
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}</>;
}
@@ -257,10 +239,21 @@ export const WidgetContextMenu = ({
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
<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 />
@@ -326,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;
@@ -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>
);
+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-desktop-visible="false"] {
display: none;
}
.desktopContent {
width: 100%;
height: 100%;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -73,41 +64,6 @@
flex: 0 0 auto;
}
.mobileContent {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-xs);
}
.mobileIdentity,
.mobileActions {
min-width: 0;
}
.mobileIdentity {
flex: 1 1 auto;
overflow: hidden;
}
.mobileIdentity > :last-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.mobileIdentity h2 {
overflow: hidden;
text-overflow: ellipsis;
}
.mobileActions {
flex: 0 0 auto;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
@@ -155,21 +111,18 @@
}
@media (max-width: $mantine-breakpoint-sm) {
.header[data-desktop-visible="false"] {
display: block;
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
}
.desktopContent {
display: none;
.zone {
gap: var(--mantine-spacing-xs);
}
.mobileContent {
display: flex;
}
.floatingControls,
.floatingControlsCorner {
display: none;
.search {
width: auto;
flex: 0 0 auto;
}
}
@@ -2,7 +2,15 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrandDocker,
IconHome,
@@ -108,15 +116,14 @@ export const ConfigurableHeader = ({
return (
<>
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-desktop-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}
@@ -130,31 +137,8 @@ export const ConfigurableHeader = ({
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</div>
<div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
</Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
{actions}
{boardEditAction}
{boardSettingsAction}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group>
</div>
</AppShellHeader>
{!headerPreferences.visible ? (
</AppShellHeader>
) : (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
@@ -164,7 +148,7 @@ export const ConfigurableHeader = ({
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
) : null}
)}
<LazySpotlight />
</>
);
@@ -307,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>
@@ -315,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;
@@ -425,5 +416,3 @@ const HeaderItem = ({
</TourTarget>
);
};
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
@@ -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 -2
View File
@@ -22,12 +22,12 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
const isHeaderVisible = hasHeader && headerPreferences.visible;
return (
<AppShell
{...backgroundProps}
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();
@@ -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>
);
}
-1
View File
@@ -43,7 +43,6 @@
"lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
-20
View File
@@ -1,16 +1,5 @@
import type { QueryKey } from "@tanstack/react-query";
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
interface ForbiddenTrpcError {
data: { code: "FORBIDDEN" };
}
export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => {
if (!isRecord(error) || !isRecord(error.data)) return false;
return error.data.code === "FORBIDDEN";
};
// RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000;
@@ -19,7 +8,6 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]);
// Beszel system stats are large time-series payloads and were deliberately kept out of
// the persisted cache in #6289; live stats come from an SSE subscription instead.
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
@@ -78,11 +66,3 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
return isWidgetDataTrpcPath(path.join("."));
};
export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
const joinedPath = path.join(".");
return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath);
};
+5 -12
View File
@@ -86,14 +86,9 @@ interface BoardItemPlacementRectangle {
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const defaultManageOverviewPreviewRows = 12;
const maxManageOverviewPreviewRows = 48;
const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z
.object({
fullPreview: z.boolean().default(false),
previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(),
userId: z.string().optional(),
})
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
.optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
@@ -316,8 +311,6 @@ export const boardRouter = createTRPCRouter({
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows;
if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows;
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
@@ -395,7 +388,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, previewRowLimit),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
@@ -420,7 +413,7 @@ export const boardRouter = createTRPCRouter({
inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0),
lt(sectionLayouts.yOffset, previewRowLimit),
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0),
),
@@ -455,7 +448,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, previewContainerSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, previewRowLimit),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
@@ -84,13 +84,15 @@ export const getDockerReconciliationAsync = async (db: Database) => {
const appAmbiguous =
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
const ambiguous = integrationAmbiguous || appAmbiguous;
const state = getReconciliationState({
integrationMoved,
linked,
represented: Boolean(integration || app),
recognized: Boolean(match),
});
const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
const state: DockerReconciliationState = integrationMoved
? "moved"
: linked
? "linked"
: integration || app
? "represented"
: match
? "newRecognized"
: "newApp";
return {
candidateKey: container.resourceId,
@@ -101,7 +103,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
match,
urlCandidates,
state,
nextAction,
nextAction: integrationAmbiguous
? ("reviewIntegration" as const)
: appAmbiguous
? ("viewRepresentation" as const)
: nextActionForState(state),
representation: {
integration: integration
? {
@@ -141,100 +147,87 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
status: endpoint.status,
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
})),
services: reconciliation.candidates.map((candidate) => {
const integration = candidate.representation.integration;
const app = candidate.representation.app;
const itemCount = integration?.itemCount;
return {
key: candidate.candidateKey,
identity: {
endpointId: candidate.endpointId,
nativeId: candidate.nativeId,
name: candidate.container.name,
integration,
app,
services: reconciliation.candidates.map((candidate) => ({
key: candidate.candidateKey,
identity: {
endpointId: candidate.endpointId,
nativeId: candidate.nativeId,
name: candidate.container.name,
integration: candidate.representation.integration,
app: candidate.representation.app,
},
layers: [
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
{
layer: "integrationConfiguration" as const,
status: integrationHealthStatus(
candidate.state,
candidate.match?.kind,
Boolean(candidate.representation.integration),
),
nextAction:
candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.state === "newRecognized"
? ("setupIntegration" as const)
: ("none" as const),
},
layers: [
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
{
layer: "integrationConfiguration" as const,
status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)),
nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction),
},
{
layer: "authentication" as const,
status: "notObserved" as const,
nextAction: getAuthenticationNextAction(
Boolean(integration),
Boolean(candidate.match),
candidate.nextAction,
),
},
{
layer: "apiRequest" as const,
status: "notObserved" as const,
nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const),
},
{
layer: "appRepresentation" as const,
status: app ? ("linked" as const) : ("missing" as const),
nextAction: getAppRepresentationNextAction(
Boolean(app),
candidate.representation.signals.ambiguous,
candidate.nextAction,
),
},
{
layer: "widgetConfiguration" as const,
status: getWidgetConfigurationStatus(itemCount),
nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const),
},
{
layer: "widgetQuery" as const,
status: "notObserved" as const,
nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
};
}),
{
layer: "authentication" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("testConnection" as const)
: candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.match
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "apiRequest" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("openIntegrationDiagnostics" as const)
: ("none" as const),
},
{
layer: "appRepresentation" as const,
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
nextAction: candidate.representation.app
? ("none" as const)
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
? ("viewRepresentation" as const)
: ("createApp" as const),
},
{
layer: "widgetConfiguration" as const,
status: candidate.representation.integration
? candidate.representation.integration.itemCount > 0
? ("linked" as const)
: ("unused" as const)
: ("notApplicable" as const),
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount === 0
? ("addWidget" as const)
: ("none" as const),
},
{
layer: "widgetQuery" as const,
status: "notObserved" as const,
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount > 0
? ("openBoard" as const)
: ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
})),
};
};
const normalizeName = (value: string) => value.trim().toLowerCase();
interface ReconciliationStateInput {
integrationMoved: boolean;
linked: boolean;
represented: boolean;
recognized: boolean;
}
const getReconciliationState = ({
integrationMoved,
linked,
represented,
recognized,
}: ReconciliationStateInput): DockerReconciliationState => {
if (integrationMoved) return "moved";
if (linked) return "linked";
if (represented) return "represented";
if (recognized) return "newRecognized";
return "newApp";
};
const getCandidateNextAction = (
state: DockerReconciliationState,
integrationAmbiguous: boolean,
appAmbiguous: boolean,
) => {
if (integrationAmbiguous) return "reviewIntegration" as const;
if (appAmbiguous) return "viewRepresentation" as const;
return nextActionForState(state);
};
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
if (state === "newRecognized") return "setupIntegration";
if (state === "newApp") return "createApp";
@@ -242,42 +235,6 @@ const nextActionForState = (state: DockerReconciliationState): DockerReconciliat
return "viewRepresentation";
};
const getIntegrationConfigurationNextAction = (
state: DockerReconciliationState,
nextAction: DockerReconciliationNextAction,
) => {
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
if (state === "newRecognized") return "setupIntegration" as const;
return "none" as const;
};
const getAuthenticationNextAction = (
hasIntegration: boolean,
hasMatch: boolean,
nextAction: DockerReconciliationNextAction,
) => {
if (hasIntegration) return "testConnection" as const;
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
if (hasMatch) return "setupIntegration" as const;
return "none" as const;
};
const getAppRepresentationNextAction = (
hasApp: boolean,
ambiguous: boolean,
nextAction: DockerReconciliationNextAction,
) => {
if (hasApp) return "none" as const;
if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const;
return "createApp" as const;
};
const getWidgetConfigurationStatus = (itemCount: number | undefined) => {
if (itemCount === undefined) return "notApplicable" as const;
if (itemCount > 0) return "linked" as const;
return "unused" as const;
};
const integrationHealthStatus = (
state: DockerReconciliationState,
kind: IntegrationKind | undefined,
@@ -285,6 +242,5 @@ const integrationHealthStatus = (
) => {
if (!kind) return "notApplicable" as const;
if (state === "moved") return "changed" as const;
if (hasIntegration) return "configured" as const;
return "missing" as const;
return hasIntegration ? ("configured" as const) : ("missing" as const);
};
@@ -14,7 +14,6 @@ import { dockerRouter } from "../../docker/docker-router";
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
const resetDockerSingletonMock = vi.hoisted(() => vi.fn());
const getDockerDataAsyncMock = vi.hoisted(() =>
vi.fn<
() => Promise<{
@@ -63,7 +62,7 @@ vi.mock("@homarr/docker", () => ({
},
},
],
reset: resetDockerSingletonMock,
reset: () => undefined,
},
}));
vi.mock("@homarr/request-handler/docker", () => ({
@@ -502,7 +501,6 @@ test("invalidates the shared Docker inventory on explicit refresh", async () =>
await createAdminCaller().refreshInventory();
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
expect(resetDockerSingletonMock).toHaveBeenCalledOnce();
});
test("projects persisted service layers without inventing runtime health", async () => {
+1 -4
View File
@@ -50,10 +50,7 @@ export const immichRouter = createTRPCRouter({
)
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
return mockWidgetData.immichAlbums.slice(0, input.limit);
}
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
const innerHandler = immichAlbumsRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
+1 -2
View File
@@ -6,8 +6,7 @@ import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness
const harnessCommandNames = new Set(["harness", "local-harness"]);
function isHarnessInvocation(argv: string[]): boolean {
const command = argv[2];
return command !== undefined && harnessCommandNames.has(command);
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
}
async function main(): Promise<void> {
-2
View File
@@ -1,8 +1,6 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./board-create/board-create-form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";
@@ -1,5 +1,3 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
@@ -8,16 +6,12 @@ import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
interface BoardCreateFormProps {
onCancel?: () => void;
}
export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
export const AddBoardModal = createModal(({ actions }) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
@@ -44,6 +38,7 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
if (!boardNameStatus.canSubmit) return;
mutate(values, {
onSuccess: (result) => {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.create.notification.success.title"),
message: tBoard("action.create.notification.success.message", { name: values.name }),
@@ -61,17 +56,24 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
});
})}
>
<Stack maw={720}>
<Stack>
<TextInput
label={tCommon("field.name")}
withAsterisk
autoFocus
data-autofocus
{...form.getInputProps("name")}
description={<BoardNameAvailability status={boardNameStatus} />}
description={
boardNameStatus.description ? (
<span>
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
</span>
) : null
}
/>
<InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider
aria-label={tBoard("field.columnCount.label")}
min={boardColumnCountSchema.minValue ?? undefined}
max={boardColumnCountSchema.maxValue ?? undefined}
step={1}
@@ -85,71 +87,50 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
{...form.getInputProps("isPublic")}
/>
<Group justify="end" wrap="wrap">
{onCancel && (
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
)}
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
}).withOptions({
defaultTitle: (t) => t("management.page.board.action.new.label"),
});
export const useBoardNameStatus = (name: string) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const [debouncedName] = useDebouncedValue(name, 250);
const isValidName = boardNameSchema.safeParse(name).success;
const isDebouncing = name !== debouncedName;
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
enabled: boardNameSchema.safeParse(debouncedName).success,
});
let description: { label: string; icon?: TablerIcon; color?: string } | undefined;
if (debouncedName.trim() !== "") {
if (isDebouncing || isLoading) {
description = {
label: tBoard("action.create.availability.checking"),
};
} else if (boardExists !== undefined) {
if (boardExists) {
description = {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"),
color: "red",
};
} else {
description = {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
};
}
}
}
return {
canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading,
description,
canSubmit: !boardExists && !isLoading,
description:
debouncedName.trim() === ""
? undefined
: isLoading
? {
label: tBoard("action.create.availability.checking"),
}
: boardExists === undefined
? undefined
: boardExists
? {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
color: "red",
}
: {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
},
};
};
export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>;
export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => {
if (!status.description) return null;
const Icon = status.description.icon;
return (
<Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite">
{Icon && <Icon size={16} />}
<span>{status.description.label}</span>
</Group>
);
};
@@ -1,32 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardDuplicateSchema } from "@homarr/validation/board";
interface DuplicateBoardFormProps {
import { createModal } from "../../../modals/src/creator";
import { useBoardNameStatus } from "./add-board-modal";
interface InnerProps {
board: {
id: string;
name: string;
};
onCancel: () => void;
}
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
mode: "controlled",
initialValues: {
name: board.name,
name: innerProps.board.name,
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
@@ -39,21 +36,20 @@ export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps)
return (
<form
onSubmit={form.onSubmit(async (values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
await mutateAsync(
{
...values,
id: board.id,
id: innerProps.board.id,
},
{
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.duplicate.notification.success.title"),
message: tBoard("action.duplicate.notification.success.message"),
});
onCancel();
router.refresh();
},
onError() {
showErrorNotification({
@@ -65,26 +61,39 @@ export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps)
);
})}
>
<Stack maw={720}>
<Stack>
<Text size="sm" c="gray.6">
{tBoard("action.duplicate.message", { name: board.name })}
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
</Text>
<TextInput
label={tCommon("field.name")}
autoFocus
data-autofocus
{...form.getInputProps("name")}
description={<BoardNameAvailability status={boardNameStatus} />}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
withAsterisk
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onCancel}>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tBoard("action.duplicate.title")}
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
}).withOptions({
defaultTitle(t) {
return t("board.action.duplicate.title");
},
});
@@ -0,0 +1,2 @@
export { AddBoardModal } from "./add-board-modal";
export { DuplicateBoardModal } from "./duplicate-board-modal";
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more