merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
|
||||
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
|
||||
const metricStyle = {
|
||||
background: "color-mix(in srgb, currentColor 4%, transparent)",
|
||||
border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as const;
|
||||
|
||||
const readDiagnostics = (queryClient: QueryClient) => {
|
||||
const queries = queryClient.getQueryCache().getAll();
|
||||
const mutations = queryClient.getMutationCache().getAll();
|
||||
const board = document.querySelector("[data-homarr-dev-benchmark-board]");
|
||||
const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
|
||||
const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
|
||||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||||
|
||||
return {
|
||||
active: queries.filter((query) => query.getObserversCount() > 0).length,
|
||||
errors: queries.filter((query) => query.state.status === "error").length,
|
||||
fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
|
||||
pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
|
||||
stale: queries.filter((query) => query.isStale()).length,
|
||||
total: queries.length,
|
||||
readyWidgets,
|
||||
widgetItems,
|
||||
scriptKiB: Math.round(
|
||||
resources
|
||||
.filter((resource) => resource.initiatorType === "script")
|
||||
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
<div style={metricStyle}>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
||||
<output style={{ ...valueStyle, display: "block", fontSize: 22, fontWeight: 600, marginTop: 4 }}>{value}</output>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const update = () => setDiagnostics(readDiagnostics(queryClient));
|
||||
let frame: number | undefined;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== undefined) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
update();
|
||||
});
|
||||
};
|
||||
const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
|
||||
const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
|
||||
const observer = new MutationObserver(scheduleUpdate);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
update();
|
||||
|
||||
return () => {
|
||||
unsubscribeQueries();
|
||||
unsubscribeMutations();
|
||||
observer.disconnect();
|
||||
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, pathname, queryClient]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<section data-homarr-devtools-panel style={{ fontFamily: "system-ui, sans-serif", padding: 16 }}>
|
||||
<header style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Homarr performance</h2>
|
||||
<p style={{ marginBlock: "4px 0", opacity: 0.7 }}>Live state for {pathname}</p>
|
||||
</header>
|
||||
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))" }}>
|
||||
<Metric label="Queries" value={diagnostics.total} />
|
||||
<Metric label="Active" value={diagnostics.active} />
|
||||
<Metric label="Fetching" value={diagnostics.fetching} />
|
||||
<Metric label="Stale" value={diagnostics.stale} />
|
||||
<Metric label="Errors" value={diagnostics.errors} />
|
||||
<Metric label="Mutations" value={diagnostics.pendingMutations} />
|
||||
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
|
||||
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const developmentToolsPlugins = [
|
||||
{
|
||||
id: "tanstack-query",
|
||||
name: "TanStack Query",
|
||||
render: () => <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
id: "homarr-performance",
|
||||
name: "Homarr",
|
||||
defaultOpen: true,
|
||||
render: (_element, { devtoolsOpen }) => <HomarrDevtoolsPanel active={devtoolsOpen} />,
|
||||
},
|
||||
] satisfies TanStackDevtoolsReactPlugin[];
|
||||
|
||||
export const DevelopmentTools = () => (
|
||||
<TanStackDevtools config={{ position: "bottom-right", hideUntilHover: true }} plugins={developmentToolsPlugins} />
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||
|
||||
const queryCacheStorage = {
|
||||
getItem: (_key: string) => null as string | null,
|
||||
async setItem(_key: string, value: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||
console.warn("[query-cache] persist failed", error);
|
||||
});
|
||||
},
|
||||
async removeItem(_key: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||
console.warn("[query-cache] remove failed", error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const createWidgetQueryPersister = () =>
|
||||
createAsyncStoragePersister({
|
||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||
key: queryCacheStoragePrefix,
|
||||
throttleTime: 2000,
|
||||
serialize: (data) => superjson.stringify(data),
|
||||
deserialize: (data) => superjson.parse(data),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const createTrpcError = (code: string) => {
|
||||
const error = Object.assign(new Error(code), { data: { code } });
|
||||
Object.setPrototypeOf(error, TRPCClientError.prototype);
|
||||
return error;
|
||||
};
|
||||
|
||||
describe("createQueryRetry", () => {
|
||||
it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
|
||||
"does not retry the permanent %s response",
|
||||
(code) => {
|
||||
expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("retries transient and network failures only up to the configured limit", () => {
|
||||
const retry = createQueryRetry(3);
|
||||
|
||||
expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
|
||||
expect(retry(2, new Error("connection reset"))).toBe(true);
|
||||
expect(retry(3, new Error("connection reset"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
const nonRetryableTrpcCodes = new Set([
|
||||
"PARSE_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"METHOD_NOT_SUPPORTED",
|
||||
"CONFLICT",
|
||||
"PRECONDITION_FAILED",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"UNPROCESSABLE_CONTENT",
|
||||
]);
|
||||
|
||||
export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
|
||||
if (failureCount >= maxRetries) return false;
|
||||
if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
|
||||
describe("getSessionQueryScope", () => {
|
||||
it("is stable when permission order changes", () => {
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
|
||||
getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes for a different user or permission set", () => {
|
||||
const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
|
||||
|
||||
expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the previous session subtree before requesting a document reload", async () => {
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const onScopeChange = vi.fn();
|
||||
const renderScope = async (currentScope: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
SessionQueryScopeGuard,
|
||||
{ currentScope, initialScope: "a", onScopeChange },
|
||||
createElement("span", null, "session-a-secret"),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await renderScope("a");
|
||||
expect(host.textContent).toBe("session-a-secret");
|
||||
|
||||
await renderScope("b");
|
||||
expect(host.textContent).not.toContain("session-a-secret");
|
||||
expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(onScopeChange).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createElement, useEffect } from "react";
|
||||
|
||||
interface QuerySession {
|
||||
user: {
|
||||
id: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
|
||||
session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
|
||||
|
||||
interface SessionQueryScopeGuardProps {
|
||||
initialScope: string | null;
|
||||
currentScope: string | null;
|
||||
onScopeChange: () => void;
|
||||
}
|
||||
|
||||
export const SessionQueryScopeGuard = ({
|
||||
initialScope,
|
||||
currentScope,
|
||||
onScopeChange,
|
||||
children,
|
||||
}: PropsWithChildren<SessionQueryScopeGuardProps>) => {
|
||||
const hasScopeChanged = initialScope !== currentScope;
|
||||
useEffect(() => {
|
||||
if (hasScopeChanged) onScopeChange();
|
||||
}, [hasScopeChanged, onScopeChange]);
|
||||
|
||||
if (!hasScopeChanged) return children;
|
||||
|
||||
return createElement("div", {
|
||||
"aria-busy": true,
|
||||
"aria-label": "Refreshing session",
|
||||
role: "status",
|
||||
style: { minHeight: "100dvh" },
|
||||
});
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -24,19 +23,24 @@ import { TRPCClientError } from "@trpc/client";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheBuster,
|
||||
dashboardSupportingQueryPolicies,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import type { WidgetDefinition } from "@homarr/widgets";
|
||||
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
process.env.NODE_ENV === "development"
|
||||
? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
|
||||
: () => null;
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -59,21 +63,46 @@ const constructWebsocketUrl = () => {
|
||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||
};
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
});
|
||||
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [persister] = useState(() => createWidgetQueryPersister());
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
lazy: { enabled: true, closeMs: 30_000 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
void wsClient.close();
|
||||
},
|
||||
[wsClient],
|
||||
);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 3,
|
||||
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
@@ -91,26 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as WidgetDefinition & { kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
for (const queryKey of getWidgetQueryKeys(def)) {
|
||||
const path = queryKey[0];
|
||||
if (!Array.isArray(path) || path[0] !== "widget") continue;
|
||||
client.setQueryDefaults(queryKey, { refetchInterval: interval });
|
||||
}
|
||||
client.setQueryDefaults([["widget"]], {
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
}
|
||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
const [trpcClient] = useState(() => {
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
return clientApi.createClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
@@ -150,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
}, [wsClient]);
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
buster: queryCacheBuster,
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</PersistQueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</QueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
position: relative;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
transform: translateY(var(--board-gutter-sticky-offset, 0px));
|
||||
}
|
||||
@@ -1,35 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const useFixedBoardGutters = () => {
|
||||
const [columns, setColumns] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!columns) return;
|
||||
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const columnsRect = columns.getBoundingClientRect();
|
||||
const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0;
|
||||
const stickyTop = Math.max(0, headerBottom + 16);
|
||||
|
||||
for (const gutter of columns.querySelectorAll<HTMLElement>("[data-board-gutter]")) {
|
||||
const rect = gutter.getBoundingClientRect();
|
||||
if (gutter.offsetWidth <= 0) continue;
|
||||
|
||||
const effectiveScale = rect.width / gutter.offsetWidth;
|
||||
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue;
|
||||
|
||||
const previousOffset = Number(gutter.dataset.stickyOffset ?? 0);
|
||||
const uncorrectedTop = rect.top - previousOffset * effectiveScale;
|
||||
const maximumTop = columnsRect.bottom - rect.height;
|
||||
const targetTop = Math.min(stickyTop, maximumTop);
|
||||
const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale;
|
||||
|
||||
gutter.dataset.stickyOffset = String(nextOffset);
|
||||
gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`);
|
||||
}
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== 0) return;
|
||||
frame = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
||||
resizeObserver.observe(columns);
|
||||
document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener("scroll", scheduleUpdate, { capture: true });
|
||||
window.removeEventListener("scroll", scheduleUpdate);
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
};
|
||||
}, [columns]);
|
||||
|
||||
return setColumns;
|
||||
};
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const columnsRef = useFixedBoardGutters();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
|
||||
if (!currentLayout) throw new Error("Expected the board to contain a layout");
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
const mainSection = getRootSectionForLane(board, "main");
|
||||
const leftSection = getRootSectionForLane(board, "left");
|
||||
const rightSection = getRootSectionForLane(board, "right");
|
||||
if (!mainSection) throw new Error("Expected the board to contain a main canvas section");
|
||||
|
||||
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
|
||||
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
|
||||
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative">
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div
|
||||
ref={columnsRef}
|
||||
className={classes.columns}
|
||||
style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}
|
||||
>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cache } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
@@ -17,7 +11,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
@@ -25,10 +18,11 @@ import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
|
||||
@@ -61,7 +55,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}
|
||||
return acc;
|
||||
}, new Map<WidgetKind, Item[]>());
|
||||
|
||||
const [integrations] = await Promise.all([
|
||||
getIntegrationsWithPermissionsAsync(session),
|
||||
...Array.from(itemsMap).map(([kind, items]) =>
|
||||
@@ -77,19 +70,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
),
|
||||
]);
|
||||
|
||||
const userId = session?.user.id ?? "anonymous";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<ClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
@@ -120,19 +106,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||
try {
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
return <AddMenu />;
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import type * as EditActionsModule from "./_edit-actions";
|
||||
|
||||
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
|
||||
const loadEditActionsAsync = () => {
|
||||
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
|
||||
editActionsModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return editActionsModulePromise;
|
||||
};
|
||||
const loadBoardEditorAsync = async () =>
|
||||
await Promise.all([
|
||||
loadGridEditorAsync(),
|
||||
loadEditActionsAsync(),
|
||||
import("~/components/board/items/item-menu"),
|
||||
import("~/components/board/sections/container/container-menu"),
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
@@ -52,16 +53,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
@@ -69,100 +70,17 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal({ board, persistImmediately: false });
|
||||
}, [board, openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const latestBoardRef = useRef(board);
|
||||
|
||||
latestBoardRef.current = board;
|
||||
|
||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
@@ -170,6 +88,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||
close();
|
||||
},
|
||||
@@ -186,40 +105,101 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
close();
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const prepareEditorAsync = useCallback(async () => {
|
||||
if (editorLoadState === "ready") return;
|
||||
|
||||
setEditorLoadState("loading");
|
||||
try {
|
||||
await loadBoardEditorAsync();
|
||||
setEditorLoadState("ready");
|
||||
} catch {
|
||||
setEditorLoadState("error");
|
||||
throw new Error("Unable to load the board editor");
|
||||
}
|
||||
}, [editorLoadState]);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleGridEditorWarmup(async () => {
|
||||
setEditorLoadState("loading");
|
||||
try {
|
||||
await loadBoardEditorAsync();
|
||||
setEditorLoadState("ready");
|
||||
} catch {
|
||||
setEditorLoadState("error");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prewarmEditor = useCallback(() => {
|
||||
void prepareEditorAsync().catch(() => {
|
||||
// A click or hotkey retries and reports the error.
|
||||
});
|
||||
}, [prepareEditorAsync]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (isEditMode) {
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
return saveBoard(latestBoardRef.current);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
setIsEnteringEditMode(true);
|
||||
try {
|
||||
await prepareEditorAsync();
|
||||
startTransition(open);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("notification.loadError.title"),
|
||||
message: t("notification.loadError.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnteringEditMode(false);
|
||||
}
|
||||
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
<TourTarget id="board-edit-mode">
|
||||
<HeaderButton
|
||||
onClick={() => void toggle()}
|
||||
onFocus={prewarmEditor}
|
||||
onPointerEnter={prewarmEditor}
|
||||
loading={isPending || isEnteringEditMode}
|
||||
data-testid="board-edit-mode-toggle"
|
||||
data-board-editor-preload-state={editorLoadState}
|
||||
aria-busy={isPending || isEnteringEditMode}
|
||||
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
|
||||
aria-pressed={isEditMode}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const t = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
@@ -234,7 +214,7 @@ const SelectBoardsMenu = () => {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -247,15 +227,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const target = (event.target as HTMLElement).closest("a");
|
||||
|
||||
if (!target) {
|
||||
console.warn("No anchor element found for click event", event);
|
||||
return;
|
||||
}
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
@@ -281,19 +259,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
const anchors = document.querySelectorAll(anchorSelector);
|
||||
anchors.forEach((link) => {
|
||||
link.addEventListener("click", handleClick);
|
||||
});
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
anchors.forEach((link) => {
|
||||
link.removeEventListener("click", handleClick);
|
||||
});
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardIntegrationProvider } from "./_integration-provider";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
providedIntegrations: undefined as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
IntegrationProvider: ({ children, integrations }: { children: React.ReactNode; integrations: unknown }) => {
|
||||
mocks.providedIntegrations = integrations;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("board integration provider", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
mocks.providedIntegrations = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("provides the permission-filtered server result without a second client query", () => {
|
||||
const initialIntegrations = [{ id: "initial" }];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardIntegrationProvider,
|
||||
{ initialIntegrations: initialIntegrations as never },
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.providedIntegrations).toBe(initialIntegrations);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
|
||||
interface Props {
|
||||
initialIntegrations: RouterOutputs["integration"]["all"];
|
||||
}
|
||||
|
||||
export const BoardIntegrationProvider = ({ initialIntegrations, children }: PropsWithChildren<Props>) => {
|
||||
return <IntegrationProvider integrations={initialIntegrations}>{children}</IntegrationProvider>;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { BoardOtherHeaderActions } from "../_header-actions";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
||||
export default createBoardLayout<{ locale: string; name: string }>({
|
||||
headerActions: <BoardOtherHeaderActions />,
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -19,130 +19,106 @@ import {
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ board }: Props) => {
|
||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||
initialValues: {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
},
|
||||
});
|
||||
export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition } from "react";
|
||||
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
|
||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||
initialValues: {
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
},
|
||||
});
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||
const medias = clientApi.media.getPaginated.useQuery({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
@@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(uploadedMedias) => {
|
||||
const first = uploadedMedias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,48 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||
import { Switch } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
disableStatus: board.disableStatus,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||
import { Alert, Input } from "@mantine/core";
|
||||
import { highlight, languages } from "prismjs";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
@@ -8,59 +8,44 @@ import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
{isAdmin && <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />}
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomCssInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
@@ -70,6 +55,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
htmlFor: "custom-css",
|
||||
}}
|
||||
description={customCssT("description")}
|
||||
error={error}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import classes from "./danger.module.css";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
@@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
buttonText={t("section.dangerZone.action.rename.button")}
|
||||
onClick={onRenameClick}
|
||||
action={
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{hideVisibility ? null : (
|
||||
<>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
</>
|
||||
{!hideVisibility && (
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
buttonText={t("section.dangerZone.action.delete.button")}
|
||||
onClick={onDeleteClick}
|
||||
isPending={isDeletePending}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DangerZoneRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</DangerZoneRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||
import { useEffect } from "react";
|
||||
import { Grid, Loader, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n();
|
||||
const ref = useRef({
|
||||
pageTitle: board.pageTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
});
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(
|
||||
boardSavePartialSettingsSchema
|
||||
.pick({
|
||||
pageTitle: true,
|
||||
logoImageUrl: true,
|
||||
metaTitle: true,
|
||||
faviconImageUrl: true,
|
||||
})
|
||||
.required(),
|
||||
{
|
||||
initialValues: {
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
},
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
// Cleanup for not applied changes of the page title and logo image URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: ref.current.pageTitle,
|
||||
logoImageUrl: ref.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// Save the current values to the ref so that it does not reset if the form is submitted
|
||||
ref.current = {
|
||||
pageTitle: values.pageTitle,
|
||||
logoImageUrl: values.logoImageUrl,
|
||||
};
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
};
|
||||
};
|
||||
|
||||
// Previously we used the useFavicon hook from Mantine
|
||||
// However if the user tried to click on a link to a different page
|
||||
// it caused an error "Cannot read properties of null (reading 'removeChild')"
|
||||
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
|
||||
// See https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
.gutterSettings {
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
||||
}
|
||||
|
||||
.gutterPreview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
transition: grid-template-columns 180ms ease-out;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewCanvas > div,
|
||||
.previewGutter {
|
||||
background: var(--mantine-color-default);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.previewCanvas > div:nth-child(1) {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.previewCanvas > div:nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.previewGutter {
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gutterPreview {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Fieldset,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./_layout.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
|
||||
export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) =>
|
||||
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||
? {
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: [
|
||||
...(previous.layouts ?? []),
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={layout.columnCount}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
onChange={(columnCount) => {
|
||||
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
|
||||
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
|
||||
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
|
||||
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
|
||||
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface GutterSettingsProps {
|
||||
left: number;
|
||||
right: number;
|
||||
columnCount: number;
|
||||
onLeftChange: (value: number) => void;
|
||||
onRightChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const gutterMarks = [
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
];
|
||||
|
||||
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
|
||||
const t = useI18n();
|
||||
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
|
||||
|
||||
return (
|
||||
<Paper className={classes.gutterSettings} p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("layout.field.gutters.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<GutterPreview left={left} right={right} columnCount={columnCount} />
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<GutterControl
|
||||
side="left"
|
||||
value={left}
|
||||
max={Math.min(maxGutterWidth, columnCount - right - 1)}
|
||||
onChange={onLeftChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<GutterControl
|
||||
side="right"
|
||||
value={right}
|
||||
max={Math.min(maxGutterWidth, columnCount - left - 1)}
|
||||
onChange={onRightChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const GutterControl = ({
|
||||
side,
|
||||
value,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
value: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const enabled = value > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={max < 1}
|
||||
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
|
||||
label={t(`layout.field.gutters.${side}.label`)}
|
||||
description={t(`layout.field.gutters.${side}.description`)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={max}
|
||||
value={Math.min(value, max)}
|
||||
marks={gutterMarks.filter((mark) => mark.value <= max)}
|
||||
restrictToMarks
|
||||
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
|
||||
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
|
||||
const t = useI18n();
|
||||
const safeColumnCount = Math.max(1, columnCount);
|
||||
const main = Math.max(1, safeColumnCount - left - right);
|
||||
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
|
||||
.filter((track) => track !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes.gutterPreview}
|
||||
aria-label={t("layout.field.gutters.preview.label")}
|
||||
style={{ gridTemplateColumns: tracks }}
|
||||
>
|
||||
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
|
||||
<Box className={classes.previewCanvas}>
|
||||
<Box />
|
||||
<Box />
|
||||
<Box />
|
||||
<Box />
|
||||
</Box>
|
||||
{right > 0 && <Box className={classes.previewGutter} data-side="right" />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||
.extend({
|
||||
layouts: boardSaveLayoutsSchema.shape.layouts,
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
|
||||
|
||||
const PARTIAL_FORM_KEYS = [
|
||||
"pageTitle",
|
||||
"metaTitle",
|
||||
"logoImageUrl",
|
||||
"faviconImageUrl",
|
||||
"backgroundImageUrl",
|
||||
"backgroundImageAttachment",
|
||||
"backgroundImageRepeat",
|
||||
"backgroundImageSize",
|
||||
"primaryColor",
|
||||
"secondaryColor",
|
||||
"opacity",
|
||||
"iconColor",
|
||||
"itemRadius",
|
||||
"customCss",
|
||||
"disableStatus",
|
||||
] as const;
|
||||
|
||||
const buildInitialValues = (board: Board): FormValues => ({
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
layouts: board.layouts,
|
||||
});
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
board: Board;
|
||||
permissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
hasFullAccess: boolean;
|
||||
hideVisibility: boolean;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
|
||||
const form = useZodForm(boardSettingsFormSchema, {
|
||||
initialValues: buildInitialValues(board),
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const initialValuesRef = useRef(buildInitialValues(board));
|
||||
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const { layouts, ...partialSettings } = values;
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: changed(...PARTIAL_FORM_KEYS),
|
||||
action: () =>
|
||||
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("layouts"),
|
||||
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
|
||||
if (promises.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
<BehaviorSettingsContent form={form} />
|
||||
|
||||
{hasFullAccess && (
|
||||
<SectionCard title={tSection("access.title")}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// Previously part of the general settings section; applied on the unified save
|
||||
// https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -11,3 +11,13 @@ export const useSavePartialSettingsMutation = (board: Board) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveLayoutsMutation = (board: Board) => {
|
||||
const utils = clientApi.useUtils();
|
||||
return clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
@media (min-width: 36em) {
|
||||
.dangerZoneGroup {
|
||||
--group-wrap: nowrap !important;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,20 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconFileTypeCss,
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
import { BoardSettingsForm } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
name: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||
}>;
|
||||
}
|
||||
|
||||
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||
@@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
} catch (error) {
|
||||
// Ignore not found errors and redirect to 404
|
||||
// error is already logged in _layout-creator.tsx
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage(props: Props) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
@@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||
<AccordionItemFor value="general" icon={IconSettings}>
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||
<BehaviorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent
|
||||
hideVisibility={
|
||||
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||
}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
</ActiveTabAccordion>
|
||||
<BoardSettingsForm
|
||||
board={board}
|
||||
permissions={permissions}
|
||||
hasFullAccess={hasFullAccess}
|
||||
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionItemForProps = PropsWithChildren<{
|
||||
value: keyof TranslationObject["board"]["setting"]["section"];
|
||||
icon: TablerIcon;
|
||||
danger?: boolean;
|
||||
noPadding?: boolean;
|
||||
}>;
|
||||
|
||||
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
|
||||
const t = await getScopedI18n("board.setting.section");
|
||||
return (
|
||||
<AccordionItem
|
||||
value={value}
|
||||
styles={
|
||||
danger
|
||||
? {
|
||||
item: {
|
||||
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||
borderWidth: 4,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionControl icon={<Icon />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t(`${value}.title`)}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -21,11 +26,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
||||
if (!hasSession) return <>{children}</>;
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
@@ -43,8 +43,34 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
const resolvedParams = await params;
|
||||
const sessionPromise = auth();
|
||||
const initialBoardPromise = getInitialBoard(resolvedParams).then(
|
||||
(board) => ({ status: "fulfilled", board }) as const,
|
||||
(error: unknown) => ({ status: "rejected", error }) as const,
|
||||
);
|
||||
const colorSchemePromise = getCurrentColorSchemeAsync();
|
||||
const initialViewportWidthPromise = getInitialViewportWidthAsync();
|
||||
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!withTour || !session || env.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedBoardTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the board tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, initialViewportWidth] = await Promise.all([
|
||||
sessionPromise,
|
||||
initialBoardPromise,
|
||||
colorSchemePromise,
|
||||
shouldRunBoardTourPromise,
|
||||
initialViewportWidthPromise,
|
||||
]);
|
||||
if (initialBoardResult.status === "rejected") {
|
||||
const { error } = initialBoardResult;
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
@@ -60,25 +86,32 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardProvider
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
<ModalProvider>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</ModalProvider>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
@@ -88,3 +121,22 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
|
||||
return Layout;
|
||||
};
|
||||
|
||||
const getInitialViewportWidthAsync = async () => {
|
||||
const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value;
|
||||
const cookieWidth = Number(cookieValue);
|
||||
if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) {
|
||||
return cookieWidth;
|
||||
}
|
||||
|
||||
const requestHeaders = await headers();
|
||||
const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width"));
|
||||
if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) {
|
||||
return clientHintWidth;
|
||||
}
|
||||
|
||||
const userAgent = requestHeaders.get("user-agent") ?? "";
|
||||
if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024;
|
||||
if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390;
|
||||
return 1440;
|
||||
};
|
||||
@@ -7,10 +7,9 @@ export type Item = Board["items"][number];
|
||||
export type ItemLayout = Item["layouts"][number];
|
||||
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
|
||||
|
||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||
export type DynamicSectionLayout = DynamicSection["layouts"][number];
|
||||
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
|
||||
export type ContainerSection = Extract<Section, { kind: "container" }>;
|
||||
export type ContainerSectionLayout = ContainerSection["layouts"][number];
|
||||
export type ContainerSectionItem = Omit<ContainerSection, "layouts"> & ContainerSectionLayout & { type: "section" };
|
||||
|
||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface RequestDetails {
|
||||
widgetName: string;
|
||||
sourceName: string;
|
||||
kinds: Array<"apiKey" | "username" | "password">;
|
||||
expiresAt: number;
|
||||
status: "pending" | "completed";
|
||||
source: {
|
||||
baseUrl: string;
|
||||
networkScope: "public" | "private" | "loopback";
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||
const t = useScopedI18n("customWidget.secretEntry");
|
||||
const [details, setDetails] = useState<RequestDetails | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`)
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as RequestDetails | { error: string };
|
||||
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
|
||||
setDetails(body);
|
||||
setBaseUrl(body.source.baseUrl);
|
||||
setNetworkScope(body.source.networkScope);
|
||||
})
|
||||
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [t, token]);
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, networkScope, secrets: values }),
|
||||
});
|
||||
const body = (await response.json()) as { status?: string; error?: string };
|
||||
if (!response.ok) throw new Error(body.error ?? t("saveError"));
|
||||
setDetails((current) => (current ? { ...current, status: "completed" } : current));
|
||||
setValues({});
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("saveError"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center mih="100dvh" p="md">
|
||||
<Card withBorder shadow="md" radius="lg" p="xl" w="100%" maw={480}>
|
||||
<Stack gap="lg">
|
||||
<ThemeIcon size={48} radius="xl" variant="light">
|
||||
<IconLock size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Title order={1} size="h2">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text c="dimmed">{t("description")}</Text>
|
||||
</Stack>
|
||||
{loading && <Text c="dimmed">{t("loading")}</Text>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
{details?.status === "completed" && (
|
||||
<Alert color="green" icon={<IconCheck size={18} />}>
|
||||
{t("saved")}
|
||||
</Alert>
|
||||
)}
|
||||
{details?.status === "pending" && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder bg="var(--mantine-color-default-hover)">
|
||||
<Text fw={600}>{details.widgetName}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("source", { name: details.sourceName })}
|
||||
</Text>
|
||||
</Card>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={networkScope}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
|
||||
/>
|
||||
{details.kinds.map((kind) => {
|
||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Input
|
||||
key={kind}
|
||||
label={t(`field.${kind}`)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={values[kind] ?? ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
loading={saving}
|
||||
disabled={!URL.canParse(baseUrl) || details.kinds.some((kind) => !values[kind])}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("expires", { time: new Date(details.expiresAt).toLocaleTimeString() })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { CustomWidgetConfigurationEntry } from "./configuration-entry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Configure custom widget API source",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const { token } = await params;
|
||||
return <CustomWidgetConfigurationEntry token={token} />;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
const getCurrentOnboardingStepAsync = async () => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await api.onboard.currentStep();
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
|
||||
|
||||
@@ -13,23 +13,26 @@ import { notFound } from "next/navigation";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { env as authEnv } from "@homarr/auth/env";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SettingsProvider } from "@homarr/settings";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { env } from "~/env";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { ViewportHint } from "~/components/layout/viewport-hint";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
@@ -43,6 +46,8 @@ const fontSans = Inter({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
@@ -52,7 +57,7 @@ export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: "https://homarr.dev",
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
@@ -78,19 +83,36 @@ export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: SupportedLanguage }>;
|
||||
}) {
|
||||
if (!isLocaleSupported((await props.params).locale)) {
|
||||
const { locale } = await props.params;
|
||||
if (!isLocaleSupported(locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const sessionPromise = auth();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
|
||||
return null;
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
]);
|
||||
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||
return <AuthProvider session={session} logoutUrl={authEnv.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||
},
|
||||
(innerProps) => (
|
||||
<SettingsProvider
|
||||
@@ -125,8 +147,6 @@ export default async function Layout(props: {
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
const { locale } = await props.params;
|
||||
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
@@ -139,11 +159,14 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<ViewportHint />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
|
||||
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("discussionLink")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
|
||||
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
{...props}
|
||||
messages={{
|
||||
languageJsx: t("language.jsx"),
|
||||
languageJson: t("language.json"),
|
||||
undo: t("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: t("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: t("reference.schema"),
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
|
||||
describe("Copy AI prompt", () => {
|
||||
it("uses one self-contained prompt without an embedded-skill branch", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("buildCustomWidgetAiPrompt");
|
||||
expect(source).not.toContain("embedded-authoring-prompt");
|
||||
expect(source).not.toContain("Menu");
|
||||
});
|
||||
|
||||
it("copies a complete prompt within the public size limit", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
|
||||
expect(prompt.length).toBeLessThanOrEqual(12_000);
|
||||
expect(prompt).toContain("exactly one complete `json` fenced block");
|
||||
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
|
||||
});
|
||||
|
||||
it("keeps AI actions and section navigation readable on narrow screens", () => {
|
||||
const card = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
const styles = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
|
||||
"utf8",
|
||||
);
|
||||
expect(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
|
||||
expect(styles).toContain(".sectionNav > a");
|
||||
expect(styles).toContain("flex: 0 0 auto");
|
||||
});
|
||||
});
|
||||
@@ -1,343 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
|
||||
|
||||
## Your Task
|
||||
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
|
||||
|
||||
## Output Format
|
||||
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
|
||||
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
|
||||
- If your environment supports structured/JSON output mode, use it.
|
||||
|
||||
## JSON Schema
|
||||
The output must conform to this JSON Schema:
|
||||
|
||||
`;
|
||||
|
||||
const PROMPT_RULES = `
|
||||
|
||||
## Key Rules
|
||||
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
|
||||
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
|
||||
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
|
||||
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
|
||||
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
|
||||
- For arrays of items, inspect the API response to find the correct paths
|
||||
|
||||
## Display Type Guide
|
||||
- \`singleValue\`: Show one prominent number/text (e.g. total count, status)
|
||||
- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array
|
||||
- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers
|
||||
- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63")
|
||||
- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc.
|
||||
- \`statusIndicator\`: Green/red dots based on value matching — for service health checks
|
||||
- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors
|
||||
- \`raw\`: Raw JSON display — for debugging or complex data
|
||||
- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions
|
||||
- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`).
|
||||
|
||||
## Custom JSX (\`customJsx\`) — Available Mantine Components
|
||||
When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below.
|
||||
|
||||
### Layout Components
|
||||
- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\`
|
||||
- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean)
|
||||
- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\`
|
||||
- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\`
|
||||
- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\`
|
||||
- **Center** — Centers content. Props: \`inline\` (boolean)
|
||||
- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width)
|
||||
- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean)
|
||||
- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9)
|
||||
|
||||
### Typography Components
|
||||
- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span)
|
||||
- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\`
|
||||
- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block)
|
||||
- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[])
|
||||
- **Mark** — Highlighted text background. Props: \`color\`
|
||||
- **Kbd** — Keyboard key display
|
||||
- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\`
|
||||
- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never")
|
||||
- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number)
|
||||
|
||||
### Data Display Components
|
||||
- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\`
|
||||
- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\`
|
||||
- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\`
|
||||
- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\`
|
||||
- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\`
|
||||
- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string)
|
||||
- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\`
|
||||
- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\`
|
||||
- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\`
|
||||
- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content.
|
||||
- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean)
|
||||
- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\`
|
||||
- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\`
|
||||
|
||||
### Feedback Components
|
||||
- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\`
|
||||
- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode)
|
||||
- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean)
|
||||
- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots)
|
||||
|
||||
### Media Components
|
||||
- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill)
|
||||
- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\`
|
||||
- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\`
|
||||
- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\`
|
||||
- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\`
|
||||
- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover)
|
||||
|
||||
### Charts (@mantine/charts)
|
||||
- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line)
|
||||
- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\`
|
||||
- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\`
|
||||
- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\`
|
||||
- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\`
|
||||
|
||||
### Common Prop Patterns
|
||||
- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers
|
||||
- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4"
|
||||
- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\`
|
||||
- **Conditional rendering:** Use ternaries: \`{condition ? <Text>Yes</Text> : <Text>No</Text>}\`
|
||||
- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\`
|
||||
|
||||
### Interactive Components (built-in, state-managed)
|
||||
These components manage their own internal state — they provide interactivity without event handlers:
|
||||
|
||||
**PaginatedList** — Paginates its children with prev/next buttons.
|
||||
Props: \`pageSize\` (number, default 6)
|
||||
Usage: Wrap a \`.map()\` expression to paginate results.
|
||||
\`\`\`jsx
|
||||
<PaginatedList pageSize={8}>
|
||||
{data.results.map(item =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Text>{item.name}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
\`\`\`
|
||||
|
||||
**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching.
|
||||
TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab)
|
||||
TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text)
|
||||
\`\`\`jsx
|
||||
<TabsContainer defaultTab="overview">
|
||||
<TabPanel value="overview" label="Overview">
|
||||
<Text>{data.description}</Text>
|
||||
</TabPanel>
|
||||
<TabPanel value="stats" label="Statistics">
|
||||
<Text>Total: {data.count}</Text>
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
\`\`\`
|
||||
|
||||
**Collapsible** — Expandable/collapsible section with a title.
|
||||
Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false)
|
||||
\`\`\`jsx
|
||||
<Collapsible title="Details" defaultOpen={true}>
|
||||
<Text>{data.details}</Text>
|
||||
</Collapsible>
|
||||
\`\`\`
|
||||
|
||||
**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress).
|
||||
Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color)
|
||||
\`\`\`jsx
|
||||
<StatBar label="HP" value={45} max={100} color="red" />
|
||||
<StatBar label="ATK" value={80} max={255} color="orange" />
|
||||
\`\`\`
|
||||
|
||||
**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy).
|
||||
Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm)
|
||||
\`\`\`jsx
|
||||
<Group gap="xs">
|
||||
<TypeBadge type="fire" />
|
||||
<TypeBadge type="flying" />
|
||||
</Group>
|
||||
\`\`\`
|
||||
|
||||
**Available bindings in templates:**
|
||||
- \`data\` — the full API response JSON object
|
||||
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers
|
||||
- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\`
|
||||
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\`
|
||||
- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => <Text>{item.name}</Text>)}\`
|
||||
- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\`
|
||||
|
||||
**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch
|
||||
|
||||
**Chart data formats:**
|
||||
- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row
|
||||
- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series
|
||||
- Sparkline: \`data={[1,2,3]}\` — flat number array
|
||||
- NumberFormatter: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
|
||||
|
||||
### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible)
|
||||
API: \`https://pokeapi.co/api/v2/pokemon/charizard\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
|
||||
<Stack gap={4} style={{flex: 1}}>
|
||||
<Title order={3} tt="capitalize">{data.name}</Title>
|
||||
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
|
||||
<Group gap="xs">
|
||||
{data.types.map(t => <TypeBadge type={t.type.name} />)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<TabsContainer defaultTab="stats">
|
||||
<TabPanel value="stats" label="Stats">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{data.stats.map(s =>
|
||||
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
|
||||
)}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel value="abilities" label="Abilities">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{data.abilities.map(a =>
|
||||
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
|
||||
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel value="moves" label="Moves">
|
||||
<Collapsible title="Move list">
|
||||
<Group gap="xs" pt="xs">
|
||||
{data.moves.slice(0, 20).map(m =>
|
||||
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Collapsible>
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Paginated List Example
|
||||
API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
|
||||
</Group>
|
||||
<PaginatedList pageSize={10}>
|
||||
{data.results.map((pokemon, i) =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar
|
||||
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
|
||||
size="lg"
|
||||
radius="sm"
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
## API Response
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_YOUR_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const PROMPT_FOOTER = `
|
||||
## Your Request
|
||||
Describe what you want the widget to show:
|
||||
|
||||
`;
|
||||
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const schemaStr = JSON.stringify(jsonSchema, null, 2);
|
||||
const responseSection = rawResponse
|
||||
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: PROMPT_NO_RESPONSE;
|
||||
|
||||
const configSection = currentConfig
|
||||
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
|
||||
: "";
|
||||
|
||||
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
}
|
||||
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
currentConfig,
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
try {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
close();
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
showSuccessNotification({
|
||||
title: t("action.copyAiPrompt"),
|
||||
message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
|
||||
{!rawResponse && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconClipboard,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconToggleLeft,
|
||||
IconToggleRight,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
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 { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -26,6 +31,8 @@ interface WidgetRef {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
valid: boolean;
|
||||
migrationRequired: boolean;
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
@@ -33,26 +40,71 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleEnabledMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const copyMigrationPrompt = async () => {
|
||||
try {
|
||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||
await navigator.clipboard.writeText(result.prompt);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyMigrationPrompt"),
|
||||
message: t("notification.migrationPromptCopied"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.copyMigrationPrompt"),
|
||||
message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const queueMigratedWidget = (text: string) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
};
|
||||
|
||||
const pasteMigratedWidget = async () => {
|
||||
try {
|
||||
queueMigratedWidget(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
try {
|
||||
queueMigratedWidget(loadEvent.target?.result as string);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -79,7 +131,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.duplicate"),
|
||||
message: t("notification.duplicated", { name: result.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
@@ -94,120 +146,104 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
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.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
const callbacks = {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
);
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
};
|
||||
deleteMutation.mutate({ id: widget.id }, callbacks);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
|
||||
<IconDots {...iconProps} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
|
||||
disabled={toggleEnabledMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
disabled={duplicateMutation.isPending}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
},
|
||||
});
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const json = JSON.parse(e.target?.result as string);
|
||||
importMutation.mutate(json);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label="Import custom widget"
|
||||
/>
|
||||
<Menu withinPortal position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
|
||||
<IconDots {...iconProps} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
disabled={duplicateMutation.isPending}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void pasteMigratedWidget()} leftSection={<IconClipboard {...iconProps} />}>
|
||||
{t("action.pasteMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => migrationFileInputRef.current?.click()}
|
||||
leftSection={<IconUpload {...iconProps} />}
|
||||
>
|
||||
{t("action.importMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{!widget.migrationRequired && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
ref={migrationFileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleMigrationFile}
|
||||
aria-label={t("action.importMigration")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={migrationOpened}
|
||||
widget={migratedWidget}
|
||||
legacyId={widget.id}
|
||||
onClose={() => {
|
||||
migrationControls.close();
|
||||
setMigratedWidget(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Accordion } from "@mantine/core";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(
|
||||
{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
...(form.values.description ? { description: form.values.description } : {}),
|
||||
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
|
||||
sources: parseJson(form.values.sources),
|
||||
requests: parseJson(form.values.requests),
|
||||
options: parseJson(form.values.options),
|
||||
template: form.values.template,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="manifest">
|
||||
<Accordion.Control>{t("advancedManifest")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="raw-widget-manifest"
|
||||
label={t("manifestFilename")}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
applyDefinition(form, parsed.data);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
|
||||
|
||||
interface AiCardProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
request: string;
|
||||
onRequestChange(value: string): void;
|
||||
documentationUrl: string;
|
||||
onDocumentationUrlChange(value: string): void;
|
||||
onPaste(): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("documentationUrl")}
|
||||
value={props.documentationUrl}
|
||||
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<CopyAiPromptButton
|
||||
currentConfig={props.candidate}
|
||||
request={props.request}
|
||||
documentationUrl={props.documentationUrl}
|
||||
/>
|
||||
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
|
||||
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
|
||||
const options = parseJson(values.options);
|
||||
const optionKeys = Object.keys(isRecord(options) ? options : {});
|
||||
return [
|
||||
...requestIds.flatMap((id) => [
|
||||
{ label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` },
|
||||
{ label: `status["${id}"]`, type: "variable", detail: `Status for ${id}` },
|
||||
{ label: `requestId="${id}"`, type: "property", detail: "Named request" },
|
||||
]),
|
||||
...parseSources(values.sources).map(({ id }) => ({ label: id, type: "constant", detail: "Source ID" })),
|
||||
...optionKeys.map((key) => ({ label: `options.${key}`, type: "variable", detail: "Widget option" })),
|
||||
{ label: "inputs", type: "variable", detail: "Temporary bound input values" },
|
||||
];
|
||||
}
|
||||
|
||||
export function getInvalidCustomWidgetSections(
|
||||
issues: readonly { path: PropertyKey[] }[],
|
||||
requestDiagnostics: EditorDiagnostic[],
|
||||
templateDiagnostics: EditorDiagnostic[],
|
||||
) {
|
||||
const result = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
const field = String(issue.path[0] ?? "");
|
||||
if (["name", "description", "iconUrl"].includes(field)) result.add("general");
|
||||
else if (field === "sources") result.add("sources");
|
||||
else if (field === "requests") result.add("requests");
|
||||
else if (field === "options") result.add("options");
|
||||
else if (field === "template") result.add("jsx");
|
||||
}
|
||||
if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests");
|
||||
if (templateDiagnostics.some((entry) => entry.severity === "error")) result.add("jsx");
|
||||
if (result.size > 0) result.add("preview");
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export function EditorSection({
|
||||
id,
|
||||
title,
|
||||
icon: SectionIcon,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<SectionIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SaveActions({
|
||||
dirty,
|
||||
savePending,
|
||||
previewPending,
|
||||
invalid,
|
||||
mode,
|
||||
onPreview,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
invalid: boolean;
|
||||
mode: "create" | "edit";
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench");
|
||||
return (
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
{invalid ? (
|
||||
<IconAlertTriangle size={16} color="var(--mantine-color-red-6)" />
|
||||
) : (
|
||||
<IconCheck size={16} color="var(--mantine-color-green-6)" />
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{invalid ? t("status.invalid") : dirty ? t("status.unsaved") : t("status.ready")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={onPreview}
|
||||
loading={previewPending}
|
||||
disabled={invalid || savePending}
|
||||
>
|
||||
{previewPending ? t("action.previewLoading") : t("action.preview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
|
||||
CUSTOM_WIDGET_REQUEST_EXAMPLES,
|
||||
getCustomWidgetOptionsJsonSchema,
|
||||
getCustomWidgetRequestsJsonSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
export const customWidgetRequestReference = {
|
||||
schema: getCustomWidgetRequestsJsonSchema(),
|
||||
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
|
||||
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
|
||||
};
|
||||
|
||||
export const customWidgetOptionsSchemaReference = {
|
||||
schema: getCustomWidgetOptionsJsonSchema(),
|
||||
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal,
|
||||
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full,
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
applyCustomWidgetAiResponse,
|
||||
filterSecretsForSourceAuthentication,
|
||||
getChangedSecrets,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
loadPreviewQueries,
|
||||
parseSources,
|
||||
} from "./_custom-widget-form-utils";
|
||||
|
||||
const previewQuery = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@homarr/api/client", () => ({ fetchApi: { customWidget: { previewQuery: { query: previewQuery } } } }));
|
||||
|
||||
const definition = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Preview options",
|
||||
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
|
||||
requests: { containers: { path: "/endpoints/{option:environmentId}/containers" } },
|
||||
options: { environmentId: { label: "Environment", control: "number", default: 1 } },
|
||||
template: "<Text>Preview</Text>",
|
||||
});
|
||||
|
||||
describe("Custom Widget workbench preview options", () => {
|
||||
it("loads one complete AI response into every editor field, including JSX", () => {
|
||||
const setValues = vi.fn();
|
||||
const result = applyCustomWidgetAiResponse(
|
||||
{
|
||||
values: {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "",
|
||||
secrets: [],
|
||||
},
|
||||
setValues,
|
||||
} as never,
|
||||
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
|
||||
);
|
||||
|
||||
if (!result.success) throw new Error("Expected the complete AI response to be accepted");
|
||||
expect(setValues).toHaveBeenCalledOnce();
|
||||
const values = setValues.mock.calls[0]?.[0];
|
||||
expect(values).toMatchObject({
|
||||
name: "Preview options",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
template: "<Text>Preview</Text>",
|
||||
secrets: [],
|
||||
});
|
||||
expect(JSON.parse(values?.sources as string)).toEqual(result.widget.sources);
|
||||
expect(JSON.parse(values?.requests as string)).toEqual(result.widget.requests);
|
||||
expect(JSON.parse(values?.options as string)).toEqual(result.widget.options);
|
||||
});
|
||||
|
||||
it("shows the default source first regardless of manifest key order", () => {
|
||||
expect(
|
||||
parseSources(
|
||||
JSON.stringify({
|
||||
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "none" },
|
||||
default: { baseUrl: "https://default.example.com", networkScope: "public", auth: "none" },
|
||||
}),
|
||||
).map(({ id }) => id),
|
||||
).toEqual(["default", "secondary"]);
|
||||
});
|
||||
|
||||
it("keeps load-query option resolution on the server", async () => {
|
||||
previewQuery.mockResolvedValueOnce({ ok: true, status: 200, data: [] });
|
||||
await loadPreviewQueries(definition, "preview-1");
|
||||
expect(previewQuery).toHaveBeenCalledWith({ sessionId: "preview-1", requestId: "containers", params: {} });
|
||||
});
|
||||
|
||||
it("rejects invalid selected options", () => {
|
||||
expect(getCustomWidgetPreviewOptionIssues(definition, { environmentId: "wrong" })).toEqual([
|
||||
{ path: "configuration.environmentId", message: "Expected a number" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes only changed secrets", () => {
|
||||
expect(
|
||||
getChangedSecrets({
|
||||
name: "Widget",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "<Text />",
|
||||
secrets: [
|
||||
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
|
||||
{ sourceId: "other", kind: "password", value: "replacement" },
|
||||
],
|
||||
}),
|
||||
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
|
||||
});
|
||||
|
||||
it("drops credentials that do not apply after an authentication transition", () => {
|
||||
expect(
|
||||
filterSecretsForSourceAuthentication(
|
||||
[
|
||||
{ sourceId: "default", kind: "username", value: "admin", hasValue: true },
|
||||
{ sourceId: "default", kind: "password", value: "secret", hasValue: true },
|
||||
{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true },
|
||||
],
|
||||
"default",
|
||||
"bearer",
|
||||
),
|
||||
).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetRequiredSecretKinds,
|
||||
parseCustomWidgetAiResponse,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
|
||||
|
||||
export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
return customWidgetDefinitionSchema.safeParse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
iconUrl: values.iconUrl || undefined,
|
||||
sources: parseJson(values.sources),
|
||||
requests: parseJson(values.requests),
|
||||
options: parseJson(values.options),
|
||||
template: values.template,
|
||||
});
|
||||
}
|
||||
|
||||
export function getChangedSecrets(values: CustomWidgetFormValues) {
|
||||
return values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
sourceId,
|
||||
kind: kind as "apiKey" | "username" | "password",
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterSecretsForSourceAuthentication(
|
||||
secrets: CustomWidgetFormValues["secrets"],
|
||||
sourceId: string,
|
||||
authType: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0],
|
||||
) {
|
||||
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
|
||||
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
|
||||
}
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
name: widget.name,
|
||||
description: widget.description ?? "",
|
||||
iconUrl: widget.iconUrl ?? "",
|
||||
sources: JSON.stringify(widget.sources, null, 2),
|
||||
requests: JSON.stringify(widget.requests, null, 2),
|
||||
options: JSON.stringify(widget.options, null, 2),
|
||||
template: widget.template,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCustomWidgetAiResponse(form: CustomWidgetWorkbenchForm, response: string) {
|
||||
const result = parseCustomWidgetAiResponse(response);
|
||||
if (result.success) applyDefinition(form, result.widget);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSources(value: string): Array<CustomWidgetSource & { id: string }> {
|
||||
const parsed = parseJson(value);
|
||||
return isRecord(parsed)
|
||||
? Object.entries(parsed)
|
||||
.flatMap(([id, entry]) => (isRecord(entry) ? [{ id, ...(entry as CustomWidgetSource) }] : []))
|
||||
.toSorted((left, right) => Number(right.id === "default") - Number(left.id === "default"))
|
||||
: [];
|
||||
}
|
||||
|
||||
export function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getCustomWidgetPreviewOptionIssues(definition: HomarrCustomWidgetV2, options: Record<string, unknown>) {
|
||||
return validateCustomWidgetOptions(definition.options, options);
|
||||
}
|
||||
|
||||
export function isRuntimeParams(
|
||||
value: unknown,
|
||||
expectedNames: readonly string[],
|
||||
): value is Record<string, string | number | boolean> {
|
||||
if (!isRecord(value)) return false;
|
||||
const names = Object.keys(value).toSorted();
|
||||
return (
|
||||
names.length === expectedNames.length &&
|
||||
names.every((name, index) => name === expectedNames[index]) &&
|
||||
Object.values(value).every((entry) => ["string", "number", "boolean"].includes(typeof entry))
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
|
||||
const data: Record<string, unknown> = {};
|
||||
const status: Record<string, unknown> = {};
|
||||
for (const [requestId] of Object.entries(definition.requests).filter(
|
||||
([, entry]) => entry.kind === "query" && entry.trigger === "load",
|
||||
)) {
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
|
||||
data[requestId] = result.data;
|
||||
status[requestId] = { loading: false, ...result };
|
||||
} catch (error) {
|
||||
data[requestId] = null;
|
||||
status[requestId] = {
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
return { data, status };
|
||||
}
|
||||
|
||||
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
|
||||
return getCustomWidgetDefaultOptions(definition.options);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
.form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionNav {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
padding: 0.35rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sectionNav > a {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.editorSection {
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: var(--mantine-spacing-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.paneSwitcher {
|
||||
position: sticky;
|
||||
top: var(--mantine-spacing-xs);
|
||||
z-index: 10;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@media (max-width: 74.9375em) {
|
||||
.sectionNav {
|
||||
top: 3.5rem;
|
||||
}
|
||||
|
||||
.workbench[data-mobile-pane="configure"] .previewPane,
|
||||
.workbench[data-mobile-pane="preview"] .configuration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.configuration,
|
||||
.previewPane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
position: sticky;
|
||||
bottom: var(--mantine-spacing-sm);
|
||||
z-index: 10;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.mobileSaveBar {
|
||||
position: sticky;
|
||||
bottom: var(--mantine-spacing-sm);
|
||||
z-index: 10;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@media (min-width: 75em) {
|
||||
.paneSwitcher {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileSaveBar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
top: 5rem;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 100em) {
|
||||
.workbench {
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+48
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { TextInput } from "@mantine/core";
|
||||
|
||||
export function CustomWidgetIdentifierInput({
|
||||
label,
|
||||
value,
|
||||
error,
|
||||
disabled,
|
||||
style,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
error?: ReactNode;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
onCommit(value: string): void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
const commit = () => {
|
||||
if (draft !== value) onCommit(draft);
|
||||
};
|
||||
return (
|
||||
<TextInput
|
||||
label={label}
|
||||
value={draft}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setDraft(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
|
||||
|
||||
describe("legacy custom widget migration UI", () => {
|
||||
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
|
||||
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
|
||||
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
|
||||
|
||||
expect(list).toContain("widget.migrationRequired");
|
||||
expect(list).toContain('t("page.list.migrationInstructions")');
|
||||
expect(actions).toContain("legacyMigrationPrompt.fetch");
|
||||
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
|
||||
expect(actions).toContain("navigator.clipboard.readText()");
|
||||
expect(actions).toContain('t("action.pasteMigration")');
|
||||
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
|
||||
expect(actions).toContain("legacyId={widget.id}");
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { IconApi, IconPencil } from "@tabler/icons-react";
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
const displayTypeBadgeColors: Record<string, string> = {
|
||||
singleValue: "blue",
|
||||
keyValue: "green",
|
||||
table: "orange",
|
||||
statGrid: "violet",
|
||||
progressBars: "teal",
|
||||
statusIndicator: "cyan",
|
||||
countGrid: "indigo",
|
||||
raw: "gray",
|
||||
actionButton: "red",
|
||||
customJsx: "pink",
|
||||
};
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface CustomWidgetListProps {
|
||||
definitions: RouterOutputs["customWidget"]["all"];
|
||||
definitions: RouterOutputs["customWidget"]["list"];
|
||||
}
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
@@ -43,59 +31,173 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("table.name")}</Table.Th>
|
||||
<Table.Th>{t("table.url")}</Table.Th>
|
||||
<Table.Th>{t("table.display")}</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{def.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
|
||||
{t(`displayType.${def.displayType}` as never)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${def.id}`}
|
||||
variant="filled"
|
||||
color="red"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil {...iconProps} />
|
||||
</ActionIcon>
|
||||
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Stack gap="sm">
|
||||
{definitions.map((def) => (
|
||||
<CustomWidgetCard key={def.id} widget={def} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{widget.iconUrl ? (
|
||||
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<Avatar size={36} radius="sm" color="red">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge
|
||||
color="red"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
|
||||
export function DefaultValueEditor({
|
||||
id,
|
||||
control,
|
||||
value,
|
||||
choices = [],
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
control: string;
|
||||
value: unknown;
|
||||
choices?: Array<{ label: string; value: string | number }>;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
if (control === "switch")
|
||||
return (
|
||||
<Switch
|
||||
label={t("default")}
|
||||
checked={value === true}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
if (["number", "slider", "duration"].includes(control))
|
||||
return <NumberInput label={t("default")} value={typeof value === "number" ? value : 0} onChange={onChange} />;
|
||||
const choiceData = choices.map((choice) => ({ label: choice.label, value: String(choice.value) }));
|
||||
if (control === "select" && choices.length > 0)
|
||||
return (
|
||||
<Select
|
||||
label={t("default")}
|
||||
data={choiceData}
|
||||
value={value === undefined ? null : String(value)}
|
||||
onChange={(next) => onChange(coerceChoice(next, choices))}
|
||||
/>
|
||||
);
|
||||
if (control === "multiSelect" && choices.length > 0)
|
||||
return (
|
||||
<MultiSelect
|
||||
label={t("default")}
|
||||
data={choiceData}
|
||||
value={Array.isArray(value) ? value.map(String) : []}
|
||||
onChange={(next) => onChange(next.map((entry) => coerceChoice(entry, choices)))}
|
||||
/>
|
||||
);
|
||||
if (control === "json" || control === "multiSelect")
|
||||
return <JsonValueEditor id={id} label={t("default")} value={value} onChange={onChange} />;
|
||||
return (
|
||||
<TextInput
|
||||
label={t("default")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function optionForControl(option: Record<string, unknown>, control: string | null): Record<string, unknown> {
|
||||
const nextControl = control ?? "text";
|
||||
const next: Record<string, unknown> = { ...option, control: nextControl, default: defaultForControl(nextControl) };
|
||||
if (!["select", "multiSelect"].includes(nextControl)) {
|
||||
next.choices = undefined;
|
||||
next.choicesFrom = undefined;
|
||||
}
|
||||
if (!["number", "slider", "duration"].includes(nextControl)) {
|
||||
next.min = undefined;
|
||||
next.max = undefined;
|
||||
next.step = undefined;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function OptionChoicesEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Array<{ label: string; value: string | number }>;
|
||||
onChange(value: Array<{ label: string; value: string | number }>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{value.map((choice, index) => (
|
||||
<Group key={index} align="end" wrap="wrap">
|
||||
<TextInput
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={t("choiceLabel")}
|
||||
value={choice.label}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
value.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, label: event.currentTarget.value } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={t("choiceValue")}
|
||||
value={String(choice.value)}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
value.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, value: parseChoiceValue(event.currentTarget.value) } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label={t("removeChoice")}
|
||||
onClick={() => onChange(value.filter((_, entryIndex) => entryIndex !== index))}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onChange([...value, { label: t("newChoice"), value: `choice-${value.length + 1}` }])}
|
||||
>
|
||||
{t("addChoice")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function JsonValueEditor({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={`option-${id}`}
|
||||
label={label}
|
||||
description={description}
|
||||
language="json"
|
||||
value={draft}
|
||||
height="140px"
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
onChange(JSON.parse(next) as unknown);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultForControl(control: string | null) {
|
||||
if (control === "switch") return false;
|
||||
if (["number", "slider", "duration"].includes(control ?? "")) return 0;
|
||||
if (control === "multiSelect") return [];
|
||||
if (control === "json") return {};
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseChoiceValue(value: string): string | number {
|
||||
return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim()) ? Number(value) : value;
|
||||
}
|
||||
|
||||
function coerceChoice(value: string | null, choices: Array<{ value: string | number }>) {
|
||||
if (value === null) return "";
|
||||
const numeric = choices.find((choice) => typeof choice.value === "number" && String(choice.value) === value);
|
||||
return numeric?.value ?? value;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { DefaultValueEditor, OptionChoicesEditor, optionForControl } from "./_custom-widget-option-fields";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
export function CustomWidgetOptionsEditor({
|
||||
form,
|
||||
onRename,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentName: string, nextName: string): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const parsed = parseJson(form.values.options);
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const requests = parseJson(form.values.requests);
|
||||
const requestIds = isRecord(requests) ? Object.keys(requests) : [];
|
||||
const commit = (next: Array<[string, unknown]>) =>
|
||||
form.setFieldValue("options", JSON.stringify(Object.fromEntries(next), null, 2));
|
||||
const update = (index: number, id: string, option: Record<string, unknown>) =>
|
||||
commit(entries.map((entry, i) => (i === index ? [id, option] : entry)));
|
||||
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const next = [...entries];
|
||||
const target = index + direction;
|
||||
if (!next[index] || !next[target]) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
commit(next);
|
||||
};
|
||||
const add = () => {
|
||||
let suffix = entries.length + 1;
|
||||
while (entries.some(([id]) => id === `option-${suffix}`)) suffix += 1;
|
||||
commit([...entries, [`option-${suffix}`, { label: `Option ${suffix}`, control: "text", default: "" }]]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{entries.map(([id, rawOption], index) => {
|
||||
const option = isRecord(rawOption) ? rawOption : {};
|
||||
const control = typeof option.control === "string" ? option.control : "text";
|
||||
const choicesFrom = isRecord(option.choicesFrom) ? option.choicesFrom : null;
|
||||
const choices = Array.isArray(option.choices)
|
||||
? option.choices.flatMap((choice) =>
|
||||
isRecord(choice) &&
|
||||
typeof choice.label === "string" &&
|
||||
(typeof choice.value === "string" || typeof choice.value === "number")
|
||||
? [{ label: choice.label, value: choice.value }]
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("optionName")}
|
||||
value={id}
|
||||
error={form.errors.options}
|
||||
onCommit={(value) => onRename(id, value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("label")}
|
||||
value={typeof option.label === "string" ? option.label : ""}
|
||||
onChange={(event) => update(index, id, { ...option, label: event.currentTarget.value })}
|
||||
/>
|
||||
<Select
|
||||
label={t("control")}
|
||||
data={[...customWidgetOptionControls]}
|
||||
value={control}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => update(index, id, optionForControl(option, value))}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("description")}
|
||||
value={typeof option.description === "string" ? option.description : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...option, description: event.currentTarget.value || undefined })
|
||||
}
|
||||
/>
|
||||
<DefaultValueEditor
|
||||
id={`${index}-default`}
|
||||
control={control}
|
||||
value={option.default}
|
||||
choices={choices}
|
||||
onChange={(value) => update(index, id, { ...option, default: value })}
|
||||
/>
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="advanced">
|
||||
<Accordion.Control>{t("optionDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{["number", "slider", "duration"].includes(control) && (
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("minimum")}
|
||||
value={typeof option.min === "number" ? option.min : ""}
|
||||
onChange={(value) =>
|
||||
update(index, id, { ...option, min: typeof value === "number" ? value : undefined })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("maximum")}
|
||||
value={typeof option.max === "number" ? option.max : ""}
|
||||
onChange={(value) =>
|
||||
update(index, id, { ...option, max: typeof value === "number" ? value : undefined })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("step")}
|
||||
value={typeof option.step === "number" ? option.step : ""}
|
||||
onChange={(value) =>
|
||||
update(index, id, { ...option, step: typeof value === "number" ? value : undefined })
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{["select", "multiSelect"].includes(control) && (
|
||||
<OptionChoicesEditor
|
||||
value={choices}
|
||||
onChange={(nextChoices) =>
|
||||
update(index, id, {
|
||||
...option,
|
||||
choices: nextChoices.length > 0 ? nextChoices : undefined,
|
||||
choicesFrom: undefined,
|
||||
default:
|
||||
control === "select" &&
|
||||
!nextChoices.some((choice) => Object.is(choice.value, option.default))
|
||||
? (nextChoices[0]?.value ?? "")
|
||||
: option.default,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{["select", "multiSelect"].includes(control) && (
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("choicesRequest")}
|
||||
data={requestIds}
|
||||
value={typeof choicesFrom?.request === "string" ? choicesFrom.request : null}
|
||||
clearable
|
||||
onChange={(value) =>
|
||||
update(index, id, {
|
||||
...option,
|
||||
choices: value ? undefined : option.choices,
|
||||
choicesFrom: value ? { request: value, valuePath: "id", labelPath: "name" } : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{choicesFrom && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("itemsPath")}
|
||||
placeholder={t("itemsPathPlaceholder")}
|
||||
value={String(choicesFrom.itemsPath ?? "")}
|
||||
onChange={(event) =>
|
||||
update(index, id, {
|
||||
...option,
|
||||
choicesFrom: { ...choicesFrom, itemsPath: event.currentTarget.value || undefined },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("valuePath")}
|
||||
value={String(choicesFrom.valuePath ?? "")}
|
||||
onChange={(event) =>
|
||||
update(index, id, {
|
||||
...option,
|
||||
choicesFrom: { ...choicesFrom, valuePath: event.currentTarget.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("labelPath")}
|
||||
value={String(choicesFrom.labelPath ?? "")}
|
||||
onChange={(event) =>
|
||||
update(index, id, {
|
||||
...option,
|
||||
choicesFrom: { ...choicesFrom, labelPath: event.currentTarget.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label={t("group")}
|
||||
value={typeof option.group === "string" ? option.group : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...option, group: event.currentTarget.value || undefined })
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
mt="xl"
|
||||
label={t("advancedOption")}
|
||||
checked={option.advanced === true}
|
||||
onChange={(event) => update(index, id, { ...option, advanced: event.currentTarget.checked })}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
aria-label={t("moveOptionUp")}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<IconArrowUp size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
aria-label={t("moveOptionDown")}
|
||||
disabled={index === entries.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
{t("removeOption")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
})}
|
||||
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
|
||||
{t("addOption")}
|
||||
</Button>
|
||||
{form.errors.options && (
|
||||
<Text c="red" size="xs">
|
||||
{form.errors.options}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Fieldset, Stack, Textarea } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
export function PreviewActionControl({
|
||||
request,
|
||||
sessionId,
|
||||
}: {
|
||||
request: CustomJsxRequest & { id: string };
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const actionMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
|
||||
const initialParams = Object.fromEntries(expectedParams.map((name) => [name, ""]));
|
||||
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
|
||||
const parsed = parseJson(params);
|
||||
const validParams = isRuntimeParams(parsed, expectedParams) ? parsed : null;
|
||||
const confirmation = getCustomWidgetConfirmation(request);
|
||||
const run = async () => {
|
||||
if (!sessionId || !validParams) return;
|
||||
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
|
||||
if (needsConfirmation) {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
openConfirmModal({
|
||||
title: confirmation?.title ?? request.id,
|
||||
children: confirmation?.message ?? t("deleteConfirmation"),
|
||||
labels: { confirm: confirmation?.confirmLabel },
|
||||
confirmProps: { color: confirmation?.destructive || request.method === "DELETE" ? "red.9" : "blue" },
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
actionMutation.mutate(
|
||||
{ sessionId, requestId: request.id, params: validParams, confirmed: needsConfirmation },
|
||||
{
|
||||
onSuccess: (result) =>
|
||||
showSuccessNotification({
|
||||
title: request.id,
|
||||
message: result.simulated ? t("simulated") : t("httpStatus", { status: result.status }),
|
||||
}),
|
||||
onError: (error) => showErrorNotification({ title: request.id, message: error.message }),
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Fieldset legend={`${request.id} · ${request.method}`}>
|
||||
<Stack gap="xs">
|
||||
{expectedParams.length > 0 && (
|
||||
<Textarea
|
||||
label={t("actionParameters")}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={params}
|
||||
error={validParams ? undefined : t("invalidParameters")}
|
||||
onChange={(event) => setParams(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
color={confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
|
||||
disabled={!sessionId || !validParams}
|
||||
loading={actionMutation.isPending}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{confirmation?.confirmLabel ??
|
||||
(confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
interface PreviewSnapshot {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
export function createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
preview,
|
||||
options,
|
||||
fixtureError,
|
||||
}: {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
fixture: "live" | "loading" | "empty" | "error";
|
||||
preview: PreviewSnapshot;
|
||||
options: Record<string, unknown>;
|
||||
fixtureError: string;
|
||||
}): Record<string, unknown> | null {
|
||||
if (!candidate) return null;
|
||||
const requests = Object.entries(candidate.requests);
|
||||
const data = fixture === "empty" ? Object.fromEntries(requests.map(([id]) => [id, []])) : preview.data;
|
||||
const status =
|
||||
fixture === "loading"
|
||||
? Object.fromEntries(requests.map(([id]) => [id, { loading: true }]))
|
||||
: fixture === "error"
|
||||
? Object.fromEntries(requests.map(([id]) => [id, { loading: false, ok: false, error: fixtureError }]))
|
||||
: preview.status;
|
||||
return {
|
||||
template: candidate.template,
|
||||
data,
|
||||
status,
|
||||
options,
|
||||
requestCapabilities: requests.map(([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission: permission,
|
||||
confirmation:
|
||||
typeof confirmation === "string" ? { title: "Confirm action", message: confirmation } : confirmation,
|
||||
invalidates,
|
||||
})),
|
||||
previewSessionId: preview.session?.id,
|
||||
previewLiveActions: preview.session?.liveActions ?? false,
|
||||
queriesDisabled: fixture !== "live",
|
||||
isEditMode: fixture !== "live",
|
||||
};
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
customWidget: {
|
||||
setPreviewLiveActions: { useMutation: () => ({ isPending: false, mutate: vi.fn() }) },
|
||||
previewJournal: { useQuery: () => ({ data: [] }) },
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
|
||||
const rendererAttempt = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
default: ({ data }: { data: Record<string, unknown> }) => {
|
||||
rendererAttempt(data);
|
||||
if ((data.options as Record<string, unknown> | undefined)?.throwPreview === true) {
|
||||
throw new Error("authored widget failed");
|
||||
}
|
||||
return <div>widget-renderer</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Custom Widget preview panel", () => {
|
||||
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={null}
|
||||
validationIssues={[{ path: "sources.0.baseUrl", message: "Enter a complete URL." }]}
|
||||
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const previewCanvas = host.querySelector('[class*="previewCanvas"]');
|
||||
expect(previewCanvas?.textContent).toContain("invalid");
|
||||
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
|
||||
expect(host.textContent).toContain(`tab.${tab}`);
|
||||
}
|
||||
|
||||
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.data"),
|
||||
);
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.options"),
|
||||
);
|
||||
await act(async () => optionsTab?.click());
|
||||
expect(host.textContent).toContain("instanceOptions");
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const actionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.actions"),
|
||||
);
|
||||
await act(async () => actionsTab?.click());
|
||||
expect(host.textContent).toContain("liveActions");
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.diagnostics"),
|
||||
);
|
||||
expect(diagnosticsTab).toBeDefined();
|
||||
await act(async () => diagnosticsTab?.click());
|
||||
|
||||
expect(host.textContent).toContain("sources.0.baseUrl");
|
||||
expect(host.textContent).toContain("Enter a complete URL.");
|
||||
});
|
||||
|
||||
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
rendererAttempt.mockClear();
|
||||
const bundledWidget = BUNDLED_CUSTOM_WIDGETS.at(0);
|
||||
if (!bundledWidget) throw new Error("Expected at least one bundled Custom Widget");
|
||||
const candidate = customWidgetDefinitionSchema.parse(bundledWidget.widget);
|
||||
const preview = { data: {}, status: {}, session: null, outcome: "idle" } as const;
|
||||
const failingOptions = { throwPreview: true };
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={{
|
||||
...candidate,
|
||||
requests: Object.fromEntries(
|
||||
Object.entries(candidate.requests).map(([id, request]) => [id, { ...request }]),
|
||||
),
|
||||
}}
|
||||
validationIssues={[]}
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{ ...failingOptions }}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("title");
|
||||
expect(host.querySelector('[class*="previewCanvas"]')?.textContent).toContain("title");
|
||||
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
|
||||
expect(host.textContent).toContain(`tab.${tab}`);
|
||||
}
|
||||
const attemptsAfterFailure = rendererAttempt.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate}
|
||||
validationIssues={[]}
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={failingOptions}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
expect(rendererAttempt).toHaveBeenCalledTimes(attemptsAfterFailure);
|
||||
|
||||
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.data"),
|
||||
);
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
|
||||
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.diagnostics"),
|
||||
);
|
||||
await act(async () => diagnosticsTab?.click());
|
||||
expect(host.textContent).toContain("validation.title");
|
||||
|
||||
const widgetTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.widget"),
|
||||
);
|
||||
await act(async () => widgetTab?.click());
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate}
|
||||
validationIssues={[]}
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).toContain("widget-renderer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
MantineProvider,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
|
||||
import type { PreviewOutcome } from "./_custom-widget-preview-status";
|
||||
import { PreviewActionControl } from "./_custom-widget-preview-action";
|
||||
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
|
||||
import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export interface PreviewState {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; expiresAt: number; liveActions: boolean } | null;
|
||||
outcome: PreviewOutcome;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
validationIssues: Array<{ path?: string; message: string }>;
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(value: string): void;
|
||||
theme: "light" | "dark";
|
||||
onThemeChange(value: "light" | "dark"): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange(value: Record<string, unknown>): void;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
|
||||
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: props.preview.session?.id ?? "" },
|
||||
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
|
||||
);
|
||||
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
const candidate = props.candidate;
|
||||
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
|
||||
const displayData = createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
preview: props.preview,
|
||||
options: props.optionsSnapshot,
|
||||
fixtureError: t("fixtureError"),
|
||||
});
|
||||
const previewSummary = getPreviewSummary(props.preview.status);
|
||||
const rendererResetKey = JSON.stringify({
|
||||
template: candidate?.template,
|
||||
requests: candidate?.requests,
|
||||
fixture,
|
||||
data: props.preview.data,
|
||||
status: props.preview.status,
|
||||
sessionId: props.preview.session?.id,
|
||||
options: props.optionsSnapshot,
|
||||
});
|
||||
const previewResult =
|
||||
props.preview.outcome === "success"
|
||||
? {
|
||||
title: t("result.success.title"),
|
||||
description: t("result.success.description", previewSummary),
|
||||
}
|
||||
: props.preview.outcome === "error"
|
||||
? {
|
||||
title: t("result.error.title"),
|
||||
description: t("result.error.description", previewSummary),
|
||||
}
|
||||
: props.preview.outcome === "loading"
|
||||
? { title: t("result.loading.title"), description: t("result.loading.description") }
|
||||
: { title: "", description: "" };
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.size}
|
||||
onChange={props.onSizeChange}
|
||||
data={[
|
||||
{ value: "compact", label: t("size.compact") },
|
||||
{ value: "standard", label: t("size.standard") },
|
||||
{ value: "wide", label: t("size.wide") },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.theme}
|
||||
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
|
||||
data={[
|
||||
{ value: "light", label: t("theme.light") },
|
||||
{ value: "dark", label: t("theme.dark") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
disabled={!candidate}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
{candidate && (
|
||||
<PreviewResult
|
||||
outcome={props.preview.outcome}
|
||||
title={previewResult.title}
|
||||
description={previewResult.description}
|
||||
/>
|
||||
)}
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
|
||||
<Tabs.Tab key={tab} value={tab}>
|
||||
<Group gap={6} wrap="nowrap" justify="center">
|
||||
{t(`tab.${tab}`)}
|
||||
{tab === "data" && (
|
||||
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
|
||||
)}
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Box className={classes.previewCanvas}>
|
||||
<MantineProvider forceColorScheme={props.theme}>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</MantineProvider>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="data" pt="sm">
|
||||
<CodeEditor
|
||||
id="preview-data"
|
||||
label={t("requestData")}
|
||||
language="json"
|
||||
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="options" pt="sm">
|
||||
<JsonPreviewEditor
|
||||
id="preview-options"
|
||||
label={t("instanceOptions")}
|
||||
value={props.optionsSnapshot}
|
||||
onChange={props.onOptionsChange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="actions" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
label={t("liveActions")}
|
||||
description={t("liveActionsDescription")}
|
||||
checked={props.preview.session?.liveActions ?? false}
|
||||
disabled={!props.preview.session || liveActionsMutation.isPending}
|
||||
onChange={(event) => {
|
||||
if (!props.preview.session) return;
|
||||
const enabled = event.currentTarget.checked;
|
||||
liveActionsMutation.mutate(
|
||||
{ sessionId: props.preview.session.id, enabled },
|
||||
{
|
||||
onSuccess: () => props.onLiveActionsChange(enabled),
|
||||
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{Object.entries(candidate?.requests ?? {})
|
||||
.filter(([, request]) => request.kind === "action")
|
||||
.map(([id, request]) => (
|
||||
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
|
||||
))}
|
||||
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noActions")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
{props.preview.session ? (
|
||||
<Badge color="green">{t("sessionActive")}</Badge>
|
||||
) : (
|
||||
<Badge color="gray">{t("simulationPending")}</Badge>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("containedFailures")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("validation.title")}
|
||||
</Text>
|
||||
{props.validationIssues.length === 0 ? (
|
||||
<Alert color="green">{t("validation.ready")}</Alert>
|
||||
) : (
|
||||
props.validationIssues.map((issue, index) => (
|
||||
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
|
||||
{issue.message}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{t("accessibility.title")}
|
||||
</Text>
|
||||
{!candidate ? (
|
||||
<Alert color="gray">{t("accessibility.pending")}</Alert>
|
||||
) : accessibilityIssues.length === 0 ? (
|
||||
<Alert color="green">{t("accessibility.ready")}</Alert>
|
||||
) : (
|
||||
accessibilityIssues.map((issue) => (
|
||||
<Alert key={issue} color="yellow">
|
||||
{t(`accessibility.${issue}`)}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
{props.preview.session && (
|
||||
<CodeEditor
|
||||
id="preview-journal"
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Box, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import { isRecord } from "./_custom-widget-form-utils";
|
||||
|
||||
export type PreviewOutcome = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function PreviewResult({
|
||||
outcome,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
outcome: PreviewOutcome;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
if (outcome === "idle") return null;
|
||||
return (
|
||||
<Alert color={outcome === "error" ? "red" : outcome === "success" ? "green" : "yellow"}>
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewStatusDot({ outcome, label }: { outcome: PreviewOutcome; label: string }) {
|
||||
const colors = { idle: "gray", loading: "yellow", success: "green", error: "red" } as const;
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Box
|
||||
component="output"
|
||||
aria-label={label}
|
||||
w={8}
|
||||
h={8}
|
||||
bg={`var(--mantine-color-${colors[outcome]}-6)`}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function getPreviewSummary(status: Record<string, unknown>) {
|
||||
return Object.values(status).reduce<{ succeeded: number; failed: number }>(
|
||||
(summary, value) => {
|
||||
if (isRecord(value) && value.ok === false) summary.failed += 1;
|
||||
else summary.succeeded += 1;
|
||||
return summary;
|
||||
},
|
||||
{ succeeded: 0, failed: 0 },
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
|
||||
import { displayComponents } from "@homarr/widgets/custom-api/component";
|
||||
|
||||
interface PreviewInput {
|
||||
url: string;
|
||||
method: string;
|
||||
authType: string;
|
||||
headerName?: string;
|
||||
requestBody?: string;
|
||||
displayType: string;
|
||||
displayConfig: Record<string, unknown>;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseInfo: { status: number } | null;
|
||||
rawResponse: string | null;
|
||||
}
|
||||
|
||||
interface CustomWidgetPreviewProps {
|
||||
getFormValues: () => PreviewInput;
|
||||
formValues: PreviewInput;
|
||||
fetchResult: PreviewFetchResult | null;
|
||||
cachedJson: unknown;
|
||||
onTest: () => void;
|
||||
isTesting: boolean;
|
||||
testError?: string | null;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview({
|
||||
getFormValues,
|
||||
formValues,
|
||||
fetchResult,
|
||||
cachedJson,
|
||||
onTest,
|
||||
isTesting,
|
||||
testError,
|
||||
}: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
const displayData = useMemo((): Record<string, unknown> | null => {
|
||||
const { displayType, displayConfig } = formValues;
|
||||
if (displayType === "actionButton") {
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
if (!fetchResult?.success || cachedJson == null) return null;
|
||||
return extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
|
||||
}, [fetchResult?.success, cachedJson, formValues]);
|
||||
|
||||
const handleTest = () => {
|
||||
const values = getFormValues();
|
||||
if (!values.url) return;
|
||||
onTest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetchResult && !fetchResult.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{fetchResult.error}
|
||||
</Text>
|
||||
{fetchResult.responseInfo && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{fetchResult.responseInfo.status}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) && (
|
||||
<Stack gap="xs">
|
||||
{fetchResult?.success && (
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{fetchResult.responseInfo?.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mah={320} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Card>
|
||||
|
||||
{fetchResult?.success && fetchResult.rawResponse && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!displayData && !testError && !isTesting && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
No data
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const dataType = typed.type as string | undefined;
|
||||
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm">
|
||||
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
|
||||
{(typed.buttonLabel as string) ?? "Execute"}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
return <Component data={typed} displayMode="advanced" width={320} height={240} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h={100}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { applyDefinition } from "./_custom-widget-form-utils";
|
||||
import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
interface RenameHandlersInput {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
candidate: ReturnType<typeof buildDefinition>;
|
||||
invalidWidgetMessage: string;
|
||||
}
|
||||
|
||||
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
|
||||
const renameRequest = (currentId: string, nextId: string) => {
|
||||
if (!input.candidate.success) {
|
||||
input.form.setFieldError("requests", input.invalidWidgetMessage);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId));
|
||||
input.form.clearFieldError("requests");
|
||||
} catch (error) {
|
||||
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
|
||||
}
|
||||
};
|
||||
const renameOption = (currentName: string, nextName: string) => {
|
||||
if (!input.candidate.success) {
|
||||
input.form.setFieldError("options", input.invalidWidgetMessage);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
|
||||
input.form.clearFieldError("options");
|
||||
} catch (error) {
|
||||
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
|
||||
}
|
||||
};
|
||||
return { renameRequest, renameOption };
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
export function QueryValuesEditor({
|
||||
value,
|
||||
optionNames,
|
||||
allowParams,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, unknown>;
|
||||
optionNames: string[];
|
||||
allowParams: boolean;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const entries = Object.entries(value);
|
||||
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("queryValues")}
|
||||
</Text>
|
||||
{entries.map(([name, bound], index) => {
|
||||
const kind =
|
||||
isRecord(bound) && typeof bound.$option === "string"
|
||||
? "option"
|
||||
: isRecord(bound) && typeof bound.$param === "string"
|
||||
? "param"
|
||||
: "literal";
|
||||
const current =
|
||||
kind === "option"
|
||||
? String((bound as Record<string, unknown>).$option)
|
||||
: kind === "param"
|
||||
? String((bound as Record<string, unknown>).$param)
|
||||
: String(bound ?? "");
|
||||
const commit = (nextName: string, nextKind: string, nextValue: string) =>
|
||||
onChange(
|
||||
Object.fromEntries(
|
||||
entries.map(([key, child], i) =>
|
||||
i === index
|
||||
? [
|
||||
nextName,
|
||||
nextKind === "option"
|
||||
? { $option: nextValue }
|
||||
: nextKind === "param"
|
||||
? { $param: nextValue }
|
||||
: parseLiteral(nextValue),
|
||||
]
|
||||
: [key, child],
|
||||
),
|
||||
),
|
||||
);
|
||||
const sourceChoices = [
|
||||
{ value: "literal", label: t("literal") },
|
||||
{ value: "option", label: t("widgetOption") },
|
||||
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
|
||||
];
|
||||
return (
|
||||
<Group key={index} align="end" wrap="wrap">
|
||||
<CustomWidgetIdentifierInput
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={t("queryKey")}
|
||||
value={name}
|
||||
error={nameErrors[index]}
|
||||
onCommit={(nextName) => {
|
||||
if (
|
||||
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
|
||||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
|
||||
) {
|
||||
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
|
||||
return;
|
||||
}
|
||||
setNameErrors((currentErrors) => {
|
||||
const nextErrors = { ...currentErrors };
|
||||
delete nextErrors[index];
|
||||
return nextErrors;
|
||||
});
|
||||
commit(nextName, kind, current);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={t("valueSource")}
|
||||
data={sourceChoices}
|
||||
value={kind}
|
||||
allowDeselect={false}
|
||||
onChange={(nextKind) => commit(name, nextKind ?? "literal", current)}
|
||||
/>
|
||||
{kind === "option" ? (
|
||||
<Select
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={t("option")}
|
||||
data={optionNames}
|
||||
value={current}
|
||||
searchable
|
||||
onChange={(next) => commit(name, kind, next ?? "")}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={kind === "param" ? t("parameterName") : t("value")}
|
||||
value={current}
|
||||
onChange={(event) => commit(name, kind, event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label={t("removeQueryValue")}
|
||||
onClick={() => onChange(Object.fromEntries(entries.filter((_, i) => i !== index)))}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onChange({ ...value, [`value${entries.length + 1}`]: "" })}
|
||||
>
|
||||
{t("addQueryValue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function RequestBodyEditor({
|
||||
id,
|
||||
value,
|
||||
optionNames,
|
||||
allowParams,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: unknown;
|
||||
optionNames: string[];
|
||||
allowParams: boolean;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
const [optionName, setOptionName] = useState<string | null>(optionNames[0] ?? null);
|
||||
const [parameterName, setParameterName] = useState("");
|
||||
const [insertion, setInsertion] = useState({ text: "", key: 0 });
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<CodeEditor
|
||||
id={`${id}-request-body-editor`}
|
||||
label={t("jsonBody")}
|
||||
description={t("jsonBodyDescription")}
|
||||
language="json"
|
||||
value={draft}
|
||||
height="160px"
|
||||
error={error}
|
||||
insertText={insertion.text}
|
||||
insertKey={insertion.key}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Group align="end" wrap="wrap">
|
||||
<Select label={t("option")} data={optionNames} value={optionName} searchable onChange={setOptionName} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
disabled={!optionName}
|
||||
onClick={() =>
|
||||
optionName &&
|
||||
setInsertion((current) => ({ text: JSON.stringify({ $option: optionName }), key: current.key + 1 }))
|
||||
}
|
||||
>
|
||||
{t("insertOptionReference")}
|
||||
</Button>
|
||||
{allowParams && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("parameterName")}
|
||||
value={parameterName}
|
||||
onChange={(event) => setParameterName(event.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
disabled={!parameterName.trim()}
|
||||
onClick={() =>
|
||||
setInsertion((current) => ({
|
||||
text: JSON.stringify({ $param: parameterName.trim() }),
|
||||
key: current.key + 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t("insertParameterReference")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function parseLiteral(value: string): string | number | boolean {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim())) return Number(value);
|
||||
return value;
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
export function CustomWidgetRequestsEditor({
|
||||
form,
|
||||
onRename,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentId: string, nextId: string): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const parsed = parseJson(form.values.requests);
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const sources = parseJson(form.values.sources);
|
||||
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
|
||||
|
||||
const commit = (next: Array<[string, unknown]>) =>
|
||||
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
|
||||
const update = (index: number, id: string, request: Record<string, unknown>) =>
|
||||
commit(entries.map((entry, i) => (i === index ? [id, request] : entry)));
|
||||
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const next = [...entries];
|
||||
const target = index + direction;
|
||||
if (!next[index] || !next[target]) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
commit(next);
|
||||
};
|
||||
const add = () => {
|
||||
let suffix = entries.length + 1;
|
||||
while (entries.some(([id]) => id === `request-${suffix}`)) suffix += 1;
|
||||
commit([...entries, [`request-${suffix}`, { path: "/api/data" }]]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{entries.map(([id, rawRequest], index) => {
|
||||
const request = isRecord(rawRequest) ? rawRequest : {};
|
||||
const kind = request.kind === "action" ? "action" : "query";
|
||||
const trigger = request.trigger === "manual" ? "manual" : "load";
|
||||
return (
|
||||
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("requestId")}
|
||||
value={id}
|
||||
error={form.errors.requests}
|
||||
onCommit={(value) => onRename(id, value)}
|
||||
/>
|
||||
<Select
|
||||
label={t("type")}
|
||||
data={["query", "action"]}
|
||||
value={kind}
|
||||
allowDeselect={false}
|
||||
onChange={(value) =>
|
||||
update(index, id, {
|
||||
...request,
|
||||
kind: value,
|
||||
...(value === "action" ? { trigger: "manual" } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("method")}
|
||||
data={methods}
|
||||
value={typeof request.method === "string" ? request.method : "GET"}
|
||||
allowDeselect={false}
|
||||
onChange={(value) =>
|
||||
update(index, id, {
|
||||
...request,
|
||||
method: value,
|
||||
...(value === "DELETE" ? { permission: "full" } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("source")}
|
||||
data={sourceIds}
|
||||
value={typeof request.source === "string" ? request.source : "default"}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => update(index, id, { ...request, source: value })}
|
||||
/>
|
||||
{kind === "query" && (
|
||||
<Select
|
||||
label={t("run")}
|
||||
data={[
|
||||
{ value: "load", label: t("runLoad") },
|
||||
{ value: "manual", label: t("runManual") },
|
||||
]}
|
||||
value={trigger}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => update(index, id, { ...request, trigger: value })}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={t("permission")}
|
||||
data={["view", "modify", "full"]}
|
||||
value={
|
||||
typeof request.permission === "string" ? request.permission : kind === "action" ? "modify" : "view"
|
||||
}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => update(index, id, { ...request, permission: value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("path")}
|
||||
description={t("pathDescription")}
|
||||
value={typeof request.path === "string" ? request.path : ""}
|
||||
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
|
||||
/>
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="values">
|
||||
<Accordion.Control>{t("requestDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<QueryValuesEditor
|
||||
value={isRecord(request.query) ? request.query : {}}
|
||||
optionNames={Object.keys(
|
||||
isRecord(parseJson(form.values.options))
|
||||
? (parseJson(form.values.options) as Record<string, unknown>)
|
||||
: {},
|
||||
)}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(query) => update(index, id, { ...request, query })}
|
||||
/>
|
||||
<RequestBodyEditor
|
||||
id={id}
|
||||
value={request.body}
|
||||
optionNames={Object.keys(
|
||||
isRecord(parseJson(form.values.options))
|
||||
? (parseJson(form.values.options) as Record<string, unknown>)
|
||||
: {},
|
||||
)}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(body) => update(index, id, { ...request, body })}
|
||||
/>
|
||||
{kind === "action" && (
|
||||
<TextInput
|
||||
label={t("confirmation")}
|
||||
value={typeof request.confirmation === "string" ? request.confirmation : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{kind === "action" && (
|
||||
<TextInput
|
||||
label={t("invalidation")}
|
||||
description={t("invalidationDescription")}
|
||||
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, {
|
||||
...request,
|
||||
invalidates: event.currentTarget.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
label={t("noAuth")}
|
||||
checked={request.auth === "none"}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
aria-label={t("moveRequestUp")}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<IconArrowUp size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
aria-label={t("moveRequestDown")}
|
||||
disabled={index === entries.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
{t("removeRequest")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
})}
|
||||
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
|
||||
{t("addRequest")}
|
||||
</Button>
|
||||
{form.errors.requests && (
|
||||
<Text c="red" size="xs">
|
||||
{form.errors.requests}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
|
||||
|
||||
describe("extractCustomWidgetSaveIssues", () => {
|
||||
it("extracts every flattened server validation message", () => {
|
||||
expect(
|
||||
extractCustomWidgetSaveIssues({
|
||||
data: {
|
||||
zodError: {
|
||||
fieldErrors: {
|
||||
template: [
|
||||
"UNKNOWN_COMPONENT: 'Radio.Group' is not available. Did you mean 'RadioGroup'?",
|
||||
"UNKNOWN_COMPONENT: 'Radio.Card' is not available. Did you mean 'RadioCard'?",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ path: "template", message: "UNKNOWN_COMPONENT: 'Radio.Group' is not available. Did you mean 'RadioGroup'?" },
|
||||
{ path: "template", message: "UNKNOWN_COMPONENT: 'Radio.Card' is not available. Did you mean 'RadioCard'?" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts paths and messages from a serialized Zod issue list", () => {
|
||||
const message = JSON.stringify([
|
||||
{
|
||||
code: "custom",
|
||||
message: "UNKNOWN_COMPONENT: 'Radio.Indicator' is not available. Did you mean 'RadioIndicator'?",
|
||||
path: ["template"],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
|
||||
{
|
||||
path: "template",
|
||||
message: "UNKNOWN_COMPONENT: 'Radio.Indicator' is not available. Did you mean 'RadioIndicator'?",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves array indexes in serialized Zod issue paths", () => {
|
||||
const message = JSON.stringify([
|
||||
{
|
||||
code: "custom",
|
||||
message: "A load parameter requires an explicit binding.",
|
||||
path: ["requests", "containers", "path"],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
|
||||
{
|
||||
path: "requests.containers.path",
|
||||
message: "A load parameter requires an explicit binding.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts root validation errors from the tRPC error shape", () => {
|
||||
expect(
|
||||
extractCustomWidgetSaveIssues({
|
||||
shape: {
|
||||
data: {
|
||||
zodError: {
|
||||
formErrors: ["The widget definition is invalid."],
|
||||
fieldErrors: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([{ message: "The widget definition is invalid." }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface CustomWidgetSaveIssue {
|
||||
path?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_SAVE_ISSUES = 20;
|
||||
|
||||
export function extractCustomWidgetSaveIssues(error: unknown): CustomWidgetSaveIssue[] {
|
||||
const record = asRecord(error);
|
||||
const data = asRecord(record?.data) ?? asRecord(asRecord(record?.shape)?.data);
|
||||
const zodError = asRecord(data?.zodError);
|
||||
const fieldErrors = asRecord(zodError?.fieldErrors);
|
||||
const flattened = fieldErrors
|
||||
? Object.entries(fieldErrors).flatMap(([path, value]) =>
|
||||
Array.isArray(value)
|
||||
? value.flatMap((message) => (typeof message === "string" ? [{ path, message }] : []))
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
const formErrors = Array.isArray(zodError?.formErrors)
|
||||
? zodError.formErrors.flatMap((message) => (typeof message === "string" ? [{ message }] : []))
|
||||
: [];
|
||||
if (flattened.length > 0 || formErrors.length > 0) return uniqueIssues([...formErrors, ...flattened]);
|
||||
|
||||
const message = typeof record?.message === "string" ? record.message : null;
|
||||
if (!message) return [];
|
||||
const parsed = parseZodIssueMessage(message);
|
||||
return parsed.length > 0 ? uniqueIssues(parsed) : [{ message }];
|
||||
}
|
||||
|
||||
function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
|
||||
try {
|
||||
const parsed = JSON.parse(message) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.flatMap((candidate) => {
|
||||
const issue = asRecord(candidate);
|
||||
if (typeof issue?.message !== "string") return [];
|
||||
const path = Array.isArray(issue.path)
|
||||
? issue.path
|
||||
.filter((part) => typeof part === "string" || typeof part === "number")
|
||||
.map(String)
|
||||
.join(".")
|
||||
: undefined;
|
||||
return [{ path: path || undefined, message: issue.message }];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueIssues(issues: CustomWidgetSaveIssue[]) {
|
||||
return issues
|
||||
.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index,
|
||||
)
|
||||
.slice(0, MAX_SAVE_ISSUES);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
|
||||
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
|
||||
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
|
||||
const t = useScopedI18n("customWidget.workbench.saveError");
|
||||
if (issues.length === 0) return null;
|
||||
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
|
||||
|
||||
return (
|
||||
<Alert color="red" title={t("title")} icon={<IconAlertCircle size={18} />}>
|
||||
<Text size="sm" mb="xs">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea value={value} readOnly autosize minRows={2} maxRows={10} aria-label={t("errors")} mb="xs" />
|
||||
<CopyButton value={value} timeout={2_000}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={copied ? "green" : "red"}
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
|
||||
|
||||
const requests = JSON.stringify({
|
||||
status: { source: "default", path: "/status" },
|
||||
restart: { source: "secondary", path: "/restart", kind: "action" },
|
||||
logs: { source: "secondary", path: "/logs" },
|
||||
});
|
||||
|
||||
describe("custom widget source dependencies", () => {
|
||||
it("lists every request that would be removed with a source", () => {
|
||||
expect(getDependentRequestIds(requests, "secondary")).toEqual(["restart", "logs"]);
|
||||
});
|
||||
|
||||
it("removes only requests that use the selected source", () => {
|
||||
expect(removeDependentRequests(requests, "secondary")).toEqual({
|
||||
status: { source: "default", path: "/status" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
export function getDependentRequestIds(requestsJson: string, sourceId: string) {
|
||||
const requests = parseRequestRecord(requestsJson);
|
||||
return Object.entries(requests).flatMap(([requestId, request]) =>
|
||||
isRecord(request) && request.source === sourceId ? [requestId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function removeDependentRequests(requestsJson: string, sourceId: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parseRequestRecord(requestsJson)).filter(
|
||||
([, request]) => !isRecord(request) || request.source !== sourceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseRequestRecord(requestsJson: string): Record<string, unknown> {
|
||||
try {
|
||||
const requests = JSON.parse(requestsJson) as unknown;
|
||||
return isRecord(requests) ? requests : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from "@mantine/core";
|
||||
import { IconKey, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
type SecretKind = "apiKey" | "username" | "password";
|
||||
|
||||
const secretFields: Record<string, Array<{ kind: SecretKind }>> = {
|
||||
bearer: [{ kind: "apiKey" }],
|
||||
basic: [{ kind: "username" }, { kind: "password" }],
|
||||
apiKeyHeader: [{ kind: "apiKey" }],
|
||||
apiKeyQuery: [{ kind: "apiKey" }],
|
||||
};
|
||||
|
||||
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
|
||||
invalid: "baseUrlError.invalid",
|
||||
protocol: "baseUrlError.protocol",
|
||||
credentials: "baseUrlError.credentials",
|
||||
queryOrFragment: "baseUrlError.queryOrFragment",
|
||||
};
|
||||
|
||||
interface CustomWidgetSourceFieldProps {
|
||||
source: CustomWidgetSource & { id: string };
|
||||
index: number;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
clearSecretPending: boolean;
|
||||
onUpdate(index: number, changes: Partial<CustomWidgetSource> & { id?: string }): void;
|
||||
onSetAuthentication(index: number, type: string): void;
|
||||
onSetSecret(sourceId: string, kind: string, value: string): void;
|
||||
onClearSecret(sourceId: string, kind: SecretKind): Promise<void>;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetSourceField({
|
||||
source,
|
||||
index,
|
||||
form,
|
||||
definitionId,
|
||||
clearSecretPending,
|
||||
onUpdate,
|
||||
onSetAuthentication,
|
||||
onSetSecret,
|
||||
onClearSecret,
|
||||
onRemove,
|
||||
}: CustomWidgetSourceFieldProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
|
||||
return (
|
||||
<Fieldset legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("id")}
|
||||
value={source.id}
|
||||
disabled={source.id === "default" || Boolean(definitionId)}
|
||||
error={form.errors.sources}
|
||||
onCommit={(value) => onUpdate(index, { id: value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
||||
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={authType}
|
||||
onChange={(value) => onSetAuthentication(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
|
||||
/>
|
||||
)}
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
|
||||
<TextInput
|
||||
label={t("queryParameter")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
|
||||
/>
|
||||
)}
|
||||
{(secretFields[authType] ?? []).map(({ kind }) => {
|
||||
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
|
||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Group key={kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretPending}
|
||||
onClick={() => void onClearSecret(source.id, kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
|
||||
import { CustomWidgetSourceField } from "./_custom-widget-source-field";
|
||||
|
||||
export function CustomWidgetSourcesEditor({
|
||||
form,
|
||||
definitionId,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||
const sources = parseSources(form.values.sources);
|
||||
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
|
||||
const previousId = sources[index]?.id;
|
||||
const nextId = changes.id ?? previousId;
|
||||
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
|
||||
form.setFieldError("sources", t("duplicateSourceId"));
|
||||
return;
|
||||
}
|
||||
if (form.errors.sources === t("duplicateSourceId")) form.clearFieldError("sources");
|
||||
const sourceEntries = sources.map(({ id, ...source }) => [id, source] as const);
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
sourceEntries.map(([id, source], i) => [
|
||||
i === index ? nextId : id,
|
||||
i === index ? { ...source, ...changes, id: undefined } : source,
|
||||
]),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (previousId && nextId && previousId !== nextId) {
|
||||
const parsedRequests = parseJson(form.values.requests);
|
||||
const requests = isRecord(parsedRequests)
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsedRequests).map(([id, request]) => [
|
||||
id,
|
||||
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
|
||||
]),
|
||||
)
|
||||
: {};
|
||||
form.setFieldValue("requests", JSON.stringify(requests, null, 2));
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.map((secret) =>
|
||||
secret.sourceId === previousId ? { ...secret, sourceId: nextId } : secret,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
const setAuth = (index: number, type: string) => {
|
||||
const auth =
|
||||
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
|
||||
update(index, { auth: auth as CustomWidgetSource["auth"] });
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
filterSecretsForSourceAuthentication(
|
||||
form.values.secrets,
|
||||
sources[index]?.id ?? "",
|
||||
type as Parameters<typeof filterSecretsForSourceAuthentication>[2],
|
||||
),
|
||||
);
|
||||
};
|
||||
const setSecret = (sourceId: string, kind: string, value: string) => {
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
const existing = form.values.secrets.find((secret) => secret.sourceId === sourceId && secret.kind === kind);
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
};
|
||||
const addSource = () => {
|
||||
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
Object.fromEntries([
|
||||
...sources.map(({ id: sourceId, ...source }) => [sourceId, source] as const),
|
||||
[
|
||||
id,
|
||||
{
|
||||
name: t("newName", { count: sources.length + 1 }),
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: sources.length === 0 ? "public" : "private",
|
||||
auth: "none",
|
||||
},
|
||||
],
|
||||
]),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
};
|
||||
const removeSourceAndDependents = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
Object.fromEntries(sources.filter((_, i) => i !== index).map(({ id, ...source }) => [id, source])),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (!removedId) return;
|
||||
form.setFieldValue("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2));
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
|
||||
);
|
||||
};
|
||||
const removeSource = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
if (!removedId) return;
|
||||
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
|
||||
if (dependentRequestIds.length === 0) {
|
||||
removeSourceAndDependents(index);
|
||||
return;
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("remove"),
|
||||
children: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{w("requests.title")} ({dependentRequestIds.length})
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{dependentRequestIds.map((requestId) => (
|
||||
<List.Item key={requestId}>{requestId}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
),
|
||||
confirmProps: { children: t("remove") },
|
||||
onConfirm: () => removeSourceAndDependents(index),
|
||||
});
|
||||
};
|
||||
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
|
||||
if (!definitionId) return;
|
||||
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value: "", hasValue: false }]);
|
||||
await utils.customWidget.get.invalidate({ id: definitionId });
|
||||
showSuccessNotification({ title: t("credentialRemoved"), message: t("credentialRemovedDescription") });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{sources.map((source, index) => {
|
||||
return (
|
||||
<CustomWidgetSourceField
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
form={form}
|
||||
definitionId={definitionId}
|
||||
clearSecretPending={clearSecretMutation.isPending}
|
||||
onUpdate={update}
|
||||
onSetAuthentication={setAuth}
|
||||
onSetSecret={setSecret}
|
||||
onClearSecret={clearSecret}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="advanced">
|
||||
<Accordion.Control>{t("advanced")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={addSource}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{form.errors.sources && (
|
||||
<Text c="red" size="xs">
|
||||
{form.errors.sources}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FormErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface FormErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErrorBoundaryState> {
|
||||
public state: FormErrorBoundaryState = { error: null };
|
||||
|
||||
public static getDerivedStateFromError(error: Error): FormErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, _info: ErrorInfo) {
|
||||
console.error("Custom widget form error:", error);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
return <FormErrorFallback error={this.state.error} reset={() => this.setState({ error: null })} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("editor.errorBoundary.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{error.message}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" color="red" onClick={reset}>
|
||||
{t("editor.errorBoundary.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
formatCustomWidgetImportIssues,
|
||||
looksLikeCustomWidgetClipboard,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
const queueImport = useCallback(
|
||||
(value: HomarrCustomWidgetV2) => {
|
||||
setPendingImport(value);
|
||||
openReview();
|
||||
},
|
||||
[openReview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!looksLikeCustomWidgetClipboard(text)) return;
|
||||
event.preventDefault();
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
queueImport(result.widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [queueImport, t]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
try {
|
||||
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
|
||||
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
queueImport(result.widget);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
message: error instanceof Error ? error.message : t("notification.importError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
export function JsonPreviewEditor({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={id}
|
||||
label={label}
|
||||
language="json"
|
||||
value={draft}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
const parsed = parseJson(next);
|
||||
if (isRecord(parsed)) onChange(parsed);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup());
|
||||
document.body.replaceChildren();
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
});
|
||||
|
||||
describe("custom widget unsaved changes guard", () => {
|
||||
it("blocks client-side link navigation and requests confirmation for a dirty form", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
|
||||
const link = document.createElement("a");
|
||||
link.href = "/en/manage/custom-widgets";
|
||||
link.textContent = "Custom widgets";
|
||||
document.body.append(link);
|
||||
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
|
||||
link.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
|
||||
});
|
||||
|
||||
it("allows section links and navigation after the form is clean", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
let dirty = true;
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation }));
|
||||
const sectionLink = document.createElement("a");
|
||||
sectionLink.href = "#sources";
|
||||
document.body.append(sectionLink);
|
||||
|
||||
expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(
|
||||
true,
|
||||
);
|
||||
dirty = false;
|
||||
const listLink = document.createElement("a");
|
||||
listLink.href = "/en/manage/custom-widgets";
|
||||
document.body.append(listLink);
|
||||
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
|
||||
expect(confirmNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
interface UnsavedChangesGuardOptions {
|
||||
isDirty(): boolean;
|
||||
confirmNavigation(href: string): void;
|
||||
}
|
||||
|
||||
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
|
||||
const eventTarget = event.target instanceof Element ? event.target : null;
|
||||
const anchor = eventTarget?.closest<HTMLAnchorElement>("a[href]");
|
||||
if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
|
||||
|
||||
const destination = new URL(anchor.href, window.location.href);
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.origin !== current.origin) return;
|
||||
if (
|
||||
destination.pathname === current.pathname &&
|
||||
destination.search === current.search &&
|
||||
destination.hash !== current.hash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
|
||||
};
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!isDirty()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import {
|
||||
applyCustomWidgetAiResponse,
|
||||
buildDefinition,
|
||||
getChangedSecrets,
|
||||
getDefinitionDefaults,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
isRecord,
|
||||
loadPreviewQueries,
|
||||
} from "./_custom-widget-form-utils";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
|
||||
interface FormActionsInput {
|
||||
mode: "create" | "edit";
|
||||
definitionId?: string;
|
||||
form: UseFormReturnType<CustomWidgetFormValues>;
|
||||
candidate: ReturnType<typeof buildDefinition>;
|
||||
preview: PreviewState;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const utils = clientApi.useUtils();
|
||||
const createMutation = clientApi.customWidget.create.useMutation();
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
|
||||
const [previewPending, setPreviewPending] = useState(false);
|
||||
|
||||
const save = input.form.onSubmit(async (values) => {
|
||||
clearSaveIssues(input.form, saveIssues);
|
||||
setSaveIssues([]);
|
||||
const definition = buildDefinition(values);
|
||||
if (!definition.success) {
|
||||
reportSaveIssues(
|
||||
input.form,
|
||||
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
|
||||
setSaveIssues,
|
||||
t("action.save"),
|
||||
(count) => w("saveError.more", { count }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const changedSecrets = getChangedSecrets(values);
|
||||
try {
|
||||
if (input.mode === "create") {
|
||||
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
|
||||
await utils.customWidget.list.invalidate();
|
||||
showSuccessNotification({
|
||||
title: t("action.create"),
|
||||
message: t("notification.created", { name: values.name }),
|
||||
});
|
||||
router.push(`/manage/custom-widgets/edit/${result.id}`);
|
||||
} else if (input.definitionId) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: input.definitionId,
|
||||
...definition.data,
|
||||
secrets: changedSecrets.length ? changedSecrets : undefined,
|
||||
});
|
||||
await Promise.all([
|
||||
utils.customWidget.list.invalidate(),
|
||||
utils.customWidget.get.invalidate({ id: input.definitionId }),
|
||||
utils.widget.customApi.getData.invalidate(),
|
||||
]);
|
||||
input.form.setInitialValues(values);
|
||||
input.form.resetDirty();
|
||||
showSuccessNotification({
|
||||
title: t("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const issues = extractCustomWidgetSaveIssues(error);
|
||||
if (issues.length > 0) {
|
||||
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
|
||||
w("saveError.more", { count }),
|
||||
);
|
||||
} else {
|
||||
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const runPreview = async () => {
|
||||
if (previewPending) return;
|
||||
if (!input.candidate.success) {
|
||||
showErrorNotification({
|
||||
title: w("section.preview"),
|
||||
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
|
||||
if (optionIssues.length > 0) {
|
||||
const issue = optionIssues[0];
|
||||
showErrorNotification({
|
||||
title: w("section.preview"),
|
||||
message: issue ? `${issue.path}: ${issue.message}` : w("invalidWidget"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
input.setMobilePane("preview");
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
|
||||
setPreviewPending(true);
|
||||
try {
|
||||
const created = await previewMutation.mutateAsync({
|
||||
definition: input.candidate.data,
|
||||
definitionId: input.definitionId,
|
||||
options: input.optionsSnapshot,
|
||||
secrets: getChangedSecrets(input.form.values),
|
||||
});
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
|
||||
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
|
||||
input.setPreview({
|
||||
...snapshot,
|
||||
session: created.previewSession,
|
||||
outcome: failed > 0 ? "error" : "success",
|
||||
});
|
||||
if (failed > 0) {
|
||||
showWarningNotification({
|
||||
title: w("preview.result.error.title"),
|
||||
message: w("preview.result.error.description", {
|
||||
succeeded: Object.keys(snapshot.status).length - failed,
|
||||
failed,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
showSuccessNotification({
|
||||
title: w("preview.result.success.title"),
|
||||
message: w("preview.result.success.description", {
|
||||
succeeded: Object.keys(snapshot.status).length,
|
||||
failed: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
input.setPreview((current) => ({ ...current, outcome: "error" }));
|
||||
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
|
||||
} finally {
|
||||
setPreviewPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pasteAiResponse = async () => {
|
||||
try {
|
||||
const result = applyCustomWidgetAiResponse(input.form, await navigator.clipboard.readText());
|
||||
if (!result.success) {
|
||||
throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
}
|
||||
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
showSuccessNotification({
|
||||
title: w("ai.response"),
|
||||
message:
|
||||
result.warnings.length > 0
|
||||
? `${w("ai.loaded")} ${formatCustomWidgetImportIssues(result.warnings)}`
|
||||
: w("ai.loaded"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: w("ai.response"),
|
||||
message:
|
||||
error instanceof Error ? w("ai.invalidResponseDetail", { message: error.message }) : w("ai.invalidResponse"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
save,
|
||||
runPreview,
|
||||
pasteAiResponse,
|
||||
saveIssues,
|
||||
savePending: createMutation.isPending || updateMutation.isPending,
|
||||
previewPending,
|
||||
};
|
||||
}
|
||||
|
||||
function reportSaveIssues(
|
||||
form: UseFormReturnType<CustomWidgetFormValues>,
|
||||
issues: CustomWidgetSaveIssue[],
|
||||
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
|
||||
title: string,
|
||||
getRemainingLabel: (count: number) => string,
|
||||
) {
|
||||
setIssues(issues);
|
||||
const messagesByPath = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
if (!issue.path) continue;
|
||||
const field = issue.path.split(".")[0] ?? issue.path;
|
||||
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
|
||||
}
|
||||
for (const [path, messages] of messagesByPath) {
|
||||
form.setFieldError(path, messages.join(" "));
|
||||
}
|
||||
const remaining = issues.length - 1;
|
||||
showErrorNotification({
|
||||
title,
|
||||
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
|
||||
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
|
||||
form.clearFieldError(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const dirtyRef = useRef(isDirty);
|
||||
const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
|
||||
dirtyRef.current = isDirty;
|
||||
confirmNavigationRef.current = (href) => {
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
confirmProps: { children: t("common.action.discard") },
|
||||
onConfirm: () => router.push(href),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
registerUnsavedChangesGuard({
|
||||
isDirty: () => dirtyRef.current,
|
||||
confirmNavigation: (href) => confirmNavigationRef.current(href),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -8,141 +8,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { CustomWidgetBetaBanner } from "../../_beta-banner";
|
||||
import { CustomWidgetForm } from "../../_custom-widget-form";
|
||||
|
||||
const authTypeExpectedSecrets: Record<string, string[]> = {
|
||||
bearer: ["apiKey"],
|
||||
basic: ["username", "password"],
|
||||
apiKeyHeader: ["apiKey"],
|
||||
apiKeyQuery: ["apiKey"],
|
||||
};
|
||||
|
||||
function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string; hasValue?: boolean }>) {
|
||||
const expected = authTypeExpectedSecrets[authType] ?? [];
|
||||
const existingKinds = new Set(dbSecrets.map((s) => s.kind));
|
||||
const result = dbSecrets.map((s) => ({ kind: s.kind, value: "", hasValue: s.hasValue ?? false }));
|
||||
for (const kind of expected) {
|
||||
if (!existingKinds.has(kind)) {
|
||||
result.push({ kind, value: "", hasValue: false });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: "",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (
|
||||
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
|
||||
).map((item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
})),
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({
|
||||
label: bar.label,
|
||||
valuePath: bar.valuePath,
|
||||
maxPath: bar.maxPath ?? "",
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
})),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
goodValues: item.goodValues.join(", "),
|
||||
}),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template: (config.template as string) ?? "",
|
||||
}),
|
||||
};
|
||||
|
||||
const overrides = typeOverrides[displayType]?.() ?? {};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
import { FormErrorBoundary } from "../../_form-error-boundary";
|
||||
|
||||
interface EditCustomWidgetPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -153,12 +19,8 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
const displayConfig = definition.displayConfig as Record<string, unknown>;
|
||||
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
|
||||
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -167,22 +29,27 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
<Stack>
|
||||
<Title>{definition.name}</Title>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetForm
|
||||
mode="edit"
|
||||
definitionId={params.id}
|
||||
initialValues={{
|
||||
name: definition.name,
|
||||
description: definition.description ?? "",
|
||||
iconUrl: definition.iconUrl ?? "",
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName ?? "",
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody ?? "",
|
||||
...displayValues,
|
||||
secrets: buildInitialSecrets(definition.authType, definition.secrets),
|
||||
}}
|
||||
/>
|
||||
<FormErrorBoundary>
|
||||
<CustomWidgetForm
|
||||
mode="edit"
|
||||
definitionId={params.id}
|
||||
initialValues={{
|
||||
name: definition.name,
|
||||
description: definition.description ?? "",
|
||||
iconUrl: definition.iconUrl ?? "",
|
||||
sources: JSON.stringify(definition.sources, null, 2),
|
||||
requests: JSON.stringify(definition.requests, null, 2),
|
||||
options: JSON.stringify(definition.options, null, 2),
|
||||
template: definition.template,
|
||||
secrets: definition.secrets.map((secret) => ({
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
value: "",
|
||||
hasValue: true,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</FormErrorBoundary>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -7,13 +7,13 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetBetaBanner } from "../_beta-banner";
|
||||
import { CustomWidgetForm } from "../_custom-widget-form";
|
||||
import { FormErrorBoundary } from "../_form-error-boundary";
|
||||
|
||||
export default async function NewCustomWidgetPage() {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
@@ -28,7 +28,9 @@ export default async function NewCustomWidgetPage() {
|
||||
</Text>
|
||||
</div>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetForm mode="create" />
|
||||
<FormErrorBoundary>
|
||||
<CustomWidgetForm mode="create" />
|
||||
</FormErrorBoundary>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Group } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { IconExternalLink, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -10,7 +10,8 @@ import { Link } from "@homarr/ui";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
@@ -19,8 +20,7 @@ export default async function CustomWidgetsPage() {
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect(session ? "/" : "/auth/login");
|
||||
}
|
||||
|
||||
const definitions = await api.customWidget.all();
|
||||
const definitions = await api.customWidget.list();
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
@@ -28,6 +28,17 @@ export default async function CustomWidgetsPage() {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<MobileAffixButton
|
||||
component="a"
|
||||
href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
variant="default"
|
||||
>
|
||||
{t("action.downloadSkill")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopInstallButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Badge, Box, Container, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect("/");
|
||||
const { id } = await params;
|
||||
const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null);
|
||||
if (!preview) notFound();
|
||||
|
||||
const loadRequests = preview.requests.filter((request) => request.kind === "query" && request.trigger === "load");
|
||||
const results = await Promise.all(
|
||||
loadRequests.map(async (request) => {
|
||||
const result = await api.customWidget
|
||||
.previewQuery({ sessionId: id, requestId: request.id, params: {} })
|
||||
.catch((cause: unknown) => ({
|
||||
ok: false,
|
||||
status: 0,
|
||||
statusText: "Error",
|
||||
data: null,
|
||||
error: cause instanceof Error ? cause.message : "Request failed",
|
||||
}));
|
||||
return [request.id, result] as const;
|
||||
}),
|
||||
);
|
||||
const data = Object.fromEntries(results.map(([requestId, result]) => [requestId, result.data]));
|
||||
const status = Object.fromEntries(
|
||||
results.map(([requestId, result]) => [
|
||||
requestId,
|
||||
{ loading: false, ok: result.ok, status: result.status, error: result.error },
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="end">
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" fw={600}>
|
||||
CUSTOM WIDGET PREVIEW
|
||||
</Text>
|
||||
<Title>{preview.name}</Title>
|
||||
</Box>
|
||||
<Badge variant="light">Expires {new Date(preview.expiresAt).toLocaleTimeString()}</Badge>
|
||||
</Group>
|
||||
<Paper withBorder radius="lg" p="md" mih={360} style={{ overflow: "auto" }}>
|
||||
<CustomJsxDisplay
|
||||
data={{
|
||||
template: preview.template,
|
||||
data,
|
||||
status,
|
||||
options: preview.options,
|
||||
requestCapabilities: preview.requests,
|
||||
previewSessionId: preview.id,
|
||||
previewLiveActions: preview.liveActions,
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconApi,
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBuildingStore,
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
@@ -27,8 +28,10 @@ import {
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { env } from "@homarr/docker/env";
|
||||
@@ -39,11 +42,29 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
const session = await auth();
|
||||
const sessionPromise = auth();
|
||||
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!session || nextEnv.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedManageTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the management tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, session, shouldRunManageTour] = await Promise.all([
|
||||
getScopedI18n("management.navbar"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -80,6 +101,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
@@ -225,7 +252,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
return (
|
||||
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||
{shell}
|
||||
</ManageTourGate>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { SwitchSetting } from "./setting-switch";
|
||||
|
||||
interface AnalyticsSettingsProps {
|
||||
initialData: typeof defaultServerSettings.analytics;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||
const { instanceId: _, ...formDefaults } = initialData;
|
||||
const form = useForm({
|
||||
initialValues: formDefaults,
|
||||
onValuesChange: (updatedValues, _prev) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "analytics",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
<SectionCard title={t("title")}>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
+21
-20
@@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
|
||||
const tApperance = useScopedI18n("management.page.settings.section.appearance");
|
||||
interface AppearanceSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
|
||||
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
label={tApperance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={ApperanceCustomOption}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tAppearance("title")}>
|
||||
<SelectWithCustomItems
|
||||
label={tAppearance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tAppearance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,7 +40,7 @@ const appearanceIcons = {
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const Icon = appearanceIcons[value];
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,49 +3,50 @@
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
interface BoardSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SectionCard title={tBoard("title")}>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||
settingKey,
|
||||
defaultValues,
|
||||
children,
|
||||
}: {
|
||||
settingKey: TKey;
|
||||
defaultValues: ServerSettings[TKey];
|
||||
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tSettings("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: defaultValues,
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
|
||||
await mutateAsync({
|
||||
settingsKey: settingKey,
|
||||
value: values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="sm">
|
||||
{children(form)}
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+21
-54
@@ -1,68 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { SwitchSetting } from "./setting-switch";
|
||||
|
||||
interface CrawlingAndIndexingSettingsProps {
|
||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
||||
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
||||
const form = useForm({
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noTranslate"
|
||||
title={t("noTranslate.title")}
|
||||
text={t("noTranslate.text")}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
<SectionCard title={t("title")}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
+16
-14
@@ -1,26 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
|
||||
interface CultureSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
const tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||
const localeInputProps = form.getInputProps("defaultLocale");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||
{...form.getInputProps("defaultLocale")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tCulture("title")}>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -3,27 +3,28 @@
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||
interface SearchSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tSearch("title")}>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -3,10 +3,13 @@ import React from "react";
|
||||
import type { MantineSpacing } from "@mantine/core";
|
||||
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
|
||||
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
type BooleanKeys<TFormValue> = {
|
||||
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
|
||||
}[keyof TFormValue];
|
||||
|
||||
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
form,
|
||||
ms,
|
||||
title,
|
||||
@@ -14,13 +17,8 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
formKey,
|
||||
disabled,
|
||||
}: {
|
||||
form: Modify<
|
||||
UseFormReturnType<TFormValue, TFormValue>,
|
||||
{
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
>;
|
||||
formKey: keyof TFormValue;
|
||||
form: UseFormReturnType<TFormValue, TFormValue>;
|
||||
formKey: string & BooleanKeys<TFormValue>;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
text: ReactNode;
|
||||
@@ -31,7 +29,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue(formKey, (previous) => !previous);
|
||||
form.setFieldValue(formKey, (previous) => !previous as never);
|
||||
}, [form, formKey, disabled]);
|
||||
|
||||
return (
|
||||
@@ -44,7 +42,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
import { AnalyticsSettings } from "./analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
import { UserSettingsForm } from "./user-settings-form";
|
||||
|
||||
const settingsFormSchema = z.object({
|
||||
enableGeneral: z.boolean(),
|
||||
noIndex: z.boolean(),
|
||||
noFollow: z.boolean(),
|
||||
noTranslate: z.boolean(),
|
||||
noSiteLinksSearchBox: z.boolean(),
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
enableStatusByDefault: z.boolean(),
|
||||
forceDisableStatus: z.boolean(),
|
||||
enableGravatar: z.boolean(),
|
||||
defaultSearchEngineId: z.string().nullable(),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
|
||||
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
enableGeneral: initialData.analytics.enableGeneral,
|
||||
noIndex: initialData.crawlingAndIndexing.noIndex,
|
||||
noFollow: initialData.crawlingAndIndexing.noFollow,
|
||||
noTranslate: initialData.crawlingAndIndexing.noTranslate,
|
||||
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
|
||||
homeBoardId: initialData.board.homeBoardId,
|
||||
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: initialData.board.enableStatusByDefault,
|
||||
forceDisableStatus: initialData.board.forceDisableStatus,
|
||||
enableGravatar: initialData.user.enableGravatar,
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
const initialValues = buildInitialValues(initialData);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
|
||||
const form = useZodForm(settingsFormSchema, {
|
||||
initialValues,
|
||||
});
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onError(error) {
|
||||
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const groups: {
|
||||
settingsKey: (typeof defaultServerSettingsKeys)[number];
|
||||
when: boolean;
|
||||
value: Record<string, unknown>;
|
||||
}[] = [
|
||||
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
|
||||
{
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
|
||||
value: {
|
||||
noIndex: values.noIndex,
|
||||
noFollow: values.noFollow,
|
||||
noTranslate: values.noTranslate,
|
||||
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
|
||||
},
|
||||
},
|
||||
{
|
||||
settingsKey: "board",
|
||||
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
|
||||
value: {
|
||||
homeBoardId: values.homeBoardId,
|
||||
mobileHomeBoardId: values.mobileHomeBoardId,
|
||||
enableStatusByDefault: values.enableStatusByDefault,
|
||||
forceDisableStatus: values.forceDisableStatus,
|
||||
},
|
||||
},
|
||||
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
|
||||
{
|
||||
settingsKey: "search",
|
||||
when: changed("defaultSearchEngineId"),
|
||||
value: { defaultSearchEngineId: values.defaultSearchEngineId },
|
||||
},
|
||||
{
|
||||
settingsKey: "appearance",
|
||||
when: changed("defaultColorScheme"),
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
.filter((group) => group.when)
|
||||
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
|
||||
if (promises.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(promises);
|
||||
if (!results.every((result) => result.status === "fulfilled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,25 +2,26 @@
|
||||
|
||||
import { Switch } from "@mantine/core";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
|
||||
interface UserSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
|
||||
const tUser = useScopedI18n("management.page.settings.section.user");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tUser("title")}>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<Center h="40vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,8 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||
import { SettingsForm } from "./_components/settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -32,33 +26,13 @@ export default async function SettingsPage() {
|
||||
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tSettings("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.user.title")}</Title>
|
||||
<UserSettingsForm defaultValues={serverSettings.user} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const ResetTours = () => {
|
||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||
@@ -18,6 +20,7 @@ export const ResetTours = () => {
|
||||
message: t("resetSuccess.message"),
|
||||
});
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
const { workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
size="xl"
|
||||
primaryAction={
|
||||
<Button
|
||||
component="a"
|
||||
href={workshopWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<WorkshopInstaller />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||
|
||||
interface WidgetPreviewPageContentProps {
|
||||
kind: WidgetKind;
|
||||
integrationData: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||
export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => {
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
width: 128,
|
||||
height: 128,
|
||||
@@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditWidgetModal = useCallback(() => {
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
const handleOpenEditWidgetModal = useCallback(async () => {
|
||||
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
|
||||
setIsEditorLoading(true);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
definition: currentDefinition,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
|
||||
{
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
});
|
||||
}, [dimensions, openPreviewDimensionsModal]);
|
||||
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState({ ...state, options: { ...state.options, newOptions } });
|
||||
const updateOptions = useCallback(
|
||||
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Comp
|
||||
<Component
|
||||
options={state.options as never}
|
||||
integrationIds={state.integrationIds}
|
||||
width={dimensions.width}
|
||||
@@ -127,7 +137,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={handleOpenEditWidgetModal}
|
||||
onClick={() => void handleOpenEditWidgetModal()}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
loading={isEditorLoading}
|
||||
aria-label={t("common.action.edit")}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/definitions";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
const getLinks = () => {
|
||||
return Object.entries(widgetImports).map(([key, value]) => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.kind,
|
||||
};
|
||||
});
|
||||
};
|
||||
const links = widgetKinds.map((kind) => ({
|
||||
href: `/widgets/${kind}`,
|
||||
icon: widgetCatalogIcons[kind],
|
||||
label: kind,
|
||||
}));
|
||||
|
||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||
const links = getLinks();
|
||||
|
||||
return (
|
||||
<ClientShell hasHeader={false}>
|
||||
<MainNavigation links={links} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Center } from "@mantine/core";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
@@ -13,24 +12,16 @@ interface Props {
|
||||
}
|
||||
|
||||
export default async function WidgetPreview(props: Props) {
|
||||
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
const { kind } = await props.params;
|
||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const integrationData = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sort = (await props.params).kind as WidgetKind;
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 940 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user