From 9d2a35489fbd2da59f2382e1405145f5e9797064 Mon Sep 17 00:00:00 2001 From: Bjorn Lammers Date: Sat, 26 Apr 2025 00:20:06 +0200 Subject: [PATCH] feat(icon-components): Improve image loading/error handling and add WebP support across icon-related components --- web/src/app/icons/[icon]/page.tsx | 15 +- web/src/components/icon-card.tsx | 57 +++++-- web/src/components/icon-details.tsx | 179 ++++++++++++++------ web/src/components/recently-added-icons.tsx | 70 ++++++-- 4 files changed, 235 insertions(+), 86 deletions(-) diff --git a/web/src/app/icons/[icon]/page.tsx b/web/src/app/icons/[icon]/page.tsx index 3d83d4fb..e143f5ff 100644 --- a/web/src/app/icons/[icon]/page.tsx +++ b/web/src/app/icons/[icon]/page.tsx @@ -33,7 +33,7 @@ export async function generateMetadata({ params, searchParams }: Props, parent: console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`) - const iconImageUrl = `${BASE_URL}/png/${icon}.png` + const iconPreviewImageUrl = `${BASE_URL}/webp/${icon}.webp` const pageUrl = `${WEB_URL}/icons/${icon}` const formattedIconName = icon .split("-") @@ -43,7 +43,11 @@ export async function generateMetadata({ params, searchParams }: Props, parent: return { title: `${formattedIconName} Icon | Dashboard Icons`, description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, - assets: [iconImageUrl], + assets: [ + `${BASE_URL}/svg/${icon}.svg`, + `${BASE_URL}/png/${icon}.png`, + `${BASE_URL}/webp/${icon}.webp`, + ], keywords: [ `${formattedIconName} icon`, `${formattedIconName} icon download`, @@ -57,7 +61,7 @@ export async function generateMetadata({ params, searchParams }: Props, parent: "app directory", ], icons: { - icon: iconImageUrl, + icon: iconPreviewImageUrl, }, abstract: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, openGraph: { @@ -70,17 +74,18 @@ export async function generateMetadata({ params, searchParams }: Props, parent: modifiedTime: updateDate.toISOString(), section: "Icons", tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"], + images: [iconPreviewImageUrl], }, twitter: { card: "summary_large_image", title: `${formattedIconName} Icon | Dashboard Icons`, description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, - images: [iconImageUrl], + images: [iconPreviewImageUrl], }, alternates: { canonical: pageUrl, media: { - png: iconImageUrl, + png: `${BASE_URL}/png/${icon}.png`, svg: `${BASE_URL}/svg/${icon}.svg`, webp: `${BASE_URL}/webp/${icon}.webp`, }, diff --git a/web/src/components/icon-card.tsx b/web/src/components/icon-card.tsx index 190e3d2b..49372f26 100644 --- a/web/src/components/icon-card.tsx +++ b/web/src/components/icon-card.tsx @@ -4,6 +4,8 @@ import type { Icon } from "@/types/icons" import Image from "next/image" import Link from "next/link" import { useState } from "react" +import { AlertTriangle } from "lucide-react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" export function IconCard({ name, @@ -15,33 +17,54 @@ export function IconCard({ matchedAlias?: string }) { const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) // Construct URLs for both WebP and the original format const webpSrc = `${BASE_URL}/webp/${name}.webp` const originalSrc = `${BASE_URL}/${iconData.base}/${name}.${iconData.base}` + const handleLoadingComplete = () => { + setIsLoading(false) + setHasError(false) + } + + const handleError = () => { + setIsLoading(false) + setHasError(true) + } + return ( -
- {isLoading && ( +
+ {isLoading && !hasError && (
)} - {/* Use tag for WebP support with fallback */} - - - - {/* next/image as the img fallback and for optimization */} - {`${name} setIsLoading(false)} - // Add sizes prop for responsive optimization if needed, e.g., - // sizes="(max-width: 640px) 50vw, (max-width: 1280px) 33vw, 16.6vw" - /> - + {hasError ? ( + + + + + + +

Image failed to load, likely due to size limits. Please raise an issue on GitHub.

