From 1d0f264ddaa651f4ded8a5d27a5f1efdb48f22db Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 2 Oct 2025 16:20:10 +0200 Subject: [PATCH] feat: implement TanStack React Form for icon submission - Create new AdvancedIconSubmissionFormTanStack component - Replace useState form management with useForm hook - Add comprehensive field-level validation - Implement type-safe form data structure - Add real-time validation with immediate feedback - Maintain all existing functionality (file uploads, previews, variants) - Improve performance with optimized re-renders --- ...advanced-icon-submission-form-tanstack.tsx | 570 ++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 web/src/components/advanced-icon-submission-form-tanstack.tsx diff --git a/web/src/components/advanced-icon-submission-form-tanstack.tsx b/web/src/components/advanced-icon-submission-form-tanstack.tsx new file mode 100644 index 00000000..cc41c023 --- /dev/null +++ b/web/src/components/advanced-icon-submission-form-tanstack.tsx @@ -0,0 +1,570 @@ +"use client" + +import { AlertCircle, Check, Plus, X } from "lucide-react" +import { useForm } from "@tanstack/react-form" +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" +import { useState } from "react" + +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", +] + +interface FormData { + iconName: string + isExistingIcon: boolean + activeVariants: string[] + files: Record + filePreviews: Record + aliases: string[] + aliasInput: string + categories: string[] + description: string +} + +export function AdvancedIconSubmissionFormTanStack() { + const [filePreviews, setFilePreviews] = useState>({}) + + const form = useForm({ + defaultValues: { + iconName: "", + isExistingIcon: false, + activeVariants: ["base"], + files: {}, + filePreviews: {}, + aliases: [], + aliasInput: "", + categories: [], + description: "", + }, + validators: { + onChange: ({ value }) => { + const errors: Partial> = {} + + if (!value.iconName.trim()) { + errors.iconName = "Icon name is required" + } else if (!/^[a-z0-9-]+$/.test(value.iconName)) { + errors.iconName = "Icon name must contain only lowercase letters, numbers, and hyphens" + } + + if (!value.files.base || value.files.base.length === 0) { + errors.files = "At least the base icon is required" + } + + if (value.categories.length === 0) { + errors.categories = "At least one category is required" + } + + return Object.keys(errors).length > 0 ? errors : undefined + }, + }, + onSubmit: async ({ value }) => { + if (!pb.authStore.isValid) { + toast.error("You must be logged in to submit an icon") + return + } + + try { + const assetFiles: File[] = [] + + // Add base file + if (value.files.base?.[0]) { + assetFiles.push(value.files.base[0]) + } + + // Build extras object + const extras: any = { + aliases: value.aliases, + categories: value.categories, + base: value.files.base[0]?.name.split(".").pop() || "svg", + } + + // Add color variants if present + if (value.files.dark?.[0] || value.files.light?.[0]) { + extras.colors = {} + if (value.files.dark?.[0]) { + extras.colors.dark = value.files.dark[0].name + assetFiles.push(value.files.dark[0]) + } + if (value.files.light?.[0]) { + extras.colors.light = value.files.light[0].name + assetFiles.push(value.files.light[0]) + } + } + + // Add wordmark variants if present + if (value.files.wordmark?.[0] || value.files.wordmark_dark?.[0]) { + extras.wordmark = {} + if (value.files.wordmark?.[0]) { + extras.wordmark.light = value.files.wordmark[0].name + assetFiles.push(value.files.wordmark[0]) + } + if (value.files.wordmark_dark?.[0]) { + extras.wordmark.dark = value.files.wordmark_dark[0].name + assetFiles.push(value.files.wordmark_dark[0]) + } + } + + // Create submission + const submissionData = { + name: value.iconName, + assets: assetFiles, + created_by: pb.authStore.model?.id, + status: "pending", + extras: extras, + } + + await pb.collection("submissions").create(submissionData) + + toast.success(value.isExistingIcon ? "Icon update submitted!" : "Icon submitted!", { + description: value.isExistingIcon + ? `Your update for "${value.iconName}" has been submitted for review` + : `Your icon "${value.iconName}" has been submitted for review`, + }) + + // Reset form + form.reset() + setFilePreviews({}) + } catch (error: any) { + console.error("Submission error:", error) + toast.error("Failed to submit icon", { + description: error?.message || "Please try again later", + }) + } + }, + }) + + const handleAddVariant = (variantId: string) => { + const currentVariants = form.getFieldValue("activeVariants") + if (!currentVariants.includes(variantId)) { + form.setFieldValue("activeVariants", [...currentVariants, variantId]) + } + } + + const handleRemoveVariant = (variantId: string) => { + if (variantId !== "base") { + const currentVariants = form.getFieldValue("activeVariants") + form.setFieldValue("activeVariants", currentVariants.filter((id) => id !== variantId)) + + const currentFiles = form.getFieldValue("files") + const newFiles = { ...currentFiles } + delete newFiles[variantId] + form.setFieldValue("files", newFiles) + + const newPreviews = { ...filePreviews } + delete newPreviews[variantId] + setFilePreviews(newPreviews) + } + } + + const handleFileDrop = (variantId: string, droppedFiles: File[]) => { + const currentFiles = form.getFieldValue("files") + form.setFieldValue("files", { + ...currentFiles, + [variantId]: droppedFiles, + }) + + // Generate preview for the first file + if (droppedFiles.length > 0) { + const reader = new FileReader() + reader.onload = (e) => { + if (typeof e.target?.result === 'string') { + setFilePreviews({ + ...filePreviews, + [variantId]: e.target.result, + }) + } + } + reader.readAsDataURL(droppedFiles[0]) + } + } + + const handleAddAlias = () => { + const aliasInput = form.getFieldValue("aliasInput") + const trimmedAlias = aliasInput.trim() + if (trimmedAlias) { + const currentAliases = form.getFieldValue("aliases") + if (!currentAliases.includes(trimmedAlias)) { + form.setFieldValue("aliases", [...currentAliases, trimmedAlias]) + } + form.setFieldValue("aliasInput", "") + } + } + + const handleRemoveAlias = (alias: string) => { + const currentAliases = form.getFieldValue("aliases") + form.setFieldValue("aliases", currentAliases.filter((a) => a !== alias)) + } + + const toggleCategory = (category: string) => { + const currentCategories = form.getFieldValue("categories") + if (currentCategories.includes(category)) { + form.setFieldValue("categories", currentCategories.filter((c) => c !== category)) + } else { + form.setFieldValue("categories", [...currentCategories, category]) + } + } + + return ( +
+
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + {/* Icon Name Section */} + + + Icon Identification + Choose a unique identifier for your icon + + + { + if (!value) return "Icon name is required" + if (!/^[a-z0-9-]+$/.test(value)) { + return "Icon name must contain only lowercase letters, numbers, and hyphens" + } + return undefined + }, + }} + > + {(field) => ( +
+ + form.setFieldValue("isExistingIcon", isExisting)} + /> +

Use lowercase letters, numbers, and hyphens only

+ {!field.state.meta.isValid && field.state.meta.isTouched && ( +

{field.state.meta.errors.join(", ")}

+ )} +
+ )} +
+ + + {(field) => ( + <> + {field.state.value && ( + + + + This icon ID already exists. Your submission will be treated as an update to the + existing icon. + + + )} + + {form.getFieldValue("iconName") && !field.state.value && ( + + + This is a new icon submission. + + + )} + + )} + +
+
+ + {/* Icon Variants Section */} + + +
+
+ Icon Variants + Upload different versions of your icon +
+
+
+ + + {(field) => ( + <> + {field.state.value.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={form.getFieldValue("files")[variantId]} + > + + + {filePreviews[variantId] && ( +
+ Preview +
+ )} +
+
+
+ ) + })} + + )} +
+ + + +
+

Add variant:

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

Select all categories that apply to your icon

+ {!field.state.meta.isValid && field.state.meta.isTouched && ( +

{field.state.meta.errors.join(", ")}

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

Alternative names that users might search for

+
+ + + + {/* Description */} + + {(field) => ( +
+ +