mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-26 21:19:04 +08:00
refactor: migrate to TanStack Form and fix IconNameCombobox integration
- Remove old advanced-icon-submission-form.tsx (replaced by TanStack version) - Fix TanStack Form implementation: - Remove generic type argument and use type assertion instead - Fix form.Subscribe selector to return object instead of array - Remove unused IconCard import - Update editable-icon-details.tsx to use new IconNameCombobox API: - Remove deprecated onIsExisting prop - Remove isExistingIcon state management - Simplify form submission messages All components now use the updated IconNameCombobox with error/isInvalid props instead of the old onIsExisting callback pattern.
This commit is contained in:
@@ -4,7 +4,6 @@ import { Check, FileImage, FileType, Plus, X } from "lucide-react"
|
|||||||
import { useForm } from "@tanstack/react-form"
|
import { useForm } from "@tanstack/react-form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { IconNameCombobox } from "@/components/icon-name-combobox"
|
import { IconNameCombobox } from "@/components/icon-name-combobox"
|
||||||
import { IconCard } from "@/components/icon-card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -100,7 +99,7 @@ export function AdvancedIconSubmissionFormTanStack() {
|
|||||||
const [filePreviews, setFilePreviews] = useState<Record<string, string>>({})
|
const [filePreviews, setFilePreviews] = useState<Record<string, string>>({})
|
||||||
const { data: existingIcons = [] } = useExistingIconNames()
|
const { data: existingIcons = [] } = useExistingIconNames()
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
iconName: "",
|
iconName: "",
|
||||||
selectedVariants: ["base"], // Base is always selected by default
|
selectedVariants: ["base"], // Base is always selected by default
|
||||||
@@ -110,22 +109,7 @@ export function AdvancedIconSubmissionFormTanStack() {
|
|||||||
aliasInput: "",
|
aliasInput: "",
|
||||||
categories: [],
|
categories: [],
|
||||||
description: "",
|
description: "",
|
||||||
},
|
} as FormData,
|
||||||
validators: {
|
|
||||||
onChange: ({ value }) => {
|
|
||||||
const errors: Partial<Record<keyof FormData, string>> = {}
|
|
||||||
|
|
||||||
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 }) => {
|
onSubmit: async ({ value }) => {
|
||||||
if (!pb.authStore.isValid) {
|
if (!pb.authStore.isValid) {
|
||||||
toast.error("You must be logged in to submit an icon")
|
toast.error("You must be logged in to submit an icon")
|
||||||
@@ -588,11 +572,14 @@ export function AdvancedIconSubmissionFormTanStack() {
|
|||||||
Clear Form
|
Clear Form
|
||||||
</Button>
|
</Button>
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
selector={(state) => ({
|
||||||
|
canSubmit: state.canSubmit,
|
||||||
|
isSubmitting: state.isSubmitting,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{([canSubmit, isSubmitting]: [boolean, boolean]) => (
|
{(state) => (
|
||||||
<Button type="submit" disabled={!canSubmit || isSubmitting} size="lg">
|
<Button type="submit" disabled={!state.canSubmit || state.isSubmitting} size="lg">
|
||||||
{isSubmitting ? "Submitting..." : "Submit New Icon"}
|
{state.isSubmitting ? "Submitting..." : "Submit New Icon"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
|
|||||||
@@ -1,480 +0,0 @@
|
|||||||
"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<string[]>(["base"])
|
|
||||||
const [files, setFiles] = useState<Record<string, File[]>>({})
|
|
||||||
const [filePreviews, setFilePreviews] = useState<Record<string, string>>({})
|
|
||||||
const [aliases, setAliases] = useState<string[]>([])
|
|
||||||
const [aliasInput, setAliasInput] = useState("")
|
|
||||||
const [categories, setCategories] = useState<string[]>([])
|
|
||||||
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 }
|
|
||||||
const newPreviews = { ...filePreviews }
|
|
||||||
delete newFiles[variantId]
|
|
||||||
delete newPreviews[variantId]
|
|
||||||
setFiles(newFiles)
|
|
||||||
setFilePreviews(newPreviews)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileDrop = (variantId: string, droppedFiles: File[]) => {
|
|
||||||
setFiles({
|
|
||||||
...files,
|
|
||||||
[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 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({})
|
|
||||||
setFilePreviews({})
|
|
||||||
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 (
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
|
||||||
{/* Icon Name Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Icon Identification</CardTitle>
|
|
||||||
<CardDescription>Choose a unique identifier for your icon</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="icon-name">Icon Name / ID</Label>
|
|
||||||
<IconNameCombobox value={iconName} onValueChange={setIconName} onIsExisting={setIsExistingIcon} />
|
|
||||||
<p className="text-sm text-muted-foreground">Use lowercase letters, numbers, and hyphens only</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExistingIcon && (
|
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
This icon ID already exists. Your submission will be treated as an <strong>update</strong> to the
|
|
||||||
existing icon.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{iconName && !isExistingIcon && (
|
|
||||||
<Alert className="border-green-500/50 bg-green-500/10">
|
|
||||||
<AlertDescription className="text-green-600 dark:text-green-400">
|
|
||||||
This is a new icon submission.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Icon Variants Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Icon Variants</CardTitle>
|
|
||||||
<CardDescription>Upload different versions of your icon</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{activeVariants.map((variantId) => {
|
|
||||||
const variant = VARIANTS.find((v) => v.id === variantId)
|
|
||||||
if (!variant) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={variantId} className="space-y-3 p-4 border rounded-lg bg-background/50">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label className="text-base font-semibold">{variant.label}</Label>
|
|
||||||
{variant.id === "base" && <Badge variant="secondary">Required</Badge>}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{variant.description}</p>
|
|
||||||
</div>
|
|
||||||
{variant.id !== "base" && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRemoveVariant(variantId)}
|
|
||||||
className="text-destructive"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dropzone
|
|
||||||
accept={{
|
|
||||||
"image/svg+xml": [".svg"],
|
|
||||||
"image/png": [".png"],
|
|
||||||
"image/webp": [".webp"],
|
|
||||||
}}
|
|
||||||
maxSize={1024 * 1024 * 5}
|
|
||||||
maxFiles={1}
|
|
||||||
onDrop={(droppedFiles) => handleFileDrop(variantId, droppedFiles)}
|
|
||||||
onError={(error) => toast.error(error.message)}
|
|
||||||
src={files[variantId]}
|
|
||||||
>
|
|
||||||
<DropzoneEmptyState />
|
|
||||||
<DropzoneContent>
|
|
||||||
{filePreviews[variantId] && (
|
|
||||||
<div className="h-[102px] w-full">
|
|
||||||
<img
|
|
||||||
alt="Preview"
|
|
||||||
className="absolute top-0 left-0 h-full w-full object-cover"
|
|
||||||
src={filePreviews[variantId]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DropzoneContent>
|
|
||||||
</Dropzone>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground w-full mb-2">Add variant:</p>
|
|
||||||
{VARIANTS.filter((v) => !activeVariants.includes(v.id)).map((variant) => (
|
|
||||||
<Button
|
|
||||||
key={variant.id}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleAddVariant(variant.id)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{variant.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Metadata Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Icon Metadata</CardTitle>
|
|
||||||
<CardDescription>Provide additional information about your icon</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Categories */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Categories</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{AVAILABLE_CATEGORIES.map((category) => (
|
|
||||||
<Badge
|
|
||||||
key={category}
|
|
||||||
variant={categories.includes(category) ? "default" : "outline"}
|
|
||||||
className="cursor-pointer hover:bg-primary/80"
|
|
||||||
onClick={() => toggleCategory(category)}
|
|
||||||
>
|
|
||||||
{category.replace(/-/g, " ")}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Select all categories that apply to your icon</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Aliases */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="alias-input">Aliases</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="alias-input"
|
|
||||||
placeholder="Add alternative name..."
|
|
||||||
value={aliasInput}
|
|
||||||
onChange={(e) => setAliasInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
handleAddAlias()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="button" onClick={handleAddAlias}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{aliases.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{aliases.map((alias) => (
|
|
||||||
<Badge key={alias} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
|
|
||||||
{alias}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
|
||||||
onClick={() => handleRemoveAlias(alias)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">Alternative names that users might search for</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="description">Description (Optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
placeholder="Brief description of the icon or service it represents..."
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">This helps reviewers understand your submission</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIconName("")
|
|
||||||
setFiles({})
|
|
||||||
setFilePreviews({})
|
|
||||||
setActiveVariants(["base"])
|
|
||||||
setAliases([])
|
|
||||||
setCategories([])
|
|
||||||
setDescription("")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear Form
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={handleSubmit} disabled={isSubmitting} size="lg">
|
|
||||||
{isSubmitting ? "Submitting..." : isExistingIcon ? "Submit Update" : "Submit New Icon"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
719
web/src/components/editable-icon-details.tsx
Normal file
719
web/src/components/editable-icon-details.tsx
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import confetti from "canvas-confetti"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
FileType,
|
||||||
|
Github,
|
||||||
|
Moon,
|
||||||
|
PaletteIcon,
|
||||||
|
Plus,
|
||||||
|
Sun,
|
||||||
|
Type,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import type React from "react"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { IconNameCombobox } from "@/components/icon-name-combobox"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { BASE_URL, REPO_PATH } from "@/constants"
|
||||||
|
import { formatIconName } from "@/lib/utils"
|
||||||
|
import { MagicCard } from "./magicui/magic-card"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "./ui/shadcn-io/dropzone"
|
||||||
|
import { pb } from "@/lib/pb"
|
||||||
|
|
||||||
|
interface VariantFile {
|
||||||
|
file: File
|
||||||
|
preview: string
|
||||||
|
type: "base" | "light" | "dark" | "wordmark-light" | "wordmark-dark"
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditableIconData {
|
||||||
|
iconName: string
|
||||||
|
variants: VariantFile[]
|
||||||
|
categories: string[]
|
||||||
|
aliases: string[]
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_CATEGORIES = [
|
||||||
|
"automation",
|
||||||
|
"cloud",
|
||||||
|
"database",
|
||||||
|
"development",
|
||||||
|
"entertainment",
|
||||||
|
"finance",
|
||||||
|
"gaming",
|
||||||
|
"home-automation",
|
||||||
|
"media",
|
||||||
|
"monitoring",
|
||||||
|
"network",
|
||||||
|
"security",
|
||||||
|
"social",
|
||||||
|
"storage",
|
||||||
|
"tools",
|
||||||
|
"utility",
|
||||||
|
"other",
|
||||||
|
]
|
||||||
|
|
||||||
|
type AddVariantCardProps = {
|
||||||
|
onAddVariant: (type: VariantFile["type"], label: string) => void
|
||||||
|
existingTypes: VariantFile["type"][]
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddVariantCard({ onAddVariant, existingTypes }: AddVariantCardProps) {
|
||||||
|
const [showOptions, setShowOptions] = useState(false)
|
||||||
|
|
||||||
|
const availableVariants = [
|
||||||
|
{ type: "base" as const, label: "Base Icon", icon: FileType },
|
||||||
|
{ type: "light" as const, label: "Light Theme", icon: Sun },
|
||||||
|
{ type: "dark" as const, label: "Dark Theme", icon: Moon },
|
||||||
|
{ type: "wordmark-light" as const, label: "Wordmark Light", icon: Type },
|
||||||
|
{ type: "wordmark-dark" as const, label: "Wordmark Dark", icon: Type },
|
||||||
|
].filter((v) => !existingTypes.includes(v.type))
|
||||||
|
|
||||||
|
if (availableVariants.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={500}>
|
||||||
|
<MagicCard className="p-0 rounded-md">
|
||||||
|
<div className="flex flex-col items-center justify-center p-4 h-full min-h-[280px]">
|
||||||
|
{!showOptions ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors flex items-center justify-center"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => setShowOptions(true)}
|
||||||
|
aria-label="Add new variant"
|
||||||
|
>
|
||||||
|
<Plus className="w-12 h-12 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</motion.button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Add a new variant</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
<p className="text-sm font-medium text-center mb-3">Select variant type:</p>
|
||||||
|
{availableVariants.map(({ type, label, icon: Icon }) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => {
|
||||||
|
onAddVariant(type, label)
|
||||||
|
setShowOptions(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-2" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setShowOptions(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium mt-2">Add Variant</p>
|
||||||
|
</div>
|
||||||
|
</MagicCard>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariantCardProps = {
|
||||||
|
variant: VariantFile
|
||||||
|
onRemove: () => void
|
||||||
|
canRemove: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariantCard({ variant, onRemove, canRemove }: VariantCardProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={500}>
|
||||||
|
<MagicCard className="p-0 rounded-md relative group">
|
||||||
|
{canRemove && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center p-4 transition-all">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="relative w-28 h-28 mb-3 rounded-xl overflow-hidden">
|
||||||
|
<div className="absolute inset-0 border-2 border-primary/20 rounded-xl z-10" />
|
||||||
|
<Image
|
||||||
|
src={variant.preview}
|
||||||
|
alt={`${variant.label} preview`}
|
||||||
|
fill
|
||||||
|
className="object-contain p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{variant.label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<p className="text-sm font-medium">{variant.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{variant.file.name.split(".").pop()?.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</MagicCard>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditableIconDetailsProps = {
|
||||||
|
onSubmit?: (data: EditableIconData) => void
|
||||||
|
initialData?: Partial<EditableIconData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetailsProps) {
|
||||||
|
const [iconName, setIconName] = useState(initialData?.iconName || "")
|
||||||
|
const [variants, setVariants] = useState<VariantFile[]>(initialData?.variants || [])
|
||||||
|
const [categories, setCategories] = useState<string[]>(initialData?.categories || [])
|
||||||
|
const [aliases, setAliases] = useState<string[]>(initialData?.aliases || [])
|
||||||
|
const [aliasInput, setAliasInput] = useState("")
|
||||||
|
const [description, setDescription] = useState(initialData?.description || "")
|
||||||
|
|
||||||
|
const launchConfetti = useCallback((originX?: number, originY?: number) => {
|
||||||
|
if (typeof confetti !== "function") return
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
startVelocity: 15,
|
||||||
|
spread: 180,
|
||||||
|
ticks: 50,
|
||||||
|
zIndex: 20,
|
||||||
|
disableForReducedMotion: true,
|
||||||
|
colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originX !== undefined && originY !== undefined) {
|
||||||
|
confetti({
|
||||||
|
...defaults,
|
||||||
|
particleCount: 50,
|
||||||
|
origin: {
|
||||||
|
x: originX / window.innerWidth,
|
||||||
|
y: originY / window.innerHeight,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
confetti({
|
||||||
|
...defaults,
|
||||||
|
particleCount: 50,
|
||||||
|
origin: { x: 0.5, y: 0.5 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAddVariant = async (type: VariantFile["type"], label: string) => {
|
||||||
|
// Create a file input to get the file
|
||||||
|
const input = document.createElement("input")
|
||||||
|
input.type = "file"
|
||||||
|
input.accept = "image/svg+xml,image/png,image/webp"
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const preview = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => resolve(e.target?.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
setVariants([...variants, { file, preview, type, label }])
|
||||||
|
launchConfetti()
|
||||||
|
toast.success("Variant added", {
|
||||||
|
description: `${label} has been added to your submission`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveVariant = (index: number) => {
|
||||||
|
setVariants(variants.filter((_, i) => i !== index))
|
||||||
|
toast.info("Variant removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setCategories(
|
||||||
|
categories.includes(category)
|
||||||
|
? categories.filter((c) => c !== category)
|
||||||
|
: [...categories, category]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!iconName.trim()) {
|
||||||
|
toast.error("Icon name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(iconName)) {
|
||||||
|
toast.error("Icon name must contain only lowercase letters, numbers, and hyphens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseVariant = variants.find((v) => v.type === "base")
|
||||||
|
if (!baseVariant) {
|
||||||
|
toast.error("Base icon variant is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
toast.error("At least one category is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
toast.error("You must be logged in to submit an icon")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare submission data
|
||||||
|
const assetFiles: File[] = []
|
||||||
|
const extras: any = {
|
||||||
|
aliases,
|
||||||
|
categories,
|
||||||
|
base: baseVariant.file.name.split(".").pop() || "svg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base variant
|
||||||
|
assetFiles.push(baseVariant.file)
|
||||||
|
|
||||||
|
// Check for color variants (light/dark)
|
||||||
|
const lightVariant = variants.find((v) => v.type === "light")
|
||||||
|
const darkVariant = variants.find((v) => v.type === "dark")
|
||||||
|
|
||||||
|
if (lightVariant || darkVariant) {
|
||||||
|
extras.colors = {}
|
||||||
|
if (lightVariant) {
|
||||||
|
extras.colors.light = lightVariant.file.name
|
||||||
|
assetFiles.push(lightVariant.file)
|
||||||
|
}
|
||||||
|
if (darkVariant) {
|
||||||
|
extras.colors.dark = darkVariant.file.name
|
||||||
|
assetFiles.push(darkVariant.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wordmark variants
|
||||||
|
const wordmarkLight = variants.find((v) => v.type === "wordmark-light")
|
||||||
|
const wordmarkDark = variants.find((v) => v.type === "wordmark-dark")
|
||||||
|
|
||||||
|
if (wordmarkLight || wordmarkDark) {
|
||||||
|
extras.wordmark = {}
|
||||||
|
if (wordmarkLight) {
|
||||||
|
extras.wordmark.light = wordmarkLight.file.name
|
||||||
|
assetFiles.push(wordmarkLight.file)
|
||||||
|
}
|
||||||
|
if (wordmarkDark) {
|
||||||
|
extras.wordmark.dark = wordmarkDark.file.name
|
||||||
|
assetFiles.push(wordmarkDark.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create submission
|
||||||
|
const submissionData = {
|
||||||
|
name: iconName,
|
||||||
|
assets: assetFiles,
|
||||||
|
created_by: pb.authStore.model?.id,
|
||||||
|
status: "pending",
|
||||||
|
extras: extras,
|
||||||
|
}
|
||||||
|
|
||||||
|
await pb.collection("submissions").create(submissionData)
|
||||||
|
|
||||||
|
launchConfetti()
|
||||||
|
toast.success("Icon submitted!", {
|
||||||
|
description: `Your icon "${iconName}" has been submitted for review`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setIconName("")
|
||||||
|
setVariants([])
|
||||||
|
setCategories([])
|
||||||
|
setAliases([])
|
||||||
|
setDescription("")
|
||||||
|
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit({ iconName, variants, categories, aliases, description })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Submission error:", error)
|
||||||
|
toast.error("Failed to submit icon", {
|
||||||
|
description: error?.message || "Please try again later",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvailableFormats = () => {
|
||||||
|
const baseVariant = variants.find((v) => v.type === "base")
|
||||||
|
if (!baseVariant) return []
|
||||||
|
|
||||||
|
const baseFormat = baseVariant.file.name.split(".").pop()?.toLowerCase()
|
||||||
|
switch (baseFormat) {
|
||||||
|
case "svg":
|
||||||
|
return ["svg", "png", "webp"]
|
||||||
|
case "png":
|
||||||
|
return ["png", "webp"]
|
||||||
|
default:
|
||||||
|
return [baseFormat || ""]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableFormats = getAvailableFormats()
|
||||||
|
const formattedIconName = iconName ? formatIconName(iconName) : "Your Icon"
|
||||||
|
const baseVariant = variants.find((v) => v.type === "base")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Left Column - Icon Info & Metadata */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full bg-background/50 border shadow-lg">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{baseVariant ? (
|
||||||
|
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3">
|
||||||
|
<Image
|
||||||
|
src={baseVariant.preview}
|
||||||
|
priority
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
alt={`${formattedIconName} icon preview`}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative w-32 h-32 rounded-xl overflow-hidden border border-dashed border-muted-foreground/30 flex items-center justify-center p-3">
|
||||||
|
<Upload className="w-12 h-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full mt-4">
|
||||||
|
<Label htmlFor="icon-name" className="text-sm font-medium mb-2 block">
|
||||||
|
Icon Name
|
||||||
|
</Label>
|
||||||
|
<IconNameCombobox
|
||||||
|
value={iconName}
|
||||||
|
onValueChange={setIconName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Categories */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Categories</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AVAILABLE_CATEGORIES.map((category) => (
|
||||||
|
<Badge
|
||||||
|
key={category}
|
||||||
|
variant={categories.includes(category) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer hover:bg-primary/80 text-xs"
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
>
|
||||||
|
{category
|
||||||
|
.split("-")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ")}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<p className="text-xs text-destructive mt-2">At least one category required</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aliases */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Aliases</h3>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add alias..."
|
||||||
|
value={aliasInput}
|
||||||
|
onChange={(e) => setAliasInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleAddAlias()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" onClick={handleAddAlias} className="h-8">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{aliases.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{aliases.map((alias) => (
|
||||||
|
<Badge
|
||||||
|
key={alias}
|
||||||
|
variant="secondary"
|
||||||
|
className="inline-flex items-center px-2.5 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{alias}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveAlias(alias)}
|
||||||
|
className="ml-1 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||||
|
About this icon
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-muted-foreground space-y-2">
|
||||||
|
<p>
|
||||||
|
{variants.length > 0
|
||||||
|
? `${variants.length} variant${variants.length > 1 ? "s" : ""} uploaded`
|
||||||
|
: "No variants uploaded yet"}
|
||||||
|
</p>
|
||||||
|
{availableFormats.length > 0 && (
|
||||||
|
<p>
|
||||||
|
Available in{" "}
|
||||||
|
{availableFormats.length > 1
|
||||||
|
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
|
||||||
|
: `${availableFormats[0].toUpperCase()} format`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Column - Icon Variants */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full bg-background/50 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<h2>Icon Variants</h2>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Add different versions of your icon for various themes</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-10">
|
||||||
|
{/* Base/Default Variants */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2 mb-1">
|
||||||
|
<FileType className="w-4 h-4 text-blue-500" />
|
||||||
|
Icon Variants
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Upload your icon files. Base icon is required.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{variants.map((variant, index) => (
|
||||||
|
<VariantCard
|
||||||
|
key={index}
|
||||||
|
variant={variant}
|
||||||
|
onRemove={() => handleRemoveVariant(index)}
|
||||||
|
canRemove={variant.type !== "base" || variants.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddVariantCard
|
||||||
|
onAddVariant={handleAddVariant}
|
||||||
|
existingTypes={variants.map((v) => v.type)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold">Variant Types:</h4>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
<strong>Base Icon:</strong> Main icon file (required)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Light Theme:</strong> Optimized for light backgrounds
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Dark Theme:</strong> Optimized for dark backgrounds
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Wordmark:</strong> Logo with text/brand name
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Technical Details & Actions */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full bg-background/50 border shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Technical Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Base Format</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileType className="w-4 h-4 text-blue-500" />
|
||||||
|
<div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">
|
||||||
|
{baseVariant
|
||||||
|
? baseVariant.file.name.split(".").pop()?.toUpperCase()
|
||||||
|
: "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableFormats.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||||
|
Available Formats
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableFormats.map((format) => (
|
||||||
|
<div
|
||||||
|
key={format}
|
||||||
|
className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium"
|
||||||
|
>
|
||||||
|
{format.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variants.some((v) => v.type === "light" || v.type === "dark") && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||||
|
Color Variants
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{variants
|
||||||
|
.filter((v) => v.type === "light" || v.type === "dark")
|
||||||
|
.map((variant, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<PaletteIcon className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="capitalize font-medium text-sm">
|
||||||
|
{variant.type}:
|
||||||
|
</span>
|
||||||
|
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">
|
||||||
|
{variant.file.name}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variants.some((v) => v.type.startsWith("wordmark")) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||||
|
Wordmark Variants
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{variants
|
||||||
|
.filter((v) => v.type.startsWith("wordmark"))
|
||||||
|
.map((variant, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Type className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="capitalize font-medium text-sm">
|
||||||
|
{variant.type.replace("wordmark-", "")}:
|
||||||
|
</span>
|
||||||
|
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">
|
||||||
|
{variant.file.name}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
<Button type="submit" className="w-full" size="lg">
|
||||||
|
Submit Icon
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setIconName("")
|
||||||
|
setVariants([])
|
||||||
|
setCategories([])
|
||||||
|
setAliases([])
|
||||||
|
setDescription("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Form
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user