diff --git a/web/package.json b/web/package.json index 3789b19a..d719d360 100644 --- a/web/package.json +++ b/web/package.json @@ -13,8 +13,7 @@ "ci": "biome check --write", "backend:start": "cd backend && ./pocketbase serve", "backend:download": "cd backend && curl -L -o pocketbase.zip https://github.com/pocketbase/pocketbase/releases/download/v0.30.0/pocketbase_0.30.0_darwin_arm64.zip && unzip pocketbase.zip && rm pocketbase.zip && rm CHANGELOG.md && rm LICENSE.md", - "seed": "bun run seed-db.ts", - "benchmark:og": "bun run scripts/benchmark-og-images.tsx" + "seed": "bun run seed-db.ts" }, "dependencies": { "@hookform/resolvers": "^5.2.1", diff --git a/web/scripts/benchmark-og-images.tsx b/web/scripts/benchmark-og-images.tsx deleted file mode 100644 index 107cab02..00000000 --- a/web/scripts/benchmark-og-images.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { ImageResponse } from "next/og"; -import React from "react"; -import { METADATA_URL } from "../src/constants"; -import type { IconFile } from "../src/types/icons"; - -// Standalone cached functions for benchmarking (no Next.js dependencies) -let iconsDataCache: IconFile | null = null; -const iconFileCache = new Map(); -let preloadDone = false; - -async function getAllIconsStandalone(): Promise { - if (iconsDataCache) { - return iconsDataCache; - } - const response = await fetch(METADATA_URL); - if (!response.ok) { - throw new Error(`Failed to fetch icons: ${response.statusText}`); - } - iconsDataCache = (await response.json()) as IconFile; - return iconsDataCache; -} - -async function preloadAllIconsStandalone(): Promise { - if (preloadDone) { - return; - } - const startTime = Date.now(); - const iconsData = await getAllIconsStandalone(); - const iconNames = Object.keys(iconsData); - const pngDir = join(process.cwd(), `../png`); - - console.log(`[Preload] Loading ${iconNames.length} icons into memory...`); - - const loadPromises = iconNames.map(async (iconName) => { - if (iconFileCache.has(iconName)) { - return; - } - try { - const iconPath = join(pngDir, `${iconName}.png`); - const buffer = await readFile(iconPath); - iconFileCache.set(iconName, buffer); - } catch (_error) { - iconFileCache.set(iconName, null); - } - }); - - await Promise.all(loadPromises); - const duration = Date.now() - startTime; - const loadedCount = Array.from(iconFileCache.values()).filter( - (v) => v !== null, - ).length; - console.log( - `[Preload] Loaded ${loadedCount}/${iconNames.length} icons in ${duration}ms (${(loadedCount / duration).toFixed(2)} icons/ms)\n`, - ); - preloadDone = true; -} - -async function readIconFileStandalone( - iconName: string, -): Promise { - if (iconFileCache.has(iconName)) { - return iconFileCache.get(iconName)!; - } - try { - const iconPath = join(process.cwd(), `../png/${iconName}.png`); - const buffer = await readFile(iconPath); - iconFileCache.set(iconName, buffer); - return buffer; - } catch (_error) { - iconFileCache.set(iconName, null); - return null; - } -} - -const size = { - width: 1200, - height: 630, -}; - -async function generateOGImage( - icon: string, - iconsData: Record, - totalIcons: number, - index: number, - profileTimings: Map, -) { - const stepTimings: Record = {}; - let stepStart: number; - - stepStart = Date.now(); - const formattedIconName = icon - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - stepTimings.formatName = Date.now() - stepStart; - - stepStart = Date.now(); - const iconData = await readIconFileStandalone(icon); - stepTimings.readFile = Date.now() - stepStart; - - stepStart = Date.now(); - const iconUrl = iconData - ? `data:image/png;base64,${iconData.toString("base64")}` - : null; - stepTimings.base64 = Date.now() - stepStart; - - stepStart = Date.now(); - const imageResponse = await new ImageResponse( -
-
-
-
-
-
- {iconUrl ? ( - {formattedIconName} - ) : ( -
- {formattedIconName} -
- )} -
-
-
- Download {formattedIconName} icon for free -
-
- Amongst {totalIcons} other high-quality dashboard icons -
-
- {["SVG", "PNG", "WEBP"].map((format) => ( -
- {format} -
- ))} -
-
-
-
-
-
- dashboardicons.com -
-
-
, - { - ...size, - }, - ); - stepTimings.imageResponse = Date.now() - stepStart; - - for (const [step, timing] of Object.entries(stepTimings)) { - if (!profileTimings.has(step)) { - profileTimings.set(step, []); - } - profileTimings.get(step)!.push(timing); - } - - return imageResponse; -} - -async function benchmark() { - console.log("Starting OG image generation benchmark...\n"); - - const startTime = Date.now(); - - console.log("Fetching icons data..."); - const iconsData = await getAllIconsStandalone(); - const iconNames = Object.keys(iconsData); - const totalIcons = iconNames.length; - const testIcons = iconNames.slice(0, 100); - - await preloadAllIconsStandalone(); - - console.log(`Testing with ${testIcons.length} icons\n`); - - const times: number[] = []; - const profileTimings = new Map(); - - for (let i = 0; i < testIcons.length; i++) { - const icon = testIcons[i]; - const iconStartTime = Date.now(); - - try { - await generateOGImage(icon, iconsData, totalIcons, i, profileTimings); - const iconEndTime = Date.now(); - const duration = iconEndTime - iconStartTime; - times.push(duration); - - if ((i + 1) % 10 === 0) { - const avgTime = times.slice(-10).reduce((a, b) => a + b, 0) / 10; - console.log( - `Generated ${i + 1}/${testIcons.length} images (avg: ${avgTime.toFixed(2)}ms per image)`, - ); - } - } catch (error) { - console.error(`Failed to generate image for ${icon}:`, error); - } - } - - const endTime = Date.now(); - const totalDuration = endTime - startTime; - const avgTime = times.reduce((a, b) => a + b, 0) / times.length; - const minTime = Math.min(...times); - const maxTime = Math.max(...times); - - console.log("\n" + "=".repeat(50)); - console.log("Benchmark Results"); - console.log("=".repeat(50)); - console.log(`Total images generated: ${testIcons.length}`); - console.log(`Total time: ${(totalDuration / 1000).toFixed(2)}s`); - console.log(`Average time per image: ${avgTime.toFixed(2)}ms`); - console.log(`Min time: ${minTime.toFixed(2)}ms`); - console.log(`Max time: ${maxTime.toFixed(2)}ms`); - console.log( - `Images per second: ${((testIcons.length / totalDuration) * 1000).toFixed(2)}`, - ); - console.log("\n" + "-".repeat(50)); - console.log("Performance Breakdown (per image):"); - console.log("-".repeat(50)); - for (const [step, timings] of profileTimings.entries()) { - const avg = timings.reduce((a, b) => a + b, 0) / timings.length; - const min = Math.min(...timings); - const max = Math.max(...timings); - const total = timings.reduce((a, b) => a + b, 0); - const percentage = ( - (total / times.reduce((a, b) => a + b, 0)) * - 100 - ).toFixed(1); - console.log( - ` ${step.padEnd(15)}: avg ${avg.toFixed(2)}ms | min ${min.toFixed(2)}ms | max ${max.toFixed(2)}ms | ${percentage}%`, - ); - } - console.log("=".repeat(50)); -} - -benchmark().catch(console.error); diff --git a/web/src/app/community/[icon]/opengraph-image.tsx b/web/src/app/community/[icon]/opengraph-image.tsx index 5b8913e5..94f17d72 100644 --- a/web/src/app/community/[icon]/opengraph-image.tsx +++ b/web/src/app/community/[icon]/opengraph-image.tsx @@ -2,26 +2,16 @@ import { permanentRedirect, redirect } from "next/navigation" import { ImageResponse } from "next/og" import { getCommunityGalleryRecord, getCommunitySubmissionByName, getCommunitySubmissions } from "@/lib/community" -export const revalidate = 21600 // 6 hours - -export async function generateStaticParams() { - const icons = await getCommunitySubmissions() - const validIcons = icons.filter((icon) => icon.name) - if (process.env.CI_MODE === "false") { - return validIcons.slice(0, 5).map((icon) => ({ - icon: icon.name, - })) - } - return validIcons.map((icon) => ({ - icon: icon.name, - })) -} +export const dynamic = "force-dynamic"; export const size = { width: 1200, height: 630, } +export const alt = "Community Icon Open Graph Image"; +export const contentType = "image/png"; + export default async function Image({ params }: { params: Promise<{ icon: string }> }) { const { icon } = await params @@ -135,7 +125,9 @@ export default async function Image({ params }: { params: Promise<{ icon: string } } - const iconUrl = iconDataBuffer ? `data:image/png;base64,${iconDataBuffer.toString("base64")}` : null + const iconUrl = iconDataBuffer + ? `data:image/png;base64,${iconDataBuffer.toString("base64")}` + : `https://placehold.co/600x400?text=${formattedIconName}`; return new ImageResponse(
- {iconUrl ? ( - {formattedIconName} - ) : ( -
- {formattedIconName} -
- )} + {formattedIconName}
({ - icon, - })) - } - return Object.keys(iconsData).map((icon) => ({ - icon, - })) -} +export const dynamic = "force-dynamic"; export const size = { width: 1200, height: 630, -} -export default async function Image({ params }: { params: Promise<{ icon: string }> }) { - const { icon } = await params +}; + +export const alt = "Icon Open Graph Image"; +export const contentType = "image/png"; +export default async function Image({ + params, +}: { + params: Promise<{ icon: string }>; +}) { + const { icon } = await params; if (!icon) { - console.error(`[Opengraph Image] Icon not found for ${icon}`) + console.error(`[Opengraph Image] Icon not found for ${icon}`); return new ImageResponse(
, { ...size }, - ) + ); } - await preloadAllIcons() - - const iconsData = await getAllIcons() - const totalIcons = Object.keys(iconsData).length - const index = Object.keys(iconsData).indexOf(icon) + const iconsData = await getAllIcons(); + const totalIcons = Object.keys(iconsData).length; + const index = Object.keys(iconsData).indexOf(icon); + // Format the icon name for display const formattedIconName = icon .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ") + .join(" "); - const iconData = await readIconFile(icon) - const iconUrl = iconData ? `data:image/png;base64,${iconData.toString("base64")}` : null + // Read the icon file from local filesystem + let iconData: Buffer | null = null; + try { + const iconPath = join(process.cwd(), `../png/${icon}.png`); + console.log( + `Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`, + ); + iconData = await readFile(iconPath); + } catch (_error) { + console.error(`Icon ${icon} was not found locally`); + } + + // Convert the image data to a data URL or use placeholder + const iconUrl = iconData + ? `data:image/png;base64,${iconData.toString("base64")}` + : null; return new ImageResponse(
- {iconUrl ? ( - {formattedIconName} - ) : ( -
- {formattedIconName} -
- )} + {formattedIconName}
{/* Text content */} @@ -289,5 +281,5 @@ export default async function Image({ params }: { params: Promise<{ icon: string { ...size, }, - ) + ); } diff --git a/web/src/app/icons/[icon]/page.tsx b/web/src/app/icons/[icon]/page.tsx index 1151ba9e..9a87398a 100644 --- a/web/src/app/icons/[icon]/page.tsx +++ b/web/src/app/icons/[icon]/page.tsx @@ -79,11 +79,26 @@ export async function generateMetadata({ params, searchParams }: Props, _parent: url: pageUrl, siteName: "Dashboard Icons", images: [ + { + url: `${BASE_URL}/png/${icon}.png`, + width: 512, + height: 512, + alt: `${formattedIconName} icon`, + type: "image/png", + }, { url: `${BASE_URL}/webp/${icon}.webp`, width: 512, height: 512, alt: `${formattedIconName} icon`, + type: "image/webp", + }, + { + url: `${BASE_URL}/svg/${icon}.svg`, + width: 512, + height: 512, + alt: `${formattedIconName} icon`, + type: "image/svg+xml", }, ], }, @@ -91,6 +106,7 @@ export async function generateMetadata({ params, searchParams }: Props, _parent: 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: [`${BASE_URL}/png/${icon}.png`], }, alternates: { canonical: `${WEB_URL}/icons/${icon}`, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 74016469..672e0d77 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,17 +1,15 @@ import { unstable_cache } from "next/cache"; -import { cache } from "react"; import { METADATA_URL } from "@/constants"; import { ApiError } from "@/lib/errors"; import type { AuthorData, IconFile, IconWithName } from "@/types/icons"; /** - * Raw fetch function for icon data (without caching) + * Fetches all icon data from the metadata.json file + * Uses fetch with revalidate for caching */ -async function fetchAllIconsRaw(): Promise { +export async function getAllIcons(): Promise { try { - const response = await fetch(METADATA_URL, { - next: { revalidate: 3600 }, - }); + const response = await fetch(METADATA_URL); if (!response.ok) { throw new ApiError( @@ -30,28 +28,6 @@ async function fetchAllIconsRaw(): Promise { } } -/** - * Cached version using unstable_cache for build-time caching - * Revalidates every hour (3600 seconds) - */ -const getAllIconsCached = unstable_cache( - async () => fetchAllIconsRaw(), - ["all-icons"], - { - revalidate: 3600, - tags: ["icons"], - }, -); - -/** - * Fetches all icon data from the metadata.json file - * Uses React cache() for request-level memoization and unstable_cache for build-level caching - * This prevents duplicate fetches within the same request and across builds - */ -export const getAllIcons = cache(async (): Promise => { - return getAllIconsCached(); -}); - /** * Gets a list of all icon names. */ diff --git a/web/src/lib/icon-cache.ts b/web/src/lib/icon-cache.ts deleted file mode 100644 index 09bb582a..00000000 --- a/web/src/lib/icon-cache.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFile } from "node:fs/promises" -import { join } from "node:path" -import { cache } from "react" -import { getAllIcons } from "./api" - -/** - * In-memory cache for icon files during build/request - * This persists across multiple calls within the same build process - */ -const iconFileCache = new Map() -let preloadPromise: Promise | null = null -let isPreloaded = false - -/** - * Preloads all icon files into memory - * This should be called once at the start of the build process - */ -export async function preloadAllIcons(): Promise { - if (isPreloaded || preloadPromise) { - return preloadPromise || Promise.resolve() - } - - preloadPromise = (async () => { - const startTime = Date.now() - const iconsData = await getAllIcons() - const iconNames = Object.keys(iconsData) - const pngDir = join(process.cwd(), `../png`) - - console.log(`[Icon Cache] Preloading ${iconNames.length} icons into memory...`) - - const loadPromises = iconNames.map(async (iconName) => { - if (iconFileCache.has(iconName)) { - return - } - try { - const iconPath = join(pngDir, `${iconName}.png`) - const buffer = await readFile(iconPath) - iconFileCache.set(iconName, buffer) - } catch (_error) { - iconFileCache.set(iconName, null) - } - }) - - await Promise.all(loadPromises) - const duration = Date.now() - startTime - const loadedCount = Array.from(iconFileCache.values()).filter((v) => v !== null).length - console.log( - `[Icon Cache] Preloaded ${loadedCount}/${iconNames.length} icons in ${duration}ms (${(loadedCount / duration).toFixed(2)} icons/ms)`, - ) - isPreloaded = true - })() - - return preloadPromise -} - -/** - * Reads an icon PNG file from the filesystem - * Uses React cache() for request-level memoization - * Uses in-memory Map for build-level caching - * If preloaded, returns immediately from cache - */ -export const readIconFile = cache(async (iconName: string): Promise => { - if (iconFileCache.has(iconName)) { - return iconFileCache.get(iconName)! - } - - try { - const iconPath = join(process.cwd(), `../png/${iconName}.png`) - const buffer = await readFile(iconPath) - iconFileCache.set(iconName, buffer) - return buffer - } catch (_error) { - iconFileCache.set(iconName, null) - return null - } -})