fix(custom-widgets): refine v2 authoring
This commit is contained in:
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,
|
||||
|
||||
+1
-3
@@ -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 : ""];
|
||||
}),
|
||||
|
||||
+1
-15
@@ -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") && (
|
||||
|
||||
+18
-6
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user