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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"packageManager": "bun@latest",
"packageManager": "bun@1.3.5",
"scripts": {
"preinstall": "npx only-allow bun",
"dev": "bunx --bun vite dev",
+25 -18
View File
@@ -1,29 +1,36 @@
<script lang="ts" module>
import { renderIconSVG } from "$lib/server/icons";
import { renderIconSVG } from "$lib/server/icons";
</script>
<script lang="ts">
import { cn } from "$lib/utils";
import { cn } from "$lib/utils";
interface Props {
icon: string;
class?: string;
size?: number;
fallback?: string;
}
interface Props {
icon: string;
class?: string;
size?: number;
fallback?: string;
}
let { icon, class: className, size, fallback = "lucide:help-circle" }: Props = $props();
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) -->
<!-- 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}
{#if svg}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html svg}
{:else}
<!-- Fallback icon if primary fails -->
{#await renderIconSVG( fallback, { class: cn("inline-block", className), size }, ) then fallbackSvg}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html fallbackSvg}
{/await}
{/if}
{/await}
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { MockProject } from "$lib/mock-data/projects";
interface Props {
project: MockProject;
class?: string;
}
let { project, class: className }: Props = $props();
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffHours <= 48) return "yesterday";
if (diffDays < 30) return `${diffDays}d ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
return `${Math.floor(diffDays / 365)}y ago`;
}
</script>
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
class={cn(
"group flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 transition-all hover:border-zinc-700 hover:bg-zinc-800/70",
className,
)}
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<h3
class="truncate font-medium text-lg sm:text-base text-zinc-100 transition-colors group-hover:text-white"
>
{project.name}
</h3>
<span class="shrink-0 sm:text-[0.83rem] text-zinc-300">
{formatDate(project.updatedAt)}
</span>
</div>
<p class="line-clamp-3 sm:text-sm leading-relaxed text-zinc-400">
{project.description}
</p>
</div>
<div class="mt-auto flex flex-wrap gap-1">
{#each project.tags as tag (tag.name)}
<!-- TODO: Add link to project search with tag filtering -->
<span
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-300 border-l-3 border-l-cyan-500"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each}
</div>
</a>
+307 -298
View File
@@ -1,325 +1,334 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { IconCollection } from "$lib/types/icons";
import { SvelteMap } from "svelte/reactivity";
import { cn } from "$lib/utils";
import type { IconCollection } from "$lib/types/icons";
interface Props {
selectedIcon: string;
label?: string;
placeholder?: string;
class?: string;
}
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 {
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;
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);
// Load SVG cache for preview
let iconSvgCache = new SvelteMap<string, string>();
let selectedIconSvg = $state<string | null>(null);
// IntersectionObserver for lazy loading icons
let observer: IntersectionObserver | 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)}`;
// 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 collections on mount and setup observer
$effect(() => {
loadCollections();
setupIntersectionObserver();
// Load selected icon SVG
$effect(() => {
if (selectedIcon) {
loadIconSvg(selectedIcon);
}
});
return () => {
if (observer) {
observer.disconnect();
}
};
});
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,
},
);
}
// Load selected icon SVG
$effect(() => {
if (selectedIcon) {
loadIconSvg(selectedIcon);
}
});
// Debounced search
$effect(() => {
if (searchQuery) {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
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,
},
);
}
debounceTimer = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showDropdown = false;
}
});
// Debounced search
$effect(() => {
if (searchQuery) {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
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);
}
}
debounceTimer = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showDropdown = false;
}
});
async function performSearch() {
isLoading = true;
showDropdown = true;
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);
}
}
try {
// Build query with collection filter if not "all"
let query = searchQuery;
if (selectedCollection !== "all" && !query.includes(":")) {
query = `${selectedCollection}:${query}`;
}
async function performSearch() {
isLoading = true;
showDropdown = true;
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;
}
}
try {
// Build query with collection filter if not "all"
let query = searchQuery;
if (selectedCollection !== "all" && !query.includes(":")) {
query = `${selectedCollection}:${query}`;
}
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);
}
}
const response = await fetch(
`/api/icons/search?q=${encodeURIComponent(query)}&limit=100`,
);
if (response.ok) {
const data = await response.json();
searchResults = data.icons;
async function loadIconSvg(identifier: string) {
// Check cache first
if (iconSvgCache.has(identifier)) {
if (identifier === selectedIcon) {
selectedIconSvg = iconSvgCache.get(identifier)!;
}
return;
}
// Wait for DOM to update, then observe icon elements
setTimeout(() => observeIconElements(), 100);
}
} catch (error) {
console.error("Failed to search icons:", error);
} finally {
isLoading = false;
}
}
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 observeIconElements() {
if (!observer) return;
function selectIcon(identifier: string) {
selectedIcon = identifier;
searchQuery = "";
showDropdown = false;
loadIconSvg(identifier);
}
// Find all icon button elements and observe them
const iconButtons = document.querySelectorAll(`[data-icon-id]`);
for (const button of iconButtons) {
observer.observe(button);
}
}
function handleInputFocus() {
if (searchQuery && searchResults.length > 0) {
showDropdown = true;
}
}
async function loadIconSvg(identifier: string) {
// Check cache first
if (iconSvgCache.has(identifier)) {
if (identifier === selectedIcon) {
selectedIconSvg = iconSvgCache.get(identifier)!;
}
return;
}
function handleInputBlur() {
setTimeout(() => {
showDropdown = false;
}, 200);
}
try {
const [collection, name] = identifier.split(":");
const response = await fetch(`/api/icons/${collection}/${name}`);
if (response.ok) {
const data = await response.json();
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 clearSelection() {
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}
{#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}
<!-- 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}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@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>
<!-- 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 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>
<!-- 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}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@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>
<!-- 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>
{#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>
<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 -->
@@ -327,14 +336,14 @@
<!-- Could add "star" button to favorite frequently used icons -->
<style>
/* Ensure SVG icons inherit size */
:global(.size-9 svg) {
width: 100%;
height: 100%;
}
/* Ensure SVG icons inherit size */
:global(.size-9 svg) {
width: 100%;
height: 100%;
}
:global(.size-10 svg) {
width: 1.5rem;
height: 1.5rem;
}
:global(.size-10 svg) {
width: 1.5rem;
height: 1.5rem;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
export interface MockProjectTag {
name: string;
icon: string; // Icon identifier like "simple-icons:rust"
iconSvg?: string; // Pre-rendered SVG (populated server-side)
}
export interface MockProject {
id: string;
name: string;
description: string;
url: string;
tags: MockProjectTag[];
updatedAt: string;
clockIconSvg?: string; // Pre-rendered clock icon for "Updated" text
}
export const MOCK_PROJECTS: MockProject[] = [
{
id: "1",
name: "xevion.dev",
description:
"Personal portfolio showcasing projects and technical expertise. Built with Rust backend, SvelteKit frontend, and PostgreSQL.",
url: "https://github.com/Xevion/xevion.dev",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "SvelteKit", icon: "simple-icons:svelte" },
{ name: "PostgreSQL", icon: "cib:postgresql" },
],
updatedAt: "2026-01-06T22:12:37Z",
},
{
id: "2",
name: "historee",
description:
"Powerful browser history analyzer for visualizing and understanding web browsing patterns across multiple browsers.",
url: "https://github.com/Xevion/historee",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "CLI", icon: "lucide:terminal" },
{ name: "Analytics", icon: "lucide:bar-chart-3" },
],
updatedAt: "2026-01-06T06:01:27Z",
},
{
id: "3",
name: "satori-html",
description:
"HTML adapter for Vercel's Satori library, enabling generation of beautiful social card images from HTML markup.",
url: "https://github.com/Xevion/satori-html",
tags: [
{ name: "TypeScript", icon: "simple-icons:typescript" },
{ name: "NPM", icon: "simple-icons:npm" },
{ name: "Graphics", icon: "lucide:image" },
],
updatedAt: "2026-01-05T20:23:07Z",
},
{
id: "4",
name: "byte-me",
description:
"Cross-platform media bitrate visualizer with real-time analysis. Built with Tauri for native performance and modern UI.",
url: "https://github.com/Xevion/byte-me",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "Tauri", icon: "simple-icons:tauri" },
{ name: "Desktop", icon: "lucide:monitor" },
{ name: "Media", icon: "lucide:video" },
],
updatedAt: "2026-01-05T05:09:09Z",
},
{
id: "5",
name: "rdap",
description:
"Modern RDAP query client for domain registration data lookup. Clean interface built with static Next.js for instant loads.",
url: "https://github.com/Xevion/rdap",
tags: [
{ name: "TypeScript", icon: "simple-icons:typescript" },
{ name: "Next.js", icon: "simple-icons:nextdotjs" },
{ name: "Networking", icon: "lucide:network" },
],
updatedAt: "2026-01-05T10:36:55Z",
},
{
id: "6",
name: "rebinded",
description:
"Cross-platform key remapping daemon with per-application context awareness and intelligent stateful debouncing.",
url: "https://github.com/Xevion/rebinded",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "System", icon: "lucide:settings-2" },
{ name: "Cross-platform", icon: "lucide:globe" },
],
updatedAt: "2026-01-01T00:34:09Z",
},
];
+6 -6
View File
@@ -6,13 +6,13 @@ import type { RequestEvent } from "@sveltejs/kit";
* 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");
const sessionUser = event.request.headers.get("x-session-user");
if (!sessionUser) {
throw error(401, "Unauthorized");
}
if (!sessionUser) {
throw error(401, "Unauthorized");
}
return sessionUser;
return sessionUser;
}
/**
@@ -20,5 +20,5 @@ export function requireAuth(event: RequestEvent): string {
* Returns the username if authenticated, null if not
*/
export function getAuth(event: RequestEvent): string | null {
return event.request.headers.get("x-session-user");
return event.request.headers.get("x-session-user");
}
+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
+16 -16
View File
@@ -8,38 +8,38 @@ export type IconIdentifier = `${string}:${string}`;
* Icon metadata for search results and picker
*/
export interface IconMetadata {
identifier: IconIdentifier;
collection: string;
name: string;
keywords?: string[];
identifier: IconIdentifier;
collection: string;
name: string;
keywords?: string[];
}
/**
* Icon collection information
*/
export interface IconCollection {
id: string;
name: string;
total: number;
category?: string;
prefix: string;
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;
identifier: IconIdentifier;
collection: string;
name: string;
svg: string;
}
/**
* Options for rendering icon SVG
*/
export interface IconRenderOptions {
class?: string;
size?: number;
color?: string;
class?: string;
size?: number;
color?: string;
}
+36
View File
@@ -0,0 +1,36 @@
import type { PageServerLoad } from "./$types";
import { MOCK_PROJECTS } from "$lib/mock-data/projects";
import { renderIconSVG } from "$lib/server/icons";
// import { apiFetch } from '$lib/api.server';
// import type { ApiProjectWithTags } from '$lib/admin-types';
export const load: PageServerLoad = async () => {
// TODO: Replace with real API data
// const projects = await apiFetch<ApiProjectWithTags[]>('/api/projects', { fetch });
// Pre-render icon SVGs for tags (server-side only)
const projectsWithIcons = await Promise.all(
MOCK_PROJECTS.map(async (project) => {
const tagsWithIcons = await Promise.all(
project.tags.map(async (tag) => ({
...tag,
iconSvg: (await renderIconSVG(tag.icon, { size: 12 })) || "",
})),
);
const clockIconSvg =
(await renderIconSVG("lucide:clock", { size: 12 })) || "";
return {
...project,
tags: tagsWithIcons,
clockIconSvg,
};
}),
);
return {
projects: projectsWithIcons,
};
};
+14 -1
View File
@@ -1,10 +1,15 @@
<script lang="ts">
import AppWrapper from "$lib/components/AppWrapper.svelte";
import ProjectCard from "$lib/components/ProjectCard.svelte";
import type { PageData } from "./$types";
import IconSimpleIconsGithub from "~icons/simple-icons/github";
import IconSimpleIconsLinkedin from "~icons/simple-icons/linkedin";
import IconSimpleIconsDiscord from "~icons/simple-icons/discord";
import MaterialSymbolsMailRounded from "~icons/material-symbols/mail-rounded";
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
let { data }: { data: PageData } = $props();
const projects = data.projects;
</script>
<AppWrapper class="overflow-x-hidden font-schibsted">
@@ -24,7 +29,7 @@
</div>
<div class="py-4 text-zinc-200">
<p class="text-sm sm:text-[0.95em]">
<p class="sm:text-[0.95em]">
A fanatical software engineer with expertise and passion for sound,
scalable and high-performance applications. I'm always working on
something new. <br />
@@ -74,5 +79,13 @@
</div>
</div>
</div>
<div class="max-w-2xl mx-4 mt-5 sm:mx-6">
<div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{#each projects as project (project.id)}
<ProjectCard {project} />
{/each}
</div>
</div>
</div>
</AppWrapper>
@@ -100,7 +100,6 @@
}
function navigateToTab(tab: Tab) {
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`/admin/settings/${tab}`, { replaceState: true });
}
</script>
@@ -4,17 +4,17 @@ import { requireAuth } from "$lib/server/auth";
import { getIcon } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(event);
// Require authentication
requireAuth(event);
const { collection, name } = event.params;
const identifier = `${collection}:${name}`;
const { collection, name } = event.params;
const identifier = `${collection}:${name}`;
const iconData = await getIcon(identifier);
const iconData = await getIcon(identifier);
if (!iconData) {
throw error(404, `Icon not found: ${identifier}`);
}
if (!iconData) {
throw error(404, `Icon not found: ${identifier}`);
}
return json(iconData);
return json(iconData);
};
@@ -4,13 +4,13 @@ import { requireAuth } from "$lib/server/auth";
import { getCollections } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(event);
// Require authentication
requireAuth(event);
const collections = await getCollections();
const collections = await getCollections();
return json({
collections,
count: collections.length,
});
return json({
collections,
count: collections.length,
});
};
+11 -11
View File
@@ -4,18 +4,18 @@ import { requireAuth } from "$lib/server/auth";
import { searchIcons } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(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 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);
const results = await searchIcons(query, limit);
return json({
icons: results,
query,
count: results.length,
});
return json({
icons: results,
query,
count: results.length,
});
};
+3 -3
View File
@@ -20,7 +20,7 @@ export interface Project {
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) => ({
@@ -28,9 +28,9 @@ export const load: PageServerLoad = async ({ url }) => {
iconSvg: await renderIconSVG(project.icon ?? "lucide:heart", {
class: "text-3xl opacity-80 saturate-0",
}),
}))
})),
);
return {
projects: projectsWithIcons,
metadata: {
+3 -1
View File
@@ -24,6 +24,7 @@
{@const links = project.links}
{@const useAnchor = links.length > 0}
{@const href = useAnchor ? links[0].url : undefined}
{@const iconSvg = project.iconSvg}
<div class="max-w-fit">
<svelte:element
@@ -35,7 +36,8 @@
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">
{@html (project as any).iconSvg}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html iconSvg}
</div>
<div class="overflow-hidden">
<span class="text-sm md:text-base lg:text-lg">