wip (don't push)

This commit is contained in:
ajnart 2025-04-26 10:42:10 +02:00
parent e5777ff2d6
commit d1152dbaf6
No known key found for this signature in database
GPG Key ID: A678F374F428457B
10 changed files with 4595 additions and 161 deletions

View File

@ -1 +0,0 @@
<svg width="196" height="256" viewBox="0 0 196 256" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M109.337 48.428c0-6.421-2.518-12.579-7-17.12-4.483-4.54-10.563-7.092-16.902-7.094 6.34-.002 12.42-2.554 16.902-7.094s7-10.7 7-17.12c0 6.421 2.518 12.58 7.002 17.121a23.76 23.76 0 0 0 16.905 7.093 23.76 23.76 0 0 0-16.905 7.093c-4.484 4.54-7.002 10.7-7.002 17.12m29.694 44.055a31.5 31.5 0 0 0-11.353-15.22 30.87 30.87 0 0 0-17.943-5.805 30.86 30.86 0 0 0-17.964 5.74 31.5 31.5 0 0 0-11.406 15.18l-25.795 73.16h37.412l17.46-57.182 22.773 77.722 10.669 31.871c3.384 11.026 10.162 20.667 19.343 27.512 9.182 6.846 20.285 10.537 31.687 10.534H196zM22.692 256h1.791a53 53 0 0 0 31.165-10.155c9.085-6.602 15.897-15.923 19.477-26.651l.418-1.249.307-.961H36.44z" fill="#000"/><path d="M79.43 209.426c17.013-3.116 29.917-18.19 29.917-36.321H35.864c-9.511.001-18.633 3.829-25.358 10.64S0 199.793 0 209.426z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 924 B

View File

@ -8,8 +8,7 @@ import { BASE_URL, REPO_PATH } from "@/constants"
import type { AuthorData, Icon } from "@/types/icons" import type { AuthorData, Icon } from "@/types/icons"
import confetti from "canvas-confetti" import confetti from "canvas-confetti"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react" import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun, Type } from "lucide-react"
import { useTheme } from "next-themes"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
@ -24,14 +23,312 @@ export type IconDetailsProps = {
authorData: AuthorData authorData: AuthorData
} }
function IconVariant({
format,
iconName,
theme,
isWordmark = false,
iconData,
onCopy,
onDownload,
copiedVariants
}: {
format: string
iconName: string
theme?: "light" | "dark"
isWordmark?: boolean
iconData: Icon
onCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
onDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
}) {
let variantName = '';
if (isWordmark) {
if (theme && iconData.wordmark && typeof iconData.wordmark !== 'string') {
variantName = iconData.wordmark[theme] || `${iconName}_wordmark_${theme}`;
} else if (iconData.wordmark && typeof iconData.wordmark === 'string') {
variantName = iconData.wordmark;
} else {
variantName = `${iconName}_wordmark`;
}
} else {
if (theme && iconData.colors) {
variantName = iconData.colors[theme] || `${iconName}_${theme}`;
} else {
variantName = iconName;
}
}
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`;
const githubUrl = `${REPO_PATH}/tree/main/${format}/${variantName}.${format}`;
const variantKey = `${format}-${theme || "default"}${isWordmark ? "-wordmark" : ""}`;
const isCopied = copiedVariants[variantKey] || false;
const btnCopied = copiedVariants[`btn-${variantKey}`] || false;
return (
<TooltipProvider delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => onCopy(imageUrl, variantKey, e)}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
{isCopied && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
)}
</motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p>
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => onDownload(e, imageUrl, `${iconName}.${format}`)}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download icon file</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => onCopy(imageUrl, `btn-${variantKey}`, e)}
>
{btnCopied && <Check className="w-4 h-4 text-green-500" />}
{!btnCopied && <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</MagicCard>
</TooltipProvider>
)
}
function IconVariantsSection({
availableFormats,
icon,
theme,
isWordmark = false,
iconData,
handleCopy,
handleDownload,
copiedVariants,
title,
iconElement
}: {
availableFormats: string[]
icon: string
theme?: "light" | "dark"
isWordmark?: boolean
iconData: Icon
handleCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
title: string
iconElement: React.ReactNode
}) {
return (
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
{iconElement}
{title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme={theme}
isWordmark={isWordmark}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)
}
function WordmarkSection({
iconData,
icon,
availableFormats,
handleCopy,
handleDownload,
copiedVariants
}: {
iconData: Icon
icon: string
availableFormats: string[]
handleCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
}) {
if (!iconData.wordmark) return null;
const isStringWordmark = typeof iconData.wordmark === 'string';
const hasLightDarkVariants = !isStringWordmark && (iconData.wordmark.light || iconData.wordmark.dark);
return (
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" />
Wordmark variants
</h3>
{(isStringWordmark || !hasLightDarkVariants) && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
)}
{hasLightDarkVariants && (
<div className="space-y-6">
{iconData.wordmark.light && (
<div>
<h4 className="text-md font-medium flex items-center gap-2 ml-4 mb-2">
<Sun className="w-3 h-3 text-amber-500" />
Light
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme="light"
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)}
{iconData.wordmark.dark && (
<div>
<h4 className="text-md font-medium flex items-center gap-2 ml-4 mb-2">
<Moon className="w-3 h-3 text-indigo-500" />
Dark
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme="dark"
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { export function IconDetails({ icon, iconData, authorData }: 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 formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", { const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
day: "numeric", day: "numeric",
month: "long", month: "long",
year: "numeric", year: "numeric",
}) })
const getAvailableFormats = () => { const getAvailableFormats = () => {
switch (iconData.base) { switch (iconData.base) {
case "svg": case "svg":
@ -46,7 +343,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
const availableFormats = getAvailableFormats() const availableFormats = getAvailableFormats()
const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({}) const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({})
// Launch confetti from the pointer position
const launchConfetti = useCallback((originX?: number, originY?: number) => { const launchConfetti = useCallback((originX?: number, originY?: number) => {
const defaults = { const defaults = {
startVelocity: 15, startVelocity: 15,
@ -57,7 +353,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"], colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"],
} }
// If we have origin coordinates, use them
if (originX !== undefined && originY !== undefined) { if (originX !== undefined && originY !== undefined) {
confetti({ confetti({
...defaults, ...defaults,
@ -68,7 +363,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
}, },
}) })
} else { } else {
// Default to center of screen
confetti({ confetti({
...defaults, ...defaults,
particleCount: 50, particleCount: 50,
@ -90,7 +384,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
})) }))
}, 2000) }, 2000)
// Launch confetti from click position or center of screen
if (event) { if (event) {
launchConfetti(event.clientX, event.clientY) launchConfetti(event.clientX, event.clientY)
} else { } else {
@ -104,27 +397,18 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
const handleDownload = async (event: React.MouseEvent, url: string, filename: string) => { const handleDownload = async (event: React.MouseEvent, url: string, filename: string) => {
event.preventDefault() event.preventDefault()
// Launch confetti from download button position
launchConfetti(event.clientX, event.clientY) launchConfetti(event.clientX, event.clientY)
try { try {
// Show loading toast
toast.loading("Preparing download...") toast.loading("Preparing download...")
// Fetch the file first as a blob
const response = await fetch(url) const response = await fetch(url)
const blob = await response.blob() const blob = await response.blob()
// Create a blob URL and use it for download
const blobUrl = URL.createObjectURL(blob) const blobUrl = URL.createObjectURL(blob)
const link = document.createElement("a") const link = document.createElement("a")
link.href = blobUrl link.href = blobUrl
link.download = filename link.download = filename
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
// Clean up
document.body.removeChild(link) document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(blobUrl), 100) setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
@ -141,120 +425,9 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
} }
} }
const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => {
const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`
const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
const variantKey = `${format}-${theme || "default"}`
const isCopied = copiedVariants[variantKey] || false
return (
<TooltipProvider key={variantKey} delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: isCopied ? 1 : 0.5,
opacity: isCopied ? 1 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
</motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p>
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download icon file</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)}
>
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</MagicCard>
</TooltipProvider>
)
}
return ( return (
<div className="container mx-auto pt-12 pb-14"> <div className="container mx-auto pt-12 pb-14">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column: Icon Info and Author */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg"> <Card className="h-full bg-background/50 border shadow-lg">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
@ -287,7 +460,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<AvatarImage src={authorData.avatar_url} alt={authorName} /> <AvatarImage src={authorData.avatar_url} alt={authorName} />
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback> <AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
</Avatar> </Avatar>
{authorData.html_url ? ( {authorData.html_url && (
<Link <Link
href={authorData.html_url} href={authorData.html_url}
target="_blank" target="_blank"
@ -296,7 +469,8 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
> >
{authorName} {authorName}
</Link> </Link>
) : ( )}
{!authorData.html_url && (
<span className="text-sm">{authorName}</span> <span className="text-sm">{authorName}</span>
)} )}
</div> </div>
@ -348,11 +522,15 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="text-xs text-muted-foreground space-y-2"> <div className="text-xs text-muted-foreground space-y-2">
<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) => f.toUpperCase()).join(", ")})`
: `${availableFormats[0].toUpperCase()} format`}{" "} )}
{availableFormats.length === 1 && (
`${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."}
{iconData.wordmark && " Wordmark variants are also available for enhanced branding options."}
</p> </p>
<p> <p>
Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user
@ -365,7 +543,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</Card> </Card>
</div> </div>
{/* Middle Column: Icon variants */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="h-full bg-background/50 shadow-lg"> <Card className="h-full bg-background/50 shadow-lg">
<CardHeader> <CardHeader>
@ -373,37 +550,61 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription> <CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!iconData.colors ? ( <div className="space-y-10">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <IconVariantsSection
{availableFormats.map((format) => renderVariant(format, icon))} availableFormats={availableFormats}
</div> icon={icon}
) : ( iconData={iconData}
<div className="space-y-10"> handleCopy={handleCopy}
<div> handleDownload={handleDownload}
<h3 className="text-lg font-semibold flex items-center gap-2"> copiedVariants={copiedVariants}
<Sun className="w-4 h-4 text-amber-500" /> title="Default"
Light theme iconElement={<FileType className="w-4 h-4 text-blue-500" />}
</h3> />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg ">
{availableFormats.map((format) => renderVariant(format, icon, "light"))} {iconData.colors && (
</div> <>
</div> <IconVariantsSection
<div> availableFormats={availableFormats}
<h3 className="text-lg font-semibold flex items-center gap-2"> icon={icon}
<Moon className="w-4 h-4 text-indigo-500" /> theme="light"
Dark theme iconData={iconData}
</h3> handleCopy={handleCopy}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg "> handleDownload={handleDownload}
{availableFormats.map((format) => renderVariant(format, icon, "dark"))} copiedVariants={copiedVariants}
</div> title="Light theme"
</div> iconElement={<Sun className="w-4 h-4 text-amber-500" />}
</div> />
)}
<IconVariantsSection
availableFormats={availableFormats}
icon={icon}
theme="dark"
iconData={iconData}
handleCopy={handleCopy}
handleDownload={handleDownload}
copiedVariants={copiedVariants}
title="Dark theme"
iconElement={<Moon className="w-4 h-4 text-indigo-500" />}
/>
</>
)}
{iconData.wordmark && (
<WordmarkSection
iconData={iconData}
icon={icon}
availableFormats={availableFormats}
handleCopy={handleCopy}
handleDownload={handleDownload}
copiedVariants={copiedVariants}
/>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Right Column: Technical details */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg"> <Card className="h-full bg-background/50 border shadow-lg">
<CardHeader> <CardHeader>
@ -445,6 +646,28 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</div> </div>
)} )}
{iconData.wordmark && (
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Wordmark variants</h3>
<div className="space-y-2">
{iconData.wordmark.light && (
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" />
<span className="capitalize font-medium text-sm">Light:</span>
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">{iconData.wordmark.light}</code>
</div>
)}
{iconData.wordmark.dark && (
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" />
<span className="capitalize font-medium text-sm">Dark:</span>
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">{iconData.wordmark.dark}</code>
</div>
)}
</div>
</div>
)}
<div className=""> <div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3> <h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
<Button variant="outline" className="w-full" asChild> <Button variant="outline" className="w-full" asChild>

