"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import posthog from "posthog-js"; import { Check, CheckCircle, ClipboardCopy, ExternalLink, Github, Loader2, Palette, Upload, X, XCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { validateSvg, type ValidationResult } from "@/lib/svg-validation"; const GITHUB_ISSUES_URL = "https://github.com/glincker/thesvg/issues/new"; // All known categories from the dataset const KNOWN_CATEGORIES = [ "AI", "Analytics", "API", "Auth", "CDN", "CLI", "CMS", "Cloud", "Config", "Database", "Design", "Devtool", "Email", "Ecommerce", "Finance", "Framework", "Gaming", "Hardware", "Hosting", "IDE", "Language", "Library", "Logging", "Mapping", "Media", "Mobile", "Monitoring", "Networking", "OS", "Payment", "Protocol", "Runtime", "Security", "Social", "Software", "Storage", "Testing", "UI", "Version Control", "Web", ]; interface FileState { name: string; size: number; content: string; dataUrl: string; validation: ValidationResult | null; } interface FormState { iconName: string; slug: string; websiteUrl: string; guidelinesUrl: string; hex: string; categories: string[]; newCategory: string; } function toKebabCase(str: string): string { return str .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function filenameToName(filename: string): string { return filename .replace(/\.svg$/i, "") .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } // --- Sub-components --- function ValidationBadge({ passed }: { passed: boolean }) { if (passed) { return ; } return ; } function ValidationPanel({ result }: { result: ValidationResult }) { return (
{result.valid ? ( ) : ( )} {result.valid ? "All checks passed" : "Some checks failed"}
    {result.checks.map((check) => (
  • {check.name}:{" "} {check.message}
  • ))}
); } function SvgPreview({ dataUrl, name }: { dataUrl: string; name: string }) { const SIZES = [16, 32, 64, 128] as const; return (

Preview

{/* Light background */}

Light

{SIZES.map((size) => (
{name} {size}px
))}
{/* Dark background */}

Dark

{SIZES.map((size) => (
{name} {size}px
))}
); } function CategorySelector({ categories, selected, onToggle, newCategory, onNewCategoryChange, onAddCategory, }: { categories: string[]; selected: string[]; onToggle: (cat: string) => void; newCategory: string; onNewCategoryChange: (val: string) => void; onAddCategory: () => void; }) { return (
{categories.map((cat) => { const active = selected.includes(cat); return ( ); })} {/* Custom categories added by user */} {selected .filter((c) => !categories.includes(c)) .map((cat) => ( ))}
onNewCategoryChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onAddCategory(); } }} className="h-7 text-xs" />
); } // --- Main Form Component --- export function SubmitForm({ availableCategories, }: { availableCategories?: string[]; }) { const [fileState, setFileState] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isValidating, setIsValidating] = useState(false); const [copied, setCopied] = useState(false); const [form, setForm] = useState({ iconName: "", slug: "", websiteUrl: "", guidelinesUrl: "", hex: "", categories: [], newCategory: "", }); const fileInputRef = useRef(null); const allCategories = availableCategories ?? KNOWN_CATEGORIES; // Keep slug in sync with name unless user has manually edited it const slugEditedRef = useRef(false); function updateField(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })); } function handleNameChange(val: string) { updateField("iconName", val); if (!slugEditedRef.current) { updateField("slug", toKebabCase(val)); } } function handleSlugChange(val: string) { slugEditedRef.current = true; updateField("slug", toKebabCase(val)); } function toggleCategory(cat: string) { setForm((prev) => ({ ...prev, categories: prev.categories.includes(cat) ? prev.categories.filter((c) => c !== cat) : [...prev.categories, cat], })); } function addNewCategory() { const cat = form.newCategory.trim(); if (!cat || form.categories.includes(cat)) return; setForm((prev) => ({ ...prev, categories: [...prev.categories, cat], newCategory: "", })); } const processFile = useCallback((file: File) => { if (!file.name.endsWith(".svg")) { return; } setIsValidating(true); const reader = new FileReader(); reader.onload = (e) => { const content = e.target?.result as string; const result = validateSvg(content, file.size); // Generate data URL for preview const blob = new Blob([content], { type: "image/svg+xml" }); const dataUrl = URL.createObjectURL(blob); setFileState({ name: file.name, size: file.size, content, dataUrl, validation: result, }); posthog.capture("submit_svg_uploaded", { file_size_bytes: file.size, validation_passed: result.valid, failed_checks: result.checks.filter((c) => !c.passed).map((c) => c.name), }); // Auto-fill name from filename const suggestedName = filenameToName(file.name); setForm((prev) => ({ ...prev, iconName: prev.iconName || suggestedName, slug: prev.iconName ? prev.slug : toKebabCase(suggestedName), })); setIsValidating(false); }; reader.readAsText(file); }, []); function handleDragOver(e: React.DragEvent) { e.preventDefault(); setIsDragging(true); } function handleDragLeave(e: React.DragEvent) { e.preventDefault(); setIsDragging(false); } function handleDrop(e: React.DragEvent) { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) processFile(file); } function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (file) processFile(file); } function clearFile() { if (fileState?.dataUrl) { URL.revokeObjectURL(fileState.dataUrl); } setFileState(null); slugEditedRef.current = false; if (fileInputRef.current) fileInputRef.current.value = ""; } // Cleanup object URLs on unmount useEffect(() => { return () => { if (fileState?.dataUrl) { URL.revokeObjectURL(fileState.dataUrl); } }; // Only run on unmount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function buildSubmissionBody(): string { const validation = fileState?.validation; const checksText = validation ? validation.checks .map((c) => `- [${c.passed ? "x" : " "}] ${c.name}: ${c.message}`) .join("\n") : "No file uploaded"; const timestamp = new Date().toISOString(); const lines = [ `## Icon Submission: ${form.iconName || "(unnamed)"}`, ``, `### Details`, `- **Name**: ${form.iconName || "-"}`, `- **Slug**: ${form.slug || "-"}`, `- **Website URL**: ${form.websiteUrl || "-"}`, `- **Brand Guidelines**: ${form.guidelinesUrl || "-"}`, `- **Brand Hex Color**: ${form.hex ? `#${form.hex.replace(/^#/, "")}` : "-"}`, `- **Categories**: ${form.categories.length ? form.categories.join(", ") : "-"}`, ``, `### SVG Validation`, checksText, ``, `### SVG Content`, `\`\`\`svg`, fileState?.content ?? "(paste SVG here)", `\`\`\``, ``, `### icons.json Entry`, `\`\`\`json`, JSON.stringify( { slug: form.slug || "your-brand", title: form.iconName || "Your Brand", aliases: [], hex: form.hex.replace(/^#/, "") || "000000", categories: form.categories, variants: { default: `/icons/${form.slug || "your-brand"}/default.svg`, }, license: "TODO", url: form.websiteUrl || "https://yourbrand.com", ...(form.guidelinesUrl ? { guidelines: form.guidelinesUrl } : {}), }, null, 2 ), `\`\`\``, ``, `---`, `> Submitted via [thesvg.org](https://thesvg.org/submit) on ${timestamp.slice(0, 10)}`, ]; return lines.join("\n"); } function buildGitHubUrl(): string { const title = encodeURIComponent( `[Icon Request] ${form.iconName || "New Icon"} (via thesvg.org)` ); const body = encodeURIComponent(buildSubmissionBody()); return `${GITHUB_ISSUES_URL}?title=${title}&body=${body}&labels=icon-request,submitted-via-thesvg`; } async function handleCopy() { try { await navigator.clipboard.writeText(buildSubmissionBody()); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Fallback: do nothing } } const canSubmit = fileState !== null && form.iconName.trim() !== ""; return (
{/* Drag & Drop Zone */}

