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

@@ -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>