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 confetti from "canvas-confetti"
import { motion } from "framer-motion"
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun, Type } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useCallback, useState } from "react"
@ -24,132 +23,51 @@ export type IconDetailsProps = {
authorData: AuthorData
}
export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
const authorName = authorData.name || authorData.login || ""
const iconColorVariants = iconData.colors
const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})
const getAvailableFormats = () => {
switch (iconData.base) {
case "svg":
return ["svg", "png", "webp"]
case "png":
return ["png", "webp"]
default:
return [iconData.base]
}
}
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 = '';
const availableFormats = getAvailableFormats()
const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({})
// Launch confetti from the pointer position
const launchConfetti = useCallback((originX?: number, originY?: number) => {
const defaults = {
startVelocity: 15,
spread: 180,
ticks: 50,
zIndex: 20,
disableForReducedMotion: true,
colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"],
}
// If we have origin coordinates, use them
if (originX !== undefined && originY !== undefined) {
confetti({
...defaults,
particleCount: 50,
origin: {
x: originX / window.innerWidth,
y: originY / window.innerHeight,
},
})
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 {
// Default to center of screen
confetti({
...defaults,
particleCount: 50,
origin: { x: 0.5, y: 0.5 },
})
variantName = `${iconName}_wordmark`;
}
}, [])
const handleCopy = (url: string, variantKey: string, event?: React.MouseEvent) => {
navigator.clipboard.writeText(url)
setCopiedVariants((prev) => ({
...prev,
[variantKey]: true,
}))
setTimeout(() => {
setCopiedVariants((prev) => ({
...prev,
[variantKey]: false,
}))
}, 2000)
// Launch confetti from click position or center of screen
if (event) {
launchConfetti(event.clientX, event.clientY)
} else {
launchConfetti()
}
toast.success("URL copied", {
description: "The icon URL has been copied to your clipboard. Ready to use!",
})
}
const handleDownload = async (event: React.MouseEvent, url: string, filename: string) => {
event.preventDefault()
// Launch confetti from download button position
launchConfetti(event.clientX, event.clientY)
try {
// Show loading toast
toast.loading("Preparing download...")
// Fetch the file first as a blob
const response = await fetch(url)
const blob = await response.blob()
// Create a blob URL and use it for download
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
// Clean up
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
toast.dismiss()
toast.success("Download started", {
description: "Your icon file is being downloaded and will be saved to your device.",
})
} catch (error) {
console.error("Download error:", error)
toast.dismiss()
toast.error("Download failed", {
description: "There was an error downloading the file. Please try again.",
})
if (theme && iconData.colors) {
variantName = iconData.colors[theme] || `${iconName}_${theme}`;
} else {
variantName = iconName;
}
}
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
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 key={variantKey} delayDuration={500}>
<TooltipProvider delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
@ -158,7 +76,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
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)}
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" />
@ -168,11 +86,12 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
{isCopied && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: isCopied ? 1 : 0.5,
opacity: isCopied ? 1 : 0,
scale: 1,
opacity: 1,
}}
transition={{
type: "spring",
@ -182,6 +101,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
)}
</motion.div>
<Image
@ -206,7 +126,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)}
onClick={(e) => onDownload(e, imageUrl, `${iconName}.${format}`)}
>
<Download className="w-4 h-4" />
</Button>
@ -222,9 +142,10 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)}
onClick={(e) => onCopy(imageUrl, `btn-${variantKey}`, e)}
>
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
{btnCopied && <Check className="w-4 h-4 text-green-500" />}
{!btnCopied && <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
@ -251,10 +172,262 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
)
}
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) {
const authorName = authorData.name || authorData.login || ""
const iconColorVariants = iconData.colors
const iconWordmarkVariants = iconData.wordmark
const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})
const getAvailableFormats = () => {
switch (iconData.base) {
case "svg":
return ["svg", "png", "webp"]
case "png":
return ["png", "webp"]
default:
return [iconData.base]
}
}
const availableFormats = getAvailableFormats()
const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({})
const launchConfetti = useCallback((originX?: number, originY?: number) => {
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 handleCopy = (url: string, variantKey: string, event?: React.MouseEvent) => {
navigator.clipboard.writeText(url)
setCopiedVariants((prev) => ({
...prev,
[variantKey]: true,
}))
setTimeout(() => {
setCopiedVariants((prev) => ({
...prev,
[variantKey]: false,
}))
}, 2000)
if (event) {
launchConfetti(event.clientX, event.clientY)
} else {
launchConfetti()
}
toast.success("URL copied", {
description: "The icon URL has been copied to your clipboard. Ready to use!",
})
}
const handleDownload = async (event: React.MouseEvent, url: string, filename: string) => {
event.preventDefault()
launchConfetti(event.clientX, event.clientY)
try {
toast.loading("Preparing download...")
const response = await fetch(url)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
toast.dismiss()
toast.success("Download started", {
description: "Your icon file is being downloaded and will be saved to your device.",
})
} catch (error) {
console.error("Download error:", error)
toast.dismiss()
toast.error("Download failed", {
description: "There was an error downloading the file. Please try again.",
})
}
}
return (
<div className="container mx-auto pt-12 pb-14">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column: Icon Info and Author */}
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg">
<CardHeader className="pb-4">
@ -287,7 +460,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<AvatarImage src={authorData.avatar_url} alt={authorName} />
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
</Avatar>
{authorData.html_url ? (
{authorData.html_url && (
<Link
href={authorData.html_url}
target="_blank"
@ -296,7 +469,8 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
>
{authorName}
</Link>
) : (
)}
{!authorData.html_url && (
<span className="text-sm">{authorName}</span>
)}
</div>
@ -348,11 +522,15 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="text-xs text-muted-foreground space-y-2">
<p>
Available in{" "}
{availableFormats.length > 1
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
: `${availableFormats[0].toUpperCase()} format`}{" "}
{availableFormats.length > 1 && (
`${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
)}
{availableFormats.length === 1 && (
`${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."}
{iconData.wordmark && " Wordmark variants are also available for enhanced branding options."}
</p>
<p>
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>
</div>
{/* Middle Column: Icon variants */}
<div className="lg:col-span-2">
<Card className="h-full bg-background/50 shadow-lg">
<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>
</CardHeader>
<CardContent>
{!iconData.colors ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{availableFormats.map((format) => renderVariant(format, icon))}
</div>
) : (
<div className="space-y-10">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Sun className="w-4 h-4 text-amber-500" />
Light theme
</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"))}
</div>
</div>
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Moon className="w-4 h-4 text-indigo-500" />
Dark theme
</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, "dark"))}
</div>
</div>
</div>
<IconVariantsSection
availableFormats={availableFormats}
icon={icon}
iconData={iconData}
handleCopy={handleCopy}
handleDownload={handleDownload}
copiedVariants={copiedVariants}
title="Default"
iconElement={<FileType className="w-4 h-4 text-blue-500" />}
/>
{iconData.colors && (
<>
<IconVariantsSection
availableFormats={availableFormats}
icon={icon}
theme="light"
iconData={iconData}
handleCopy={handleCopy}
handleDownload={handleDownload}
copiedVariants={copiedVariants}
title="Light theme"
iconElement={<Sun className="w-4 h-4 text-amber-500" />}
/>
<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>
</Card>
</div>
{/* Right Column: Technical details */}
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg">
<CardHeader>
@ -445,6 +646,28 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</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="">
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
<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"
// TODO: Change back before merge
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
}
export type IconWordmarkColors = {
dark?: string
light?: string
}
export type Icon = {
base: string | "svg" | "png" | "webp"
aliases: string[]
categories: string[]
update: IconUpdate
colors?: IconColors
wordmark?: IconWordmarkColors
}
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
}