fix(custom-widgets): refine v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 14:14:53 +02:00
1 parent b050c267b6
commit f9863b8402
124 files changed
+2089 -913

No files matched your search

@@ -1,51 +0,0 @@
import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import classes from "./_code-editor.module.css";
interface CodeEditorDiagnosticsProps {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
title: string;
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
}
export function CodeEditorDiagnostics({
diagnostics,
editorView,
title,
formatDiagnostic,
}: CodeEditorDiagnosticsProps) {
if (diagnostics.length === 0) return null;
return (
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
<UnstyledButton
type="button"
w="100%"
className={classes.diagnosticButton}
onClick={() => {
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
}}
>
<Alert
color={diagnostic.severity === "error" ? "red" : "yellow"}
variant="light"
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
</Alert>
</UnstyledButton>
</li>
))}
</Stack>
);
}
@@ -1,80 +0,0 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.root:focus-within {
border-color: var(--mantine-primary-color-filled);
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
outline-offset: 1px;
}
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.diagnosticButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.root {
scroll-behavior: auto;
}
}
@@ -1,276 +1,48 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { autocompletion } 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";
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 { ComponentReference } from "@homarr/custom-widgets/workbench";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } 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";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
ssr: false,
loading: () => (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
),
});
interface CodeEditorProps {
id: string;
value: string;
onChange: (value: string) => void;
label: string;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: React.ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
}
export function CodeEditor({
id,
value,
onChange,
label,
description,
placeholder,
language,
diagnostics = [],
error,
required,
maxLength,
starter,
completions = [],
revealText,
revealKey,
}: CodeEditorProps) {
export function CodeEditor(props: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorView | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({
id,
"aria-label": label,
});
return language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(completions)] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
]
: [json(), diagnosticsExtension, accessibilityExtension];
}, [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 {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}, [language, value]);
return (
<Input.Wrapper
label={label}
description={description}
error={error}
required={required}
labelProps={{ htmlFor: id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{language === "jsx" ? t("language.jsx") : t("language.json")}
</Text>
</Group>
<Group gap={6}>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{t("action.undo")}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{t("action.redo")}
</Button>
{language === "jsx" && (
<ComponentReference
messages={{
action: t("action.components"),
search: t("componentReference.search"),
empty: t("componentReference.empty"),
count: (count) => t("componentReference.count", { count }),
}}
/>
)}
{starter && value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => onChange(starter)}
>
{t("action.insertStarter")}
</Button>
)}
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => onChange(formattedValue)}
disabled={formattedValue === value}
>
{t("action.format")}
</Button>
</Group>
</Group>
<div className={classes.viewport}>
<Editor
id={`${id}-root`}
value={value}
onChange={onChange}
placeholder={placeholder}
extensions={extensions}
theme={colorScheme}
height={language === "jsx" ? "320px" : "220px"}
basicSetup={{
autocompletion: language !== "jsx",
bracketMatching: true,
closeBrackets: true,
foldGutter: true,
history: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
indentOnInput: true,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{t("status.errors", { count: errorCount })}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{t("status.warnings", { count: warningCount })}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{t("status.ready")}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{t("status.position", cursor)}
</Text>
</Group>
<Text
size="xs"
c={maxLength && value.length > maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"}
>
{maxLength
? t("status.charactersWithLimit", { count: value.length, limit: maxLength })
: t("status.characters", { count: value.length })}
</Text>
</Group>
</div>
<CodeEditorDiagnostics
diagnostics={diagnostics}
editorView={editorView}
title={t("diagnostics.title")}
formatDiagnostic={(diagnostic) =>
<CustomWidgetCodeEditor
{...props}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`
}
/>
</Input.Wrapper>
)}`,
}}
/>
);
}
@@ -7,7 +7,12 @@ import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpl
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import {
formatCustomWidgetImportIssues,
getImportReview,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -161,14 +166,22 @@ export const ImportCustomWidgetButton = () => {
const handlePaste = (event: ClipboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, [contenteditable='true']")) return;
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
queueImport(widget);
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport]);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -177,11 +190,14 @@ export const ImportCustomWidgetButton = () => {
const reader = new FileReader();
reader.addEventListener("load", (e) => {
try {
const widget = parseCustomWidgetClipboard(e.target?.result as string);
if (!widget) throw new Error("Invalid import");
queueImport(widget);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
const result = parseCustomWidgetClipboardDetailed(e.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
@@ -1,57 +0,0 @@
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 };
},
};
});
@@ -5,8 +5,6 @@ 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}` },
@@ -15,7 +13,7 @@ export function createCustomWidgetCompletions(values: CustomWidgetFormValues, re
]),
...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" })),
{ label: "inputs", type: "variable", detail: "Temporary bound input values" },
];
}
@@ -31,7 +29,6 @@ export function getInvalidCustomWidgetSections(
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");
@@ -16,8 +16,6 @@ export function buildDefinition(values: CustomWidgetFormValues) {
requests: parseJson(values.requests),
optionsSchema: parseJson(values.optionsSchema),
defaultOptions: parseJson(values.defaultOptions),
stateSchema: parseJson(values.stateSchema),
defaultState: parseJson(values.defaultState),
template: values.template,
});
}
@@ -32,8 +30,6 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
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,
});
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
@@ -12,6 +12,13 @@ import {
IconSettings,
} from "@tabler/icons-react";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
@@ -28,7 +35,7 @@ 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 { applyDefinition, buildDefinition, isRecord, 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";
@@ -47,11 +54,26 @@ const sectionLinks = [
["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;
const requestReference = {
schema: getCustomWidgetRequestsJsonSchema(),
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
};
const optionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.schema,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema,
};
const defaultOptionsReference = {
schema: getCustomWidgetDefaultOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.defaults,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults,
};
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
@@ -65,7 +87,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
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();
@@ -78,17 +99,13 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
),
[form.values.requests],
);
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],
() => analyzeJsxTemplate(form.values.template, { requestIds }),
[form.values.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
@@ -113,7 +130,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
setPreview,
setMobilePane,
setOptionsSnapshot,
setStateSnapshot,
request,
documentationUrl,
});
@@ -188,6 +204,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
error={form.errors.requests}
revealText={revealedRequest.text}
revealKey={revealedRequest.key}
reference={requestReference}
required
/>
</EditorSection>
@@ -199,6 +216,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
value={form.values.optionsSchema}
onChange={(value) => form.setFieldValue("optionsSchema", value)}
error={form.errors.optionsSchema}
reference={optionsSchemaReference}
/>
<CodeEditor
id="default-options"
@@ -207,27 +225,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
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}
reference={defaultOptionsReference}
/>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
@@ -282,8 +280,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
stateSnapshot={stateSnapshot}
onStateChange={setStateSnapshot}
onLiveActionsChange={(enabled) =>
setPreview((current) => ({
...current,
@@ -15,19 +15,17 @@ 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];
const bound = options[name];
if (typeof bound === type) return [name, bound];
return [name, type === "number" ? 0 : type === "boolean" ? false : ""];
}),
@@ -43,8 +43,6 @@ interface PreviewPanelProps {
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;
}
@@ -75,8 +73,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
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,
@@ -92,7 +88,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
previewLiveActions: props.preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
onStateChange: props.onStateChange,
};
return (
@@ -132,7 +127,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
/>
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "state", "actions", "diagnostics"] as const).map((tab) => (
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
{t(`tab.${tab}`)}
</Tabs.Tab>
@@ -164,14 +159,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
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
@@ -199,7 +186,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
request={request}
sessionId={props.preview.session?.id}
options={props.optionsSnapshot}
state={props.stateSnapshot}
/>
))}
{candidate.requests.every((request) => request.kind !== "action") && (
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import type { UseFormReturnType } from "@mantine/form";
@@ -8,7 +9,8 @@ import { clientApi, fetchApi } from "@homarr/api/client";
import {
buildCustomWidgetFixPrompt,
customWidgetDefinitionSchema,
parseCustomWidgetClipboardDetailed,
formatCustomWidgetImportIssues,
parseCustomWidgetAiResponse,
} from "@homarr/custom-widgets/core";
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -28,7 +30,6 @@ interface FormActionsInput {
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;
}
@@ -41,6 +42,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [importIssues, setImportIssues] = useState<string[]>([]);
const save = input.form.onSubmit(async (values) => {
const definition = buildDefinition(values);
@@ -112,7 +114,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
})),
});
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 {
@@ -122,11 +123,21 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const pasteAiResponse = async () => {
try {
const result = parseCustomWidgetClipboardDetailed(await navigator.clipboard.readText());
if (!result.success) throw new Error(result.error);
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
if (!result.success) {
setImportIssues(result.issues.map((issue) => `${issue.path?.join(".") ?? "widget"}: ${issue.message}`));
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
setImportIssues([]);
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setPreview({ data: {}, status: {}, session: null });
showSuccessNotification({ title: w("ai.response"), message: w("ai.loaded") });
showSuccessNotification({
title: w("ai.response"),
message:
result.warnings.length > 0
? `${w("ai.loaded")} ${formatCustomWidgetImportIssues(result.warnings)}`
: w("ai.loaded"),
});
} catch (error) {
showErrorNotification({
title: w("ai.response"),
@@ -140,6 +151,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
try {
const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics]
.map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`)
.concat(importIssues.map((issue) => `IMPORT: ${issue}`))
.join("\n");
const journal = input.preview.session
? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => [])
@@ -40,8 +40,6 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
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,
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Group } from "@mantine/core";
import { IconDownload, IconPlus } from "@tabler/icons-react";
import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -30,8 +30,10 @@ export default async function CustomWidgetsPage() {
<Group gap="xs">
<MobileAffixButton
component="a"
href="https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip"
leftSection={<IconDownload size={16} />}
href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget"
target="_blank"
rel="noopener noreferrer"
leftSection={<IconExternalLink size={16} />}
variant="default"
>
{t("action.downloadSkill")}
@@ -60,8 +60,6 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
data,
status,
options: preview.defaultOptions,
stateSchema: preview.stateSchema,
defaultState: preview.defaultState,
requestCapabilities: preview.requests,
previewSessionId: preview.id,
previewLiveActions: preview.liveActions,
@@ -254,7 +254,7 @@ const mcpHandler = createMcpHandler(
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`;
text = `${skill.skillMd}\n\nInstall from skills.sh: ${skill.skillsShUrl}\nSource: ${skill.sourceUrl}\nCommand: ${skill.installCommand}\n`;
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
@@ -36,7 +36,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
@@ -63,6 +63,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
const [reportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -80,7 +82,19 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const login = useMutation({ mutationFn: () => client.signInWithGitHub(), onSuccess: setUser });
const signIn = () => {
setLoginPending(true);
setLoginError(null);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : t("signInError");
setLoginError(message);
showErrorNotification({ title: t("signIn"), message });
})
.finally(() => setLoginPending(false));
};
const install = useMutation({
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
onSuccess: () => setSelectedId(null),
@@ -136,7 +150,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
</Button>
</Group>
) : (
<Button size="xs" loading={login.isPending} onClick={() => login.mutate()}>
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
@@ -145,7 +159,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("securityNotice")}
</Alert>
{login.error && <Alert color="red">{login.error.message}</Alert>}
{loginError && <Alert color="red">{loginError}</Alert>}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={t("search")}
@@ -4,6 +4,7 @@ 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 { showErrorNotification } from "@homarr/notifications";
import type { WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
@@ -78,7 +79,10 @@ export function WorkshopPublishModal({
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => setError(cause.message))
.catch((cause: Error) => {
setError(cause.message);
showErrorNotification({ title: t("signIn"), message: cause.message });
})
}
>
{t("signIn")}