mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user