import React, { useCallback, useEffect, useRef, useState } from "react"; import { IconBraces, IconCheck, IconChevronLeft, IconChevronRight, IconFileUpload, IconLoader2, IconPalette, IconPhoto, IconUpload, IconX, } from "@tabler/icons-react"; import type { SubmissionType } from "@site/src/lib/store-schema"; import { validateSubmissionContent } from "@site/src/lib/store-schema"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn, errorMessage } from "@/lib/utils"; import type { SubmitInput } from "./useStore"; interface Props { onClose: () => void; onSubmit: (input: SubmitInput) => Promise; } const steps = ["Type", "Details", "Media"] as const; const navDirection = [-1, 1]; const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"]; const backLabels = ["Cancel", "Back"]; const submitLabels = ["Submit", "Submitting…"]; const connectorClass = ["bg-border", "bg-primary"]; const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"]; const placeholders: Record = { widget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}', css: ".grid-stack-item-content {\n border-radius: 16px;\n}", }; const contentLabels: Record = { widget: "Widget JSON (homarr-custom-widget-v2)", css: "Custom CSS", }; const parseJsonObject = (json: string): Record | null => { try { const parsed = JSON.parse(json); if (typeof parsed === "object" && parsed !== null) return parsed; } catch { /* invalid json */ } return null; }; export const SubmitForm = ({ onClose, onSubmit }: Props) => { const [step, setStep] = useState(0); const [direction, setDirection] = useState(1); const [type, setType] = useState(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [content, setContent] = useState(""); const [screenshots, setScreenshots] = useState([]); const [previews, setPreviews] = useState([]); const [error, setError] = useState(null); const [pending, setPending] = useState(false); const [jsonDropHint, setJsonDropHint] = useState(false); const previewUrlsRef = useRef>(new Set()); 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 setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => { setContent(json); const meta = parseJsonObject(json); if (!meta) return; if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name); if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description)); }, []); const handleJsonFileDrop = useCallback( (files: FileList | File[]) => { 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) => { const text = e.target?.result as string; if (!type) setType("widget"); setContentAndAutofill(text, title, description); if (step === 0) goTo(1); }; reader.onerror = () => setError("Could not read the file"); reader.readAsText(jsonFile); return true; }, [type, step, title, description, setContentAndAutofill], ); const addImageFiles = useCallback( (files: FileList | File[]) => { const incoming = Array.from(files) .filter((f) => f.type.startsWith("image/")) .slice(0, 5 - screenshots.length); if (incoming.length === 0) return; setScreenshots((prev) => [...prev, ...incoming].slice(0, 5)); const urls = incoming.map((f) => { const url = URL.createObjectURL(f); previewUrlsRef.current.add(url); return url; }); setPreviews((prev) => [...prev, ...urls].slice(0, 5)); }, [screenshots.length], ); const removeScreenshot = (idx: number) => { const url = previews[idx]; if (url) { previewUrlsRef.current.delete(url); URL.revokeObjectURL(url); } setScreenshots((prev) => prev.filter((_, i) => i !== idx)); setPreviews((prev) => prev.filter((_, i) => i !== idx)); }; useEffect( () => () => { previewUrlsRef.current.forEach(URL.revokeObjectURL); previewUrlsRef.current.clear(); }, [], ); const handleGlobalDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setJsonDropHint(false); if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files); }, [handleJsonFileDrop, step, addImageFiles], ); const handleSubmit = async () => { if (!type) return; const validation = validateSubmissionContent(type, content); if (!validation.success) { setError(validation.error); goTo(1); return; } setError(null); setPending(true); try { await onSubmit({ type, title, description, content, screenshots }); } catch (caught) { setError(errorMessage(caught, "Submission failed")); } finally { setPending(false); } }; const onContentChange = (value: string) => { if (type === "widget") setContentAndAutofill(value, title, description); else setContent(value); }; return ( { if (!open && !pending) onClose(); }} > { e.preventDefault(); setJsonDropHint(true); }} onDragLeave={() => setJsonDropHint(false)} onDrop={handleGlobalDrop} >

Drop JSON file to import widget

Title and description will be auto-filled

Share with the community Submit a custom CSS theme or widget to the Workshop.
{steps.map((label, i) => ( {i > 0 &&
} ))}
{error && (
{error}
)}
0)])}> {step === 0 && } {step === 1 && type && ( )} {step === 2 && ( )}
{step < 2 && ( )} {step >= 2 && ( )}
); }; const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [ { type: "widget", icon: IconBraces, label: "Custom Widget", desc: "A JSON-based widget using the homarr-custom-widget-v2 schema", }, { type: "css", icon: IconPalette, label: "Custom CSS", desc: "A CSS theme or style override for Homarr dashboards" }, ]; const StepType = ({ value, onChange, onJsonDrop, }: { value: SubmissionType | null; onChange: (t: SubmissionType) => void; onJsonDrop: (files: FileList | File[]) => boolean; }) => { const [dropActive, setDropActive] = useState(false); return (
{typeCards.map(({ type, icon: Icon, label, desc }) => ( ))}
); }; const StepDetails = ({ type, title, setTitle, description, setDescription, content, onContentChange, }: { type: SubmissionType; title: string; setTitle: (v: string) => void; description: string; setDescription: (v: string) => void; content: string; onContentChange: (v: string) => void; }) => (