diff --git a/web/src/components/advanced-icon-submission-form.tsx b/web/src/components/advanced-icon-submission-form.tsx new file mode 100644 index 00000000..523e5371 --- /dev/null +++ b/web/src/components/advanced-icon-submission-form.tsx @@ -0,0 +1,451 @@ +"use client" + +import { AlertCircle, Check, Plus, X } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { IconNameCombobox } from "@/components/icon-name-combobox" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { Dropzone, DropzoneContent, DropzoneEmptyState } from "@/components/ui/shadcn-io/dropzone" +import { Textarea } from "@/components/ui/textarea" +import { pb } from "@/lib/pb" + +interface VariantConfig { + id: string + label: string + description: string + field: "base" | "dark" | "light" | "wordmark" | "wordmark_dark" +} + +const VARIANTS: VariantConfig[] = [ + { + id: "base", + label: "Base Icon", + description: "Main icon file (required)", + field: "base", + }, + { + id: "dark", + label: "Dark Variant", + description: "Icon optimized for dark backgrounds", + field: "dark", + }, + { + id: "light", + label: "Light Variant", + description: "Icon optimized for light backgrounds", + field: "light", + }, + { + id: "wordmark", + label: "Wordmark", + description: "Logo with text/wordmark", + field: "wordmark", + }, + { + id: "wordmark_dark", + label: "Wordmark Dark", + description: "Wordmark optimized for dark backgrounds", + field: "wordmark_dark", + }, +] + +const AVAILABLE_CATEGORIES = [ + "automation", + "cloud", + "database", + "development", + "entertainment", + "finance", + "gaming", + "home-automation", + "media", + "monitoring", + "network", + "security", + "social", + "storage", + "tools", + "utility", + "other", +] + +export function AdvancedIconSubmissionForm() { + const [iconName, setIconName] = useState("") + const [isExistingIcon, setIsExistingIcon] = useState(false) + const [activeVariants, setActiveVariants] = useState(["base"]) + const [files, setFiles] = useState>({}) + const [aliases, setAliases] = useState([]) + const [aliasInput, setAliasInput] = useState("") + const [categories, setCategories] = useState([]) + const [description, setDescription] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleAddVariant = (variantId: string) => { + if (!activeVariants.includes(variantId)) { + setActiveVariants([...activeVariants, variantId]) + } + } + + const handleRemoveVariant = (variantId: string) => { + if (variantId !== "base") { + setActiveVariants(activeVariants.filter((id) => id !== variantId)) + const newFiles = { ...files } + delete newFiles[variantId] + setFiles(newFiles) + } + } + + const handleFileDrop = (variantId: string, droppedFiles: File[]) => { + setFiles({ + ...files, + [variantId]: droppedFiles, + }) + } + + const handleAddAlias = () => { + const trimmedAlias = aliasInput.trim() + if (trimmedAlias && !aliases.includes(trimmedAlias)) { + setAliases([...aliases, trimmedAlias]) + setAliasInput("") + } + } + + const handleRemoveAlias = (alias: string) => { + setAliases(aliases.filter((a) => a !== alias)) + } + + const toggleCategory = (category: string) => { + if (categories.includes(category)) { + setCategories(categories.filter((c) => c !== category)) + } else { + setCategories([...categories, category]) + } + } + + const handleSubmit = async () => { + if (!iconName.trim()) { + toast.error("Please enter an icon name") + return + } + + if (!files.base || files.base.length === 0) { + toast.error("Please upload at least the base icon") + return + } + + if (categories.length === 0) { + toast.error("Please select at least one category") + return + } + + if (!pb.authStore.isValid) { + toast.error("You must be logged in to submit an icon") + return + } + + setIsSubmitting(true) + + try { + const assetFiles: File[] = [] + + // Add base file + if (files.base?.[0]) { + assetFiles.push(files.base[0]) + } + + // Build extras object + const extras: any = { + aliases: aliases, + categories: categories, + base: files.base[0]?.name.split(".").pop() || "svg", + } + + // Add color variants if present + if (files.dark?.[0] || files.light?.[0]) { + extras.colors = {} + if (files.dark?.[0]) { + extras.colors.dark = files.dark[0].name + assetFiles.push(files.dark[0]) + } + if (files.light?.[0]) { + extras.colors.light = files.light[0].name + assetFiles.push(files.light[0]) + } + } + + // Add wordmark variants if present + if (files.wordmark?.[0] || files.wordmark_dark?.[0]) { + extras.wordmark = {} + if (files.wordmark?.[0]) { + extras.wordmark.light = files.wordmark[0].name + assetFiles.push(files.wordmark[0]) + } + if (files.wordmark_dark?.[0]) { + extras.wordmark.dark = files.wordmark_dark[0].name + assetFiles.push(files.wordmark_dark[0]) + } + } + + // Create submission + const submissionData = { + name: iconName, + assets: assetFiles, + created_by: pb.authStore.model?.id, + status: "pending", + extras: extras, + } + + await pb.collection("submissions").create(submissionData) + + toast.success(isExistingIcon ? "Icon update submitted!" : "Icon submitted!", { + description: isExistingIcon + ? `Your update for "${iconName}" has been submitted for review` + : `Your icon "${iconName}" has been submitted for review`, + }) + + // Reset form + setIconName("") + setFiles({}) + setActiveVariants(["base"]) + setAliases([]) + setCategories([]) + setDescription("") + } catch (error: any) { + console.error("Submission error:", error) + toast.error("Failed to submit icon", { + description: error?.message || "Please try again later", + }) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ {/* Icon Name Section */} + + + Icon Identification + Choose a unique identifier for your icon + + +
+ + +

Use lowercase letters, numbers, and hyphens only

+
+ + {isExistingIcon && ( + + + + This icon ID already exists. Your submission will be treated as an update to the + existing icon. + + + )} + + {iconName && !isExistingIcon && ( + + + + This is a new icon submission. + + + )} +
+
+ + {/* Icon Variants Section */} + + +
+
+ Icon Variants + Upload different versions of your icon +
+
+
+ + {activeVariants.map((variantId) => { + const variant = VARIANTS.find((v) => v.id === variantId) + if (!variant) return null + + return ( +
+
+
+
+ + {variant.id === "base" && Required} +
+

{variant.description}

+
+ {variant.id !== "base" && ( + + )} +
+ + handleFileDrop(variantId, droppedFiles)} + onError={(error) => toast.error(error.message)} + src={files[variantId]} + > + + + +
+ ) + })} + + + +
+

Add variant:

+ {VARIANTS.filter((v) => !activeVariants.includes(v.id)).map((variant) => ( + + ))} +
+
+
+ + {/* Metadata Section */} + + + Icon Metadata + Provide additional information about your icon + + + {/* Categories */} +
+ +
+ {AVAILABLE_CATEGORIES.map((category) => ( + toggleCategory(category)} + > + {category.replace(/-/g, " ")} + + ))} +
+

Select all categories that apply to your icon

+
+ + + + {/* Aliases */} +
+ +
+ setAliasInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddAlias() + } + }} + /> + +
+ {aliases.length > 0 && ( +
+ {aliases.map((alias) => ( + + {alias} + + + ))} +
+ )} +

Alternative names that users might search for

+
+ + + + {/* Description */} +
+ +