refactor(icons): improve icon detail components

- Update icon details, actions, and editable components
- Enhance icon name combobox functionality
- Improve user interaction with icon metadata
This commit is contained in:
Thomas Camlong
2025-11-07 08:11:09 +01:00
parent 59843eac88
commit 938facc889
4 changed files with 106 additions and 134 deletions

View File

@@ -2,19 +2,7 @@
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 { 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"
@@ -28,11 +16,12 @@ 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 { pb } from "@/lib/pb"
import { formatIconName } from "@/lib/utils"
import { revalidateAllSubmissions } from "@/app/actions/submissions"
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
@@ -128,13 +117,7 @@ function AddVariantCard({ onAddVariant, existingTypes }: AddVariantCardProps) {
{label}
</Button>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => setShowOptions(false)}
>
<Button type="button" variant="ghost" size="sm" className="w-full" onClick={() => setShowOptions(false)}>
Cancel
</Button>
</div>
@@ -172,12 +155,7 @@ function VariantCard({ variant, onRemove, canRemove }: VariantCardProps) {
<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"
/>
<Image src={variant.preview} alt={`${variant.label} preview`} fill className="object-contain p-4" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -186,9 +164,7 @@ function VariantCard({ variant, onRemove, canRemove }: VariantCardProps) {
</Tooltip>
<p className="text-sm font-medium">{variant.label}</p>
<p className="text-xs text-muted-foreground">
{variant.file.name.split(".").pop()?.toUpperCase()}
</p>
<p className="text-xs text-muted-foreground">{variant.file.name.split(".").pop()?.toUpperCase()}</p>
</div>
</MagicCard>
</TooltipProvider>
@@ -268,11 +244,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
}
const toggleCategory = (category: string) => {
setCategories(
categories.includes(category)
? categories.filter((c) => c !== category)
: [...categories, category]
)
setCategories(categories.includes(category) ? categories.filter((c) => c !== category) : [...categories, category])
}
const handleAddAlias = () => {
@@ -365,13 +337,16 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
const submissionData = {
name: iconName,
assets: assetFiles,
created_by: pb.authStore.model?.id,
created_by: pb.authStore.record?.id,
status: "pending",
extras: extras,
}
await pb.collection("submissions").create(submissionData)
// Revalidate Next.js cache for community pages
await revalidateAllSubmissions()
launchConfetti()
toast.success("Icon submitted!", {
description: `Your icon "${iconName}" has been submitted for review`,
@@ -443,10 +418,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
<Label htmlFor="icon-name" className="text-sm font-medium mb-2 block">
Icon Name
</Label>
<IconNameCombobox
value={iconName}
onValueChange={setIconName}
/>
<IconNameCombobox value={iconName} onValueChange={setIconName} />
</div>
</div>
</CardHeader>
@@ -470,9 +442,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
</Badge>
))}
</div>
{categories.length === 0 && (
<p className="text-xs text-destructive mt-2">At least one category required</p>
)}
{categories.length === 0 && <p className="text-xs text-destructive mt-2">At least one category required</p>}
</div>
{/* Aliases */}
@@ -498,17 +468,9 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{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"
>
<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"
>
<button type="button" onClick={() => handleRemoveAlias(alias)} className="ml-1 hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
@@ -519,9 +481,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{/* About */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
About this icon
</h3>
<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
@@ -560,9 +520,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
<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>
<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
@@ -572,10 +530,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
canRemove={variant.type !== "base" || variants.length > 1}
/>
))}
<AddVariantCard
onAddVariant={handleAddVariant}
existingTypes={variants.map((v) => v.type)}
/>
<AddVariantCard onAddVariant={handleAddVariant} existingTypes={variants.map((v) => v.type)} />
</div>
</div>
@@ -615,24 +570,17 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
<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"}
{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>
<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"
>
<div key={format} className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium">
{format.toUpperCase()}
</div>
))}
@@ -642,21 +590,15 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{variants.some((v) => v.type === "light" || v.type === "dark") && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Color Variants
</h3>
<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>
<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>
@@ -665,21 +607,15 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{variants.some((v) => v.type.startsWith("wordmark")) && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Wordmark Variants
</h3>
<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>
<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>
@@ -714,6 +650,3 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
</form>
)
}

View File

