feat: add Iconify-based icon system with search and picker UI

- Replace Font Awesome with Iconify (@iconify/json collections)
- Add IconPicker component with search, collection filtering, lazy loading
- Create authenticated icon API endpoints (search, collections, individual icons)
- Update projects page to render icons via Icon.svelte component
- Pre-cache common icon collections (Lucide, Simple Icons, etc.) on startup
This commit is contained in:
2026-01-06 16:01:09 -06:00
parent 6657d00c4e
commit eca50ef319
14 changed files with 840 additions and 23 deletions
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts" module>
import { renderIconSVG } from "$lib/server/icons";
</script>
<script lang="ts">
import { cn } from "$lib/utils";
interface Props {
icon: string;
class?: string;
size?: number;
fallback?: string;
}
let { icon, class: className, size, fallback = "lucide:help-circle" }: Props = $props();
</script>
{#await renderIconSVG(icon, { class: cn("inline-block", className), size })}
<!-- Loading state during SSR (shouldn't be visible) -->
{:then svg}
{#if svg}
{@html svg}
{:else}
<!-- Fallback icon if primary fails -->
{#await renderIconSVG(fallback, { class: cn("inline-block", className), size }) then fallbackSvg}
{@html fallbackSvg}
{/await}
{/if}
{/await}
@@ -0,0 +1,340 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { IconCollection } from "$lib/types/icons";
interface Props {
selectedIcon: string;
label?: string;
placeholder?: string;
class?: string;
}
let {
selectedIcon = $bindable(""),
label,
placeholder = "Search icons... (e.g., lucide:home or just home)",
class: className,
}: Props = $props();
let searchQuery = $state("");
let searchResults = $state<
Array<{ identifier: string; collection: string; name: string }>
>([]);
let collections = $state<IconCollection[]>([]);
let selectedCollection = $state<string>("all");
let isLoading = $state(false);
let showDropdown = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Load SVG cache for preview
let iconSvgCache = $state<Map<string, string>>(new Map());
let selectedIconSvg = $state<string | null>(null);
// IntersectionObserver for lazy loading icons
let observer: IntersectionObserver | null = null;
// Generate unique ID for accessibility
const inputId = `iconpicker-${Math.random().toString(36).substring(2, 11)}`;
// Load collections on mount and setup observer
$effect(() => {
loadCollections();
setupIntersectionObserver();
return () => {
if (observer) {
observer.disconnect();
}
};
});
// Load selected icon SVG
$effect(() => {
if (selectedIcon) {
loadIconSvg(selectedIcon);
}
});
function setupIntersectionObserver() {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const identifier = entry.target.getAttribute("data-icon-id");
if (identifier && !iconSvgCache.has(identifier)) {
loadIconSvg(identifier);
}
}
}
},
{
root: null,
rootMargin: "50px",
threshold: 0.01,
},
);
}
// Debounced search
$effect(() => {
if (searchQuery) {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showDropdown = false;
}
});
async function loadCollections() {
try {
const response = await fetch("/api/icons/collections");
if (response.ok) {
const data = await response.json();
collections = data.collections;
}
} catch (error) {
console.error("Failed to load collections:", error);
}
}
async function performSearch() {
isLoading = true;
showDropdown = true;
try {
// Build query with collection filter if not "all"
let query = searchQuery;
if (selectedCollection !== "all" && !query.includes(":")) {
query = `${selectedCollection}:${query}`;
}
const response = await fetch(`/api/icons/search?q=${encodeURIComponent(query)}&limit=100`);
if (response.ok) {
const data = await response.json();
searchResults = data.icons;
// Wait for DOM to update, then observe icon elements
setTimeout(() => observeIconElements(), 100);
}
} catch (error) {
console.error("Failed to search icons:", error);
} finally {
isLoading = false;
}
}
function observeIconElements() {
if (!observer) return;
// Find all icon button elements and observe them
const iconButtons = document.querySelectorAll(`[data-icon-id]`);
for (const button of iconButtons) {
observer.observe(button);
}
}
async function loadIconSvg(identifier: string) {
// Check cache first
if (iconSvgCache.has(identifier)) {
if (identifier === selectedIcon) {
selectedIconSvg = iconSvgCache.get(identifier)!;
}
return;
}
try {
const [collection, name] = identifier.split(":");
const response = await fetch(`/api/icons/${collection}/${name}`);
if (response.ok) {
const data = await response.json();
// Trigger reactivity by creating a new Map
iconSvgCache = new Map(iconSvgCache).set(identifier, data.svg);
if (identifier === selectedIcon) {
selectedIconSvg = data.svg;
}
}
} catch (error) {
console.error("Failed to load icon SVG:", error);
if (identifier === selectedIcon) {
selectedIconSvg = null;
}
}
}
function selectIcon(identifier: string) {
selectedIcon = identifier;
searchQuery = "";
showDropdown = false;
loadIconSvg(identifier);
}
function handleInputFocus() {
if (searchQuery && searchResults.length > 0) {
showDropdown = true;
}
}
function handleInputBlur() {
setTimeout(() => {
showDropdown = false;
}, 200);
}
function clearSelection() {
selectedIcon = "";
selectedIconSvg = null;
}
</script>
<div class={cn("space-y-2", className)}>
{#if label}
<label for={inputId} class="block text-sm font-medium text-admin-text">
{label}
</label>
{/if}
<!-- Selected icon preview -->
{#if selectedIcon}
<div
class="flex items-center gap-3 rounded-md border border-admin-border bg-admin-panel p-3"
>
<div class="flex size-10 items-center justify-center rounded bg-admin-bg">
{#if selectedIconSvg}
{@html selectedIconSvg}
{:else}
<div class="size-6 animate-pulse rounded bg-zinc-700"></div>
{/if}
</div>
<div class="flex-1">
<p class="text-sm font-medium text-admin-text">{selectedIcon}</p>
</div>
<button
type="button"
onclick={clearSelection}
class="rounded px-2 py-1 text-sm text-admin-text-muted hover:bg-admin-hover hover:text-admin-text"
>
Clear
</button>
</div>
{/if}
<!-- Collection tabs -->
<div class="flex flex-wrap gap-1">
<button
type="button"
class={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
selectedCollection === "all"
? "bg-indigo-600 text-white"
: "bg-admin-panel text-admin-text-muted hover:bg-admin-hover hover:text-admin-text",
)}
onclick={() => (selectedCollection = "all")}
>
All
</button>
{#each collections as collection (collection.id)}
<button
type="button"
class={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
selectedCollection === collection.id
? "bg-indigo-600 text-white"
: "bg-admin-panel text-admin-text-muted hover:bg-admin-hover hover:text-admin-text",
)}
onclick={() => (selectedCollection = collection.id)}
>
{collection.name}
<span class="ml-1 text-xs opacity-60">({collection.total})</span>
</button>
{/each}
</div>
<!-- Search input -->
<div class="relative">
<input
id={inputId}
type="text"
bind:value={searchQuery}
{placeholder}
class="w-full rounded-md border border-admin-border bg-admin-panel px-3 py-2 text-sm text-admin-text placeholder:text-admin-text-muted focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onfocus={handleInputFocus}
onblur={handleInputBlur}
/>
<!-- Search results dropdown -->
{#if showDropdown && searchResults.length > 0}
<div
class="absolute z-10 mt-1 max-h-96 w-full overflow-auto rounded-md border border-admin-border bg-admin-panel shadow-lg"
>
<!-- Grid layout for icons -->
<div class="grid grid-cols-8 gap-1 p-2">
{#each searchResults as result (result.identifier)}
{@const cachedSvg = iconSvgCache.get(result.identifier)}
<button
type="button"
data-icon-id={result.identifier}
class="group relative flex size-12 items-center justify-center rounded hover:bg-admin-hover"
onclick={() => selectIcon(result.identifier)}
title={result.identifier}
>
<!-- Lazy load icon SVG via IntersectionObserver -->
<div class="size-9 text-admin-text">
{#if cachedSvg}
{@html cachedSvg}
{:else}
<div class="size-full animate-pulse rounded bg-zinc-700"></div>
{/if}
</div>
<!-- Tooltip on hover -->
<div
class="pointer-events-none absolute -top-8 left-1/2 z-20 hidden -translate-x-1/2 whitespace-nowrap rounded bg-zinc-900 px-2 py-1 text-xs text-white group-hover:block"
>
{result.name}
</div>
</button>
{/each}
</div>
{#if isLoading}
<div class="border-t border-admin-border p-3 text-center text-sm text-admin-text-muted">
Loading...
</div>
{/if}
</div>
{:else if showDropdown && searchQuery && !isLoading}
<div
class="absolute z-10 mt-1 w-full rounded-md border border-admin-border bg-admin-panel p-3 text-center text-sm text-admin-text-muted shadow-lg"
>
No icons found for "{searchQuery}"
</div>
{/if}
</div>
<p class="text-xs text-admin-text-muted">
Tip: Use "collection:search" to filter (e.g., "lucide:home" or "simple-icons:react")
</p>
</div>
<!-- TODO: Future enhancement - Recent/favorite icons -->
<!-- Store recently used icons in localStorage for quick access -->
<!-- Could add "star" button to favorite frequently used icons -->
<style>
/* Ensure SVG icons inherit size */
:global(.size-9 svg) {
width: 100%;
height: 100%;
}
:global(.size-10 svg) {
width: 1.5rem;
height: 1.5rem;
}
</style>
@@ -2,6 +2,7 @@
import Button from "./Button.svelte";
import Input from "./Input.svelte";
import TagPicker from "./TagPicker.svelte";
import IconPicker from "./IconPicker.svelte";
import type {
AdminProject,
AdminTag,
@@ -168,12 +169,10 @@
</div>
<!-- Icon -->
<Input
<IconPicker
label="Icon"
type="text"
bind:value={icon}
placeholder="fa-rocket"
help="Font Awesome icon class (e.g., fa-rocket, fa-heart)"
bind:selectedIcon={icon}
placeholder="Search icons... (e.g., lucide:home or simple-icons:react)"
/>
<!-- Tags -->
+24
View File
@@ -0,0 +1,24 @@
import { error } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
/**
* Check if the request is authenticated
* Returns the username if authenticated, throws 401 error if not
*/
export function requireAuth(event: RequestEvent): string {
const sessionUser = event.request.headers.get("x-session-user");
if (!sessionUser) {
throw error(401, "Unauthorized");
}
return sessionUser;
}
/**
* Check if the request is authenticated (optional)
* Returns the username if authenticated, null if not
*/
export function getAuth(event: RequestEvent): string | null {
return event.request.headers.get("x-session-user");
}
+262
View File
@@ -0,0 +1,262 @@
import { readFile } from "fs/promises";
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";
const logger = getLogger(["server", "icons"]);
// In-memory cache for icon collections
const collectionCache = new Map<string, IconifyJSON>();
// Collections to pre-cache on server startup
const PRE_CACHE_COLLECTIONS = [
"lucide",
"simple-icons",
"material-symbols",
"heroicons",
"feather",
];
// Default fallback icon
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] };
}
/**
* 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)!;
}
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);
// Cache the collection
collectionCache.set(collection, iconSet);
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;
}
}
/**
* 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 { collection, name } = parsed;
const iconSet = await loadCollection(collection);
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;
}
// Build SVG
const svg = renderIconData(iconData, iconSet);
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);
// Get SVG body
const body = replaceIDs(iconData.body);
// Build SVG element
const attributes = {
...renderData.attributes,
xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
};
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 = {},
): Promise<string | null> {
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;
}
let svg = iconData.svg;
// 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 color (replace currentColor)
if (options.color) {
svg = svg.replace(/currentColor/g, options.color);
}
return svg;
}
/**
* Get all available collections
*/
export async function getCollections(): Promise<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,
});
}
}
return collections;
}
/**
* Search icons across collections
*/
export async function searchIcons(
query: string,
limit: number = 50,
): Promise<{ 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();
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;
for (const collectionId of collectionsToSearch) {
if (results.length >= limit) break;
const iconSet = await loadCollection(collectionId);
if (!iconSet) continue;
const iconNames = Object.keys(iconSet.icons);
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,
});
}
}
}
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,
});
const promises = PRE_CACHE_COLLECTIONS.map((collection) => loadCollection(collection));
await Promise.all(promises);
logger.info("Icon collections pre-cached", {
cached: collectionCache.size,
});
}
// TODO: Future enhancement - Support color customization in icon identifiers
// Format idea: "lucide:home#color=blue-500" or separate color field in DB
// Would allow per-project icon theming without hardcoded styles
+45
View File
@@ -0,0 +1,45 @@
/**
* Icon identifier in format "collection:name"
* Example: "lucide:home", "simple-icons:react"
*/
export type IconIdentifier = `${string}:${string}`;
/**
* Icon metadata for search results and picker
*/
export interface IconMetadata {
identifier: IconIdentifier;
collection: string;
name: string;
keywords?: string[];
}
/**
* Icon collection information
*/
export interface IconCollection {
id: string;
name: string;
total: number;
category?: string;
prefix: string;
}
/**
* Full icon data with SVG
*/
export interface IconData {
identifier: IconIdentifier;
collection: string;
name: string;
svg: string;
}
/**
* Options for rendering icon SVG
*/
export interface IconRenderOptions {
class?: string;
size?: number;
color?: string;
}
@@ -0,0 +1,20 @@
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { requireAuth } from "$lib/server/auth";
import { getIcon } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(event);
const { collection, name } = event.params;
const identifier = `${collection}:${name}`;
const iconData = await getIcon(identifier);
if (!iconData) {
throw error(404, `Icon not found: ${identifier}`);
}
return json(iconData);
};
@@ -0,0 +1,16 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { requireAuth } from "$lib/server/auth";
import { getCollections } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(event);
const collections = await getCollections();
return json({
collections,
count: collections.length,
});
};
@@ -0,0 +1,21 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { requireAuth } from "$lib/server/auth";
import { searchIcons } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(event);
const query = event.url.searchParams.get("q") || "";
const limitParam = event.url.searchParams.get("limit");
const limit = limitParam ? parseInt(limitParam, 10) : 50;
const results = await searchIcons(query, limit);
return json({
icons: results,
query,
count: results.length,
});
};
+14 -1
View File
@@ -1,6 +1,7 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { getOGImageUrl } from "$lib/og-types";
import { renderIconSVG } from "$lib/server/icons";
interface ProjectLink {
url: string;
@@ -13,13 +14,25 @@ export interface Project {
name: string;
shortDescription: string;
icon?: string;
iconSvg?: string;
links: ProjectLink[];
}
export const load: PageServerLoad = async ({ url }) => {
const projects = await apiFetch<Project[]>("/api/projects");
// Render icon SVGs server-side
const projectsWithIcons = await Promise.all(
projects.map(async (project) => ({
...project,
iconSvg: await renderIconSVG(project.icon ?? "lucide:heart", {
class: "text-3xl opacity-80 saturate-0",
}),
}))
);
return {
projects,
projects: projectsWithIcons,
metadata: {
title: "Projects | Xevion.dev",
description: "...",
+3 -8
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { cn } from "$lib/utils";
import type { PageData } from "./$types";
let { data } = $props();
let { data }: { data: PageData } = $props();
</script>
<AppWrapper>
@@ -35,12 +35,7 @@
class="flex items-center justify-start overflow-hidden rounded bg-black/10 pb-2.5 pl-3 pr-5 pt-1 text-zinc-400 transition-colors hover:bg-zinc-500/10 hover:text-zinc-50"
>
<div class="flex h-full w-14 items-center justify-center pr-5">
<i
class={cn(
project.icon ?? "fa-heart",
"fa-solid text-3xl text-opacity-80 saturate-0",
)}
></i>
{@html (project as any).iconSvg}
</div>
<div class="overflow-hidden">
<span class="text-sm md:text-base lg:text-lg">