Improve Workshop submission editor

This commit is contained in:
Thomas Camlong committed 2026-07-21 12:12:30 +02:00
1 parent 3eef50e43c
commit 59ecf04d22
11 files changed
+145 -89

No files matched your search

+2 -1
View File
@@ -42,7 +42,8 @@ Comments are publicly readable. Signed-in users can post; authors can edit or de
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
3. Add a title, description, optional screenshots, and changelog, then submit.
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
4. Submit the entry.
Submissions are published immediately. Reported entries are reviewed and may be removed.
@@ -22,9 +22,13 @@ import {
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import { CustomWidgetCodeEditor, ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CanonicalCustomWidgetExampleProps, CustomWidgetCodeExampleProps } from "./custom-widget-code";
import type {
CanonicalCustomWidgetExampleProps,
CustomWidgetCodeExampleProps,
CustomWidgetCodeInputProps,
} from "./custom-widget-code";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
@@ -51,6 +55,7 @@ const messages: CustomWidgetEditorMessages = {
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
const editableMessages: CustomWidgetEditorMessages = { ...messages, ready: "Ready" };
export function CanonicalCustomWidgetExampleClient({ id, label, example, height }: CanonicalCustomWidgetExampleProps) {
const value = example === "requests" ? CUSTOM_WIDGET_REQUEST_EXAMPLES.full : CUSTOM_WIDGET_OPTIONS_EXAMPLES.full;
@@ -73,6 +78,14 @@ export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProp
);
}
export function CustomWidgetCodeInputClient(props: CustomWidgetCodeInputProps) {
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<CustomWidgetCodeEditor {...props} messages={editableMessages} />
</MantineProvider>
);
}
export function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
@@ -3,6 +3,7 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import {
BundledCustomWidgetGalleryClient,
CanonicalCustomWidgetExampleClient,
CustomWidgetCodeInputClient,
CustomWidgetCodeExampleClient,
} from "./custom-widget-code-client";
@@ -14,6 +15,19 @@ export interface CustomWidgetCodeExampleProps {
height?: string;
}
export interface CustomWidgetCodeInputProps {
id: string;
label: string;
value: string;
onChange(value: string): void;
language: "json" | "css";
description?: string;
placeholder?: string;
height?: string;
required?: boolean;
maxLength?: number;
}
export interface CanonicalCustomWidgetExampleProps {
id: string;
label: string;
@@ -37,6 +51,14 @@ export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
);
}
export function CustomWidgetCodeInput(props: CustomWidgetCodeInputProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => <CustomWidgetCodeInputClient {...props} />}
</BrowserOnly>
);
}
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
@@ -15,6 +15,8 @@ import {
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { CustomWidgetCodeInput } from "@/components/custom-widget-code";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -79,10 +81,13 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
const goTo = (next: number) => {
setDirection(navDirection[Number(next > step)]);
setStep(next);
};
const goTo = useCallback(
(next: number) => {
setDirection(navDirection[Number(next > step)]);
setStep(next);
},
[step],
);
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
setContent(json);
@@ -97,17 +102,17 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json");
if (!jsonFile) return false;
const reader = new FileReader();
reader.onload = (e) => {
reader.addEventListener("load", (e) => {
const text = e.target?.result as string;
if (!type) setType("customWidget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
};
reader.onerror = () => setError("Could not read the file");
});
reader.addEventListener("error", () => setError("Could not read the file"));
reader.readAsText(jsonFile);
return true;
},
[type, step, title, description, setContentAndAutofill],
[type, step, title, description, setContentAndAutofill, goTo],
);
const addImageFiles = useCallback(
@@ -187,7 +192,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<DialogContent
className="max-h-[90vh] overflow-y-auto sm:max-w-xl"
className="max-h-[92vh] overflow-y-auto sm:max-w-5xl"
showCloseButton={!pending}
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
@@ -328,8 +333,9 @@ const StepType = ({
<button
key={type}
onClick={() => onChange(type)}
aria-pressed={value === type}
className={cn(
"group flex flex-col items-center gap-3 rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
"group relative flex min-h-48 flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 text-center transition-colors hover:border-primary/50 hover:bg-primary/5",
value === type ? "border-primary bg-primary/10 ring-1 ring-primary/20" : "border-border bg-card",
)}
>
@@ -348,7 +354,7 @@ const StepType = ({
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{desc}</p>
</div>
{value === type && (
<div className="flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<div className="absolute top-3 right-3 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck size={12} />
</div>
)}
@@ -416,66 +422,66 @@ const StepDetails = ({
content: string;
onContentChange: (v: string) => void;
}) => (
<div className="flex flex-col gap-4">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.55fr)]">
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-content" className="text-xs font-medium text-muted-foreground">
{contentLabels[type]} *
</label>
<Textarea
<CustomWidgetCodeInput
id="submit-content"
className="font-mono text-xs"
label={contentLabels[type]}
language={type === "customWidget" ? "json" : "css"}
value={content}
onChange={(e) => onContentChange(e.target.value)}
onChange={onContentChange}
placeholder={placeholders[type]}
rows={8}
required
/>
{type === "customWidget" && (
<p className="text-xs text-muted-foreground">
Paste your widget JSON — title and description will auto-fill from the{" "}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">name</code> field.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
Title *
</label>
<Input
id="submit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={3}
maxLength={100}
placeholder="My awesome theme"
height="min(44vh, 440px)"
maxLength={type === "customCss" ? 16_384 : undefined}
description={
type === "customWidget"
? "Paste exported widget JSON. The title and description are filled from its metadata."
: "Paste the stylesheet that users will import into their board settings."
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
Description
</label>
<Textarea
id="submit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={2}
placeholder="A brief description of what this does"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-changelog" className="text-xs font-medium text-muted-foreground">
Changelog
</label>
<Textarea
id="submit-changelog"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
maxLength={2000}
rows={2}
placeholder="What is included in this revision?"
/>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
Title *
</label>
<Input
id="submit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={3}
maxLength={100}
placeholder="My awesome theme"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
Description
</label>
<Textarea
id="submit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={5}
placeholder="A brief description of what this does"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-changelog" className="text-xs font-medium text-muted-foreground">
Changelog
</label>
<Textarea
id="submit-changelog"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
maxLength={2000}
rows={4}
placeholder="What is included in this revision?"
/>
</div>
</div>
</div>
);
@@ -544,22 +550,21 @@ const StepMedia = ({
</label>
{previews.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{previews.map((src, i) => (
<div
key={i}
className="group relative aspect-square overflow-hidden rounded-xl border border-border bg-muted"
>
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
>
<IconX size={16} className="text-white" />
</button>
</div>
))}
<div className="max-h-44 overflow-y-auto pr-1">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{previews.map((src, i) => (
<div key={i} className="group relative h-24 overflow-hidden rounded-xl border border-border bg-muted">
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
>
<IconX size={16} className="text-white" />
</button>
</div>
))}
</div>
</div>
)}
</div>
+1
View File
@@ -39,6 +39,7 @@
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "catalog:",
"@codemirror/lang-css": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lint": "catalog:",
@@ -46,7 +46,11 @@ export function CodeEditorToolbar({
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{props.language === "jsx" ? props.messages.languageJsx : props.messages.languageJson}
{props.language === "jsx"
? props.messages.languageJsx
: props.language === "css"
? "CSS"
: props.messages.languageJson}
</Text>
</Group>
<Group gap={6}>
@@ -102,7 +106,7 @@ export function CodeEditorToolbar({
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={onFormat}
disabled={props.language === "json" && formattedValue === props.value}
disabled={props.language !== "jsx" && formattedValue === props.value}
>
{props.messages.format}
</Button>
@@ -43,7 +43,7 @@ export interface CustomWidgetCodeEditorProps {
messages: CustomWidgetEditorMessages;
description?: string;
placeholder?: string;
language: "jsx" | "json";
language: "jsx" | "json" | "css";
diagnostics?: EditorDiagnostic[];
error?: ReactNode;
required?: boolean;
@@ -256,7 +256,7 @@ function EditorLoader() {
</Group>
);
}
function formatCode(value: string, language: "jsx" | "json") {
function formatCode(value: string, language: "jsx" | "json" | "css") {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
@@ -2,6 +2,7 @@
import { autocompletion } from "@codemirror/autocomplete";
import { indentWithTab } from "@codemirror/commands";
import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
@@ -18,7 +19,7 @@ import type { EditorDiagnostic } from "./analyzer";
interface DirectCodeMirrorProps {
value: string;
language: "jsx" | "json";
language: "jsx" | "json" | "css";
diagnostics: EditorDiagnostic[];
completions: Completion[];
id: string;
@@ -103,7 +104,9 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
autocompletion({ override: [createCustomJsxCompletionSource(props.completions)] }),
customJsxComponentHover,
]
: [json()];
: props.language === "css"
? [css()]
: [json()];
return [
basicSetup,
...language,
+6
View File
@@ -18,6 +18,9 @@ catalogs:
'@codemirror/commands':
specifier: ^6.10.3
version: 6.10.3
'@codemirror/lang-css':
specifier: ^6.3.1
version: 6.3.1
'@codemirror/lang-javascript':
specifier: ^6.2.5
version: 6.2.5
@@ -1731,6 +1734,9 @@ importers:
'@codemirror/commands':
specifier: 'catalog:'
version: 6.10.3
'@codemirror/lang-css':
specifier: 'catalog:'
version: 6.3.1
'@codemirror/lang-javascript':
specifier: 'catalog:'
version: 6.2.5
+1
View File
@@ -5,6 +5,7 @@ packages:
catalog:
"@codemirror/autocomplete": ^6.20.3
"@codemirror/commands": ^6.10.3
"@codemirror/lang-css": ^6.3.1
"@codemirror/lang-javascript": ^6.2.5
"@codemirror/lang-json": ^6.0.2
"@codemirror/lint": ^6.9.6