@@ -91,18 +91,20 @@ export function IconActions({
</Tooltip>
{/* View on GitHub Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer" aria-label={`View ${iconName} ${format} file on GitHub`}>
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
{githubUrl && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer" aria-label={`View ${iconName} ${format} file on GitHub`}>
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
)

View File

@@ -121,9 +121,12 @@ export type IconDetailsProps = {
iconData: Icon
authorData: AuthorData
allIcons: IconFile
status?: string
statusDisplayName?: string
statusColor?: string
}
export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) {
export function IconDetails({ icon, iconData, authorData, allIcons, status, statusDisplayName, statusColor }: IconDetailsProps) {
const authorName = authorData.name || authorData.login || ""
const _iconColorVariants = iconData.colors
const _iconWordmarkVariants = iconData.wordmark
@@ -133,7 +136,24 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
year: "numeric",
})
const isCommunityIcon = !!(iconData as any).mainIconUrl || (typeof iconData.base === "string" && iconData.base.startsWith("http"))
const mainIconUrl = (iconData as any).mainIconUrl || (isCommunityIcon ? iconData.base : null)
const assetUrls = (iconData as any).assetUrls || []
const getAvailableFormats = () => {
if (isCommunityIcon) {
if (assetUrls.length > 0) {
return assetUrls.map((url: string) => {
const ext = url.split(".").pop()?.toLowerCase() || "svg"
return ext === "svg" ? "svg" : ext === "png" ? "png" : "webp"
})
}
if (mainIconUrl) {
const ext = mainIconUrl.split(".").pop()?.toLowerCase() || "svg"
return [ext === "svg" ? "svg" : ext === "png" ? "png" : "webp"]
}
return ["svg"]
}
switch (iconData.base) {
case "svg":
return ["svg", "png", "webp"]
@@ -299,8 +319,19 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
}
const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => {
const imageUrl = `${BASE_URL}/${format}/${iconName}.${format}`
const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
let imageUrl: string
let githubUrl: string
if (isCommunityIcon && mainIconUrl) {
const formatExt = format === "svg" ? "svg" : format === "png" ? "png" : "webp"
const matchingUrl = assetUrls.find((url: string) => url.toLowerCase().endsWith(`.${formatExt}`))
imageUrl = matchingUrl || mainIconUrl
githubUrl = ""
} else {
imageUrl = `${BASE_URL}/${format}/${iconName}.${format}`
githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
}
const variantKey = `${format}-${theme || "default"}`
const isCopied = copiedVariants[variantKey] || false
@@ -387,7 +418,11 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
<div className="flex flex-col items-center">
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3">
<Image
src={`${BASE_URL}/${iconData.base}/${iconData.colors?.light || icon}.${iconData.base}`}
src={
isCommunityIcon && mainIconUrl
? mainIconUrl
: `${BASE_URL}/${iconData.base}/${iconData.colors?.light || icon}.${iconData.base}`
}
priority
width={96}
height={96}
@@ -478,7 +513,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
<p>
Available in{" "}
{availableFormats.length > 1
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) `
? `${availableFormats.length} formats (${availableFormats.map((f: string) => f.toUpperCase()).join(", ")}) `
: `${availableFormats[0].toUpperCase()} format `}
with a base format of {iconData.base.toUpperCase()}.
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
@@ -575,6 +610,14 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
</CardHeader>
<CardContent>
<div className="space-y-6">
{status && statusDisplayName && statusColor && (
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Status</h3>
<Badge variant="outline" className={statusColor}>
{statusDisplayName}
</Badge>
</div>
)}
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Base format</h3>
<div className="flex items-center gap-2">
@@ -586,7 +629,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Available formats</h3>
<div className="flex flex-wrap gap-2">
{availableFormats.map((format) => (
{availableFormats.map((format: string) => (
<div key={format} className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium">
{format.toUpperCase()}
</div>
@@ -631,15 +674,17 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
</div>
)}
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Source</h3>
<Button variant="outline" className="w-full" asChild>
<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4 mr-2" />
View on GitHub
</Link>
</Button>
</div>
{!isCommunityIcon && (
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Source</h3>
<Button variant="outline" className="w-full" asChild>
<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4 mr-2" />
View on GitHub
</Link>
</Button>
</div>
)}
</div>
</CardContent>
<Carbon />

View File

@@ -4,8 +4,8 @@ import { AlertCircle } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { useExistingIconNames } from "@/hooks/use-submissions"
import { cn } from "@/lib/utils"
interface IconNameComboboxProps {
value: string
@@ -38,12 +38,9 @@ export function IconNameCombobox({ value, onValueChange, error, isInvalid }: Ico
const filteredIcons = useMemo(() => {
const searchTerm = rawInput || value
if (!searchTerm || !existingIcons.length) return []
const lowerSearch = searchTerm.toLowerCase()
return existingIcons.filter((icon) =>
icon.value.toLowerCase().includes(lowerSearch) ||
icon.label.toLowerCase().includes(lowerSearch)
)
return existingIcons.filter((icon) => icon.value.toLowerCase().includes(lowerSearch) || icon.label.toLowerCase().includes(lowerSearch))
}, [rawInput, value, existingIcons])
const showSuggestions = isFocused && (rawInput || value) && filteredIcons.length > 0
@@ -69,10 +66,7 @@ export function IconNameCombobox({ value, onValueChange, error, isInvalid }: Ico
setTimeout(() => setIsFocused(false), 200)
}}
placeholder="Type new icon ID (e.g., my-app)..."
className={cn(
"font-mono",
isInvalid && "border-destructive focus-visible:ring-destructive/50"
)}
className={cn("font-mono", isInvalid && "border-destructive focus-visible:ring-destructive/50")}
aria-invalid={isInvalid}
aria-describedby={error ? "icon-name-error" : undefined}
/>
@@ -115,9 +109,7 @@ export function IconNameCombobox({ value, onValueChange, error, isInvalid }: Ico
{/* Helper text when no error */}
{!error && value && (
<p className="text-sm text-muted-foreground mt-1.5">
{loading ? "Checking availability..." : "✓ Available icon ID"}
</p>
<p className="text-sm text-muted-foreground mt-1.5">{loading ? "Checking availability..." : "✓ Available icon ID"}</p>
)}
</div>
)