View File

@ -1,4 +1,4 @@
export const BASE_URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons" export const BASE_URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@feat/text-icons"
export const REPO_PATH = "https://github.com/homarr-labs/dashboard-icons" export const REPO_PATH = "https://github.com/homarr-labs/dashboard-icons"
// TODO: Change back before merge // TODO: Change back before merge
export const METADATA_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/feat/text-icons/metadata.json" export const METADATA_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/feat/text-icons/metadata.json"

3568
web/src/lib/isvg.ts Normal file

File diff suppressed because it is too large Load Diff

39
web/src/scripts/README.md Normal file
View File

@ -0,0 +1,39 @@
# Icon Management Scripts
This directory contains scripts for managing the dashboard icons.
## Scripts
### `merge-icons.ts`
This script merges data from `src/lib/isvg.ts` into the metadata.json file located at the repository root. It adds wordmark information to existing icons and creates new entries for icons that don't exist yet.
#### Usage
```bash
# Using the npm script
npm run merge-icons
# Or with pnpm
pnpm merge-icons
```
### `download-icons.ts`
This script downloads icons from the [svgl](https://github.com/pheralb/svgl) repository to the `svg` folder. It skips existing icons (except for wordmark icons which are always downloaded).
#### Usage
```bash
# Using the npm script
npm run download-icons
# Or with pnpm
pnpm download-icons
```
## Notes
- The `merge-icons.ts` script will add new icons to the metadata.json file with the author set to "ajnart" and a timestamp of "2025-04-20T12:00:00Z".
- The `download-icons.ts` script will create the `svg` directory if it doesn't exist.
- Both scripts log their progress to the console.

View File

@ -0,0 +1,121 @@
#!/usr/bin/env bun
import fs from 'node:fs';
import path from 'node:path';
// Define the file paths - this script is in /web/src/scripts
const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
const METADATA_PATH = path.join(SCRIPT_DIR, '../../../metadata.json');
const SVG_DIR = path.join(SCRIPT_DIR, '../../../svg');
interface MetadataItem {
base: string;
aliases: string[];
categories: string[];
update: {
timestamp: string;
author: {
id: number | string;
name: string;
};
};
wordmark?: string | {
light: string;
dark: string;
};
colors?: {
dark: string;
light: string;
};
}
interface Metadata {
[key: string]: MetadataItem;
}
// Function to normalize a name by removing light/dark suffixes and replacing underscores
function normalizeName(name: string): string {
return name.replace(/_/g, '-').replace(/-(light|dark)(-wordmark)?$/i, '$2');
}
// Function to collect all valid icon names from metadata
function collectValidIconNames(metadata: Metadata): Set<string> {
const validNames = new Set<string>();
for (const [key, data] of Object.entries(metadata)) {
// Add the main key (icon name)
validNames.add(key.replace(/_/g, '-'));
// Add wordmark if it exists
if (data.wordmark) {
if (typeof data.wordmark === 'string') {
validNames.add(data.wordmark.replace(/_/g, '-'));
} else {
if (data.wordmark.light) validNames.add(data.wordmark.light.replace(/_/g, '-'));
if (data.wordmark.dark) validNames.add(data.wordmark.dark.replace(/_/g, '-'));
}
}
// Add colors if they exist
if (data.colors) {
if (data.colors.light) validNames.add(data.colors.light.replace(/_/g, '-'));
if (data.colors.dark) validNames.add(data.colors.dark.replace(/_/g, '-'));
}
}
return validNames;
}
async function main() {
console.log('Starting cleanup of unused SVG files...');
try {
// Read and parse metadata.json
console.log(`Reading metadata from ${METADATA_PATH}`);
const metadataContent = await fs.promises.readFile(METADATA_PATH, 'utf8');
const metadata: Metadata = JSON.parse(metadataContent);
// Collect all valid icon names
const validIconNames = collectValidIconNames(metadata);
console.log(`Found ${validIconNames.size} valid icon names in metadata`);
// Read all SVG files in the directory
const svgFiles = await fs.promises.readdir(SVG_DIR);
console.log(`Found ${svgFiles.length} SVG files in directory`);
// Files to delete
const filesToDelete: string[] = [];
// Check each SVG file
for (const file of svgFiles) {
if (!file.endsWith('.svg')) continue;
// Get the base name without extension
const baseName = file.replace('.svg', '');
// Convert any underscores to hyphens for comparison with metadata
const normalizedBaseName = baseName.replace(/_/g, '-');
// If not in valid names, mark for deletion
if (!validIconNames.has(normalizedBaseName)) {
filesToDelete.push(file);
}
}
// Delete unused files
console.log(`Found ${filesToDelete.length} unused SVG files to delete`);
for (const file of filesToDelete) {
const filePath = path.join(SVG_DIR, file);
await fs.promises.unlink(filePath);
console.log(`Deleted: ${file}`);
}
console.log('Cleanup completed successfully!');
console.log(`Deleted ${filesToDelete.length} unused SVG files`);
} catch (error) {
console.error('Error during cleanup:', error);
process.exit(1);
}
}
main();

View File

@ -0,0 +1,274 @@
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
// Import the SVG data
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { svgs } = require('../lib/isvg') as { svgs: ISVG[] };
// Define the ISVG interface
interface ISVG {
title: string;
category: string | string[];
route: string | {
light?: string;
dark?: string;
};
wordmark?: string | {
light?: string;
dark?: string;
};
url?: string;
brandUrl?: string;
}
// Base URL for GitHub raw content
const BASE_URL = 'https://raw.githubusercontent.com/pheralb/svgl/refs/heads/main/static';
// Target directory (relative to the script location)
const TARGET_DIR = path.join(__dirname, '../../..', 'svg');
// Create the target directory if it doesn't exist
if (!fs.existsSync(TARGET_DIR)) {
fs.mkdirSync(TARGET_DIR, { recursive: true });
console.log(`Created target directory: ${TARGET_DIR}`);
}
// Get a list of existing files in the target directory
const existingFiles = fs.readdirSync(TARGET_DIR);
console.log(`Found ${existingFiles.length} existing files in ${TARGET_DIR}`);
// Helper function to download a file
async function downloadFile(url: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(destPath);
https.get(url, (response) => {
if (response.statusCode !== 200) {
fs.unlinkSync(destPath); // Remove the file if download failed
reject(new Error(`Failed to download ${url}: ${response.statusCode}`));
return;
}
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve();
});
fileStream.on('error', (err) => {
fs.unlinkSync(destPath); // Remove the file if there was an error
reject(err);
});
}).on('error', (err) => {
fs.unlinkSync(destPath); // Remove the file if there was an error
reject(err);
});
});
}
// Helper function to get base icon name from route
function getBaseIconName(route: string): string {
// Extract basename, remove -icon/-logo, replace underscores with hyphens
const filename = path.basename(route, '.svg')
.replace(/(-icon|-logo)$/g, '')
.replace(/_/g, '-');
// Remove light/dark suffixes to get the base name
return filename.replace(/-(light|dark)$/i, '');
}
// Map to store icon keys to their base names
const iconKeyMap = new Map<string, string>();
// First pass to build the icon key map
function buildIconKeyMap() {
for (const svg of svgs) {
let routePath: string;
if (typeof svg.route === 'string') {
routePath = svg.route;
} else if (svg.route.light) {
routePath = svg.route.light;
} else if (svg.route.dark) {
routePath = svg.route.dark || '';
} else {
routePath = '';
}
const baseIconName = getBaseIconName(routePath);
iconKeyMap.set(svg.title, baseIconName);
}
}
// Build the key map before processing
buildIconKeyMap();
// Helper function to check if a file already exists
function fileExists(filename: string): boolean {
return existingFiles.includes(filename);
}
// Format URL correctly by joining BASE_URL with the route path
function getFullUrl(route: string): string {
// Remove leading slash if present
const cleanRoute = route.startsWith('/') ? route.substring(1) : route;
return `${BASE_URL}/${cleanRoute}`;
}
// Process each SVG icon
async function processIcons() {
let downloadCount = 0;
let skipCount = 0;
const failedDownloads: string[] = [];
for (const svg of svgs) {
try {
// Get base name for this icon
const baseIconName = iconKeyMap.get(svg.title) || '';
// Process the main route
if (typeof svg.route === 'string') {
// Simple string route - use base name
const filename = `${baseIconName}.svg`;
if (!fileExists(filename)) {
const url = getFullUrl(svg.route);
const destPath = path.join(TARGET_DIR, filename);
try {
await downloadFile(url, destPath);
console.log(`Downloaded: ${filename} (from ${path.basename(svg.route)})`);
downloadCount++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error downloading ${filename}: ${errorMessage}`);
failedDownloads.push(filename);
}
} else {
console.log(`Skipping existing file: ${filename}`);
skipCount++;
}
} else if (typeof svg.route === 'object') {
// Object with light/dark variants
if (svg.route.light) {
const filename = `${baseIconName}-light.svg`;
if (!fileExists(filename)) {
const url = getFullUrl(svg.route.light);
const destPath = path.join(TARGET_DIR, filename);
try {
await downloadFile(url, destPath);
console.log(`Downloaded: ${filename} (from ${path.basename(svg.route.light)})`);
downloadCount++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error downloading ${filename}: ${errorMessage}`);
failedDownloads.push(filename);
}
} else {
console.log(`Skipping existing file: ${filename}`);
skipCount++;
}
}
if (svg.route.dark) {
const filename = `${baseIconName}-dark.svg`;
if (!fileExists(filename)) {
const url = getFullUrl(svg.route.dark);
const destPath = path.join(TARGET_DIR, filename);
try {
await downloadFile(url, destPath);
console.log(`Downloaded: ${filename} (from ${path.basename(svg.route.dark)})`);
downloadCount++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error downloading ${filename}: ${errorMessage}`);
failedDownloads.push(filename);
}
} else {
console.log(`Skipping existing file: ${filename}`);
skipCount++;
}
}
}
// Process wordmark if present
if (svg.wordmark) {
if (typeof svg.wordmark === 'string') {
// Simple string wordmark
const filename = `${baseIconName}-wordmark.svg`;
// Download even if it exists because we want all wordmarks
const url = getFullUrl(svg.wordmark);
const destPath = path.join(TARGET_DIR, filename);
try {
await downloadFile(url, destPath);
console.log(`Downloaded wordmark: ${filename} (from ${path.basename(svg.wordmark)})`);
downloadCount++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error downloading wordmark ${filename}: ${errorMessage}`);
failedDownloads.push(filename);
}
} else if (typeof svg.wordmark === 'object') {
// Object with light/dark variants
if (svg.wordmark.light) {
const filename = `${baseIconName}-wordmark-light.svg`;
// Download even if it exists because we want all wordmarks
const url = getFullUrl(svg.wordmark.light);
const destPath = path.join(TARGET_DIR, filename);
try {
await downloadFile(url, destPath);
console.log(`Downloaded wordmark: ${filename} (from ${path.basename(svg.wordmark.light)})`);
downloadCount++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error downloading wordmark ${filename}: ${errorMessage}`);
failedDownloads.push(filename);
}
}
if (svg.wordmark.dark) {
const filename = `${baseIconName}-wordmark-dark.svg`;
// Download even if it exists because we want all wordmarks
const url = getFullUrl(svg.wordmark.dark);
const destPath = path.join(TARGET_DIR, filename);
try {
await downloadFile(url, destPath);
console.log(`Downloaded wordmark: ${filename} (from ${path.basename(svg.wordmark.dark)})`);
downloadCount++;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error downloading wordmark ${filename}: ${errorMessage}`);
failedDownloads.push(filename);
}
}
}
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error processing ${svg.title}: ${errorMessage}`);
}
}
console.log('\nSummary:');
console.log(`Total icons processed: ${svgs.length}`);
console.log(`Downloaded: ${downloadCount}`);
console.log(`Skipped: ${skipCount}`);
if (failedDownloads.length > 0) {
console.log(`Failed downloads: ${failedDownloads.length}`);
console.log(`Failed files: ${failedDownloads.join(', ')}`);
}
}
// Run the script
processIcons().catch(error => {
console.error('Error:', error);
process.exit(1);
});

View File

@ -0,0 +1,190 @@
import fs from 'node:fs';
import path from 'node:path';
// Define the types
interface ISVG {
title: string;
category: string | string[];
route: string | {
light?: string;
dark?: string;
};
wordmark?: string | {
light?: string;
dark?: string;
};
url?: string;
brandUrl?: string;
}
interface MetadataItem {
base: string;
aliases: string[];
categories: string[];
update: {
timestamp: string;
author: {
id: string;
name: string;
};
};
wordmark?: string | {
light: string;
dark: string;
};
colors?: {
dark: string;
light: string;
};
}
interface Metadata {
[key: string]: MetadataItem;
}
// Import the SVG data
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { svgs } = require('../lib/isvg') as { svgs: ISVG[] };
// Path to the metadata file (relative to the current script)
const metadataPath = path.join(__dirname, '../../..', 'metadata.json');
// Read existing metadata
let metadata: Metadata = {};
try {
const metadataContent = fs.readFileSync(metadataPath, 'utf8');
metadata = JSON.parse(metadataContent);
} catch (error) {
console.error('Error reading metadata file:', error);
process.exit(1);
}
// The author details
const author = {
id: 49837342,
name: "ajnart"
};
// The timestamp
const timestamp = "2025-04-25T12:00:00Z";
// Helper function to convert the author ID to string, since that's the expected type
function getAuthorWithStringId(authorObj: { id: number | string; name: string }): { id: string; name: string } {
return {
...authorObj,
id: String(authorObj.id)
};
}
// Convert iSVG format to metadata format
for (const svg of svgs) {
// Extract the key from the route path
let routePath: string;
if (typeof svg.route === 'string') {
routePath = svg.route;
} else if (svg.route.light) {
routePath = svg.route.light;
} else if (svg.route.dark) {
routePath = svg.route.dark || '';
} else {
routePath = '';
}
// Get the filename without extension and remove suffixes
// Also replace underscores with hyphens
let key = path.basename(routePath, '.svg')
.replace(/(-icon|-logo)$/g, '')
.replace(/_/g, '-');
// Remove -light or -dark suffixes for the base key
key = key.replace(/-(light|dark)$/i, '');
// Check if the icon already exists
if (metadata[key]) {
console.log(`Icon "${svg.title}" already exists in metadata as "${key}"`);
// Update the existing entry with wordmark if it doesn't have one
if (svg.wordmark && !metadata[key].wordmark) {
console.log(`Adding wordmark to existing icon "${key}"`);
if (typeof svg.wordmark === 'string') {
// Instead of using the original wordmark name, use standardized naming
metadata[key].wordmark = `${key}-wordmark`;
} else if (typeof svg.wordmark === 'object') {
// Object with light/dark variants - use standardized naming
metadata[key].wordmark = {
light: svg.wordmark.light ? `${key}-wordmark-light` : '',
dark: svg.wordmark.dark ? `${key}-wordmark-dark` : ''
};
}
// Update the timestamp and author
metadata[key].update = {
timestamp,
author: getAuthorWithStringId(author)
};
}
// If route contains light/dark values, update colors with standardized naming
if (typeof svg.route === 'object' && (svg.route.light || svg.route.dark)) {
if (!metadata[key].colors) {
metadata[key].colors = {
dark: svg.route.dark ? `${key}-dark` : '',
light: svg.route.light ? `${key}-light` : ''
};
}
}
} else {
// Create a new metadata entry
const newEntry: MetadataItem = {
base: "svg", // Assuming SVG format
aliases: [],
categories: [],
update: {
timestamp,
author: getAuthorWithStringId(author)
}
};
// Handle categories
if (Array.isArray(svg.category)) {
newEntry.categories = svg.category;
} else if (typeof svg.category === 'string') {
newEntry.categories = [svg.category];
}
// Handle wordmark if present with standardized naming
if (svg.wordmark) {
if (typeof svg.wordmark === 'string') {
newEntry.wordmark = `${key}-wordmark`;
} else if (typeof svg.wordmark === 'object') {
newEntry.wordmark = {
light: svg.wordmark.light ? `${key}-wordmark-light` : '',
dark: svg.wordmark.dark ? `${key}-wordmark-dark` : ''
};
}
}
// Handle colors from route with standardized naming
if (typeof svg.route === 'object' && (svg.route.light || svg.route.dark)) {
newEntry.colors = {
dark: svg.route.dark ? `${key}-dark` : '',
light: svg.route.light ? `${key}-light` : ''
};
}
// Add to metadata
metadata[key] = newEntry;
console.log(`Added new icon "${svg.title}" as "${key}"`);
}
}
// Write the updated metadata back to the file
try {
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 4));
console.log(`Successfully updated metadata file at ${metadataPath}`);
} catch (error) {
console.error('Error writing metadata file:', error);
process.exit(1);
}

View File

@ -13,12 +13,18 @@ export type IconColors = {
light?: string light?: string
} }
export type IconWordmarkColors = {
dark?: string
light?: string
}
export type Icon = { export type Icon = {
base: string | "svg" | "png" | "webp" base: string | "svg" | "png" | "webp"
aliases: string[] aliases: string[]
categories: string[] categories: string[]
update: IconUpdate update: IconUpdate
colors?: IconColors colors?: IconColors
wordmark?: IconWordmarkColors
} }
export type IconFile = { export type IconFile = {

14
web/src/types/svg.ts Normal file
View File

@ -0,0 +1,14 @@
export type iSVG = {
title: string
category: string | string[]
route: string | {
light?: string
dark?: string
}
wordmark?: string | {
light?: string
dark?: string
}
url?: string
brandUrl?: string
}