+
+
+
+ ) : ( + + + + {`${name} + + )}
{name.replace(/-/g, " ")} diff --git a/web/src/components/icon-details.tsx b/web/src/components/icon-details.tsx index d80a2572..f1cdb37f 100644 --- a/web/src/components/icon-details.tsx +++ b/web/src/components/icon-details.tsx @@ -9,7 +9,7 @@ import { BASE_URL, REPO_PATH } from "@/constants" import type { AuthorData, Icon, IconFile } from "@/types/icons" import confetti from "canvas-confetti" import { motion } from "framer-motion" -import { ArrowRight, Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react" +import { ArrowRight, Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun, AlertTriangle } from "lucide-react" import Image from "next/image" import Link from "next/link" import { useCallback, useState } from "react" @@ -26,6 +26,10 @@ export type IconDetailsProps = { } export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) { + // Add state for the main preview icon + const [isPreviewLoading, setIsPreviewLoading] = useState(true) + const [hasPreviewError, setHasPreviewError] = useState(false) + const authorName = authorData.name || authorData.login || "" const iconColorVariants = iconData.colors const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", { @@ -142,13 +146,44 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail } } + // Handlers for main preview icon + const handlePreviewLoadingComplete = () => { + setIsPreviewLoading(false) + setHasPreviewError(false) + } + + const handlePreviewError = () => { + setIsPreviewLoading(false) + setHasPreviewError(true) + } + + // URLs for main preview icon + const previewWebpSrc = `${BASE_URL}/webp/${icon}.webp` + const previewOriginalSrc = `${BASE_URL}/${iconData.base}/${icon}.${iconData.base}` + const previewOriginalFormat = iconData.base + const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => { + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) + 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 originalFormat = iconData.base + const originalImageUrl = `${BASE_URL}/${originalFormat}/${variantName}.${originalFormat}` + const webpImageUrl = `${BASE_URL}/webp/${variantName}.webp` + const githubUrl = `${REPO_PATH}/tree/main/${originalFormat}/${iconName}.${originalFormat}` const variantKey = `${format}-${theme || "default"}` const isCopied = copiedVariants[variantKey] || false + const handleLoadingComplete = () => { + setIsLoading(false) + setHasError(false) + } + + const handleError = () => { + setIsLoading(false) + setHasError(true) + } + return ( @@ -156,50 +191,65 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail handleCopy(imageUrl, variantKey, e)} - aria-label={`Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`} + className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group flex items-center justify-center" + whileHover={{ scale: hasError ? 1 : 1.05 }} + whileTap={{ scale: hasError ? 1 : 0.95 }} + onClick={(e) => !hasError && handleCopy(format === 'webp' ? webpImageUrl : originalImageUrl, variantKey, e)} + aria-label={hasError ? "Image failed to load" : `Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`} > -
+ {isLoading && !hasError && ( +
+ )} + {hasError ? ( + + ) : ( + <> +
+ + + + + - - - - - - - {`${iconName} + + + + {`${iconName} + + + )} -

Click to copy direct URL to clipboard

+

+ {hasError + ? "Image failed to load, likely due to size limits. Please raise an issue on GitHub." + : isCopied + ? "URL Copied!" + : "Click to copy direct URL to clipboard"} +

-

{format.toUpperCase()}

+

+ {format.toUpperCase()} {theme && `(${theme})`} +

@@ -208,14 +258,15 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail variant="outline" size="icon" className="h-8 w-8 rounded-lg cursor-pointer" - onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)} + onClick={(e) => !hasError && handleDownload(e, format === 'webp' ? webpImageUrl : originalImageUrl, `${variantName}.${format}`)} aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`} + disabled={hasError} > -

Download icon file

+

{hasError ? "Download unavailable" : "Download icon file"}

@@ -225,14 +276,15 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail variant="outline" size="icon" className="h-8 w-8 rounded-lg cursor-pointer" - onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)} + onClick={(e) => !hasError && handleCopy(format === 'webp' ? webpImageUrl : originalImageUrl, `btn-${variantKey}`, e)} aria-label={`Copy URL for ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`} + disabled={hasError} > {copiedVariants[`btn-${variantKey}`] ? : } -

Copy direct URL to clipboard

+

{hasError ? "Copy unavailable" : isCopied ? "URL Copied!" : "Copy direct URL to clipboard"}

@@ -243,7 +295,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail href={githubUrl} target="_blank" rel="noopener noreferrer" - aria-label={`View ${iconName} ${format} file on GitHub`} + aria-label={`View ${iconName} ${originalFormat} file on GitHub`} > @@ -268,14 +320,37 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
-
- {`High + {/* Apply loading/error handling to the main preview icon */} +
+ {isPreviewLoading && !hasPreviewError && ( +
+ )} + {hasPreviewError ? ( + + + + + + +

Preview failed to load, likely due to size limits. Please raise an issue.

+
+
+
+ ) : ( + + + + {`High + + )}

{icon.replace(/-/g, " ")}

diff --git a/web/src/components/recently-added-icons.tsx b/web/src/components/recently-added-icons.tsx index 576b95b5..ca422d0d 100644 --- a/web/src/components/recently-added-icons.tsx +++ b/web/src/components/recently-added-icons.tsx @@ -5,9 +5,11 @@ import { BASE_URL } from "@/constants" import { cn } from "@/lib/utils" import type { Icon, IconWithName } from "@/types/icons" import { format, isToday, isYesterday } from "date-fns" -import { ArrowRight, Clock, ExternalLink } from "lucide-react" +import { ArrowRight, Clock, ExternalLink, AlertTriangle } from "lucide-react" import Image from "next/image" import Link from "next/link" +import { useState } from "react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" function formatIconDate(timestamp: string): string { const date = new Date(timestamp) @@ -78,6 +80,24 @@ function RecentIconCard({ name: string data: Icon }) { + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) + + // Construct URLs + const webpSrc = `${BASE_URL}/webp/${name}.webp` + const originalSrc = `${BASE_URL}/${data.base}/${name}.${data.base}` + const originalFormat = data.base + + const handleLoadingComplete = () => { + setIsLoading(false) + setHasError(false) + } + + const handleError = () => { + setIsLoading(false) + setHasError(true) + } + return ( -
+
-
- {`${name} + {/* Image container with loading/error handling */} +
+ {isLoading && !hasError && ( +
+ )} + {hasError ? ( + + + + + + +

Image failed to load. Please raise an issue.

+
+
+
+ ) : ( + + + + {`${name} + + )}
- + + {name.replace(/-/g, " ")}
@@ -108,8 +154,8 @@ function RecentIconCard({
-
- +
+
)