feat: add homepage projects showcase with mock data

- Add ProjectCard component with tag icons and relative timestamps
- Create mock projects data with server-side icon rendering
- Fix IconPicker reactivity using SvelteMap
- Add ESLint suppressions for @html tags
This commit is contained in:
2026-01-06 17:59:08 -06:00
parent eca50ef319
commit e32c776b6d
16 changed files with 780 additions and 532 deletions
+174 -160
View File
@@ -3,7 +3,12 @@ import { join } from "path";
import type { IconifyJSON } from "@iconify/types";
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
import { getLogger } from "@logtape/logtape";
import type { IconCollection, IconData, IconIdentifier, IconRenderOptions } from "$lib/types/icons";
import type {
IconCollection,
IconData,
IconIdentifier,
IconRenderOptions,
} from "$lib/types/icons";
const logger = getLogger(["server", "icons"]);
@@ -12,11 +17,11 @@ const collectionCache = new Map<string, IconifyJSON>();
// Collections to pre-cache on server startup
const PRE_CACHE_COLLECTIONS = [
"lucide",
"simple-icons",
"material-symbols",
"heroicons",
"feather",
"lucide",
"simple-icons",
"material-symbols",
"heroicons",
"feather",
];
// Default fallback icon
@@ -25,236 +30,245 @@ const DEFAULT_FALLBACK_ICON = "lucide:help-circle";
/**
* Parse icon identifier into collection and name
*/
function parseIdentifier(identifier: string): { collection: string; name: string } | null {
const parts = identifier.split(":");
if (parts.length !== 2) {
return null;
}
return { collection: parts[0], name: parts[1] };
function parseIdentifier(
identifier: string,
): { collection: string; name: string } | null {
const parts = identifier.split(":");
if (parts.length !== 2) {
return null;
}
return { collection: parts[0], name: parts[1] };
}
/**
* Load icon collection from @iconify/json
*/
async function loadCollection(collection: string): Promise<IconifyJSON | null> {
// Check cache first
if (collectionCache.has(collection)) {
return collectionCache.get(collection)!;
}
// Check cache first
if (collectionCache.has(collection)) {
return collectionCache.get(collection)!;
}
try {
const iconifyJsonPath = join(
process.cwd(),
"node_modules",
"@iconify",
"json",
"json",
`${collection}.json`,
);
try {
const iconifyJsonPath = join(
process.cwd(),
"node_modules",
"@iconify",
"json",
"json",
`${collection}.json`,
);
const data = await readFile(iconifyJsonPath, "utf-8");
const iconSet: IconifyJSON = JSON.parse(data);
const data = await readFile(iconifyJsonPath, "utf-8");
const iconSet: IconifyJSON = JSON.parse(data);
// Cache the collection
collectionCache.set(collection, iconSet);
// Cache the collection
collectionCache.set(collection, iconSet);
logger.debug(`Loaded icon collection: ${collection}`, {
total: iconSet.info?.total || Object.keys(iconSet.icons).length,
});
logger.debug(`Loaded icon collection: ${collection}`, {
total: iconSet.info?.total || Object.keys(iconSet.icons).length,
});
return iconSet;
} catch (error) {
logger.warn(`Failed to load icon collection: ${collection}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
return iconSet;
} catch (error) {
logger.warn(`Failed to load icon collection: ${collection}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Get icon data by identifier
*/
export async function getIcon(identifier: string): Promise<IconData | null> {
const parsed = parseIdentifier(identifier);
if (!parsed) {
logger.warn(`Invalid icon identifier: ${identifier}`);
return null;
}
const parsed = parseIdentifier(identifier);
if (!parsed) {
logger.warn(`Invalid icon identifier: ${identifier}`);
return null;
}
const { collection, name } = parsed;
const iconSet = await loadCollection(collection);
const { collection, name } = parsed;
const iconSet = await loadCollection(collection);
if (!iconSet) {
return null;
}
if (!iconSet) {
return null;
}
// Get icon data from the set
const iconData = getIconData(iconSet, name);
if (!iconData) {
logger.warn(`Icon not found: ${identifier}`);
return null;
}
// Get icon data from the set
const iconData = getIconData(iconSet, name);
if (!iconData) {
logger.warn(`Icon not found: ${identifier}`);
return null;
}
// Build SVG
const svg = renderIconData(iconData, iconSet);
// Build SVG
const svg = renderIconData(iconData);
return {
identifier: identifier as IconIdentifier,
collection,
name,
svg,
};
return {
identifier: identifier as IconIdentifier,
collection,
name,
svg,
};
}
/**
* Render icon data to SVG string
*/
function renderIconData(iconData: any, iconSet: IconifyJSON): string {
// Convert icon data to SVG attributes
const renderData = iconToSVG(iconData);
function renderIconData(iconData: ReturnType<typeof getIconData>): string {
if (!iconData) {
throw new Error("Icon data is null");
}
// Get SVG body
const body = replaceIDs(iconData.body);
// Convert icon data to SVG attributes
const renderData = iconToSVG(iconData);
// Build SVG element
const attributes = {
...renderData.attributes,
xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
};
// Get SVG body
const body = replaceIDs(iconData.body);
const attributeString = Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(" ");
// Build SVG element
const attributes = {
...renderData.attributes,
xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
};
return `<svg ${attributeString}>${body}</svg>`;
const attributeString = Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(" ");
return `<svg ${attributeString}>${body}</svg>`;
}
/**
* Render icon SVG with custom options
*/
export async function renderIconSVG(
identifier: string,
options: IconRenderOptions = {},
identifier: string,
options: IconRenderOptions = {},
): Promise<string | null> {
const iconData = await getIcon(identifier);
const iconData = await getIcon(identifier);
if (!iconData) {
// Try fallback icon if provided, otherwise use default
if (identifier !== DEFAULT_FALLBACK_ICON) {
logger.warn(`Icon not found, using fallback: ${identifier}`);
return renderIconSVG(DEFAULT_FALLBACK_ICON, options);
}
return null;
}
if (!iconData) {
// Try fallback icon if provided, otherwise use default
if (identifier !== DEFAULT_FALLBACK_ICON) {
logger.warn(`Icon not found, using fallback: ${identifier}`);
return renderIconSVG(DEFAULT_FALLBACK_ICON, options);
}
return null;
}
let svg = iconData.svg;
let svg = iconData.svg;
// Apply custom class
if (options.class) {
svg = svg.replace("<svg ", `<svg class="${options.class}" `);
}
// Apply custom class
if (options.class) {
svg = svg.replace("<svg ", `<svg class="${options.class}" `);
}
// Apply custom size
if (options.size) {
svg = svg.replace(/width="[^"]*"/, `width="${options.size}"`);
svg = svg.replace(/height="[^"]*"/, `height="${options.size}"`);
}
// Apply custom size
if (options.size) {
svg = svg.replace(/width="[^"]*"/, `width="${options.size}"`);
svg = svg.replace(/height="[^"]*"/, `height="${options.size}"`);
}
// Apply custom color (replace currentColor)
if (options.color) {
svg = svg.replace(/currentColor/g, options.color);
}
// Apply custom color (replace currentColor)
if (options.color) {
svg = svg.replace(/currentColor/g, options.color);
}
return svg;
return svg;
}
/**
* Get all available collections
*/
export async function getCollections(): Promise<IconCollection[]> {
const collections: IconCollection[] = [];
const collections: IconCollection[] = [];
// Load common collections to get metadata
for (const collectionId of PRE_CACHE_COLLECTIONS) {
const iconSet = await loadCollection(collectionId);
if (iconSet && iconSet.info) {
collections.push({
id: collectionId,
name: iconSet.info.name || collectionId,
total: iconSet.info.total || Object.keys(iconSet.icons).length,
category: iconSet.info.category,
prefix: iconSet.prefix,
});
}
}
// Load common collections to get metadata
for (const collectionId of PRE_CACHE_COLLECTIONS) {
const iconSet = await loadCollection(collectionId);
if (iconSet && iconSet.info) {
collections.push({
id: collectionId,
name: iconSet.info.name || collectionId,
total: iconSet.info.total || Object.keys(iconSet.icons).length,
category: iconSet.info.category,
prefix: iconSet.prefix,
});
}
}
return collections;
return collections;
}
/**
* Search icons across collections
*/
export async function searchIcons(
query: string,
limit: number = 50,
query: string,
limit: number = 50,
): Promise<{ identifier: string; collection: string; name: string }[]> {
const results: { identifier: string; collection: string; name: string }[] = [];
const results: { identifier: string; collection: string; name: string }[] =
[];
// Parse query for collection prefix (e.g., "lucide:home" or "lucide:")
const colonIndex = query.indexOf(":");
let targetCollection: string | null = null;
let searchTerm = query.toLowerCase();
// Parse query for collection prefix (e.g., "lucide:home" or "lucide:")
const colonIndex = query.indexOf(":");
let targetCollection: string | null = null;
let searchTerm = query.toLowerCase();
if (colonIndex !== -1) {
targetCollection = query.substring(0, colonIndex);
searchTerm = query.substring(colonIndex + 1).toLowerCase();
}
if (colonIndex !== -1) {
targetCollection = query.substring(0, colonIndex);
searchTerm = query.substring(colonIndex + 1).toLowerCase();
}
// Determine which collections to search
const collectionsToSearch = targetCollection
? [targetCollection]
: PRE_CACHE_COLLECTIONS;
// Determine which collections to search
const collectionsToSearch = targetCollection
? [targetCollection]
: PRE_CACHE_COLLECTIONS;
for (const collectionId of collectionsToSearch) {
if (results.length >= limit) break;
for (const collectionId of collectionsToSearch) {
if (results.length >= limit) break;
const iconSet = await loadCollection(collectionId);
if (!iconSet) continue;
const iconSet = await loadCollection(collectionId);
if (!iconSet) continue;
const iconNames = Object.keys(iconSet.icons);
const iconNames = Object.keys(iconSet.icons);
for (const iconName of iconNames) {
if (results.length >= limit) break;
for (const iconName of iconNames) {
if (results.length >= limit) break;
// Search in icon name
if (searchTerm === "" || iconName.toLowerCase().includes(searchTerm)) {
results.push({
identifier: `${collectionId}:${iconName}`,
collection: collectionId,
name: iconName,
});
}
}
}
// Search in icon name
if (searchTerm === "" || iconName.toLowerCase().includes(searchTerm)) {
results.push({
identifier: `${collectionId}:${iconName}`,
collection: collectionId,
name: iconName,
});
}
}
}
return results;
return results;
}
/**
* Pre-cache common icon collections on server startup
*/
export async function preCacheCollections(): Promise<void> {
logger.info("Pre-caching icon collections...", {
collections: PRE_CACHE_COLLECTIONS,
});
logger.info("Pre-caching icon collections...", {
collections: PRE_CACHE_COLLECTIONS,
});
const promises = PRE_CACHE_COLLECTIONS.map((collection) => loadCollection(collection));
await Promise.all(promises);
const promises = PRE_CACHE_COLLECTIONS.map((collection) =>
loadCollection(collection),
);
await Promise.all(promises);
logger.info("Icon collections pre-cached", {
cached: collectionCache.size,
});
logger.info("Icon collections pre-cached", {
cached: collectionCache.size,
});
}
// TODO: Future enhancement - Support color customization in icon identifiers