feat(custom-widgets): complete JSX v2

This commit is contained in:
Thomas Camlong committed 2026-07-18 15:33:25 +02:00
1 parent c0f1d6624c
commit eae48f50fd
263 files changed
+65503 -17816

No files matched your search

@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { CustomWidgetSecretEntry } from "./secret-entry";
export const metadata: Metadata = {
title: "Configure custom widget credential",
robots: { index: false, follow: false },
};
export default async function CustomWidgetSecretPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
return <CustomWidgetSecretEntry token={token} />;
}
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Card, Center, PasswordInput, 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";
}
export function CustomWidgetSecretEntry({ token }: { token: string }) {
const t = useScopedI18n("customWidget.secretEntry");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
void fetch(`/api/custom-widgets/secret-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);
})
.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/secret-request/${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ 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>
{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={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>
);
}
@@ -1,22 +0,0 @@
"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={t("title")} icon={<IconFlask />}>
<Text size="sm">
{t("description")}{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
{t("link")}
</Anchor>{" "}
{t("closing")}
</Text>
</Alert>
);
}
@@ -1,9 +1,9 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { autocompletion } from "@codemirror/autocomplete";
import type { CompletionSource } from "@codemirror/autocomplete";
import type { Completion } from "@codemirror/autocomplete";
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
@@ -11,12 +11,12 @@ import { linter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
import { enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
import { ComponentReference } from "@homarr/custom-widgets/workbench";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./_custom-widget-code-language";
import classes from "./_code-editor.module.css";
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
@@ -28,19 +28,6 @@ const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => mo
),
});
const customJsxCompletionSource: CompletionSource = (context) => {
const word = context.matchBefore(/[A-Z][\w.]*/u);
if (!word && !context.explicit) return null;
return {
from: word?.from ?? context.pos,
options: enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
})),
};
};
interface CodeEditorProps {
id: string;
value: string;
@@ -54,6 +41,9 @@ interface CodeEditorProps {
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
}
export function CodeEditor({
@@ -69,6 +59,9 @@ export function CodeEditor({
required,
maxLength,
starter,
completions = [],
revealText,
revealKey,
}: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const [cursor, setCursor] = useState({ line: 1, column: 1 });
@@ -94,15 +87,27 @@ export function CodeEditor({
return language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [customJsxCompletionSource] }),
autocompletion({ override: [createCustomJsxCompletionSource(completions)] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
]
: [json(), diagnosticsExtension, accessibilityExtension];
}, [diagnostics, id, label, language, t]);
}, [completions, diagnostics, id, label, language, t]);
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
const warningCount = diagnostics.length - errorCount;
useEffect(() => {
if (!editorView || !revealText) return;
const from = editorView.state.doc.toString().indexOf(revealText);
if (from < 0) return;
editorView.dispatch({
selection: { anchor: from, head: from + revealText.length },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
editorView.focus();
}, [editorView, revealKey, revealText]);
const formattedValue = useMemo(() => {
if (language === "json") {
try {
@@ -1,10 +1,8 @@
"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/core";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,18 +10,23 @@ import { useScopedI18n } from "@homarr/translation/client";
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;
try {
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
close();
await navigator.clipboard.writeText(
buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl),
);
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
} catch {
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
@@ -31,34 +34,15 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
};
return (
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
<Popover.Target>
<Button
type="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 type="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>
);
};
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -14,6 +14,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
const iconProps = { size: 16, stroke: 1.5 };
@@ -29,6 +30,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const utils = clientApi.useUtils();
const [publishOpened, publishControls] = useDisclosure(false);
const handleExport = async () => {
try {
@@ -54,7 +56,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: () => {
@@ -77,7 +79,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.all.invalidate();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
@@ -91,34 +93,40 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
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={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>
<>
<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={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 />
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />
</>
);
};
@@ -133,7 +141,7 @@ export const ImportCustomWidgetButton = () => {
closeReview();
setPendingImport(null);
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.all.invalidate();
void utils.customWidget.list.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
@@ -0,0 +1,58 @@
"use client";
import { Button, Card, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconCopy, 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;
onCopyDiagnostics(): 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>
<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)}
/>
<Group grow wrap="wrap">
<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>
<Button type="button" variant="subtle" leftSection={<IconCopy size={16} />} onClick={props.onCopyDiagnostics}>
{t("copyDiagnostics")}
</Button>
</Group>
</Stack>
</Card>
);
}
@@ -0,0 +1,57 @@
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { hoverTooltip } from "@codemirror/view";
import {
customJsxComponentByName,
customJsxTablerIconNames,
enabledCustomJsxComponents,
} from "@homarr/custom-widgets/core";
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
info: component.documentationUrl,
}));
const propCompletions: Completion[] = [
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
]
.filter((prop) => prop !== "key")
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
label: `name="${name}"`,
type: "enum",
detail: "Safe Tabler icon",
}));
export const createCustomJsxCompletionSource =
(additional: Completion[]): CompletionSource =>
(context) => {
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
if (!word && !context.explicit) return null;
return {
from: word?.from ?? context.pos,
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
};
};
export const customJsxComponentHover = hoverTooltip((view, position) => {
const word = view.state.wordAt(position);
if (!word) return null;
const name = view.state.doc.sliceString(word.from, word.to);
const component = customJsxComponentByName.get(name);
if (!component || component.safety === "denied") return null;
return {
pos: word.from,
end: word.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.style.padding = "6px 8px";
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
return { dom };
},
};
});
@@ -0,0 +1,41 @@
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 schema = parseJson(values.optionsSchema);
const optionKeys = Object.keys(isRecord(schema) && isRecord(schema.properties) ? schema.properties : {});
const state = parseJson(values.stateSchema);
const stateKeys = Object.keys(isRecord(state) ? state : {});
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" })),
...stateKeys.map((key) => ({ label: `state.${key}`, type: "variable", detail: "Local state" })),
];
}
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 (["optionsSchema", "defaultOptions"].includes(field)) result.add("options");
else if (["stateSchema", "defaultState"].includes(field)) result.add("state");
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;
}
@@ -1,42 +0,0 @@
const serverToFormFieldMap: Record<string, Record<string, string>> = {
statGrid: { items: "statGridItems" },
countGrid: { items: "countGridItems" },
statusIndicator: { items: "statusItems" },
progressBars: { bars: "progressBars" },
keyValue: { mappings: "mappings" },
table: { columns: "columns", tablePath: "tablePath" },
singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" },
raw: { jsonPath: "rawJsonPath" },
actionButton: { buttonLabel: "buttonLabel" },
customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" },
};
export function extractServerErrors(error: unknown, displayType: string): Record<string, string> {
const errors: Record<string, string> = {};
const trpcError = error as {
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
message?: string;
};
if (trpcError.data?.zodError?.fieldErrors) {
for (const [field, messages] of Object.entries(trpcError.data.zodError.fieldErrors)) {
if (messages?.[0]) errors[field] = messages[0];
}
return errors;
}
try {
const issues = JSON.parse(trpcError.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
const fieldMap = serverToFormFieldMap[displayType] ?? {};
for (const issue of issues) {
const path = [...issue.path];
if (path[0] === "displayConfig") {
path.shift();
const serverField = String(path[0]);
path[0] = fieldMap[serverField] ?? serverField;
}
errors[path.join(".")] = issue.message;
}
} catch {
// Non-validation server errors are handled by the caller's notification.
}
return errors;
}
@@ -0,0 +1,76 @@
"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,
pending,
invalid,
mode,
onPreview,
}: {
dirty: boolean;
pending: boolean;
invalid: boolean;
mode: "create" | "edit";
onPreview(): void;
}) {
const t = useScopedI18n("customWidget.workbench");
return (
<Group justify="space-between" wrap="nowrap">
<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}>
{dirty ? t("status.unsaved") : invalid ? t("status.invalid") : t("status.ready")}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Button type="button" variant="light" leftSection={<IconPlayerPlay size={16} />} onClick={onPreview}>
{t("action.preview")}
</Button>
<Button type="submit" loading={pending} disabled={invalid}>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
</Group>
);
}
@@ -1,238 +0,0 @@
import type { RefObject } from "react";
import {
Button,
Fieldset,
Group,
PasswordInput,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { IconCode, IconLayoutGrid, IconPlayerPlay } from "@tabler/icons-react";
import { IconPicker } from "@homarr/forms-collection";
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
} from "@homarr/custom-widgets/workbench";
import type { DisplayFieldsProps } from "./_display-field-types";
import { DISPLAY_TYPE_ICONS, DisplayTypeFields, DisplayTypePicker } from "./_display-type-fields";
import classes from "./_custom-widget-form.module.css";
interface SharedSectionProps extends DisplayFieldsProps {
sectionRef: RefObject<HTMLElement | null>;
}
interface FormatSectionProps extends SharedSectionProps {
onContinue: () => void;
}
export function FormatSection({ form, t, sectionRef, onContinue }: FormatSectionProps) {
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
<IconLayoutGrid size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.format.label")} description={t("steps.format.description")} />
<Fieldset legend={t("fieldset.general")}>
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
</Stack>
</Fieldset>
<Fieldset legend={t("field.displayType")}>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("steps.format.help")}
</Text>
<DisplayTypePicker
value={form.values.displayType}
onChange={(value) => {
form.setFieldValue("displayType", value);
if (value === "customJsx" && !form.values.template.trim())
form.setFieldValue("template", CUSTOM_JSX_STARTER);
}}
t={t}
/>
</Stack>
</Fieldset>
<ContinueButton label={t("steps.continueTo", { step: t("steps.connection.label") })} onClick={onContinue} />
</Stack>
</section>
);
}
interface ConnectionSectionProps extends SharedSectionProps {
isTesting: boolean;
onTest: () => void;
onContinue: () => void;
}
export function ConnectionSection({ form, t, sectionRef, isTesting, onTest, onContinue }: ConnectionSectionProps) {
const secretFields = CUSTOM_WIDGET_AUTH_SECRET_FIELDS[form.values.authType] ?? [];
const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({
value,
label: t(`authType.${value}` as never),
}));
const handleAuthTypeChange = (value: string) => {
form.setFieldValue("authType", value);
form.setFieldValue(
"secrets",
(CUSTOM_WIDGET_AUTH_SECRET_FIELDS[value] ?? []).map((field) => ({
kind: field.kind,
value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "",
})),
);
};
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconPlayerPlay size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.connection.label")} description={t("steps.connection.description")} />
<Fieldset legend={t("fieldset.connection")}>
<Stack gap="sm">
<Group align="end" wrap="wrap" gap="xs">
<Select
label={t("field.method")}
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
value,
label: t(`method.${value}` as never),
}))}
w={110}
{...form.getInputProps("method")}
allowDeselect={false}
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
/>
<TextInput
label={t("field.url")}
required
placeholder={t("placeholder.url")}
style={{ flex: "1 1 260px" }}
{...form.getInputProps("url")}
/>
<Button
type="button"
size="sm"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={onTest}
loading={isTesting}
disabled={!form.values.url || form.values.method !== "GET"}
>
{t("preview.test")}
</Button>
</Group>
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.authType")}
</Text>
<Select
hiddenFrom="sm"
value={form.values.authType}
data={authTypeOptions}
onChange={(value) => handleAuthTypeChange(value ?? "none")}
allowDeselect={false}
aria-label={t("field.authType")}
/>
<SegmentedControl
visibleFrom="sm"
fullWidth
value={form.values.authType}
data={authTypeOptions}
onChange={handleAuthTypeChange}
/>
</div>
{CUSTOM_WIDGET_AUTH_USES_HEADER_NAME[form.values.authType] && (
<TextInput
label={t("field.headerName")}
placeholder={t("placeholder.headerName")}
{...form.getInputProps("headerName")}
/>
)}
{secretFields.map((field) => {
const index = form.values.secrets.findIndex((secret) => secret.kind === field.kind);
const secret = form.values.secrets[index];
if (index === -1 || !secret) return null;
const props = {
label: t(`secret.${field.labelKey}` as never),
placeholder: secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined,
...form.getInputProps(`secrets.${index}.value`),
};
return field.isPassword ? (
<PasswordInput key={field.kind} {...props} />
) : (
<TextInput key={field.kind} {...props} />
);
})}
{form.values.method !== "GET" && (
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
)}
</Stack>
</Fieldset>
<ContinueButton label={t("steps.continueTo", { step: t("steps.configure.label") })} onClick={onContinue} />
</Stack>
</section>
);
}
export function ConfigureSection({ form, t, previewJson, sectionRef }: SharedSectionProps) {
const SelectedIcon = DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconCode size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.configure.label")} description={t("steps.configure.description")} />
<Fieldset legend={t("fieldset.display")}>
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" size="lg">
{SelectedIcon ? <SelectedIcon size={20} /> : null}
</ThemeIcon>
<div>
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
<Text size="xs" c="dimmed">
{t(`displayTypeDescription.${form.values.displayType}` as never)}
</Text>
</div>
</Group>
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
</Stack>
</Fieldset>
</Stack>
</section>
);
}
function SectionHeading({ label, description }: { label: string; description: string }) {
return (
<div>
<Text fw={700} size="lg">
{label}
</Text>
<Text size="sm" c="dimmed">
{description}
</Text>
</div>
);
}
function ContinueButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Group justify="flex-end">
<Button type="button" variant="light" size="md" onClick={onClick}>
{label}
</Button>
</Group>
);
}
@@ -0,0 +1,97 @@
import { fetchApi } from "@homarr/api/client";
import { customWidgetDefinitionSchema } 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),
optionsSchema: parseJson(values.optionsSchema),
defaultOptions: parseJson(values.defaultOptions),
stateSchema: parseJson(values.stateSchema),
defaultState: parseJson(values.defaultState),
template: values.template,
});
}
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),
optionsSchema: JSON.stringify(widget.optionsSchema, null, 2),
defaultOptions: JSON.stringify(widget.defaultOptions, null, 2),
stateSchema: JSON.stringify(widget.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(widget.defaultState ?? {}, null, 2),
template: widget.template,
});
}
export function parseSources(value: string): CustomWidgetSource[] {
const parsed = parseJson(value);
return Array.isArray(parsed)
? parsed.filter((entry): entry is CustomWidgetSource => isRecord(entry) && typeof entry.id === "string")
: [];
}
export function parseJsonArray(value: string): unknown[] {
const parsed = parseJson(value);
return Array.isArray(parsed) ? parsed : [];
}
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 isRuntimeParams(
value: unknown,
schema: Record<string, "string" | "number" | "boolean">,
): value is Record<string, string | number | boolean> {
if (!isRecord(value)) return false;
return Object.entries(schema).every(([name, type]) => typeof value[name] === type);
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
const params = Object.fromEntries(
Object.entries(request.parameters).flatMap(([name, type]) => {
const value = definition.defaultOptions[name];
return typeof value === type ? [[name, value as string | number | boolean]] : [];
}),
);
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId: request.id, params });
data[request.id] = result.data;
status[request.id] = { loading: false, ...result };
} catch (error) {
data[request.id] = null;
status[request.id] = {
loading: false,
ok: false,
error: error instanceof Error ? error.message : "Request failed",
};
}
}
return { data, status };
}
@@ -2,6 +2,34 @@
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);
}
.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);
@@ -48,78 +76,6 @@
background: var(--mantine-color-body);
}
.formFlow {
--flow-marker-size: 2.5rem;
}
.formSection {
position: relative;
padding: 0 0 var(--mantine-spacing-xl) calc(var(--flow-marker-size) + var(--mantine-spacing-lg));
scroll-margin-top: 5rem;
outline: none;
}
.formSection:not(:last-child)::before {
position: absolute;
top: var(--flow-marker-size);
bottom: 0;
left: calc(var(--flow-marker-size) / 2);
width: 1px;
background: var(--mantine-color-default-border);
content: "";
}
.formSection:focus-visible {
border-radius: var(--mantine-radius-md);
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
}
.sectionMarker {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.displayTypeOption {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
min-height: 7rem;
padding: var(--mantine-spacing-md);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.displayTypeOption:hover {
background: var(--mantine-color-default-hover);
}
.displayTypeOption[data-selected] {
border-color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light);
}
.displayTypeOption:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.displayTypeCopy {
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.displayTypeOption {
transition: none;
}
}
@media (min-width: 75em) {
.paneSwitcher {
display: none;
@@ -1,29 +1,39 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Box, Button, Group, Paper, SegmentedControl, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
IconBraces,
IconCode,
IconDatabase,
IconEye,
IconSettings,
} from "@tabler/icons-react";
import type { CustomWidgetAuthType, CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
import { buildDisplayConfigFromFormValues, CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
parseRequestManifest,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
renameCustomWidgetRequest,
} from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
import { CustomWidgetPreview } from "./_custom-widget-preview";
import { ConfigureSection, ConnectionSection, FormatSection } from "./_custom-widget-form-sections";
import { extractServerErrors } from "./_custom-widget-form-errors";
import { useCustomWidgetClipboard } from "./_use-custom-widget-clipboard";
import { useCustomWidgetPreview } from "./_use-custom-widget-preview";
import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
import { applyDefinition, buildDefinition, isRecord, parseJson, parseJsonArray } from "./_custom-widget-form-utils";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestTools } from "./_custom-widget-request-tools";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -32,260 +42,254 @@ interface CustomWidgetFormProps {
definitionId?: string;
}
const sectionLinks = [
["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
["requests", "section.requests", IconDatabase],
["options", "section.options", IconBraces],
["state", "section.state", IconSettings],
["jsx", "section.jsx", IconCode],
["preview", "section.preview", IconEye],
] as const;
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const router = useRouter();
const globalT = useI18n();
const t = useScopedI18n("customWidget");
const utils = clientApi.useUtils();
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const [previewRefreshSignal, setPreviewRefreshSignal] = useState(0);
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const formatSectionRef = useRef<HTMLElement>(null);
const connectionSectionRef = useRef<HTMLElement>(null);
const configureSectionRef = useRef<HTMLElement>(null);
const scrollToSection = useCallback((section: number) => {
const element = [formatSectionRef.current, connectionSectionRef.current, configureSectionRef.current][section];
if (!element) return;
element.scrollIntoView({ behavior: "smooth", block: "start" });
window.setTimeout(() => element.focus({ preventScroll: true }), 250);
}, []);
const mergedInitialValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
if (mergedInitialValues.displayType === "customJsx" && !mergedInitialValues.template.trim()) {
mergedInitialValues.template = CUSTOM_JSX_STARTER;
}
const form = useZodForm(customWidgetFormSchema, { initialValues: mergedInitialValues });
const preview = useCustomWidgetPreview({
form,
definitionId,
refreshSignal: previewRefreshSignal,
onOpenPreview: () => setMobilePane("preview"),
t,
const w = useScopedI18n("customWidget.workbench");
const form = useZodForm(customWidgetFormSchema, {
initialValues: { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues },
});
useCustomWidgetClipboard({ enabled: mode === "edit", form, t, onReplace: preview.reset });
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null });
const [previewSize, setPreviewSize] = useState("standard");
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>({});
const [stateSnapshot, setStateSnapshot] = useState<Record<string, unknown>>({});
const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 });
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
const requestManifest = parseRequestManifest(form.values.requestManifest);
const requestIds = requestManifest
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
.filter((id): id is string => typeof id === "string");
const editorDiagnostics =
form.values.displayType === "customJsx"
? analyzeJsxTemplate(form.values.template, {
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
requestIds,
})
: [];
const requestDiagnostics =
form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"
? analyzeRequestManifest(form.values.requestManifest)
: [];
const hasEditorErrors = [...editorDiagnostics, ...requestDiagnostics].some(
(diagnostic) => diagnostic.severity === "error",
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const requestIds = useMemo(
() =>
parseJsonArray(form.values.requests).flatMap((entry) =>
isRecord(entry) && typeof entry.id === "string" ? [entry.id] : [],
),
[form.values.requests],
);
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const stateKeys = useMemo(() => {
const state = parseJson(form.values.stateSchema);
return Object.keys(isRecord(state) ? state : {});
}, [form.values.stateSchema]);
const jsxCompletions = useMemo(
() => createCustomWidgetCompletions(form.values, requestIds),
[form.values, requestIds],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(form.values.template, { requestIds, stateKeys }),
[form.values.template, requestIds, stateKeys],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
const invalidSections = useMemo(
() =>
getInvalidCustomWidgetSections(
candidate.success ? [] : candidate.error.issues,
requestDiagnostics,
templateDiagnostics,
),
[candidate, requestDiagnostics, templateDiagnostics],
);
const { save, runPreview, pasteAiResponse, copyDiagnostics, pending } = useCustomWidgetFormActions({
mode,
definitionId,
form,
candidate,
templateDiagnostics,
requestDiagnostics,
preview,
setPreview,
setMobilePane,
setOptionsSnapshot,
setStateSnapshot,
request,
documentationUrl,
});
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isDirtyRef.current) event.preventDefault();
const beforeUnload = (event: BeforeUnloadEvent) => {
if (dirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, []);
useEffect(() => {
if (form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2" && form.values.method !== "GET") {
form.setFieldValue("method", "GET");
form.setFieldValue("requestBody", "");
}
}, [form, form.values.displayType, form.values.jsxApiVersion, form.values.method]);
const focusStepForErrors = (errors: Record<string, unknown>) => {
const fields = Object.keys(errors);
if (fields.some((field) => ["name", "description", "iconUrl", "displayType"].includes(field.split(".")[0] ?? ""))) {
scrollToSection(0);
return;
}
if (
fields.some((field) =>
["url", "method", "authType", "headerName", "requestBody", "secrets"].includes(field.split(".")[0] ?? ""),
)
) {
scrollToSection(1);
return;
}
scrollToSection(2);
const invalid = hasDiagnostics || !candidate.success;
const renameRequest = (currentId: string, nextId: string) => {
if (!candidate.success) throw new Error(w("invalidWidget"));
applyDefinition(form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
setRevealedRequest((current) => ({ text: `"id": "${nextId}"`, key: current.key + 1 }));
};
const handleSubmit = form.onSubmit(async (values) => {
if (hasEditorErrors) return;
const displayConfig = buildDisplayConfigFromFormValues(values);
const payload = {
name: values.name,
description: values.description || undefined,
iconUrl: values.iconUrl || undefined,
url: values.url,
authType: values.authType as CustomWidgetAuthType,
headerName: values.headerName || undefined,
method: values.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
requestBody: values.requestBody || undefined,
displayType: values.displayType as CustomWidgetDisplayType,
displayConfig: displayConfig as never,
secrets: values.secrets
.filter((s) => s.value)
.map((s) => ({
kind: s.kind as "apiKey" | "username" | "password",
value: s.value,
})),
};
try {
if (mode === "create") {
const result = await createMutation.mutateAsync(payload);
showSuccessNotification({
title: t("action.create"),
message: t("notification.created", { name: values.name }),
});
await utils.customWidget.all.invalidate();
router.push(`/manage/custom-widgets/edit/${result.id}`);
} else if (definitionId) {
await updateMutation.mutateAsync({ id: definitionId, ...payload });
showSuccessNotification({
title: t("action.save"),
message: t("notification.updated", { name: values.name }),
});
setPreviewRefreshSignal((n) => n + 1);
await utils.customWidget.all.invalidate();
await utils.customWidget.byId.invalidate({ id: definitionId });
await utils.widget.customApi.getData.invalidate();
form.setInitialValues(values);
form.resetDirty();
}
} catch (err) {
const serverErrors = extractServerErrors(err, values.displayType);
if (Object.keys(serverErrors).length > 0) {
form.setErrors(serverErrors);
focusStepForErrors(serverErrors);
}
const errorKey = mode === "create" ? "notification.createError" : "notification.updateError";
showErrorNotification({
title: t("action.save"),
message: t(errorKey as never),
});
}
}, focusStepForErrors);
const continueToSection = (currentSection: number, field: string, nextSection: number) => {
if (form.validateField(field).hasError) {
scrollToSection(currentSection);
return;
}
scrollToSection(nextSection);
const revealRequest = (requestId: string) => {
setRevealedRequest((current) => ({ text: `"id": "${requestId}"`, key: current.key + 1 }));
};
return (
<form onSubmit={handleSubmit} className={classes.form}>
<form onSubmit={save} className={classes.form}>
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={mobilePane}
onChange={(value) => setMobilePane(value as "configure" | "preview")}
onChange={(value) => setMobilePane(value as typeof mobilePane)}
data={[
{ value: "configure", label: t("workbench.configure") },
{ value: "preview", label: t("workbench.preview") },
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => (
<Button
key={id}
component="a"
href={`#${id}`}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
}
>
{w(key)}
</Button>
))}
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="xl" className={classes.configuration}>
<Stack gap={0} className={classes.formFlow}>
<FormatSection
form={form}
t={t}
previewJson={preview.json}
sectionRef={formatSectionRef}
onContinue={() => continueToSection(0, "name", 1)}
<Stack gap="lg" className={classes.configuration}>
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestTools requestIds={requestIds} onGoTo={revealRequest} onRename={renameRequest} />
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
revealText={revealedRequest.text}
revealKey={revealedRequest.key}
required
/>
<ConnectionSection
form={form}
t={t}
previewJson={preview.json}
sectionRef={connectionSectionRef}
isTesting={preview.isTesting}
onTest={() => void preview.test()}
onContinue={() => continueToSection(1, "url", 2)}
</EditorSection>
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CodeEditor
id="options-schema"
label={w("options.schema")}
language="json"
value={form.values.optionsSchema}
onChange={(value) => form.setFieldValue("optionsSchema", value)}
error={form.errors.optionsSchema}
/>
<ConfigureSection form={form} t={t} previewJson={preview.json} sectionRef={configureSectionRef} />
</Stack>
<CodeEditor
id="default-options"
label={w("options.defaults")}
language="json"
value={form.values.defaultOptions}
onChange={(value) => form.setFieldValue("defaultOptions", value)}
error={form.errors.defaultOptions}
/>
</EditorSection>
<EditorSection id="state" title={w("state.title")} icon={IconSettings}>
<Text size="sm" c="dimmed">
{w("state.description")}
</Text>
<CodeEditor
id="state-schema"
label={w("state.schema")}
language="json"
value={form.values.stateSchema}
onChange={(value) => form.setFieldValue("stateSchema", value)}
error={form.errors.stateSchema}
/>
<CodeEditor
id="default-state"
label={w("state.defaults")}
language="json"
value={form.values.defaultState}
onChange={(value) => form.setFieldValue("defaultState", value)}
error={form.errors.defaultState}
/>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
onCopyDiagnostics={() => void copyDiagnostics()}
/>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>
{form.isDirty() ? globalT("common.unsavedChanges") : t("action.readyToSave")}
</Text>
<Button
type="submit"
size="md"
loading={createMutation.isPending || updateMutation.isPending}
disabled={hasEditorErrors}
>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
<SaveActions
dirty={form.isDirty()}
pending={pending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
</Stack>
<Box component="aside" className={classes.previewPane} aria-label={t("preview.title")}>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={600}>
{form.isDirty() ? globalT("common.unsavedChanges") : t("action.readyToSave")}
</Text>
<Text size="xs" c="dimmed" visibleFrom="sm">
{hasEditorErrors ? t("editor.saveBlocked") : t("editor.saveReady")}
</Text>
</div>
<Button
type="submit"
size="md"
loading={createMutation.isPending || updateMutation.isPending}
disabled={hasEditorErrors}
miw={160}
>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
<SaveActions
dirty={form.isDirty()}
pending={pending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
<CopyAiPromptButton
rawResponse={preview.fetchResult?.rawResponse}
currentConfig={{
$schema: "homarr-custom-widget-v3",
name: form.values.name,
description: form.values.description,
iconUrl: form.values.iconUrl,
url: form.values.url,
authType: form.values.authType,
headerName: form.values.headerName,
method: form.values.method,
requestBody: form.values.requestBody,
displayType: form.values.displayType,
displayConfig: preview.input.displayConfig,
}}
/>
<CustomWidgetPreview
getFormValues={preview.getInput}
formValues={preview.input}
fetchResult={preview.fetchResult}
cachedJson={preview.json}
onTest={() => void preview.test()}
isTesting={preview.isTesting}
isSampleStale={preview.isStale}
testError={preview.testError ? t("notification.previewError") : null}
onInsertDataPath={form.values.displayType === "customJsx" ? preview.insertDataPath : undefined}
onSetPreviewLiveActions={preview.setLiveActions}
isUpdatingPreviewActions={preview.isUpdatingLiveActions}
onSampleDataChange={preview.setSampleData}
<CustomWidgetPreviewPanel
candidate={candidate.success ? candidate.data : null}
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
theme={previewTheme}
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
stateSnapshot={stateSnapshot}
onStateChange={setStateSnapshot}
onLiveActionsChange={(enabled) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
}))
}
/>
</Box>
</div>
@@ -13,21 +13,8 @@ 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",
};
interface CustomWidgetListProps {
definitions: RouterOutputs["customWidget"]["all"];
definitions: RouterOutputs["customWidget"]["list"];
}
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
@@ -52,7 +39,7 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
);
};
type WidgetDef = RouterOutputs["customWidget"]["all"][number];
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
@@ -64,7 +51,7 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
@@ -99,19 +86,14 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{widget.url && (
{widget.sources[0] && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.url}
{widget.sources.map((source) => source.origin).join(" · ")}
</Text>
)}
</Stack>
<Badge
color={displayTypeBadgeColors[widget.displayType] ?? "gray"}
size="sm"
variant="light"
style={{ flexShrink: 0 }}
>
{t(`displayType.${widget.displayType}` as never)}
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
</Group>
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button, Fieldset, Stack, Textarea } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
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 { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
export function PreviewActionControl({
request,
sessionId,
options,
state,
}: {
request: HomarrCustomWidgetV2["requests"][number];
sessionId?: string;
options: Record<string, unknown>;
state: Record<string, unknown>;
}) {
const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const initialParams = Object.fromEntries(
Object.entries(request.parameters).map(([name, type]) => {
const bound = state[name] ?? options[name];
if (typeof bound === type) return [name, bound];
return [name, type === "number" ? 0 : type === "boolean" ? false : ""];
}),
);
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
const parsed = parseJson(params);
const validParams = isRuntimeParams(parsed, request.parameters) ? parsed : null;
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: request.confirmation?.title ?? request.id,
children: request.confirmation?.message ?? t("deleteConfirmation"),
labels: { confirm: request.confirmation?.confirmLabel },
confirmProps: { color: request.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">
{Object.keys(request.parameters).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={request.confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
disabled={!sessionId || !validParams}
loading={actionMutation.isPending}
onClick={() => void run()}
>
{request.confirmation?.confirmLabel ??
(request.confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
</Button>
</Stack>
</Fieldset>
);
}
@@ -0,0 +1,278 @@
"use client";
import { useEffect, 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 } 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 { isRecord, parseJson } from "./_custom-widget-form-utils";
import { PreviewActionControl } from "./_custom-widget-preview-action";
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;
}
interface PreviewPanelProps {
candidate: HomarrCustomWidgetV2 | null;
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;
stateSnapshot: Record<string, unknown>;
onStateChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
}
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useScopedI18n("customWidget.workbench.preview");
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 };
if (!props.candidate) return <Alert color="yellow">{t("invalid")}</Alert>;
const candidate = props.candidate;
const accessibilityIssues = analyzeCustomWidgetAccessibility(candidate.template);
const fixtureData =
fixture === "empty" ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []])) : props.preview.data;
const fixtureStatus =
fixture === "loading"
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
: fixture === "error"
? Object.fromEntries(
candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: t("fixtureError") }]),
)
: props.preview.status;
const displayData = {
template: candidate.template,
data: fixtureData,
status: fixtureStatus,
options: props.optionsSnapshot,
stateSchema: candidate.stateSchema ?? {},
defaultState: props.stateSnapshot,
requestCapabilities: candidate.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
id,
kind,
method,
trigger,
minimumBoardPermission,
confirmation,
invalidates,
}),
),
previewSessionId: props.preview.session?.id,
previewLiveActions: props.preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
onStateChange: props.onStateChange,
};
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}
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") },
]}
/>
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "state", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
{t(`tab.${tab}`)}
</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" }}>
<CustomJsxDisplay data={displayData} />
</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="state" pt="sm">
<JsonPreviewEditor
id="preview-state"
label={t("localState")}
value={props.stateSnapshot}
onChange={props.onStateChange}
/>
</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 }),
},
);
}}
/>
{candidate.requests
.filter((request) => request.kind === "action")
.map((request) => (
<PreviewActionControl
key={request.id}
request={request}
sessionId={props.preview.session?.id}
options={props.optionsSnapshot}
state={props.stateSnapshot}
/>
))}
{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("accessibility.title")}
</Text>
{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>
);
}
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);
}}
/>
);
}
@@ -1,277 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Card, Stack, Tabs } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
DisplayConfig,
} from "@homarr/custom-widgets/core";
import { extractDisplayData } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
getPreviewLegacyMethods,
getPreviewNamedRequests,
PreviewDiagnosticsPanel,
PreviewErrorBoundary,
PreviewHeader,
PreviewRequestPanel,
PreviewResponsePanel,
redactPreviewUrl,
} from "@homarr/custom-widgets/workbench";
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
import { useScopedI18n } from "@homarr/translation/client";
import { PreviewWidgetPanel } from "./_preview-widget-panel";
export interface PreviewInput {
url: string;
method: CustomWidgetMethod;
authType: CustomWidgetAuthType;
headerName?: string;
requestBody?: string;
displayType: CustomWidgetDisplayType;
displayConfig: DisplayConfig;
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
definitionId?: string;
}
export interface PreviewFetchResult {
success: boolean;
error?: string;
responseInfo: { status: number; statusText: string } | null;
rawResponse: string | null;
previewSession?: { id: string; expiresAt: number; liveActions: boolean } | null;
}
interface CustomWidgetPreviewProps {
getFormValues: () => PreviewInput;
formValues: PreviewInput;
fetchResult: PreviewFetchResult | null;
cachedJson: unknown;
onTest: () => void;
isTesting: boolean;
isSampleStale?: boolean;
testError?: string | null;
onInsertDataPath?: (path: string) => void;
onSetPreviewLiveActions?: (enabled: boolean) => void;
isUpdatingPreviewActions?: boolean;
onSampleDataChange?: (value: unknown) => void;
}
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget.preview");
return (
<PreviewErrorBoundary
title={t("errorBoundary.title")}
description={t("errorBoundary.description")}
retryLabel={t("errorBoundary.retry")}
>
<CustomWidgetPreviewContent {...props} />
</PreviewErrorBoundary>
);
}
function CustomWidgetPreviewContent({
getFormValues,
formValues,
fetchResult,
cachedJson,
onTest,
isTesting,
isSampleStale = false,
testError,
onInsertDataPath,
onSetPreviewLiveActions,
isUpdatingPreviewActions = false,
onSampleDataChange,
}: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget");
const editorT = useScopedI18n("customWidget.editor");
const [previewSize, setPreviewSize] = useState("standard");
const displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
const previewSessionId = fetchResult?.previewSession?.id ?? "";
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: previewSessionId },
{ enabled: previewSessionId.length > 0, retry: false, refetchInterval: 1_000 },
);
const displayData = useMemo((): Record<string, unknown> | null => {
const { displayType } = formValues;
if (displayType === "actionButton") {
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
}
if (!fetchResult?.success || cachedJson == null) return null;
const extracted = extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
return displayType === "customJsx" && fetchResult.previewSession
? {
...extracted,
previewSessionId: fetchResult.previewSession.id,
previewLiveActions: fetchResult.previewSession.liveActions,
}
: extracted;
}, [fetchResult, cachedJson, formValues, displayConfig]);
const namedRequests = useMemo(() => getPreviewNamedRequests(displayConfig), [displayConfig]);
const httpMethods = useMemo(() => {
if (formValues.displayType !== "customJsx") return [];
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
return getPreviewLegacyMethods((displayConfig.template as string) ?? "");
}, [formValues.displayType, displayConfig.template, namedRequests]);
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
const templateDiagnostics = useMemo(
() =>
formValues.displayType === "customJsx"
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
requestIds: namedRequests.map((request) => request.id),
})
: [],
[displayConfig, formValues.displayType, namedRequests],
);
const handleTest = () => {
const values = getFormValues();
if (!values.url || values.method !== "GET") return;
onTest();
};
return (
<Card withBorder p="lg">
<Stack gap="md">
<PreviewHeader
method={formValues.method}
url={formValues.url}
methods={httpMethods}
hasNamedActions={hasNamedActions}
hasPreviewSession={Boolean(fetchResult?.previewSession)}
liveActions={fetchResult?.previewSession?.liveActions ?? false}
isUpdatingLiveActions={isUpdatingPreviewActions}
isTesting={isTesting}
isSampleStale={isSampleStale}
testError={testError}
responseError={fetchResult?.success === false ? fetchResult.error : undefined}
responseStatus={fetchResult?.success === false ? fetchResult.responseInfo : undefined}
onTest={handleTest}
onSetLiveActions={onSetPreviewLiveActions}
methodColor={methodColor}
messages={{
title: t("preview.title"),
interactive: t("preview.capabilities.interactive"),
capabilitiesTitle: t("preview.capabilities.title"),
capabilitiesDescription: t("preview.capabilities.description"),
liveActions: t("preview.capabilities.liveActions"),
liveActionsDescription: t("preview.capabilities.liveActionsDescription"),
runTestFirst: t("preview.capabilities.runTestFirst"),
simulated: t("preview.capabilities.simulated"),
test: t("preview.test"),
mutationDisabled: t("preview.mutationDisabled"),
staleTitle: t("preview.stale.title"),
staleDescription: t("preview.stale.description"),
}}
/>
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("preview.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="response">{t("preview.tab.response")}</Tabs.Tab>
<Tabs.Tab value="request">{t("preview.tab.request")}</Tabs.Tab>
<Tabs.Tab value="diagnostics">{t("preview.tab.diagnostics")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<PreviewWidgetPanel
data={displayData}
displayType={formValues.displayType}
success={fetchResult?.success ?? false}
responseInfo={fetchResult?.responseInfo ?? null}
previewSize={previewSize}
onPreviewSizeChange={setPreviewSize}
/>
</Tabs.Panel>
<Tabs.Panel value="response" pt="sm">
<PreviewResponsePanel
value={cachedJson}
rawResponse={fetchResult?.rawResponse}
onInsertDataPath={onInsertDataPath}
onSampleDataChange={onSampleDataChange}
messages={{
empty: t("preview.response.empty"),
sampleHint: t("preview.response.sampleHint"),
editSample: t("preview.response.editSample"),
addSample: t("preview.response.addSample"),
copied: t("preview.response.copied"),
copy: t("preview.response.copy"),
sampleLabel: t("preview.response.sampleLabel"),
sampleDescription: t("preview.response.sampleDescription"),
invalidSample: t("preview.response.invalidSample"),
cancelSample: t("preview.response.cancelSample"),
applySample: t("preview.response.applySample"),
copyPath: t("preview.response.copyPath"),
pathCopied: t("preview.response.pathCopied"),
insertPath: t("preview.response.insertPath"),
openRaw: t("preview.rawResponse"),
}}
/>
</Tabs.Panel>
<Tabs.Panel value="request" pt="sm">
<PreviewRequestPanel
method={formValues.method}
authentication={formValues.authType}
endpoint={redactPreviewUrl(formValues.url)}
status={fetchResult?.responseInfo?.status}
namedRequests={namedRequests}
journal={journalQuery.data}
methodColor={methodColor}
messages={{
method: t("preview.request.method"),
authentication: t("preview.request.authentication"),
endpoint: t("preview.request.endpoint"),
status: t("preview.request.status"),
notRun: t("preview.request.notRun"),
named: t("preview.request.named", { count: namedRequests.length }),
journal: t("preview.request.journal"),
journalEmpty: t("preview.request.journalEmpty"),
simulated: t("preview.request.simulated"),
redacted: t("preview.request.redacted"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
duration: (duration) => t("preview.request.duration", { duration }),
}}
/>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<PreviewDiagnosticsPanel
templateLength={(displayConfig.template as string | undefined)?.length ?? 0}
namedRequestCount={namedRequests.length}
methods={httpMethods.join(", ") || "GET"}
networkScope={(displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")}
diagnostics={templateDiagnostics}
messages={{
templateSize: t("preview.diagnostics.templateSize"),
characters: (count) => t("preview.diagnostics.characters", { count }),
namedRequests: t("preview.diagnostics.namedRequests"),
methods: t("preview.diagnostics.methods"),
networkScope: t("preview.diagnostics.networkScope"),
ready: t("preview.diagnostics.ready"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}${editorT(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}}
/>
</Tabs.Panel>
</Tabs>
</Stack>
</Card>
);
}
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Group, Select, TextInput } from "@mantine/core";
import { IconEdit, IconTargetArrow } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export function CustomWidgetRequestTools({
requestIds,
onGoTo,
onRename,
}: {
requestIds: string[];
onGoTo(requestId: string): void;
onRename(currentId: string, nextId: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.requests");
const [selected, setSelected] = useState(requestIds[0] ?? "");
const [nextId, setNextId] = useState(requestIds[0] ?? "");
useEffect(() => {
if (!requestIds.includes(selected)) {
setSelected(requestIds[0] ?? "");
setNextId(requestIds[0] ?? "");
}
}, [requestIds, selected]);
if (requestIds.length === 0) return null;
const rename = () => {
try {
onRename(selected, nextId);
setSelected(nextId);
} catch (error) {
showErrorNotification({
title: t("rename"),
message: error instanceof Error ? error.message : t("renameFailed"),
});
}
};
return (
<Group align="end" grow wrap="wrap">
<Select
label={t("selected")}
data={requestIds}
value={selected}
onChange={(value) => {
setSelected(value ?? "");
setNextId(value ?? "");
}}
searchable
/>
<Button
type="button"
variant="light"
leftSection={<IconTargetArrow size={16} />}
onClick={() => onGoTo(selected)}
>
{t("goTo")}
</Button>
<TextInput label={t("newId")} value={nextId} onChange={(event) => setNextId(event.currentTarget.value)} />
<Button
type="button"
variant="light"
leftSection={<IconEdit size={16} />}
disabled={!nextId || nextId === selected}
onClick={rename}
>
{t("rename")}
</Button>
</Group>
);
}
@@ -0,0 +1,243 @@
"use client";
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJsonArray, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
bearer: [{ kind: "apiKey" }],
basic: [{ kind: "username" }, { kind: "password" }],
apiKeyHeader: [{ kind: "apiKey" }],
apiKeyQuery: [{ kind: "apiKey" }],
};
export function CustomWidgetSourcesEditor({
form,
definitionId,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.sources");
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
const update = (index: number, changes: Partial<CustomWidgetSource>) => {
const previousId = sources[index]?.id;
const nextId = changes.id;
form.setFieldValue(
"sources",
JSON.stringify(
sources.map((source, i) => (i === index ? { ...source, ...changes } : source)),
null,
2,
),
);
if (previousId && nextId && previousId !== nextId) {
const requests = parseJsonArray(form.values.requests).map((request) =>
isRecord(request) && request.sourceId === previousId ? { ...request, sourceId: 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, headerName: "X-API-Key" }
: type === "apiKeyQuery"
? { type, parameterName: "api_key" }
: { type };
update(index, { auth: auth as CustomWidgetSource["auth"] });
};
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 = `source-${sources.length + 1}`;
form.setFieldValue(
"sources",
JSON.stringify(
[
...sources,
{
id,
name: t("newName", { count: sources.length + 1 }),
baseUrl: "https://example.com",
networkScope: "private",
auth: { type: "none" },
},
],
null,
2,
),
);
};
const removeSource = (index: number) => {
const removedId = sources[index]?.id;
form.setFieldValue(
"sources",
JSON.stringify(
sources.filter((_, i) => i !== index),
null,
2,
),
);
if (!removedId) return;
form.setFieldValue(
"requests",
JSON.stringify(
parseJsonArray(form.values.requests).filter((request) => !isRecord(request) || request.sourceId !== removedId),
null,
2,
),
);
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
);
};
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) => (
<Fieldset key={source.id} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<TextInput
label={t("id")}
value={source.id}
disabled={Boolean(definitionId)}
onChange={(event) => update(index, { id: event.currentTarget.value })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => update(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.headerName}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } })
}
/>
)}
{source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.parameterName}
onChange={(event) =>
update(index, { auth: { type: "apiKeyQuery", parameterName: event.currentTarget.value } })
}
/>
)}
{(secretFields[source.auth.type] ?? []).map((field) => {
const secret = form.values.secrets.find(
(entry) => entry.sourceId === source.id && entry.kind === field.kind,
);
const Input = field.kind === "username" ? TextInput : PasswordInput;
return (
<Group key={field.kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${field.kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretMutation.isPending}
onClick={() => void clearSecret(source.id, field.kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => removeSource(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
))}
<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>
);
}
@@ -1,39 +0,0 @@
export const listItemDefaults = {
mapping: { label: "", jsonPath: "$", unit: "" },
column: { header: "", jsonPath: "$" },
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
countGridItem: { label: "", jsonPath: "$", unit: "" },
} as const;
export function cloneLast<T extends Record<string, unknown>>(items: T[], fallback: T): T {
return { ...(items.at(-1) ?? fallback) };
}
export const MANTINE_COLORS = [
"blue",
"teal",
"green",
"red",
"orange",
"yellow",
"violet",
"pink",
"cyan",
"grape",
"indigo",
"lime",
] as const;
export const REQUEST_MANIFEST_STARTER = `[
{
"id": "details",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/details/{id}",
"parameters": { "id": "string" },
"auth": "inherit",
"minimumBoardPermission": "view",
"cacheTtlSeconds": 30
}
]`;
@@ -1,12 +0,0 @@
import type { useZodForm } from "@homarr/form";
import type { useScopedI18n } from "@homarr/translation/client";
import type { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
export type CustomWidgetFormInstance = ReturnType<typeof useZodForm<typeof customWidgetFormSchema>>;
export type CustomWidgetTranslator = ReturnType<typeof useScopedI18n<"customWidget">>;
export interface DisplayFieldsProps {
form: CustomWidgetFormInstance;
t: CustomWidgetTranslator;
previewJson: unknown;
}
@@ -1,178 +0,0 @@
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function BasicDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "singleValue") {
return (
<>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
description={t("field.jsonPathHint")}
required
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps("jsonPath")}
/>
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabel")}
{...form.getInputProps("label")}
/>
<TextInput label={t("field.unit")} placeholder={t("placeholder.exampleUnit")} {...form.getInputProps("unit")} />
<Group grow>
<Select
label={t("field.valueSize")}
data={["sm", "md", "lg", "xl"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("valueSize")}
allowDeselect={false}
/>
<Select
label={t("field.labelPosition")}
data={["above", "below"].map((v) => ({
value: v,
label: t(`labelPositionOption.${v}` as never),
}))}
{...form.getInputProps("labelPosition")}
allowDeselect={false}
/>
</Group>
</>
);
}
if (dt === "keyValue") {
return (
<Stack gap="xs">
<Group grow>
<Select
label={t("field.kvLayout")}
data={["list", "grid"].map((v) => ({
value: v,
label: t(`layoutOption.${v}` as never),
}))}
{...form.getInputProps("kvLayout")}
allowDeselect={false}
/>
{form.values.kvLayout === "grid" && (
<NumberInput label={t("field.gridColumns")} min={1} max={3} {...form.getInputProps("kvColumns")} />
)}
</Group>
<Text size="sm" fw={500}>
{t("field.mappings")}
</Text>
{form.values.mappings.map((_m, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`mappings.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`mappings.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`mappings.${i}.unit`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("mappings", i)}
disabled={form.values.mappings.length <= 1}
aria-label={t("action.removeMapping")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => form.insertListItem("mappings", cloneLast(form.values.mappings, listItemDefaults.mapping))}
>
{t("action.addMapping")}
</Button>
</Stack>
);
}
if (dt === "table") {
return (
<Stack gap="xs">
<JsonPathTreePicker
json={previewJson}
label={t("field.tablePath")}
description={t("field.tablePathHint")}
required
placeholder={t("placeholder.tablePath")}
{...form.getInputProps("tablePath")}
/>
<Group grow>
<Switch label={t("field.striped")} {...form.getInputProps("striped", { type: "checkbox" })} />
<Switch label={t("field.compact")} {...form.getInputProps("compact", { type: "checkbox" })} />
</Group>
<Text size="sm" fw={500}>
{t("field.columns")}
</Text>
{form.values.columns.map((_col, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.header")}
placeholder={t("placeholder.exampleHeader")}
style={{ flex: 1 }}
{...form.getInputProps(`columns.${i}.header`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.columnJsonPath")}
{...form.getInputProps(`columns.${i}.jsonPath`)}
/>
</Box>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("columns", i)}
disabled={form.values.columns.length <= 1}
aria-label={t("action.removeColumn")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => form.insertListItem("columns", cloneLast(form.values.columns, listItemDefaults.column))}
>
{t("action.addColumn")}
</Button>
</Stack>
);
}
return null;
}
@@ -1,97 +0,0 @@
import { Alert, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
CUSTOM_JSX_TEMPLATE_LIMIT,
parseRequestManifest,
} from "@homarr/custom-widgets/workbench";
import { CodeEditor } from "./_code-editor";
import { REQUEST_MANIFEST_STARTER } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function CustomJsxDisplayFields({ form, t }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "customJsx") {
const parsedRequests = parseRequestManifest(form.values.requestManifest);
const requestIds = parsedRequests
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
.filter((id): id is string => typeof id === "string");
const templateDiagnostics = analyzeJsxTemplate(form.values.template, {
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
requestIds,
});
const manifestDiagnostics =
form.values.jsxApiVersion === "2" ? analyzeRequestManifest(form.values.requestManifest) : [];
return (
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.jsxApiVersion.label")}
</Text>
<SegmentedControl
fullWidth
value={form.values.jsxApiVersion}
onChange={(value) => form.setFieldValue("jsxApiVersion", value)}
data={[
{ value: "2", label: t("field.jsxApiVersion.v2") },
{ value: "1", label: t("field.jsxApiVersion.v1") },
]}
/>
</div>
{form.values.jsxApiVersion === "1" ? (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{t("field.jsxApiVersion.legacyWarning")}</Text>
</Alert>
) : (
<Stack gap="sm">
<Select
label={t("field.networkScope.label")}
description={t(`field.networkScope.description.${form.values.networkScope}` as never)}
value={form.values.networkScope}
onChange={(value) => form.setFieldValue("networkScope", value ?? "public")}
data={(["public", "private", "loopback"] as const).map((value) => ({
value,
label: t(`field.networkScope.option.${value}` as never),
}))}
allowDeselect={false}
/>
<CodeEditor
id="custom-widget-request-manifest"
language="json"
label={t("field.requestManifest.label")}
description={t("field.requestManifest.description")}
value={form.values.requestManifest}
onChange={(value) => form.setFieldValue("requestManifest", value)}
placeholder={REQUEST_MANIFEST_STARTER}
starter={REQUEST_MANIFEST_STARTER}
diagnostics={manifestDiagnostics}
error={form.errors.requestManifest ? t("field.requestManifest.invalid") : undefined}
/>
</Stack>
)}
<CodeEditor
id="custom-widget-jsx-template"
language="jsx"
label={t("field.template.label")}
description={t("field.template.description")}
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
placeholder={CUSTOM_JSX_STARTER}
starter={CUSTOM_JSX_STARTER}
diagnostics={templateDiagnostics}
error={form.errors.template}
required
maxLength={CUSTOM_JSX_TEMPLATE_LIMIT}
/>
</Stack>
);
}
return null;
}
@@ -1,167 +0,0 @@
import { ActionIcon, Box, Button, Group, Select, Stack, Switch, Text, TextInput, NumberInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function MetricDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "statGrid") {
return (
<Stack gap="xs">
<Group grow>
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
<Select
label={t("field.cardStyle")}
data={["filled", "outline", "subtle"].map((v) => ({
value: v,
label: t(`cardStyleOption.${v}` as never),
}))}
{...form.getInputProps("cardStyle")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.statGridItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`statGridItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`statGridItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`statGridItems.${i}.unit`)}
/>
<Select
label={t("field.color")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
style={{ width: 100 }}
{...form.getInputProps(`statGridItems.${i}.color`)}
allowDeselect={false}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("statGridItems", i)}
disabled={form.values.statGridItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("statGridItems", cloneLast(form.values.statGridItems, listItemDefaults.statGridItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "progressBars") {
return (
<Stack gap="xs">
<Group grow>
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
<Select
label={t("field.barSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("barSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.bars")}
</Text>
{form.values.progressBars.map((_bar, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`progressBars.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.valuePath")}
placeholder={t("placeholder.valuePath")}
{...form.getInputProps(`progressBars.${i}.valuePath`)}
/>
</Box>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.maxPath")}
placeholder={t("placeholder.maxPath")}
{...form.getInputProps(`progressBars.${i}.maxPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 60 }}
{...form.getInputProps(`progressBars.${i}.unit`)}
/>
<Select
label={t("field.color")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
style={{ width: 100 }}
{...form.getInputProps(`progressBars.${i}.color`)}
allowDeselect={false}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("progressBars", i)}
disabled={form.values.progressBars.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("progressBars", cloneLast(form.values.progressBars, listItemDefaults.progressBar))
}
>
{t("action.addBar")}
</Button>
</Stack>
);
}
return null;
}
@@ -1,198 +0,0 @@
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function StatusDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "statusIndicator") {
return (
<Stack gap="xs">
<Group grow>
<Select
label={t("field.kvLayout")}
data={["list", "grid"].map((v) => ({
value: v,
label: t(`layoutOption.${v}` as never),
}))}
{...form.getInputProps("statusLayout")}
allowDeselect={false}
/>
<Select
label={t("field.dotSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("dotSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.statusItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`statusItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`statusItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.goodValues")}
placeholder={t("placeholder.goodValues")}
style={{ flex: 1 }}
{...form.getInputProps(`statusItems.${i}.goodValues`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("statusItems", i)}
disabled={form.values.statusItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("statusItems", cloneLast(form.values.statusItems, listItemDefaults.statusItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "countGrid") {
return (
<Stack gap="xs">
<Group grow>
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
<Select
label={t("field.valueSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("countValueSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.countGridItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`countGridItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`countGridItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`countGridItems.${i}.unit`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("countGridItems", i)}
disabled={form.values.countGridItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("countGridItems", cloneLast(form.values.countGridItems, listItemDefaults.countGridItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "raw") {
return (
<>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
description={t("field.jsonPathHint")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps("rawJsonPath")}
/>
<NumberInput
label={t("field.maxHeight")}
min={50}
max={1000}
step={50}
{...form.getInputProps("rawMaxHeight")}
/>
</>
);
}
if (dt === "actionButton") {
return (
<Stack gap="sm">
<TextInput label={t("field.buttonLabel")} required {...form.getInputProps("buttonLabel")} />
<Select
label={t("field.buttonColor")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
{...form.getInputProps("buttonColor")}
allowDeselect={false}
/>
<TextInput
label={t("field.confirmText")}
description={t("field.confirmTextHint")}
{...form.getInputProps("confirmText")}
/>
<TextInput
label={t("field.successMessage")}
description={t("field.successMessageHint")}
{...form.getInputProps("successMessage")}
/>
</Stack>
);
}
return null;
}
@@ -1,89 +0,0 @@
import { SimpleGrid, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
Icon123,
IconActivityHeartbeat,
IconBraces,
IconCode,
IconHash,
IconLayoutGrid,
IconListDetails,
IconPointer,
IconProgress,
IconTable,
} from "@tabler/icons-react";
import type { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
import { customWidgetDisplayDescriptors } from "@homarr/custom-widgets/core";
import { BasicDisplayFields } from "./_display-fields-basic";
import { CustomJsxDisplayFields } from "./_display-fields-jsx";
import { MetricDisplayFields } from "./_display-fields-metric";
import { StatusDisplayFields } from "./_display-fields-status";
import type { DisplayFieldsProps } from "./_display-field-types";
import classes from "./_custom-widget-form.module.css";
export const DISPLAY_TYPE_ICONS = {
number: Icon123,
keyValue: IconListDetails,
table: IconTable,
grid: IconLayoutGrid,
progress: IconProgress,
status: IconActivityHeartbeat,
count: IconHash,
json: IconBraces,
action: IconPointer,
code: IconCode,
} as const;
export function DisplayTypePicker({
value,
onChange,
t,
}: {
value: string;
onChange: (value: CustomWidgetDisplayType) => void;
t: ReturnType<typeof useScopedI18n<"customWidget">>;
}) {
return (
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
{customWidgetDisplayDescriptors.map((descriptor) => {
const displayType = descriptor.type;
const Icon = DISPLAY_TYPE_ICONS[descriptor.icon];
const selected = value === displayType;
return (
<UnstyledButton
type="button"
key={displayType}
className={classes.displayTypeOption}
data-selected={selected || undefined}
onClick={() => onChange(displayType)}
aria-pressed={selected}
>
<ThemeIcon variant={selected ? "filled" : "light"} size={42} radius="md">
<Icon size={22} />
</ThemeIcon>
<div className={classes.displayTypeCopy}>
<Text fw={600} size="sm">
{t(`displayType.${displayType}` as never)}
</Text>
<Text size="xs" c="dimmed" lineClamp={3}>
{t(`displayTypeDescription.${displayType}` as never)}
</Text>
</div>
</UnstyledButton>
);
})}
</SimpleGrid>
);
}
export function DisplayTypeFields(props: DisplayFieldsProps) {
return (
<>
<BasicDisplayFields {...props} />
<MetricDisplayFields {...props} />
<StatusDisplayFields {...props} />
<CustomJsxDisplayFields {...props} />
</>
);
}
@@ -1,99 +0,0 @@
"use client";
import { useMemo } from "react";
import { Badge, Button, Center, Code, Paper, ScrollArea, SegmentedControl, Stack, Text } from "@mantine/core";
import { IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { createDisplayComponents } from "@homarr/widgets/custom-api/component";
interface PreviewWidgetPanelProps {
data: Record<string, unknown> | null;
displayType: string;
success: boolean;
responseInfo: { status: number; statusText: string } | null;
previewSize: string;
onPreviewSizeChange: (value: string) => void;
}
const HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
export function PreviewWidgetPanel({
data,
displayType,
success,
responseInfo,
previewSize,
onPreviewSizeChange,
}: PreviewWidgetPanelProps) {
const t = useScopedI18n("customWidget");
const height = HEIGHTS[previewSize] ?? HEIGHTS.standard;
return (
<Stack gap="xs">
<SegmentedControl
size="xs"
fullWidth
value={previewSize}
onChange={onPreviewSizeChange}
data={(["compact", "standard", "wide"] as const).map((value) => ({
value,
label: t(`preview.size.${value}` as never),
}))}
/>
{data && (displayType === "actionButton" || success) ? (
<>
{success && (
<Badge
size="xs"
color={responseInfo ? "green" : "gray"}
variant="light"
style={{ alignSelf: "flex-start" }}
>
{responseInfo ? `${responseInfo.status} ${responseInfo.statusText}` : t("preview.response.localSample")}
</Badge>
)}
<Paper withBorder p="xs" h={height} style={{ overflow: "auto" }}>
<PreviewDisplay data={data} />
</Paper>
</>
) : (
<Center h={height}>
<Stack gap={4} align="center" maw={280}>
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
<Text size="sm" fw={500} ta="center">
{t("preview.empty.title")}
</Text>
<Text size="xs" c="dimmed" ta="center">
{t("preview.hint")}
</Text>
</Stack>
</Center>
)}
</Stack>
);
}
function PreviewDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("customWidget");
const widgetT = useScopedI18n("widget.customApi");
const displayComponents = useMemo(() => createDisplayComponents(widgetT("openJson")), [widgetT]);
const dataType = data.type as string | undefined;
if (dataType === "actionButton") {
return (
<Center p="sm" h="100%">
<Button type="button" size="sm" color={(data.buttonColor as string) ?? "blue"} disabled>
{(data.buttonLabel as string) ?? t("preview.execute")}
</Button>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) return <Component data={data} />;
return (
<ScrollArea h="100%">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}
@@ -1,87 +0,0 @@
"use client";
import { useCallback, useEffect } from "react";
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { buildDisplayFormValues, getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_AUTH_SECRET_FIELDS } from "@homarr/custom-widgets/workbench";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
interface UseCustomWidgetClipboardOptions {
enabled: boolean;
form: CustomWidgetFormInstance;
t: CustomWidgetTranslator;
onReplace: () => void;
}
export function useCustomWidgetClipboard({ enabled, form, t, onReplace }: UseCustomWidgetClipboardOptions) {
const { openConfirmModal } = useConfirmModal();
const queueReplacement = useCallback(
(widget: Record<string, unknown>) => {
const review = getImportReview(widget);
if (!review) return;
openConfirmModal({
title: t("importReview.replaceTitle"),
children: (
<Stack gap="sm">
<Text size="sm">{t("importReview.replaceDescription", { name: review.name, origin: review.origin })}</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge key={method} color={{ DELETE: "red", GET: "blue" }[method] ?? "orange"}>
{method}
</Badge>
))}
</Group>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
</Alert>
)}
</Stack>
),
labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") },
onConfirm: () => {
const displayType = String(widget.displayType);
const displayConfig = widget.displayConfig as Record<string, unknown>;
const authType = typeof widget.authType === "string" ? widget.authType : "none";
const secrets = (CUSTOM_WIDGET_AUTH_SECRET_FIELDS[authType] ?? []).map((field) => {
const existing = form.values.secrets.find((secret) => secret.kind === field.kind);
return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false };
});
form.setValues({
...form.values,
name: String(widget.name),
description: typeof widget.description === "string" ? widget.description : "",
iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "",
url: String(widget.url),
authType,
headerName: typeof widget.headerName === "string" ? widget.headerName : "",
method: typeof widget.method === "string" ? widget.method : "GET",
requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "",
...buildDisplayFormValues(displayType, displayConfig),
secrets,
});
onReplace();
showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") });
},
});
},
[form, onReplace, openConfirmModal, t],
);
useEffect(() => {
if (!enabled) return;
const handlePaste = (event: ClipboardEvent) => {
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
event.preventDefault();
queueReplacement(widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled, queueReplacement]);
}
@@ -0,0 +1,169 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import type { UseFormReturnType } from "@mantine/form";
import { clientApi, fetchApi } from "@homarr/api/client";
import {
buildCustomWidgetFixPrompt,
customWidgetDefinitionSchema,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { applyDefinition, buildDefinition, loadPreviewQueries } from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel";
interface FormActionsInput {
mode: "create" | "edit";
definitionId?: string;
form: UseFormReturnType<CustomWidgetFormValues>;
candidate: ReturnType<typeof buildDefinition>;
templateDiagnostics: EditorDiagnostic[];
requestDiagnostics: EditorDiagnostic[];
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
setStateSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
request: string;
documentationUrl: string;
}
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 save = input.form.onSubmit(async (values) => {
const definition = buildDefinition(values);
if (!definition.success) {
showErrorNotification({
title: t("action.save"),
message: definition.error.issues[0]?.message ?? w("invalidWidget"),
});
return;
}
const changedSecrets = values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
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 {
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
}
});
const runPreview = async () => {
if (!input.candidate.success) {
showErrorNotification({
title: w("section.preview"),
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
});
return;
}
input.setMobilePane("preview");
try {
const created = await previewMutation.mutateAsync({
definition: input.candidate.data,
definitionId: input.definitionId,
secrets: input.form.values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
})),
});
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
input.setStateSnapshot(input.candidate.data.defaultState ?? {});
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
input.setPreview({ ...snapshot, session: created.previewSession });
} catch {
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
}
};
const pasteAiResponse = async () => {
try {
const result = parseCustomWidgetClipboardDetailed(await navigator.clipboard.readText());
if (!result.success) throw new Error(result.error);
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setPreview({ data: {}, status: {}, session: null });
showSuccessNotification({ title: w("ai.response"), message: w("ai.loaded") });
} catch (error) {
showErrorNotification({
title: w("ai.response"),
message:
error instanceof Error ? w("ai.invalidResponseDetail", { message: error.message }) : w("ai.invalidResponse"),
});
}
};
const copyDiagnostics = async () => {
try {
const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics]
.map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`)
.join("\n");
const journal = input.preview.session
? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => [])
: [];
await navigator.clipboard.writeText(
buildCustomWidgetFixPrompt({
currentConfig: input.candidate.success ? input.candidate.data : input.form.values,
request: input.request,
documentationUrl: input.documentationUrl,
diagnostics,
journal,
}),
);
showSuccessNotification({ title: w("ai.diagnostics"), message: w("ai.diagnosticsCopied") });
} catch {
showErrorNotification({ title: w("ai.diagnostics"), message: t("notification.aiPromptCopyError") });
}
};
return {
save,
runPreview,
pasteAiResponse,
copyDiagnostics,
pending: createMutation.isPending || updateMutation.isPending,
};
}
@@ -1,156 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
DisplayConfig,
} from "@homarr/custom-widgets/core";
import { buildDisplayConfigFromFormValues } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
import type { PreviewFetchResult, PreviewInput } from "./_custom-widget-preview";
interface UseCustomWidgetPreviewOptions {
form: CustomWidgetFormInstance;
definitionId?: string;
refreshSignal: number;
onOpenPreview: () => void;
t: CustomWidgetTranslator;
}
export function useCustomWidgetPreview({
form,
definitionId,
refreshSignal,
onOpenPreview,
t,
}: UseCustomWidgetPreviewOptions) {
const previewMutation = clientApi.customWidget.preview.useMutation();
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const [json, setJson] = useState<unknown>(null);
const [fetchResult, setFetchResult] = useState<PreviewFetchResult | null>(null);
const [lastFingerprint, setLastFingerprint] = useState<string | null>(null);
const hasTestedRef = useRef(false);
const getInput = useCallback((): PreviewInput => {
const values = form.values;
return {
url: values.url,
method: values.method as CustomWidgetMethod,
authType: values.authType as CustomWidgetAuthType,
headerName: values.headerName || undefined,
requestBody: values.requestBody || undefined,
displayType: values.displayType as CustomWidgetDisplayType,
displayConfig: buildDisplayConfigFromFormValues(values) as DisplayConfig,
secrets: values.secrets
.filter((secret) => secret.value)
.map((secret) => ({ kind: secret.kind as CustomWidgetSecretKind, value: secret.value })),
definitionId,
};
}, [definitionId, form.values]);
const test = useCallback(async () => {
const input = getInput();
if (!input.url || input.method !== "GET") return;
onOpenPreview();
setLastFingerprint(JSON.stringify(input));
try {
const result = await previewMutation.mutateAsync(input);
hasTestedRef.current = true;
setFetchResult({
success: result.success,
error: result.success ? undefined : result.error,
responseInfo: result.responseInfo,
rawResponse: result.rawResponse,
previewSession: result.previewSession,
});
if (!result.success || !result.rawResponse) return setJson(null);
try {
setJson(JSON.parse(result.rawResponse));
} catch {
setJson(null);
}
} catch {
setFetchResult({ success: false, error: t("notification.previewError"), responseInfo: null, rawResponse: null });
setJson(null);
}
}, [getInput, onOpenPreview, previewMutation, t]);
const setLiveActions = useCallback(
async (enabled: boolean) => {
const sessionId = fetchResult?.previewSession?.id;
if (!sessionId) return;
try {
const previewSession = await liveActionsMutation.mutateAsync({ sessionId, enabled });
setFetchResult((current) => (current ? { ...current, previewSession } : current));
} catch {
showErrorNotification({
title: t("preview.capabilities.liveActions"),
message: t("notification.previewActionsError"),
});
}
},
[fetchResult?.previewSession?.id, liveActionsMutation, t],
);
const testRef = useRef(test);
testRef.current = test;
const getInputRef = useRef(getInput);
getInputRef.current = getInput;
const pendingRefreshRef = useRef(false);
useEffect(() => {
if (refreshSignal <= 0 || !hasTestedRef.current) return;
if (previewMutation.isPending) return void (pendingRefreshRef.current = true);
if (getInputRef.current().method === "GET") void testRef.current();
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshSignal is the explicit event source
}, [refreshSignal]);
useEffect(() => {
if (previewMutation.isPending || !pendingRefreshRef.current) return;
pendingRefreshRef.current = false;
if (getInputRef.current().method === "GET" && hasTestedRef.current) void testRef.current();
}, [previewMutation.isPending]);
const input = getInput();
const isStale = lastFingerprint !== null && lastFingerprint !== JSON.stringify(input);
const insertDataPath = useCallback(
(path: string) => {
if (form.values.displayType !== "customJsx") return;
const template = form.values.template.trimEnd();
const snippet = `<Text>{${path}}</Text>`;
form.setFieldValue("template", template ? `${template}\n${snippet}` : snippet);
},
[form],
);
const setSampleData = useCallback((value: unknown) => {
setJson(value);
setFetchResult({ success: true, responseInfo: null, rawResponse: JSON.stringify(value, null, 2) });
setLastFingerprint(null);
}, []);
const reset = useCallback(() => {
setJson(null);
setFetchResult(null);
setLastFingerprint(null);
}, []);
return {
input,
getInput,
json,
fetchResult,
isStale,
test,
setLiveActions,
insertDataPath,
setSampleData,
reset,
isTesting: previewMutation.isPending,
testError: previewMutation.error,
isUpdatingLiveActions: liveActionsMutation.isPending,
};
}
@@ -3,48 +3,24 @@ import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { buildDisplayFormValues } from "@homarr/custom-widgets/core";
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";
import { FormErrorBoundary } from "../../_form-error-boundary";
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;
}
interface EditCustomWidgetPageProps {
params: Promise<{ id: string }>;
}
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
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 = buildDisplayFormValues(definition.displayType, displayConfig);
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
return (
<>
@@ -52,7 +28,6 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
<Container fluid>
<Stack>
<Title>{definition.name}</Title>
<CustomWidgetBetaBanner />
<FormErrorBoundary>
<CustomWidgetForm
mode="edit"
@@ -61,13 +36,19 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
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),
sources: JSON.stringify(definition.sources, null, 2),
requests: JSON.stringify(definition.requests, null, 2),
optionsSchema: JSON.stringify(definition.optionsSchema, null, 2),
defaultOptions: JSON.stringify(definition.defaultOptions, null, 2),
stateSchema: JSON.stringify(definition.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(definition.defaultState ?? {}, null, 2),
template: definition.template,
secrets: definition.secrets.map((secret) => ({
sourceId: secret.sourceId,
kind: secret.kind,
value: "",
hasValue: true,
})),
}}
/>
</FormErrorBoundary>
@@ -5,13 +5,12 @@ import { auth } from "@homarr/auth/next";
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")) {
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
redirect("/manage/custom-widgets");
}
@@ -28,7 +27,6 @@ export default async function NewCustomWidgetPage() {
{t("page.create.subtitle")}
</Text>
</div>
<CustomWidgetBetaBanner />
<FormErrorBoundary>
<CustomWidgetForm mode="create" />
</FormErrorBoundary>
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Group } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { IconDownload, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -9,18 +9,18 @@ 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 { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
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,15 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<MobileAffixButton
component="a"
href="https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip"
leftSection={<IconDownload 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")}
@@ -35,7 +44,6 @@ export default async function CustomWidgetsPage() {
</Group>
}
>
<CustomWidgetBetaBanner />
<CustomWidgetList definitions={definitions} />
</ManagePageLayout>
);
@@ -0,0 +1,74 @@
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("custom-widget-manage")) 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 requestParams = Object.fromEntries(
Object.entries(request.parameters).flatMap(([name, type]) => {
const value = preview.defaultOptions[name];
return typeof value === type ? [[name, value as string | number | boolean]] : [];
}),
);
const result = await api.customWidget
.previewQuery({ sessionId: id, requestId: request.id, params: requestParams })
.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.defaultOptions,
stateSchema: preview.stateSchema,
defaultState: preview.defaultState,
requestCapabilities: preview.requests,
previewSessionId: preview.id,
previewLiveActions: preview.liveActions,
}}
/>
</Paper>
</Stack>
</Container>
);
}
@@ -5,6 +5,7 @@ import {
IconApi,
IconBook2,
IconBox,
IconBuildingStore,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
@@ -78,7 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
hidden: !session,
hidden: !session?.user.permissions.includes("custom-widget-manage"),
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("custom-widget-manage"),
},
{
icon: IconSearch,
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("custom-widget-manage")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
return (
<ManagePageLayout title={t("title")} size="xl">
<WorkshopInstaller />
</ManagePageLayout>
);
}
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/core";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json({
version: 2,
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
resources: [
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
});
}
@@ -0,0 +1,96 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
claimCustomWidgetSecretRequest,
completeCustomWidgetSecretRequest,
getCustomWidgetSecretRequest,
releaseCustomWidgetSecretRequest,
setPreviewSessionSecrets,
} from "@homarr/api/custom-widget-secrets";
import { encryptSecret } from "@homarr/common/server";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { and, db, eq } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
interface RouteContext {
params: Promise<{ token: string }>;
}
export async function GET(_request: NextRequest, context: RouteContext) {
const { token } = await context.params;
const request = await getCustomWidgetSecretRequest(token);
if (!request) return NextResponse.json({ error: "This credential request is invalid or expired." }, { status: 404 });
return NextResponse.json(
{
widgetName: request.widgetName,
sourceName: request.sourceName,
kinds: request.kinds,
expiresAt: request.expiresAt,
status: request.status,
},
{ headers: { "Cache-Control": "no-store" } },
);
}
export async function POST(request: NextRequest, context: RouteContext) {
const { token } = await context.params;
const pending = await getCustomWidgetSecretRequest(token);
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as { secrets?: Record<string, unknown> } | null;
const secrets = pending.kinds.flatMap((kind) => {
const value = body?.secrets?.[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
});
if (secrets.length !== pending.kinds.length) {
return NextResponse.json({ error: "Enter every requested credential field." }, { status: 400 });
}
const claimed = await claimCustomWidgetSecretRequest(token);
if (!claimed) {
return NextResponse.json({ error: "This credential request is already being completed." }, { status: 409 });
}
try {
if (claimed.target.type === "preview") {
await setPreviewSessionSecrets(claimed.target.id, claimed.userId, secrets);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
} else {
const definition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, claimed.target.id),
});
if (!definition) return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
for (const secret of secrets) {
await db
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, claimed.target.id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
);
await db.insert(customWidgetSecrets).values({
definitionId: claimed.target.id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
});
}
await db
.update(customWidgetDefinitions)
.set({ updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, claimed.target.id));
}
await completeCustomWidgetSecretRequest(token);
return NextResponse.json({ status: "completed" }, { headers: { "Cache-Control": "no-store" } });
} finally {
await releaseCustomWidgetSecretRequest(token);
}
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/core";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetSkill());
}
@@ -3,13 +3,29 @@ import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createMcpHandler } from "mcp-handler";
import type { McpTool } from "trpc-to-mcp";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
buildCustomWidgetMcpPrompt,
customJsxExamples,
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetJsonSchema,
getCustomWidgetSkill,
} from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
@@ -168,6 +184,92 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
const mcpHandler = createMcpHandler(
(server) => {
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
prompts: [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
],
}));
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
return {
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: buildCustomWidgetMcpPrompt(
request.params.arguments?.request,
request.params.arguments?.documentationUrl,
),
},
},
],
};
});
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: [
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
...customJsxExamples.map((example) => ({
uri: `homarr://custom-widgets/examples/${example.id}`,
name: `Custom Widget example: ${example.title}`,
mimeType: "application/json",
})),
],
}));
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
resourceTemplates: [
{
uriTemplate: "homarr://custom-widgets/components/{name}",
name: "Custom Widget component",
description: "Metadata for one installed Custom Widget component.",
mimeType: "application/json",
},
{
uriTemplate: "homarr://custom-widgets/examples/{name}",
name: "Custom Widget example",
description: "One canonical Custom Widget example.",
mimeType: "application/json",
},
],
}));
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
const skill = getCustomWidgetSkill();
text = `${skill.skillMd}\n\nMatching archive: ${skill.downloadUrl}\nSHA-256: ${skill.sha256}\n`;
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
const component = getCustomWidgetComponent(name);
if (!component) throw new Error("Component resource not found");
text = JSON.stringify(component);
} else if (uri.startsWith("homarr://custom-widgets/examples/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/examples/".length));
const example = customJsxExamples.find((candidate) => candidate.id === name);
if (!example) throw new Error("Example resource not found");
text = JSON.stringify(example);
} else throw new Error("Resource not found");
return { contents: [{ uri, mimeType, text }] };
});
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: getTools(),
}));
@@ -219,7 +321,7 @@ const mcpHandler = createMcpHandler(
});
},
{
capabilities: { tools: {} },
capabilities: { tools: {}, prompts: {}, resources: {} },
instructions: SERVER_INSTRUCTIONS,
serverInfo: {
name: "homarr",
@@ -100,7 +100,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
const { removeItem, updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
@@ -137,6 +137,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
removeItem={() => removeItem({ itemId: item.id })}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
@@ -3,6 +3,7 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -22,7 +23,8 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
const board = useRequiredBoard();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: board.id });
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -61,17 +63,41 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
}, [items, search]);
const filteredCustomWidgets = useMemo(
() =>
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
[customWidgetDefs, search],
);
const handleAddCustomWidget = (definitionId: string) => {
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
const options = {
...defaultOptions,
definitionId: definition.id,
configuration: definition.defaultOptions,
configurationVersion: definition.updatedAt.getTime(),
};
actions.closeModal();
openEditModal(
{
kind: "customApi",
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options,
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: configuredOptions });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
},
integrationData: [],
integrationSupport: false,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
},
);
};
const handleAdd = (kind: WidgetKind) => {
@@ -180,7 +206,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
@@ -0,0 +1,416 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBuildingStore,
IconDownload,
IconFlag,
IconSearch,
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title);
link.click();
URL.revokeObjectURL(url);
}
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
const list = useQuery({
queryKey: ["workshop", "list", page, sort, debouncedSearch],
queryFn: ({ signal }) => client.list({ page, perPage: 12, sort, search: debouncedSearch, signal }),
placeholderData: keepPreviousData,
});
const detail = useQuery({
queryKey: ["workshop", "detail", selectedId],
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const login = useMutation({ mutationFn: () => client.signInWithGitHub(), onSuccess: setUser });
const install = useMutation({
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
onSuccess: () => setSelectedId(null),
});
const vote = useMutation({
mutationFn: ({ submission, value }: { submission: string; value: 1 | -1 }) => client.vote(submission, value),
onSuccess: async () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["workshop", "list"] }),
queryClient.invalidateQueries({ queryKey: ["workshop", "detail"] }),
]),
});
const report = useMutation({
mutationFn: () => client.report(selectedId ?? "", reportCategory, reportExplanation),
onSuccess: () => {
reportControls.close();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
});
return (
<Stack gap="lg">
<Card withBorder radius="lg" p="lg">
<Group justify="space-between" align="flex-start">
<Group align="flex-start">
<Box
p="sm"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
>
<IconBuildingStore size={24} />
</Box>
<Box>
<Title order={2}>{t("title")}</Title>
<Text c="dimmed" size="sm" maw={640}>
{t("description")}
</Text>
</Box>
</Group>
{user ? (
<Group gap="xs">
<Badge variant="light">{user.displayName}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</Group>
) : (
<Button size="xs" loading={login.isPending} onClick={() => login.mutate()}>
{t("signIn")}
</Button>
)}
</Group>
</Card>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("securityNotice")}
</Alert>
{login.error && <Alert color="red">{login.error.message}</Alert>}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={t("search")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Select
label={t("sort")}
value={sort}
onChange={(value) => {
setSort(value === "newest" ? "newest" : "top");
setPage(1);
}}
data={[
{ value: "top", label: t("sortTop") },
{ value: "newest", label: t("sortNewest") },
]}
allowDeselect={false}
/>
</SimpleGrid>
{list.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{t("unavailable")}</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
<Card key={item.id} withBorder radius="md" p="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Code>JSX</Code>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">v{item.revision}</Badge>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Text fw={700} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("noDescription")}
</Text>
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName })}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{t("inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Text c="dimmed" ta="center" py="xl">
{t("empty")}
</Text>
)}
{(list.data?.totalPages ?? 0) > 1 && (
<Pagination total={list.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={selectedId !== null}
onClose={() => setSelectedId(null)}
title={detail.data?.title ?? t("details")}
size="xl"
>
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
<Group>
<Badge>{t("revision", { revision: detail.data.revision })}</Badge>
<Text size="sm" c="dimmed">
SHA-256 {detail.data.contentHash.slice(0, 12)}…
</Text>
</Group>
{detail.data.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{detail.data.screenshots.map((file, index) => (
<Image
key={file}
src={client.fileUrl(detail.data.id, file, "960x640")}
radius="md"
alt={`${detail.data.title} screenshot ${index + 1}`}
/>
))}
</SimpleGrid>
)}
<CapabilitySummary content={detail.data.content} />
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
<Group justify="space-between">
<Group gap="xs">
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() => downloadWorkshopSubmission(detail.data)}
>
{t("export")}
</Button>
<Button
variant="subtle"
leftSection={<IconThumbUp size={16} />}
disabled={!user}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
</Button>
<Button
variant="subtle"
leftSection={<IconThumbDown size={16} />}
disabled={!user}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
</Button>
<Button
variant="subtle"
color="red"
leftSection={<IconFlag size={16} />}
disabled={!user}
onClick={reportControls.open}
>
{t("report")}
</Button>
</Group>
{onInstall && (
<Button loading={install.isPending} onClick={() => install.mutate(detail.data)}>
{t("install")}
</Button>
)}
</Group>
{!user && (
<Text size="xs" c="dimmed">
{t("signInHint")}
</Text>
)}
{vote.error && <Alert color="red">{t("voteError")}</Alert>}
{install.error && <Alert color="red">{install.error.message}</Alert>}
</Stack>
)}
</Modal>
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
<Stack>
<Select
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
value={reportExplanation}
onChange={(event) => setReportExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Button
loading={report.isPending}
disabled={reportExplanation.trim().length < 3}
onClick={() => report.mutate()}
>
{t("reportSend")}
</Button>
</Stack>
</Modal>
</Stack>
);
}
function CapabilitySummary({ content }: { content: string }) {
const t = useScopedI18n("workshop");
let candidate: unknown;
try {
candidate = JSON.parse(content) as unknown;
} catch {
return null;
}
const parsed = customWidgetImportSchema.safeParse(candidate);
if (!parsed.success) return null;
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("sources")}
</Text>
<Stack gap={6}>
{parsed.data.sources.map((source) => (
<Box key={source.id}>
<Text size="sm" fw={500}>
{source.name}
</Text>
<Group gap={4}>
<Badge size="xs" variant="light">
{new URL(source.baseUrl).origin}
</Badge>
<Badge size="xs" variant="outline">
{source.networkScope}
</Badge>
<Badge size="xs" variant="outline">
{source.auth.type}
</Badge>
</Group>
</Box>
))}
</Stack>
</Card>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{parsed.data.requests.length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{parsed.data.requests.map((request) => (
<Group key={request.id} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{request.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs">{request.kind}</Badge>
<Badge size="xs" variant="light">
{request.method}
</Badge>
<Badge size="xs" variant="outline">
{request.minimumBoardPermission}
</Badge>
</Group>
</Group>
))}
</Stack>
)}
</Card>
</SimpleGrid>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstallButton() {
const t = useScopedI18n("workshop");
const [opened, controls] = useDisclosure(false);
const mutation = clientApi.customWidget.import.useMutation();
const utils = clientApi.useUtils();
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={controls.open}>
{t("title")}
</Button>
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
<WorkshopBrowser
onInstall={async (submission) => {
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(widget);
await utils.customWidget.list.invalidate();
showSuccessNotification({
title: t("installed"),
message: t("installedDescription", { name: widget.name }),
});
controls.close();
}}
/>
</Modal>
</>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const t = useScopedI18n("workshop");
const router = useRouter();
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation();
const install = async (submission: WorkshopSubmissionDetail) => {
try {
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content) as unknown);
const result = await importMutation.mutateAsync(widget);
await utils.customWidget.list.invalidate();
showSuccessNotification({ title: t("installSuccess"), message: t("installSuccessDescription") });
router.push(`/manage/custom-widgets/edit/${result.id}`);
} catch (error) {
showErrorNotification({
title: t("installError"),
message: error instanceof Error ? error.message : t("installErrorDescription"),
});
throw error;
}
};
return <WorkshopBrowser onInstall={install} />;
}
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export function WorkshopPublishModal({
opened,
onClose,
widget,
}: {
opened: boolean;
onClose(): void;
widget: { id: string; name: string };
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
const utils = clientApi.useUtils();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const initialChangelog = t("publish.initialChangelog");
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
useEffect(() => {
if (!opened) return;
setTitle(widget.name);
setDescription("");
setChangelog(initialChangelog);
setScreenshots([]);
setError(null);
setPublished(false);
}, [initialChangelog, opened, widget.name]);
const publish = async () => {
setPending(true);
setError(null);
try {
const definition = await utils.customWidget.export.fetch({ id: widget.id });
await client.create({ title, description, changelog, content: JSON.stringify(definition) }, screenshots);
setPublished(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
} finally {
setPending(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
<Stack>
{published ? (
<Alert color="green">{t("publish.success")}</Alert>
) : (
<>
{!user && (
<Alert color="blue">
<Group justify="space-between">
<span>{t("publish.signInHint")}</span>
<Button
size="xs"
onClick={() =>
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => setError(cause.message))
}
>
{t("signIn")}
</Button>
</Group>
</Alert>
)}
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={3}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
minRows={2}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
/>
{error && <Alert color="red">{error}</Alert>}
<Button loading={pending} disabled={!user || title.trim().length < 3} onClick={() => void publish()}>
{t("publish.action")}
</Button>
</>
)}
</Stack>
</Modal>
);
}