mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 12:26:39 -06:00
feat: add Iconify-based icon system with search and picker UI
- Replace Font Awesome with Iconify (@iconify/json collections) - Add IconPicker component with search, collection filtering, lazy loading - Create authenticated icon API endpoints (search, collections, individual icons) - Update projects page to render icons via Icon.svelte component - Pre-cache common icon collections (Lucide, Simple Icons, etc.) on startup
This commit is contained in:
+6
-6
@@ -22,7 +22,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some("Xevion/xevion.dev"),
|
Some("Xevion/xevion.dev"),
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
Some("fa-globe"),
|
Some("lucide:globe"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"contest",
|
"contest",
|
||||||
@@ -32,7 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some("Xevion/contest"),
|
Some("Xevion/contest"),
|
||||||
Some("https://contest.xevion.dev"),
|
Some("https://contest.xevion.dev"),
|
||||||
9,
|
9,
|
||||||
Some("fa-trophy"),
|
Some("lucide:trophy"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"reforge",
|
"reforge",
|
||||||
@@ -42,7 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some("Xevion/reforge"),
|
Some("Xevion/reforge"),
|
||||||
None,
|
None,
|
||||||
8,
|
8,
|
||||||
Some("fa-file-code"),
|
Some("lucide:file-code"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"algorithms",
|
"algorithms",
|
||||||
@@ -52,7 +52,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some("Xevion/algorithms"),
|
Some("Xevion/algorithms"),
|
||||||
None,
|
None,
|
||||||
5,
|
5,
|
||||||
Some("fa-brain"),
|
Some("lucide:brain"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"wordplay",
|
"wordplay",
|
||||||
@@ -62,7 +62,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some("Xevion/wordplay"),
|
Some("Xevion/wordplay"),
|
||||||
Some("https://wordplay.example.com"),
|
Some("https://wordplay.example.com"),
|
||||||
7,
|
7,
|
||||||
Some("fa-gamepad"),
|
Some("lucide:gamepad-2"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"dotfiles",
|
"dotfiles",
|
||||||
@@ -72,7 +72,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some("Xevion/dotfiles"),
|
Some("Xevion/dotfiles"),
|
||||||
None,
|
None,
|
||||||
6,
|
6,
|
||||||
Some("fa-terminal"),
|
Some("lucide:terminal"),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+53
@@ -378,6 +378,8 @@ fn api_routes() -> Router<Arc<AppState>> {
|
|||||||
"/tags/recalculate-cooccurrence",
|
"/tags/recalculate-cooccurrence",
|
||||||
axum::routing::post(recalculate_cooccurrence_handler),
|
axum::routing::post(recalculate_cooccurrence_handler),
|
||||||
)
|
)
|
||||||
|
// Icon API - proxy to SvelteKit (authentication handled by SvelteKit)
|
||||||
|
.route("/icons/{*path}", axum::routing::get(proxy_icons_handler))
|
||||||
.fallback(api_404_and_method_handler)
|
.fallback(api_404_and_method_handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +531,57 @@ async fn projects_handler(State(state): State<Arc<AppState>>) -> impl IntoRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Icon API handler - proxy to SvelteKit
|
||||||
|
async fn proxy_icons_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
|
axum::extract::Path(path): axum::extract::Path<String>,
|
||||||
|
req: Request,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let full_path = format!("/api/icons/{}", path);
|
||||||
|
let query = req.uri().query().unwrap_or("");
|
||||||
|
|
||||||
|
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
|
||||||
|
{
|
||||||
|
if query.is_empty() {
|
||||||
|
format!("http://localhost{}", full_path)
|
||||||
|
} else {
|
||||||
|
format!("http://localhost{}?{}", full_path, query)
|
||||||
|
}
|
||||||
|
} else if query.is_empty() {
|
||||||
|
format!("{}{}", state.downstream_url, full_path)
|
||||||
|
} else {
|
||||||
|
format!("{}{}?{}", state.downstream_url, full_path, query)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build trusted headers with session info
|
||||||
|
let mut forward_headers = HeaderMap::new();
|
||||||
|
|
||||||
|
if let Some(cookie) = jar.get("admin_session") {
|
||||||
|
if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) {
|
||||||
|
if let Some(session) = state.session_manager.validate_session(session_id) {
|
||||||
|
if let Ok(username_value) = axum::http::HeaderValue::from_str(&session.username) {
|
||||||
|
forward_headers.insert("x-session-user", username_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match proxy_to_bun(&bun_url, state, forward_headers).await {
|
||||||
|
Ok((status, headers, body)) => (status, headers, body).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, path = %full_path, "Failed to proxy icon request");
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Failed to fetch icon data"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tag API handlers
|
// Tag API handlers
|
||||||
|
|
||||||
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
|||||||
+3
-3
@@ -4,9 +4,9 @@
|
|||||||
"packageManager": "bun@latest",
|
"packageManager": "bun@latest",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow bun",
|
"preinstall": "npx only-allow bun",
|
||||||
"dev": "vite dev",
|
"dev": "bunx --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "bunx --bun vite build",
|
||||||
"preview": "vite preview",
|
"preview": "bunx --bun vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { renderIconSVG } from "$lib/server/icons";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: string;
|
||||||
|
class?: string;
|
||||||
|
size?: number;
|
||||||
|
fallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon, class: className, size, fallback = "lucide:help-circle" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await renderIconSVG(icon, { class: cn("inline-block", className), size })}
|
||||||
|
<!-- Loading state during SSR (shouldn't be visible) -->
|
||||||
|
{:then svg}
|
||||||
|
{#if svg}
|
||||||
|
{@html svg}
|
||||||
|
{:else}
|
||||||
|
<!-- Fallback icon if primary fails -->
|
||||||
|
{#await renderIconSVG(fallback, { class: cn("inline-block", className), size }) then fallbackSvg}
|
||||||
|
{@html fallbackSvg}
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import type { IconCollection } from "$lib/types/icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedIcon: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedIcon = $bindable(""),
|
||||||
|
label,
|
||||||
|
placeholder = "Search icons... (e.g., lucide:home or just home)",
|
||||||
|
class: className,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let searchResults = $state<
|
||||||
|
Array<{ identifier: string; collection: string; name: string }>
|
||||||
|
>([]);
|
||||||
|
let collections = $state<IconCollection[]>([]);
|
||||||
|
let selectedCollection = $state<string>("all");
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Load SVG cache for preview
|
||||||
|
let iconSvgCache = $state<Map<string, string>>(new Map());
|
||||||
|
let selectedIconSvg = $state<string | null>(null);
|
||||||
|
|
||||||
|
// IntersectionObserver for lazy loading icons
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
// Generate unique ID for accessibility
|
||||||
|
const inputId = `iconpicker-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
|
||||||
|
// Load collections on mount and setup observer
|
||||||
|
$effect(() => {
|
||||||
|
loadCollections();
|
||||||
|
setupIntersectionObserver();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load selected icon SVG
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedIcon) {
|
||||||
|
loadIconSvg(selectedIcon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupIntersectionObserver() {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const identifier = entry.target.getAttribute("data-icon-id");
|
||||||
|
if (identifier && !iconSvgCache.has(identifier)) {
|
||||||
|
loadIconSvg(identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: "50px",
|
||||||
|
threshold: 0.01,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
$effect(() => {
|
||||||
|
if (searchQuery) {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
performSearch();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
searchResults = [];
|
||||||
|
showDropdown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCollections() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/icons/collections");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
collections = data.collections;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load collections:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSearch() {
|
||||||
|
isLoading = true;
|
||||||
|
showDropdown = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build query with collection filter if not "all"
|
||||||
|
let query = searchQuery;
|
||||||
|
if (selectedCollection !== "all" && !query.includes(":")) {
|
||||||
|
query = `${selectedCollection}:${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/icons/search?q=${encodeURIComponent(query)}&limit=100`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = data.icons;
|
||||||
|
|
||||||
|
// Wait for DOM to update, then observe icon elements
|
||||||
|
setTimeout(() => observeIconElements(), 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search icons:", error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeIconElements() {
|
||||||
|
if (!observer) return;
|
||||||
|
|
||||||
|
// Find all icon button elements and observe them
|
||||||
|
const iconButtons = document.querySelectorAll(`[data-icon-id]`);
|
||||||
|
for (const button of iconButtons) {
|
||||||
|
observer.observe(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIconSvg(identifier: string) {
|
||||||
|
// Check cache first
|
||||||
|
if (iconSvgCache.has(identifier)) {
|
||||||
|
if (identifier === selectedIcon) {
|
||||||
|
selectedIconSvg = iconSvgCache.get(identifier)!;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [collection, name] = identifier.split(":");
|
||||||
|
const response = await fetch(`/api/icons/${collection}/${name}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Trigger reactivity by creating a new Map
|
||||||
|
iconSvgCache = new Map(iconSvgCache).set(identifier, data.svg);
|
||||||
|
if (identifier === selectedIcon) {
|
||||||
|
selectedIconSvg = data.svg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load icon SVG:", error);
|
||||||
|
if (identifier === selectedIcon) {
|
||||||
|
selectedIconSvg = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIcon(identifier: string) {
|
||||||
|
selectedIcon = identifier;
|
||||||
|
searchQuery = "";
|
||||||
|
showDropdown = false;
|
||||||
|
loadIconSvg(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputFocus() {
|
||||||
|
if (searchQuery && searchResults.length > 0) {
|
||||||
|
showDropdown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputBlur() {
|
||||||
|
setTimeout(() => {
|
||||||
|
showDropdown = false;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIcon = "";
|
||||||
|
selectedIconSvg = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("space-y-2", className)}>
|
||||||
|
{#if label}
|
||||||
|
<label for={inputId} class="block text-sm font-medium text-admin-text">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Selected icon preview -->
|
||||||
|
{#if selectedIcon}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-md border border-admin-border bg-admin-panel p-3"
|
||||||
|
>
|
||||||
|
<div class="flex size-10 items-center justify-center rounded bg-admin-bg">
|
||||||
|
{#if selectedIconSvg}
|
||||||
|
{@html selectedIconSvg}
|
||||||
|
{:else}
|
||||||
|
<div class="size-6 animate-pulse rounded bg-zinc-700"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-admin-text">{selectedIcon}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearSelection}
|
||||||
|
class="rounded px-2 py-1 text-sm text-admin-text-muted hover:bg-admin-hover hover:text-admin-text"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Collection tabs -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
selectedCollection === "all"
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "bg-admin-panel text-admin-text-muted hover:bg-admin-hover hover:text-admin-text",
|
||||||
|
)}
|
||||||
|
onclick={() => (selectedCollection = "all")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{#each collections as collection (collection.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
selectedCollection === collection.id
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "bg-admin-panel text-admin-text-muted hover:bg-admin-hover hover:text-admin-text",
|
||||||
|
)}
|
||||||
|
onclick={() => (selectedCollection = collection.id)}
|
||||||
|
>
|
||||||
|
{collection.name}
|
||||||
|
<span class="ml-1 text-xs opacity-60">({collection.total})</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
{placeholder}
|
||||||
|
class="w-full rounded-md border border-admin-border bg-admin-panel px-3 py-2 text-sm text-admin-text placeholder:text-admin-text-muted focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
onfocus={handleInputFocus}
|
||||||
|
onblur={handleInputBlur}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Search results dropdown -->
|
||||||
|
{#if showDropdown && searchResults.length > 0}
|
||||||
|
<div
|
||||||
|
class="absolute z-10 mt-1 max-h-96 w-full overflow-auto rounded-md border border-admin-border bg-admin-panel shadow-lg"
|
||||||
|
>
|
||||||
|
<!-- Grid layout for icons -->
|
||||||
|
<div class="grid grid-cols-8 gap-1 p-2">
|
||||||
|
{#each searchResults as result (result.identifier)}
|
||||||
|
{@const cachedSvg = iconSvgCache.get(result.identifier)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-icon-id={result.identifier}
|
||||||
|
class="group relative flex size-12 items-center justify-center rounded hover:bg-admin-hover"
|
||||||
|
onclick={() => selectIcon(result.identifier)}
|
||||||
|
title={result.identifier}
|
||||||
|
>
|
||||||
|
<!-- Lazy load icon SVG via IntersectionObserver -->
|
||||||
|
<div class="size-9 text-admin-text">
|
||||||
|
{#if cachedSvg}
|
||||||
|
{@html cachedSvg}
|
||||||
|
{:else}
|
||||||
|
<div class="size-full animate-pulse rounded bg-zinc-700"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tooltip on hover -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -top-8 left-1/2 z-20 hidden -translate-x-1/2 whitespace-nowrap rounded bg-zinc-900 px-2 py-1 text-xs text-white group-hover:block"
|
||||||
|
>
|
||||||
|
{result.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="border-t border-admin-border p-3 text-center text-sm text-admin-text-muted">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if showDropdown && searchQuery && !isLoading}
|
||||||
|
<div
|
||||||
|
class="absolute z-10 mt-1 w-full rounded-md border border-admin-border bg-admin-panel p-3 text-center text-sm text-admin-text-muted shadow-lg"
|
||||||
|
>
|
||||||
|
No icons found for "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-admin-text-muted">
|
||||||
|
Tip: Use "collection:search" to filter (e.g., "lucide:home" or "simple-icons:react")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: Future enhancement - Recent/favorite icons -->
|
||||||
|
<!-- Store recently used icons in localStorage for quick access -->
|
||||||
|
<!-- Could add "star" button to favorite frequently used icons -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ensure SVG icons inherit size */
|
||||||
|
:global(.size-9 svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.size-10 svg) {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import Button from "./Button.svelte";
|
import Button from "./Button.svelte";
|
||||||
import Input from "./Input.svelte";
|
import Input from "./Input.svelte";
|
||||||
import TagPicker from "./TagPicker.svelte";
|
import TagPicker from "./TagPicker.svelte";
|
||||||
|
import IconPicker from "./IconPicker.svelte";
|
||||||
import type {
|
import type {
|
||||||
AdminProject,
|
AdminProject,
|
||||||
AdminTag,
|
AdminTag,
|
||||||
@@ -168,12 +169,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Icon -->
|
<!-- Icon -->
|
||||||
<Input
|
<IconPicker
|
||||||
label="Icon"
|
label="Icon"
|
||||||
type="text"
|
bind:selectedIcon={icon}
|
||||||
bind:value={icon}
|
placeholder="Search icons... (e.g., lucide:home or simple-icons:react)"
|
||||||
placeholder="fa-rocket"
|
|
||||||
help="Font Awesome icon class (e.g., fa-rocket, fa-heart)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import type { RequestEvent } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the request is authenticated
|
||||||
|
* Returns the username if authenticated, throws 401 error if not
|
||||||
|
*/
|
||||||
|
export function requireAuth(event: RequestEvent): string {
|
||||||
|
const sessionUser = event.request.headers.get("x-session-user");
|
||||||
|
|
||||||
|
if (!sessionUser) {
|
||||||
|
throw error(401, "Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the request is authenticated (optional)
|
||||||
|
* Returns the username if authenticated, null if not
|
||||||
|
*/
|
||||||
|
export function getAuth(event: RequestEvent): string | null {
|
||||||
|
return event.request.headers.get("x-session-user");
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import type { IconifyJSON } from "@iconify/types";
|
||||||
|
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
||||||
|
import { getLogger } from "@logtape/logtape";
|
||||||
|
import type { IconCollection, IconData, IconIdentifier, IconRenderOptions } from "$lib/types/icons";
|
||||||
|
|
||||||
|
const logger = getLogger(["server", "icons"]);
|
||||||
|
|
||||||
|
// In-memory cache for icon collections
|
||||||
|
const collectionCache = new Map<string, IconifyJSON>();
|
||||||
|
|
||||||
|
// Collections to pre-cache on server startup
|
||||||
|
const PRE_CACHE_COLLECTIONS = [
|
||||||
|
"lucide",
|
||||||
|
"simple-icons",
|
||||||
|
"material-symbols",
|
||||||
|
"heroicons",
|
||||||
|
"feather",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default fallback icon
|
||||||
|
const DEFAULT_FALLBACK_ICON = "lucide:help-circle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse icon identifier into collection and name
|
||||||
|
*/
|
||||||
|
function parseIdentifier(identifier: string): { collection: string; name: string } | null {
|
||||||
|
const parts = identifier.split(":");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { collection: parts[0], name: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load icon collection from @iconify/json
|
||||||
|
*/
|
||||||
|
async function loadCollection(collection: string): Promise<IconifyJSON | null> {
|
||||||
|
// Check cache first
|
||||||
|
if (collectionCache.has(collection)) {
|
||||||
|
return collectionCache.get(collection)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iconifyJsonPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
"node_modules",
|
||||||
|
"@iconify",
|
||||||
|
"json",
|
||||||
|
"json",
|
||||||
|
`${collection}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await readFile(iconifyJsonPath, "utf-8");
|
||||||
|
const iconSet: IconifyJSON = JSON.parse(data);
|
||||||
|
|
||||||
|
// Cache the collection
|
||||||
|
collectionCache.set(collection, iconSet);
|
||||||
|
|
||||||
|
logger.debug(`Loaded icon collection: ${collection}`, {
|
||||||
|
total: iconSet.info?.total || Object.keys(iconSet.icons).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return iconSet;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to load icon collection: ${collection}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon data by identifier
|
||||||
|
*/
|
||||||
|
export async function getIcon(identifier: string): Promise<IconData | null> {
|
||||||
|
const parsed = parseIdentifier(identifier);
|
||||||
|
if (!parsed) {
|
||||||
|
logger.warn(`Invalid icon identifier: ${identifier}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collection, name } = parsed;
|
||||||
|
const iconSet = await loadCollection(collection);
|
||||||
|
|
||||||
|
if (!iconSet) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get icon data from the set
|
||||||
|
const iconData = getIconData(iconSet, name);
|
||||||
|
if (!iconData) {
|
||||||
|
logger.warn(`Icon not found: ${identifier}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SVG
|
||||||
|
const svg = renderIconData(iconData, iconSet);
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier: identifier as IconIdentifier,
|
||||||
|
collection,
|
||||||
|
name,
|
||||||
|
svg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render icon data to SVG string
|
||||||
|
*/
|
||||||
|
function renderIconData(iconData: any, iconSet: IconifyJSON): string {
|
||||||
|
// Convert icon data to SVG attributes
|
||||||
|
const renderData = iconToSVG(iconData);
|
||||||
|
|
||||||
|
// Get SVG body
|
||||||
|
const body = replaceIDs(iconData.body);
|
||||||
|
|
||||||
|
// Build SVG element
|
||||||
|
const attributes = {
|
||||||
|
...renderData.attributes,
|
||||||
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
|
"xmlns:xlink": "http://www.w3.org/1999/xlink",
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeString = Object.entries(attributes)
|
||||||
|
.map(([key, value]) => `${key}="${value}"`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return `<svg ${attributeString}>${body}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render icon SVG with custom options
|
||||||
|
*/
|
||||||
|
export async function renderIconSVG(
|
||||||
|
identifier: string,
|
||||||
|
options: IconRenderOptions = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const iconData = await getIcon(identifier);
|
||||||
|
|
||||||
|
if (!iconData) {
|
||||||
|
// Try fallback icon if provided, otherwise use default
|
||||||
|
if (identifier !== DEFAULT_FALLBACK_ICON) {
|
||||||
|
logger.warn(`Icon not found, using fallback: ${identifier}`);
|
||||||
|
return renderIconSVG(DEFAULT_FALLBACK_ICON, options);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = iconData.svg;
|
||||||
|
|
||||||
|
// Apply custom class
|
||||||
|
if (options.class) {
|
||||||
|
svg = svg.replace("<svg ", `<svg class="${options.class}" `);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom size
|
||||||
|
if (options.size) {
|
||||||
|
svg = svg.replace(/width="[^"]*"/, `width="${options.size}"`);
|
||||||
|
svg = svg.replace(/height="[^"]*"/, `height="${options.size}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom color (replace currentColor)
|
||||||
|
if (options.color) {
|
||||||
|
svg = svg.replace(/currentColor/g, options.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available collections
|
||||||
|
*/
|
||||||
|
export async function getCollections(): Promise<IconCollection[]> {
|
||||||
|
const collections: IconCollection[] = [];
|
||||||
|
|
||||||
|
// Load common collections to get metadata
|
||||||
|
for (const collectionId of PRE_CACHE_COLLECTIONS) {
|
||||||
|
const iconSet = await loadCollection(collectionId);
|
||||||
|
if (iconSet && iconSet.info) {
|
||||||
|
collections.push({
|
||||||
|
id: collectionId,
|
||||||
|
name: iconSet.info.name || collectionId,
|
||||||
|
total: iconSet.info.total || Object.keys(iconSet.icons).length,
|
||||||
|
category: iconSet.info.category,
|
||||||
|
prefix: iconSet.prefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search icons across collections
|
||||||
|
*/
|
||||||
|
export async function searchIcons(
|
||||||
|
query: string,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<{ identifier: string; collection: string; name: string }[]> {
|
||||||
|
const results: { identifier: string; collection: string; name: string }[] = [];
|
||||||
|
|
||||||
|
// Parse query for collection prefix (e.g., "lucide:home" or "lucide:")
|
||||||
|
const colonIndex = query.indexOf(":");
|
||||||
|
let targetCollection: string | null = null;
|
||||||
|
let searchTerm = query.toLowerCase();
|
||||||
|
|
||||||
|
if (colonIndex !== -1) {
|
||||||
|
targetCollection = query.substring(0, colonIndex);
|
||||||
|
searchTerm = query.substring(colonIndex + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which collections to search
|
||||||
|
const collectionsToSearch = targetCollection
|
||||||
|
? [targetCollection]
|
||||||
|
: PRE_CACHE_COLLECTIONS;
|
||||||
|
|
||||||
|
for (const collectionId of collectionsToSearch) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
|
||||||
|
const iconSet = await loadCollection(collectionId);
|
||||||
|
if (!iconSet) continue;
|
||||||
|
|
||||||
|
const iconNames = Object.keys(iconSet.icons);
|
||||||
|
|
||||||
|
for (const iconName of iconNames) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
|
||||||
|
// Search in icon name
|
||||||
|
if (searchTerm === "" || iconName.toLowerCase().includes(searchTerm)) {
|
||||||
|
results.push({
|
||||||
|
identifier: `${collectionId}:${iconName}`,
|
||||||
|
collection: collectionId,
|
||||||
|
name: iconName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-cache common icon collections on server startup
|
||||||
|
*/
|
||||||
|
export async function preCacheCollections(): Promise<void> {
|
||||||
|
logger.info("Pre-caching icon collections...", {
|
||||||
|
collections: PRE_CACHE_COLLECTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises = PRE_CACHE_COLLECTIONS.map((collection) => loadCollection(collection));
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
logger.info("Icon collections pre-cached", {
|
||||||
|
cached: collectionCache.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Future enhancement - Support color customization in icon identifiers
|
||||||
|
// Format idea: "lucide:home#color=blue-500" or separate color field in DB
|
||||||
|
// Would allow per-project icon theming without hardcoded styles
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Icon identifier in format "collection:name"
|
||||||
|
* Example: "lucide:home", "simple-icons:react"
|
||||||
|
*/
|
||||||
|
export type IconIdentifier = `${string}:${string}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon metadata for search results and picker
|
||||||
|
*/
|
||||||
|
export interface IconMetadata {
|
||||||
|
identifier: IconIdentifier;
|
||||||
|
collection: string;
|
||||||
|
name: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon collection information
|
||||||
|
*/
|
||||||
|
export interface IconCollection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
category?: string;
|
||||||
|
prefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full icon data with SVG
|
||||||
|
*/
|
||||||
|
export interface IconData {
|
||||||
|
identifier: IconIdentifier;
|
||||||
|
collection: string;
|
||||||
|
name: string;
|
||||||
|
svg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for rendering icon SVG
|
||||||
|
*/
|
||||||
|
export interface IconRenderOptions {
|
||||||
|
class?: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { json, error } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { requireAuth } from "$lib/server/auth";
|
||||||
|
import { getIcon } from "$lib/server/icons";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
requireAuth(event);
|
||||||
|
|
||||||
|
const { collection, name } = event.params;
|
||||||
|
const identifier = `${collection}:${name}`;
|
||||||
|
|
||||||
|
const iconData = await getIcon(identifier);
|
||||||
|
|
||||||
|
if (!iconData) {
|
||||||
|
throw error(404, `Icon not found: ${identifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(iconData);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { requireAuth } from "$lib/server/auth";
|
||||||
|
import { getCollections } from "$lib/server/icons";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
requireAuth(event);
|
||||||
|
|
||||||
|
const collections = await getCollections();
|
||||||
|
|
||||||
|
return json({
|
||||||
|
collections,
|
||||||
|
count: collections.length,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { requireAuth } from "$lib/server/auth";
|
||||||
|
import { searchIcons } from "$lib/server/icons";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
requireAuth(event);
|
||||||
|
|
||||||
|
const query = event.url.searchParams.get("q") || "";
|
||||||
|
const limitParam = event.url.searchParams.get("limit");
|
||||||
|
const limit = limitParam ? parseInt(limitParam, 10) : 50;
|
||||||
|
|
||||||
|
const results = await searchIcons(query, limit);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
icons: results,
|
||||||
|
query,
|
||||||
|
count: results.length,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { getOGImageUrl } from "$lib/og-types";
|
import { getOGImageUrl } from "$lib/og-types";
|
||||||
|
import { renderIconSVG } from "$lib/server/icons";
|
||||||
|
|
||||||
interface ProjectLink {
|
interface ProjectLink {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -13,13 +14,25 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
iconSvg?: string;
|
||||||
links: ProjectLink[];
|
links: ProjectLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const projects = await apiFetch<Project[]>("/api/projects");
|
const projects = await apiFetch<Project[]>("/api/projects");
|
||||||
|
|
||||||
|
// Render icon SVGs server-side
|
||||||
|
const projectsWithIcons = await Promise.all(
|
||||||
|
projects.map(async (project) => ({
|
||||||
|
...project,
|
||||||
|
iconSvg: await renderIconSVG(project.icon ?? "lucide:heart", {
|
||||||
|
class: "text-3xl opacity-80 saturate-0",
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects,
|
projects: projectsWithIcons,
|
||||||
metadata: {
|
metadata: {
|
||||||
title: "Projects | Xevion.dev",
|
title: "Projects | Xevion.dev",
|
||||||
description: "...",
|
description: "...",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AppWrapper>
|
<AppWrapper>
|
||||||
@@ -35,12 +35,7 @@
|
|||||||
class="flex items-center justify-start overflow-hidden rounded bg-black/10 pb-2.5 pl-3 pr-5 pt-1 text-zinc-400 transition-colors hover:bg-zinc-500/10 hover:text-zinc-50"
|
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">
|
<div class="flex h-full w-14 items-center justify-center pr-5">
|
||||||
<i
|
{@html (project as any).iconSvg}
|
||||||
class={cn(
|
|
||||||
project.icon ?? "fa-heart",
|
|
||||||
"fa-solid text-3xl text-opacity-80 saturate-0",
|
|
||||||
)}
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<span class="text-sm md:text-base lg:text-lg">
|
<span class="text-sm md:text-base lg:text-lg">
|
||||||
|
|||||||
Reference in New Issue
Block a user