From eca50ef319dad2bb675c83a50c49654e3104b5c7 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 16:01:09 -0600 Subject: [PATCH] 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 --- src/bin/seed.rs | 12 +- src/main.rs | 53 +++ web/package.json | 6 +- web/src/lib/components/Icon.svelte | 29 ++ .../lib/components/admin/IconPicker.svelte | 340 ++++++++++++++++++ .../lib/components/admin/ProjectForm.svelte | 9 +- web/src/lib/server/auth.ts | 24 ++ web/src/lib/server/icons.ts | 262 ++++++++++++++ web/src/lib/types/icons.ts | 45 +++ .../api/icons/[collection]/[name]/+server.ts | 20 ++ .../routes/api/icons/collections/+server.ts | 16 + web/src/routes/api/icons/search/+server.ts | 21 ++ web/src/routes/projects/+page.server.ts | 15 +- web/src/routes/projects/+page.svelte | 11 +- 14 files changed, 840 insertions(+), 23 deletions(-) create mode 100644 web/src/lib/components/Icon.svelte create mode 100644 web/src/lib/components/admin/IconPicker.svelte create mode 100644 web/src/lib/server/auth.ts create mode 100644 web/src/lib/server/icons.ts create mode 100644 web/src/lib/types/icons.ts create mode 100644 web/src/routes/api/icons/[collection]/[name]/+server.ts create mode 100644 web/src/routes/api/icons/collections/+server.ts create mode 100644 web/src/routes/api/icons/search/+server.ts diff --git a/src/bin/seed.rs b/src/bin/seed.rs index b26b8e1..4af54e4 100644 --- a/src/bin/seed.rs +++ b/src/bin/seed.rs @@ -22,7 +22,7 @@ async fn main() -> Result<(), Box> { Some("Xevion/xevion.dev"), None, 10, - Some("fa-globe"), + Some("lucide:globe"), ), ( "contest", @@ -32,7 +32,7 @@ async fn main() -> Result<(), Box> { Some("Xevion/contest"), Some("https://contest.xevion.dev"), 9, - Some("fa-trophy"), + Some("lucide:trophy"), ), ( "reforge", @@ -42,7 +42,7 @@ async fn main() -> Result<(), Box> { Some("Xevion/reforge"), None, 8, - Some("fa-file-code"), + Some("lucide:file-code"), ), ( "algorithms", @@ -52,7 +52,7 @@ async fn main() -> Result<(), Box> { Some("Xevion/algorithms"), None, 5, - Some("fa-brain"), + Some("lucide:brain"), ), ( "wordplay", @@ -62,7 +62,7 @@ async fn main() -> Result<(), Box> { Some("Xevion/wordplay"), Some("https://wordplay.example.com"), 7, - Some("fa-gamepad"), + Some("lucide:gamepad-2"), ), ( "dotfiles", @@ -72,7 +72,7 @@ async fn main() -> Result<(), Box> { Some("Xevion/dotfiles"), None, 6, - Some("fa-terminal"), + Some("lucide:terminal"), ), ]; diff --git a/src/main.rs b/src/main.rs index db177e4..d261c2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -378,6 +378,8 @@ fn api_routes() -> Router> { "/tags/recalculate-cooccurrence", axum::routing::post(recalculate_cooccurrence_handler), ) + // Icon API - proxy to SvelteKit (authentication handled by SvelteKit) + .route("/icons/{*path}", axum::routing::get(proxy_icons_handler)) .fallback(api_404_and_method_handler) } @@ -529,6 +531,57 @@ async fn projects_handler(State(state): State>) -> impl IntoRespon } } +// Icon API handler - proxy to SvelteKit +async fn proxy_icons_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, + axum::extract::Path(path): axum::extract::Path, + req: Request, +) -> impl IntoResponse { + let full_path = format!("/api/icons/{}", path); + let query = req.uri().query().unwrap_or(""); + + let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./") + { + if query.is_empty() { + format!("http://localhost{}", full_path) + } else { + format!("http://localhost{}?{}", full_path, query) + } + } else if query.is_empty() { + format!("{}{}", state.downstream_url, full_path) + } else { + format!("{}{}?{}", state.downstream_url, full_path, query) + }; + + // Build trusted headers with session info + let mut forward_headers = HeaderMap::new(); + + if let Some(cookie) = jar.get("admin_session") { + if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) { + if let Some(session) = state.session_manager.validate_session(session_id) { + if let Ok(username_value) = axum::http::HeaderValue::from_str(&session.username) { + forward_headers.insert("x-session-user", username_value); + } + } + } + } + + match proxy_to_bun(&bun_url, state, forward_headers).await { + Ok((status, headers, body)) => (status, headers, body).into_response(), + Err(err) => { + tracing::error!(error = %err, path = %full_path, "Failed to proxy icon request"); + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ + "error": "Failed to fetch icon data" + })), + ) + .into_response() + } + } +} + // Tag API handlers async fn list_tags_handler(State(state): State>) -> impl IntoResponse { diff --git a/web/package.json b/web/package.json index 65b692a..69e67fc 100644 --- a/web/package.json +++ b/web/package.json @@ -4,9 +4,9 @@ "packageManager": "bun@latest", "scripts": { "preinstall": "npx only-allow bun", - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", + "dev": "bunx --bun vite dev", + "build": "bunx --bun vite build", + "preview": "bunx --bun vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/web/src/lib/components/Icon.svelte b/web/src/lib/components/Icon.svelte new file mode 100644 index 0000000..f3a2360 --- /dev/null +++ b/web/src/lib/components/Icon.svelte @@ -0,0 +1,29 @@ + + + + +{#await renderIconSVG(icon, { class: cn("inline-block", className), size })} + +{:then svg} + {#if svg} + {@html svg} + {:else} + + {#await renderIconSVG(fallback, { class: cn("inline-block", className), size }) then fallbackSvg} + {@html fallbackSvg} + {/await} + {/if} +{/await} diff --git a/web/src/lib/components/admin/IconPicker.svelte b/web/src/lib/components/admin/IconPicker.svelte new file mode 100644 index 0000000..ad1b794 --- /dev/null +++ b/web/src/lib/components/admin/IconPicker.svelte @@ -0,0 +1,340 @@ + + +
+ {#if label} + + {/if} + + + {#if selectedIcon} +
+
+ {#if selectedIconSvg} + {@html selectedIconSvg} + {:else} +
+ {/if} +
+
+

{selectedIcon}

+
+ +
+ {/if} + + +
+ + {#each collections as collection (collection.id)} + + {/each} +
+ + +
+ + + + {#if showDropdown && searchResults.length > 0} +
+ +
+ {#each searchResults as result (result.identifier)} + {@const cachedSvg = iconSvgCache.get(result.identifier)} + + {/each} +
+ + {#if isLoading} +
+ Loading... +
+ {/if} +
+ {:else if showDropdown && searchQuery && !isLoading} +
+ No icons found for "{searchQuery}" +
+ {/if} +
+ +

+ Tip: Use "collection:search" to filter (e.g., "lucide:home" or "simple-icons:react") +

+
+ + + + + + diff --git a/web/src/lib/components/admin/ProjectForm.svelte b/web/src/lib/components/admin/ProjectForm.svelte index f708a25..0ef3cac 100644 --- a/web/src/lib/components/admin/ProjectForm.svelte +++ b/web/src/lib/components/admin/ProjectForm.svelte @@ -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 @@ - diff --git a/web/src/lib/server/auth.ts b/web/src/lib/server/auth.ts new file mode 100644 index 0000000..9634eaa --- /dev/null +++ b/web/src/lib/server/auth.ts @@ -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"); +} diff --git a/web/src/lib/server/icons.ts b/web/src/lib/server/icons.ts new file mode 100644 index 0000000..a146226 --- /dev/null +++ b/web/src/lib/server/icons.ts @@ -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(); + +// 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 { + // 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 { + 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 `${body}`; +} + +/** + * Render icon SVG with custom options + */ +export async function renderIconSVG( + identifier: string, + options: IconRenderOptions = {}, +): Promise { + 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(" { + 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 { + 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 diff --git a/web/src/lib/types/icons.ts b/web/src/lib/types/icons.ts new file mode 100644 index 0000000..f0076c9 --- /dev/null +++ b/web/src/lib/types/icons.ts @@ -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; +} diff --git a/web/src/routes/api/icons/[collection]/[name]/+server.ts b/web/src/routes/api/icons/[collection]/[name]/+server.ts new file mode 100644 index 0000000..3961fec --- /dev/null +++ b/web/src/routes/api/icons/[collection]/[name]/+server.ts @@ -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); +}; diff --git a/web/src/routes/api/icons/collections/+server.ts b/web/src/routes/api/icons/collections/+server.ts new file mode 100644 index 0000000..6b3766f --- /dev/null +++ b/web/src/routes/api/icons/collections/+server.ts @@ -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, + }); +}; diff --git a/web/src/routes/api/icons/search/+server.ts b/web/src/routes/api/icons/search/+server.ts new file mode 100644 index 0000000..c7fd1fa --- /dev/null +++ b/web/src/routes/api/icons/search/+server.ts @@ -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, + }); +}; diff --git a/web/src/routes/projects/+page.server.ts b/web/src/routes/projects/+page.server.ts index 88f5db5..0a25272 100644 --- a/web/src/routes/projects/+page.server.ts +++ b/web/src/routes/projects/+page.server.ts @@ -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("/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: "...", diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte index 993e96d..a06b97b 100644 --- a/web/src/routes/projects/+page.svelte +++ b/web/src/routes/projects/+page.svelte @@ -1,8 +1,8 @@ @@ -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" >
- + {@html (project as any).iconSvg}