"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) => (
{size}px
))}
{/* Dark background */}
Dark
{SIZES.map((size) => (
{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 (
);
}
// --- 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.size / 1024).toFixed(1)}KB
)}
{/* Validation Results */}
{fileState?.validation && (
)}
{/* Preview */}
{fileState &&
}
{/* Form Fields */}
Icon Details
{/* Website URL */}
updateField("websiteUrl", e.target.value)}
/>
{/* Brand Guidelines URL */}
updateField("guidelinesUrl", e.target.value)}
/>
{/* Brand Color */}
{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.
)}
);
}