SVG File

{!fileState ? (
fileInputRef.current?.click()} role="button" tabIndex={0} aria-label="Upload SVG file" onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInputRef.current?.click(); } }} className={cn( "relative flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-12 text-center transition-colors", isDragging ? "border-orange-500 bg-orange-500/5" : "border-border hover:border-foreground/30 hover:bg-muted/30" )} >
{isValidating ? ( ) : ( )}

{isDragging ? "Drop your SVG here" : "Drag & drop your SVG"}

or click to browse - .svg files only, max 50KB

) : (
{fileState.name}

{fileState.name}

{(fileState.size / 1024).toFixed(1)}KB

)}
{/* Validation Results */} {fileState?.validation && ( )} {/* Preview */} {fileState && } {/* Form Fields */}

Icon Details

{/* Icon Name */}
handleNameChange(e.target.value)} />
{/* Slug */}
handleSlugChange(e.target.value)} className="font-mono text-xs" />
{/* Website URL */}
updateField("websiteUrl", e.target.value)} />
{/* Brand Guidelines URL */}
updateField("guidelinesUrl", e.target.value)} />
{/* Brand Color */}
updateField( "hex", e.target.value.replace(/[^0-9a-fA-F#]/g, "").replace(/^#+/, "") ) } maxLength={6} className="pl-8 font-mono uppercase" />
{form.hex.length === 6 && (
)}
{/* Categories */} updateField("newCategory", val)} onAddCategory={addNewCategory} />
{/* Submit Actions */} {!canSubmit && (

Upload an SVG file and enter an icon name to enable submission.

)}
); }