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 confetti from "canvas-confetti"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { import { ArrowRight, Check, FileType, Github, Moon, PaletteIcon, Plus, Sun, Type, Upload, X } from "lucide-react"
ArrowRight,
Check,
FileType,
Github,
Moon,
PaletteIcon,
Plus,
Sun,
Type,
Upload,
X,
} from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import type React from "react" import type React from "react"
@@ -28,11 +16,12 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants" import { BASE_URL, REPO_PATH } from "@/constants"
import { pb } from "@/lib/pb"
import { formatIconName } from "@/lib/utils" import { formatIconName } from "@/lib/utils"
import { revalidateAllSubmissions } from "@/app/actions/submissions"
import { MagicCard } from "./magicui/magic-card" import { MagicCard } from "./magicui/magic-card"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "./ui/shadcn-io/dropzone" import { Dropzone, DropzoneContent, DropzoneEmptyState } from "./ui/shadcn-io/dropzone"
import { pb } from "@/lib/pb"
interface VariantFile { interface VariantFile {
file: File file: File
@@ -128,13 +117,7 @@ function AddVariantCard({ onAddVariant, existingTypes }: AddVariantCardProps) {
{label} {label}
</Button> </Button>
))} ))}
<Button <Button type="button" variant="ghost" size="sm" className="w-full" onClick={() => setShowOptions(false)}>
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => setShowOptions(false)}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -172,12 +155,7 @@ function VariantCard({ variant, onRemove, canRemove }: VariantCardProps) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="relative w-28 h-28 mb-3 rounded-xl overflow-hidden"> <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" /> <div className="absolute inset-0 border-2 border-primary/20 rounded-xl z-10" />
<Image <Image src={variant.preview} alt={`${variant.label} preview`} fill className="object-contain p-4" />
src={variant.preview}
alt={`${variant.label} preview`}
fill
className="object-contain p-4"
/>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -186,9 +164,7 @@ function VariantCard({ variant, onRemove, canRemove }: VariantCardProps) {
</Tooltip> </Tooltip>
<p className="text-sm font-medium">{variant.label}</p> <p className="text-sm font-medium">{variant.label}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{variant.file.name.split(".").pop()?.toUpperCase()}</p>
{variant.file.name.split(".").pop()?.toUpperCase()}
</p>
</div> </div>
</MagicCard> </MagicCard>
</TooltipProvider> </TooltipProvider>
@@ -268,11 +244,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
} }
const toggleCategory = (category: string) => { const toggleCategory = (category: string) => {
setCategories( setCategories(categories.includes(category) ? categories.filter((c) => c !== category) : [...categories, category])
categories.includes(category)
? categories.filter((c) => c !== category)
: [...categories, category]
)
} }
const handleAddAlias = () => { const handleAddAlias = () => {
@@ -365,13 +337,16 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
const submissionData = { const submissionData = {
name: iconName, name: iconName,
assets: assetFiles, assets: assetFiles,
created_by: pb.authStore.model?.id, created_by: pb.authStore.record?.id,
status: "pending", status: "pending",
extras: extras, extras: extras,
} }
await pb.collection("submissions").create(submissionData) await pb.collection("submissions").create(submissionData)
// Revalidate Next.js cache for community pages
await revalidateAllSubmissions()
launchConfetti() launchConfetti()
toast.success("Icon submitted!", { toast.success("Icon submitted!", {
description: `Your icon "${iconName}" has been submitted for review`, 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"> <Label htmlFor="icon-name" className="text-sm font-medium mb-2 block">
Icon Name Icon Name
</Label> </Label>
<IconNameCombobox <IconNameCombobox value={iconName} onValueChange={setIconName} />
value={iconName}
onValueChange={setIconName}
/>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -470,9 +442,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
</Badge> </Badge>
))} ))}
</div> </div>
{categories.length === 0 && ( {categories.length === 0 && <p className="text-xs text-destructive mt-2">At least one category required</p>}
<p className="text-xs text-destructive mt-2">At least one category required</p>
)}
</div> </div>
{/* Aliases */} {/* Aliases */}
@@ -498,17 +468,9 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{aliases.length > 0 && ( {aliases.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{aliases.map((alias) => ( {aliases.map((alias) => (
<Badge <Badge key={alias} variant="secondary" className="inline-flex items-center px-2.5 py-1 text-xs">
key={alias}
variant="secondary"
className="inline-flex items-center px-2.5 py-1 text-xs"
>
{alias} {alias}
<button <button type="button" onClick={() => handleRemoveAlias(alias)} className="ml-1 hover:text-destructive">
type="button"
onClick={() => handleRemoveAlias(alias)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</Badge> </Badge>
@@ -519,9 +481,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{/* About */} {/* About */}
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2">About this icon</h3>
About this icon
</h3>
<div className="text-xs text-muted-foreground space-y-2"> <div className="text-xs text-muted-foreground space-y-2">
<p> <p>
{variants.length > 0 {variants.length > 0
@@ -560,9 +520,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
<FileType className="w-4 h-4 text-blue-500" /> <FileType className="w-4 h-4 text-blue-500" />
Icon Variants Icon Variants
</h3> </h3>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">Upload your icon files. Base icon is required.</p>
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"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{variants.map((variant, index) => ( {variants.map((variant, index) => (
<VariantCard <VariantCard
@@ -572,10 +530,7 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
canRemove={variant.type !== "base" || variants.length > 1} canRemove={variant.type !== "base" || variants.length > 1}
/> />
))} ))}
<AddVariantCard <AddVariantCard onAddVariant={handleAddVariant} existingTypes={variants.map((v) => v.type)} />
onAddVariant={handleAddVariant}
existingTypes={variants.map((v) => v.type)}
/>
</div> </div>
</div> </div>
@@ -615,24 +570,17 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileType className="w-4 h-4 text-blue-500" /> <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"> <div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">
{baseVariant {baseVariant ? baseVariant.file.name.split(".").pop()?.toUpperCase() : "N/A"}
? baseVariant.file.name.split(".").pop()?.toUpperCase()
: "N/A"}
</div> </div>
</div> </div>
</div> </div>
{availableFormats.length > 0 && ( {availableFormats.length > 0 && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2">Available Formats</h3>
Available Formats
</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{availableFormats.map((format) => ( {availableFormats.map((format) => (
<div <div key={format} className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium">
key={format}
className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium"
>
{format.toUpperCase()} {format.toUpperCase()}
</div> </div>
))} ))}
@@ -642,21 +590,15 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{variants.some((v) => v.type === "light" || v.type === "dark") && ( {variants.some((v) => v.type === "light" || v.type === "dark") && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2">Color Variants</h3>
Color Variants
</h3>
<div className="space-y-2"> <div className="space-y-2">
{variants {variants
.filter((v) => v.type === "light" || v.type === "dark") .filter((v) => v.type === "light" || v.type === "dark")
.map((variant, index) => ( .map((variant, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2">
<PaletteIcon className="w-4 h-4 text-purple-500" /> <PaletteIcon className="w-4 h-4 text-purple-500" />
<span className="capitalize font-medium text-sm"> <span className="capitalize font-medium text-sm">{variant.type}:</span>
{variant.type}: <code className="border border-border px-2 py-0.5 rounded-lg text-xs">{variant.file.name}</code>
</span>
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">
{variant.file.name}
</code>
</div> </div>
))} ))}
</div> </div>
@@ -665,21 +607,15 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
{variants.some((v) => v.type.startsWith("wordmark")) && ( {variants.some((v) => v.type.startsWith("wordmark")) && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2">Wordmark Variants</h3>
Wordmark Variants
</h3>
<div className="space-y-2"> <div className="space-y-2">
{variants {variants
.filter((v) => v.type.startsWith("wordmark")) .filter((v) => v.type.startsWith("wordmark"))
.map((variant, index) => ( .map((variant, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" /> <Type className="w-4 h-4 text-green-500" />
<span className="capitalize font-medium text-sm"> <span className="capitalize font-medium text-sm">{variant.type.replace("wordmark-", "")}:</span>
{variant.type.replace("wordmark-", "")}: <code className="border border-border px-2 py-0.5 rounded-lg text-xs">{variant.file.name}</code>
</span>
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">
{variant.file.name}
</code>
</div> </div>
))} ))}
</div> </div>
@@ -714,6 +650,3 @@ export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetai
</form> </form>
) )
} }

View File

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

View File

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

View File

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