mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -06:00
refactor: replace SVG sprite with client-side icon fetching and Rust caching
Eliminates server-side batch rendering and IconSprite pattern in favor of: - Rust handler with moka cache (10k icons, 24h TTL, immutable HTTP headers) - Client Icon component fetches SVGs on-demand with shimmer loading states - Removes renderIconsBatch, tag-icons collection logic, and page load icon preprocessing - Reduces SSR complexity and data serialization overhead
This commit is contained in:
@@ -8,6 +8,7 @@ use tower_http::cors::CorsLayer;
|
|||||||
use crate::cache::{IsrCache, IsrCacheConfig};
|
use crate::cache::{IsrCache, IsrCacheConfig};
|
||||||
use crate::config::ListenAddr;
|
use crate::config::ListenAddr;
|
||||||
use crate::github;
|
use crate::github;
|
||||||
|
use crate::icon_cache::IconCache;
|
||||||
use crate::middleware::RequestIdLayer;
|
use crate::middleware::RequestIdLayer;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::tarpit::{TarpitConfig, TarpitState};
|
use crate::tarpit::{TarpitConfig, TarpitState};
|
||||||
@@ -135,6 +136,10 @@ pub async fn run(
|
|||||||
"ISR cache initialized"
|
"ISR cache initialized"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize icon cache
|
||||||
|
let icon_cache = Arc::new(IconCache::new());
|
||||||
|
tracing::debug!("Icon cache initialized");
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
client,
|
client,
|
||||||
health_checker,
|
health_checker,
|
||||||
@@ -142,6 +147,7 @@ pub async fn run(
|
|||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
session_manager: session_manager.clone(),
|
session_manager: session_manager.clone(),
|
||||||
isr_cache,
|
isr_cache,
|
||||||
|
icon_cache,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regenerate common OGP images on startup
|
// Regenerate common OGP images on startup
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
|
||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
@@ -65,45 +64,3 @@ pub async fn handle_pgp_route(
|
|||||||
proxy::isr_handler(State(state), req).await
|
proxy::isr_handler(State(state), req).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy icon requests to SvelteKit
|
|
||||||
pub 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 path_with_query = if query.is_empty() {
|
|
||||||
full_path.clone()
|
|
||||||
} else {
|
|
||||||
format!("{full_path}?{query}")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build trusted headers with session info
|
|
||||||
let mut forward_headers = HeaderMap::new();
|
|
||||||
|
|
||||||
if let Some(cookie) = jar.get("admin_session")
|
|
||||||
&& let Ok(session_id) = ulid::Ulid::from_string(cookie.value())
|
|
||||||
&& let Some(session) = state.session_manager.validate_session(session_id)
|
|
||||||
&& let Ok(username_value) = axum::http::HeaderValue::from_str(&session.username)
|
|
||||||
{
|
|
||||||
forward_headers.insert("x-session-user", username_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
match proxy::proxy_to_bun(&path_with_query, 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Icon serving handler with caching
|
||||||
|
//!
|
||||||
|
//! Serves SVG icons with aggressive HTTP caching. On cache miss, proxies to
|
||||||
|
//! Bun's icon API and caches the result.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use crate::{proxy, state::AppState};
|
||||||
|
|
||||||
|
/// Response from Bun's icon API
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct IconApiResponse {
|
||||||
|
svg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle icon requests - serves cached SVG or proxies to Bun
|
||||||
|
///
|
||||||
|
/// Route: GET /api/icons/{*path}
|
||||||
|
/// - For `{collection}/{name}.svg` paths: serve cached SVG with aggressive caching
|
||||||
|
/// - For all other paths: proxy to Bun's icon API (search, collections, JSON responses)
|
||||||
|
pub async fn serve_icon_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
// Check if this is a cacheable SVG request: {collection}/{name}.svg
|
||||||
|
if let Some((collection, name)) = parse_svg_path(&path) {
|
||||||
|
return serve_cached_svg(state, collection, name).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not an SVG request - proxy to Bun (with query string for search)
|
||||||
|
proxy_to_bun_icons(&path, &query, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse path to extract collection and name if it matches {collection}/{name}.svg
|
||||||
|
fn parse_svg_path(path: &str) -> Option<(&str, &str)> {
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let collection = parts[0];
|
||||||
|
let filename = parts[1];
|
||||||
|
if let Some(name) = filename.strip_suffix(".svg")
|
||||||
|
&& !collection.is_empty()
|
||||||
|
&& !name.is_empty()
|
||||||
|
{
|
||||||
|
return Some((collection, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve an SVG icon with caching
|
||||||
|
async fn serve_cached_svg(state: Arc<AppState>, collection: &str, name: &str) -> Response {
|
||||||
|
let cache_key = format!("{collection}:{name}");
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if let Some(svg) = state.icon_cache.get(&cache_key).await {
|
||||||
|
tracing::trace!(
|
||||||
|
collection = %collection,
|
||||||
|
name = %name,
|
||||||
|
"Icon cache hit"
|
||||||
|
);
|
||||||
|
return svg_response(&svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - fetch from Bun's icon API
|
||||||
|
let bun_path = format!("/api/icons/{collection}/{name}");
|
||||||
|
let forward_headers = HeaderMap::new();
|
||||||
|
|
||||||
|
match proxy::proxy_to_bun(&bun_path, state.clone(), forward_headers).await {
|
||||||
|
Ok((status, _headers, body)) if status.is_success() => {
|
||||||
|
// Parse JSON response from Bun
|
||||||
|
match serde_json::from_slice::<IconApiResponse>(&body) {
|
||||||
|
Ok(data) => {
|
||||||
|
// Cache the SVG
|
||||||
|
state.icon_cache.insert(cache_key, data.svg.clone()).await;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
collection = %collection,
|
||||||
|
name = %name,
|
||||||
|
"Icon cached"
|
||||||
|
);
|
||||||
|
|
||||||
|
svg_response(&data.svg)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
collection = %collection,
|
||||||
|
name = %name,
|
||||||
|
"Failed to parse icon response"
|
||||||
|
);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid icon data").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((status, _, _)) => {
|
||||||
|
tracing::debug!(
|
||||||
|
status = %status,
|
||||||
|
collection = %collection,
|
||||||
|
name = %name,
|
||||||
|
"Icon not found"
|
||||||
|
);
|
||||||
|
(status, "Icon not found").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
collection = %collection,
|
||||||
|
name = %name,
|
||||||
|
"Failed to proxy icon request"
|
||||||
|
);
|
||||||
|
(StatusCode::BAD_GATEWAY, "Icon service unavailable").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proxy non-SVG icon requests to Bun
|
||||||
|
async fn proxy_to_bun_icons(
|
||||||
|
path: &str,
|
||||||
|
query: &HashMap<String, String>,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
) -> Response {
|
||||||
|
let query_string = if query.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let qs: Vec<String> = query
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
||||||
|
.collect();
|
||||||
|
format!("?{}", qs.join("&"))
|
||||||
|
};
|
||||||
|
let bun_path = format!("/api/icons/{path}{query_string}");
|
||||||
|
let forward_headers = HeaderMap::new();
|
||||||
|
|
||||||
|
match proxy::proxy_to_bun(&bun_path, state, forward_headers).await {
|
||||||
|
Ok((status, headers, body)) => (status, headers, body).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, path = %path, "Failed to proxy icon request");
|
||||||
|
(StatusCode::BAD_GATEWAY, "Icon service unavailable").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build SVG response with aggressive cache headers
|
||||||
|
fn svg_response(svg: &str) -> Response {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("image/svg+xml"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
|
||||||
|
(StatusCode::OK, headers, svg.to_string()).into_response()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod icons;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
@@ -10,6 +11,7 @@ pub mod tags;
|
|||||||
pub use assets::*;
|
pub use assets::*;
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use health::*;
|
pub use health::*;
|
||||||
|
pub use icons::*;
|
||||||
pub use media::*;
|
pub use media::*;
|
||||||
pub use projects::*;
|
pub use projects::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Icon cache for serving SVG icons with aggressive HTTP caching
|
||||||
|
//!
|
||||||
|
//! Icons are immutable for a given identifier (e.g., "lucide:home" always returns
|
||||||
|
//! the same SVG), so we can cache them with long TTLs and immutable HTTP headers.
|
||||||
|
|
||||||
|
use moka::future::Cache;
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
/// Cache for rendered SVG icons
|
||||||
|
pub struct IconCache {
|
||||||
|
cache: Cache<String, Arc<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconCache {
|
||||||
|
/// Create a new icon cache
|
||||||
|
///
|
||||||
|
/// Config: 10,000 max entries, 24-hour TTL
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let cache = Cache::builder()
|
||||||
|
.max_capacity(10_000)
|
||||||
|
.time_to_live(Duration::from_secs(86400)) // 24 hours
|
||||||
|
.name("icon_cache")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Self { cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a cached SVG if it exists
|
||||||
|
pub async fn get(&self, identifier: &str) -> Option<Arc<String>> {
|
||||||
|
self.cache.get(identifier).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an SVG into the cache
|
||||||
|
pub async fn insert(&self, identifier: String, svg: String) {
|
||||||
|
self.cache.insert(identifier, Arc::new(svg)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IconCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ mod github;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
mod health;
|
||||||
mod http;
|
mod http;
|
||||||
|
mod icon_cache;
|
||||||
mod media_processing;
|
mod media_processing;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod og;
|
mod og;
|
||||||
|
|||||||
+3
-2
@@ -84,8 +84,9 @@ pub fn api_routes() -> Router<Arc<AppState>> {
|
|||||||
"/settings",
|
"/settings",
|
||||||
get(handlers::get_settings_handler).put(handlers::update_settings_handler),
|
get(handlers::get_settings_handler).put(handlers::update_settings_handler),
|
||||||
)
|
)
|
||||||
// Icon API - proxy to SvelteKit (authentication handled by SvelteKit)
|
// Icon API - handles both cached SVG serving and JSON proxy
|
||||||
.route("/icons/{*path}", get(handlers::proxy_icons_handler))
|
// SVG requests (e.g., /icons/lucide/star.svg) are cached; others proxy to Bun
|
||||||
|
.route("/icons/{*path}", get(handlers::serve_icon_handler))
|
||||||
.fallback(api_404_and_method_handler)
|
.fallback(api_404_and_method_handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::SessionManager, cache::IsrCache, health::HealthChecker, http::HttpClient,
|
auth::SessionManager, cache::IsrCache, health::HealthChecker, http::HttpClient,
|
||||||
tarpit::TarpitState,
|
icon_cache::IconCache, tarpit::TarpitState,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Application state shared across all handlers
|
/// Application state shared across all handlers
|
||||||
@@ -14,6 +14,7 @@ pub struct AppState {
|
|||||||
pub pool: sqlx::PgPool,
|
pub pool: sqlx::PgPool,
|
||||||
pub session_manager: Arc<SessionManager>,
|
pub session_manager: Arc<SessionManager>,
|
||||||
pub isr_cache: Arc<IsrCache>,
|
pub isr_cache: Arc<IsrCache>,
|
||||||
|
pub icon_cache: Arc<IconCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors that can occur during proxying to Bun
|
/// Errors that can occur during proxying to Bun
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
icon: string; // "collection:name" format, e.g., "lucide:home"
|
||||||
|
class?: string;
|
||||||
|
size?: string; // Tailwind size class, e.g., "size-4"
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon, class: className = "", size = "size-4" }: Props = $props();
|
||||||
|
|
||||||
|
let svg = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state(false);
|
||||||
|
|
||||||
|
// Validate and parse icon identifier into collection and name
|
||||||
|
const iconParts = $derived.by(() => {
|
||||||
|
const colonIndex = icon.indexOf(":");
|
||||||
|
if (
|
||||||
|
colonIndex === -1 ||
|
||||||
|
colonIndex === 0 ||
|
||||||
|
colonIndex === icon.length - 1
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Invalid icon identifier: "${icon}" (expected "collection:name" format)`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
collection: icon.slice(0, colonIndex),
|
||||||
|
name: icon.slice(colonIndex + 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch icon when identifier changes
|
||||||
|
$effect(() => {
|
||||||
|
const parts = iconParts;
|
||||||
|
if (!parts) {
|
||||||
|
error = true;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/icons/${parts.collection}/${parts.name}.svg`;
|
||||||
|
loading = true;
|
||||||
|
error = false;
|
||||||
|
svg = null;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`Icon not found: ${icon}`);
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then((svgText) => {
|
||||||
|
svg = svgText;
|
||||||
|
loading = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
error = true;
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<!-- Shimmer placeholder - reserves space to prevent layout shift -->
|
||||||
|
<span
|
||||||
|
class="inline-block {size} animate-pulse rounded bg-zinc-200 dark:bg-zinc-700"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{:else if error}
|
||||||
|
<!-- Error fallback - subtle empty indicator -->
|
||||||
|
<span class="inline-block {size} rounded opacity-30" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{:else if svg}
|
||||||
|
<!-- Render SVG inline - [&>svg]:size-full makes SVG fill container -->
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center {size} {className} [&>svg]:size-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- SVG from our API (trusted @iconify/json) -->
|
||||||
|
{@html svg}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
/**
|
|
||||||
* Convert icon identifier to valid HTML ID.
|
|
||||||
* "simple-icons:rust" → "icon-simple-icons-rust"
|
|
||||||
*/
|
|
||||||
export function toSymbolId(identifier: string): string {
|
|
||||||
return `icon-${identifier.replace(/:/g, "-")}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
icons: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { icons }: Props = $props();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the inner content and viewBox from an SVG string.
|
|
||||||
* Input: '<svg viewBox="0 0 24 24" ...>content</svg>'
|
|
||||||
* Output: { viewBox: "0 0 24 24", content: "content" }
|
|
||||||
*/
|
|
||||||
function parseSvg(svg: string): { viewBox: string; content: string } {
|
|
||||||
// Extract viewBox attribute
|
|
||||||
const viewBoxMatch = svg.match(/viewBox=["']([^"']+)["']/);
|
|
||||||
const viewBox = viewBoxMatch?.[1] ?? "0 0 24 24";
|
|
||||||
|
|
||||||
// Extract content between <svg...> and </svg>
|
|
||||||
const contentMatch = svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/);
|
|
||||||
const content = contentMatch?.[1] ?? "";
|
|
||||||
|
|
||||||
return { viewBox, content };
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Hidden SVG sprite containing all icon definitions as symbols.
|
|
||||||
Icons are referenced elsewhere via <use href="#icon-{identifier}" />
|
|
||||||
-->
|
|
||||||
<svg style="display: none;" aria-hidden="true">
|
|
||||||
<defs>
|
|
||||||
{#each Object.entries(icons) as [id, svg] (id)}
|
|
||||||
{@const parsed = parseSvg(svg)}
|
|
||||||
<symbol id={toSymbolId(id)} viewBox={parsed.viewBox}>
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html parsed.content}
|
|
||||||
</symbol>
|
|
||||||
{/each}
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import { toSymbolId } from "./IconSprite.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,11 +20,7 @@
|
|||||||
|
|
||||||
{#snippet iconAndName()}
|
{#snippet iconAndName()}
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<span class="size-4.25 sm:size-3.75">
|
<Icon {icon} size="size-4.25 sm:size-3.75" />
|
||||||
<svg class="w-full h-full" aria-hidden="true">
|
|
||||||
<use href="#{toSymbolId(icon)}" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
+1
-131
@@ -24,9 +24,6 @@ const PRE_CACHE_COLLECTIONS = [
|
|||||||
"feather",
|
"feather",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default fallback icon
|
|
||||||
const DEFAULT_FALLBACK_ICON: IconIdentifier = "lucide:help-circle";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse icon identifier into collection and name
|
* Parse icon identifier into collection and name
|
||||||
*/
|
*/
|
||||||
@@ -143,134 +140,7 @@ function renderIconData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the default fallback icon (internal helper)
|
* Get single icon data (for API endpoint use - IconPicker)
|
||||||
*/
|
|
||||||
async function renderFallbackIcon(
|
|
||||||
options: IconRenderOptions,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const parsed = parseIdentifier(DEFAULT_FALLBACK_ICON);
|
|
||||||
if (!parsed) return null;
|
|
||||||
|
|
||||||
const iconSet = await loadCollection(parsed.collection);
|
|
||||||
if (!iconSet) return null;
|
|
||||||
|
|
||||||
const iconData = getIconData(iconSet, parsed.name);
|
|
||||||
if (!iconData) return null;
|
|
||||||
|
|
||||||
return renderIconData(iconData, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render multiple icons efficiently in a single batch.
|
|
||||||
* Groups icons by collection, loads each collection once, then renders all icons.
|
|
||||||
*
|
|
||||||
* @param identifiers - Array of icon identifiers (e.g., ["lucide:home", "simple-icons:github"])
|
|
||||||
* @param options - Render options applied to all icons
|
|
||||||
* @returns Map of identifier to rendered SVG string (missing icons get fallback)
|
|
||||||
*/
|
|
||||||
export async function renderIconsBatch(
|
|
||||||
identifiers: string[],
|
|
||||||
options: IconRenderOptions = {},
|
|
||||||
): Promise<Map<string, string>> {
|
|
||||||
const results = new Map<string, string>();
|
|
||||||
|
|
||||||
if (identifiers.length === 0) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and group by collection
|
|
||||||
const byCollection = new Map<
|
|
||||||
string,
|
|
||||||
{ identifier: string; name: string }[]
|
|
||||||
>();
|
|
||||||
const invalidIdentifiers: string[] = [];
|
|
||||||
|
|
||||||
for (const identifier of identifiers) {
|
|
||||||
const parsed = parseIdentifier(identifier);
|
|
||||||
if (!parsed) {
|
|
||||||
invalidIdentifiers.push(identifier);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = byCollection.get(parsed.collection) || [];
|
|
||||||
group.push({ identifier, name: parsed.name });
|
|
||||||
byCollection.set(parsed.collection, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invalidIdentifiers.length > 0) {
|
|
||||||
logger.warn("Invalid icon identifiers in batch", {
|
|
||||||
identifiers: invalidIdentifiers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all needed collections in parallel
|
|
||||||
const collections = Array.from(byCollection.keys());
|
|
||||||
const loadedCollections = await Promise.all(
|
|
||||||
collections.map(async (collection) => ({
|
|
||||||
collection,
|
|
||||||
iconSet: await loadCollection(collection),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build lookup map
|
|
||||||
const collectionMap = new Map<string, IconifyJSON>();
|
|
||||||
for (const { collection, iconSet } of loadedCollections) {
|
|
||||||
if (iconSet) {
|
|
||||||
collectionMap.set(collection, iconSet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render all icons
|
|
||||||
const missingIcons: string[] = [];
|
|
||||||
|
|
||||||
for (const [collection, icons] of byCollection) {
|
|
||||||
const iconSet = collectionMap.get(collection);
|
|
||||||
if (!iconSet) {
|
|
||||||
missingIcons.push(...icons.map((i) => i.identifier));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { identifier, name } of icons) {
|
|
||||||
const iconData = getIconData(iconSet, name);
|
|
||||||
if (!iconData) {
|
|
||||||
missingIcons.push(identifier);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const svg = renderIconData(iconData, options);
|
|
||||||
results.set(identifier, svg);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Failed to render icon", {
|
|
||||||
identifier,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
missingIcons.push(identifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add fallback for missing icons
|
|
||||||
if (missingIcons.length > 0) {
|
|
||||||
logger.warn("Icons not found in batch, using fallback", {
|
|
||||||
missing: missingIcons,
|
|
||||||
fallback: DEFAULT_FALLBACK_ICON,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render fallback icon once
|
|
||||||
const fallbackSvg = await renderFallbackIcon(options);
|
|
||||||
if (fallbackSvg) {
|
|
||||||
for (const identifier of missingIcons) {
|
|
||||||
results.set(identifier, fallbackSvg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single icon data (for API endpoint use only)
|
|
||||||
*/
|
*/
|
||||||
export async function getIconForApi(identifier: string): Promise<{
|
export async function getIconForApi(identifier: string): Promise<{
|
||||||
identifier: string;
|
identifier: string;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { renderIconsBatch } from "./icons";
|
|
||||||
import type { AdminTag } from "$lib/admin-types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect and render icons from an array of tags.
|
|
||||||
* Returns a record mapping icon identifiers to rendered SVG strings.
|
|
||||||
*
|
|
||||||
* @param tags - Array of tags to extract icons from
|
|
||||||
* @returns Record of icon identifier to SVG string
|
|
||||||
*/
|
|
||||||
export async function collectTagIcons(
|
|
||||||
tags: AdminTag[],
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
// Collect unique icon identifiers
|
|
||||||
const iconIds = new Set<string>();
|
|
||||||
for (const tag of tags) {
|
|
||||||
if (tag.icon) {
|
|
||||||
iconIds.add(tag.icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return early if no icons
|
|
||||||
if (iconIds.size === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch render all icons
|
|
||||||
const iconsMap = await renderIconsBatch([...iconIds]);
|
|
||||||
|
|
||||||
// Convert Map to plain object for serialization
|
|
||||||
const icons: Record<string, string> = {};
|
|
||||||
for (const [id, svg] of iconsMap) {
|
|
||||||
icons[id] = svg;
|
|
||||||
}
|
|
||||||
|
|
||||||
return icons;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { renderIconsBatch } from "$lib/server/icons";
|
|
||||||
import type { AdminProject } from "$lib/admin-types";
|
import type { AdminProject } from "$lib/admin-types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, parent }) => {
|
export const load: PageServerLoad = async ({ fetch, parent }) => {
|
||||||
@@ -10,37 +9,8 @@ export const load: PageServerLoad = async ({ fetch, parent }) => {
|
|||||||
|
|
||||||
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
||||||
|
|
||||||
// Collect all unique icon identifiers for batch rendering
|
|
||||||
const iconIds = new Set<string>();
|
|
||||||
|
|
||||||
// Collect tag icons
|
|
||||||
for (const project of projects) {
|
|
||||||
for (const tag of project.tags) {
|
|
||||||
if (tag.icon) {
|
|
||||||
iconIds.add(tag.icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect social link icons
|
|
||||||
for (const link of settings.socialLinks) {
|
|
||||||
if (link.icon) {
|
|
||||||
iconIds.add(link.icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch render all icons (single size, CSS handles scaling)
|
|
||||||
const iconsMap = await renderIconsBatch([...iconIds]);
|
|
||||||
|
|
||||||
// Convert Map to plain object for serialization
|
|
||||||
const icons: Record<string, string> = {};
|
|
||||||
for (const [id, svg] of iconsMap) {
|
|
||||||
icons[id] = svg;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects,
|
projects,
|
||||||
icons,
|
socialLinks: settings.socialLinks,
|
||||||
socialLinksWithIcons: settings.socialLinks,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-20
@@ -3,14 +3,14 @@
|
|||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
||||||
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
|
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
|
||||||
import IconSprite, { toSymbolId } from "$lib/components/IconSprite.svelte";
|
import Icon from "$lib/components/Icon.svelte";
|
||||||
import { telemetry } from "$lib/telemetry";
|
import { telemetry } from "$lib/telemetry";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const projects = $derived(data.projects);
|
const projects = $derived(data.projects);
|
||||||
const socialLinks = $derived(data.socialLinksWithIcons);
|
const socialLinks = $derived(data.socialLinks);
|
||||||
|
|
||||||
// Filter visible social links
|
// Filter visible social links
|
||||||
const visibleSocialLinks = $derived(
|
const visibleSocialLinks = $derived(
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Icon sprite containing all unique icons for symbol references -->
|
|
||||||
<IconSprite icons={data.icons} />
|
|
||||||
|
|
||||||
<main class="page-main overflow-x-hidden font-schibsted">
|
<main class="page-main overflow-x-hidden font-schibsted">
|
||||||
<div class="flex items-center flex-col pt-14">
|
<div class="flex items-center flex-col pt-14">
|
||||||
<div
|
<div
|
||||||
@@ -63,11 +60,11 @@
|
|||||||
onclick={() => trackSocialClick(link.value)}
|
onclick={() => trackSocialClick(link.value)}
|
||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
<Icon
|
||||||
<svg class="w-full h-full" aria-hidden="true">
|
icon={link.icon}
|
||||||
<use href="#{toSymbolId(link.icon)}" />
|
size="size-4"
|
||||||
</svg>
|
class="text-zinc-600 dark:text-zinc-300"
|
||||||
</span>
|
/>
|
||||||
<span
|
<span
|
||||||
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>{link.label}</span
|
>{link.label}</span
|
||||||
@@ -83,11 +80,11 @@
|
|||||||
openDiscordModal(link.value);
|
openDiscordModal(link.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
<Icon
|
||||||
<svg class="w-full h-full" aria-hidden="true">
|
icon={link.icon}
|
||||||
<use href="#{toSymbolId(link.icon)}" />
|
size="size-4"
|
||||||
</svg>
|
class="text-zinc-600 dark:text-zinc-300"
|
||||||
</span>
|
/>
|
||||||
<span
|
<span
|
||||||
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>{link.label}</span
|
>{link.label}</span
|
||||||
@@ -100,11 +97,11 @@
|
|||||||
onclick={() => trackSocialClick(`mailto:${link.value}`)}
|
onclick={() => trackSocialClick(`mailto:${link.value}`)}
|
||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
|
<Icon
|
||||||
<svg class="w-full h-full" aria-hidden="true">
|
icon={link.icon}
|
||||||
<use href="#{toSymbolId(link.icon)}" />
|
size="size-4.5"
|
||||||
</svg>
|
class="text-zinc-600 dark:text-zinc-300"
|
||||||
</span>
|
/>
|
||||||
<span
|
<span
|
||||||
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>{link.label}</span
|
>{link.label}</span
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { collectTagIcons } from "$lib/server/tag-icons";
|
|
||||||
import type { AdminProject } from "$lib/admin-types";
|
import type { AdminProject } from "$lib/admin-types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
||||||
|
|
||||||
// Collect all tag icons across all projects
|
|
||||||
const allTags = projects.flatMap((project) => project.tags);
|
|
||||||
const icons = await collectTagIcons(allTags);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects,
|
projects,
|
||||||
icons,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import Button from "$lib/components/admin/Button.svelte";
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
import Table from "$lib/components/admin/Table.svelte";
|
import Table from "$lib/components/admin/Table.svelte";
|
||||||
import TagChip from "$lib/components/TagChip.svelte";
|
import TagChip from "$lib/components/TagChip.svelte";
|
||||||
import IconSprite from "$lib/components/IconSprite.svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import type { ProjectStatus } from "$lib/admin-types";
|
import type { ProjectStatus } from "$lib/admin-types";
|
||||||
@@ -63,8 +62,6 @@
|
|||||||
<title>Projects | Admin</title>
|
<title>Projects | Admin</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<IconSprite icons={data.icons} />
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { collectTagIcons } from "$lib/server/tag-icons";
|
import type { AdminProject, AdminTagWithCount } from "$lib/admin-types";
|
||||||
import type {
|
|
||||||
AdminProject,
|
|
||||||
AdminTagWithCount,
|
|
||||||
AdminTag,
|
|
||||||
} from "$lib/admin-types";
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
@@ -16,16 +11,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
|||||||
apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }),
|
apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Collect icons for sprite (from available tags + project tags)
|
|
||||||
const allTags: AdminTag[] = [...availableTags];
|
|
||||||
if (project) {
|
|
||||||
allTags.push(...project.tags);
|
|
||||||
}
|
|
||||||
const icons = await collectTagIcons(allTags);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
availableTags,
|
availableTags,
|
||||||
icons,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||||
import Modal from "$lib/components/admin/Modal.svelte";
|
import Modal from "$lib/components/admin/Modal.svelte";
|
||||||
import IconSprite from "$lib/components/IconSprite.svelte";
|
|
||||||
import { updateAdminProject, deleteAdminProject } from "$lib/api";
|
import { updateAdminProject, deleteAdminProject } from "$lib/api";
|
||||||
import type { UpdateProjectData, CreateProjectData } from "$lib/admin-types";
|
import type { UpdateProjectData, CreateProjectData } from "$lib/admin-types";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -61,8 +60,6 @@
|
|||||||
<title>Edit Project | Admin</title>
|
<title>Edit Project | Admin</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<IconSprite icons={data.icons} />
|
|
||||||
|
|
||||||
<div class="max-w-3xl space-y-6">
|
<div class="max-w-3xl space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { collectTagIcons } from "$lib/server/tag-icons";
|
|
||||||
import type { AdminTagWithCount } from "$lib/admin-types";
|
import type { AdminTagWithCount } from "$lib/admin-types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
@@ -8,11 +7,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
|||||||
fetch,
|
fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect icons for sprite
|
|
||||||
const icons = await collectTagIcons(availableTags);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableTags,
|
availableTags,
|
||||||
icons,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||||
import IconSprite from "$lib/components/IconSprite.svelte";
|
|
||||||
import { createAdminProject } from "$lib/api";
|
import { createAdminProject } from "$lib/api";
|
||||||
import type { CreateProjectData } from "$lib/admin-types";
|
import type { CreateProjectData } from "$lib/admin-types";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -19,8 +18,6 @@
|
|||||||
<title>New Project | Admin</title>
|
<title>New Project | Admin</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<IconSprite icons={data.icons} />
|
|
||||||
|
|
||||||
<div class="max-w-3xl space-y-6">
|
<div class="max-w-3xl space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { collectTagIcons } from "$lib/server/tag-icons";
|
|
||||||
import type { AdminTagWithCount } from "$lib/admin-types";
|
import type { AdminTagWithCount } from "$lib/admin-types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
@@ -9,11 +8,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
|||||||
// Sort by project count descending (popularity)
|
// Sort by project count descending (popularity)
|
||||||
const sortedTags = [...tags].sort((a, b) => b.projectCount - a.projectCount);
|
const sortedTags = [...tags].sort((a, b) => b.projectCount - a.projectCount);
|
||||||
|
|
||||||
// Collect icons for sprite
|
|
||||||
const icons = await collectTagIcons(sortedTags);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags: sortedTags,
|
tags: sortedTags,
|
||||||
icons,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
|
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
|
||||||
import IconPicker from "$lib/components/admin/IconPicker.svelte";
|
import IconPicker from "$lib/components/admin/IconPicker.svelte";
|
||||||
import TagChip from "$lib/components/TagChip.svelte";
|
import TagChip from "$lib/components/TagChip.svelte";
|
||||||
import IconSprite from "$lib/components/IconSprite.svelte";
|
|
||||||
import { createAdminTag, deleteAdminTag } from "$lib/api";
|
import { createAdminTag, deleteAdminTag } from "$lib/api";
|
||||||
import type { CreateTagData, AdminTagWithCount } from "$lib/admin-types";
|
import type { CreateTagData, AdminTagWithCount } from "$lib/admin-types";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -146,8 +145,6 @@
|
|||||||
<title>Tags | Admin</title>
|
<title>Tags | Admin</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<IconSprite icons={data.icons} />
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { renderIconsBatch } from "$lib/server/icons";
|
|
||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import type { AdminTag, AdminProject } from "$lib/admin-types";
|
import type { AdminTag, AdminProject } from "$lib/admin-types";
|
||||||
|
|
||||||
@@ -37,30 +36,9 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
|||||||
// Non-fatal - just show empty related tags
|
// Non-fatal - just show empty related tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all unique icons
|
|
||||||
const iconIds = new Set<string>();
|
|
||||||
if (tagData.tag.icon) {
|
|
||||||
iconIds.add(tagData.tag.icon);
|
|
||||||
}
|
|
||||||
for (const tag of relatedTags) {
|
|
||||||
if (tag.icon) {
|
|
||||||
iconIds.add(tag.icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch render all icons
|
|
||||||
const iconsMap = await renderIconsBatch([...iconIds]);
|
|
||||||
|
|
||||||
// Convert Map to plain object for serialization
|
|
||||||
const icons: Record<string, string> = {};
|
|
||||||
for (const [id, svg] of iconsMap) {
|
|
||||||
icons[id] = svg;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tag: tagData.tag,
|
tag: tagData.tag,
|
||||||
projects: tagData.projects,
|
projects: tagData.projects,
|
||||||
relatedTags,
|
relatedTags,
|
||||||
icons,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
|
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
|
||||||
import IconPicker from "$lib/components/admin/IconPicker.svelte";
|
import IconPicker from "$lib/components/admin/IconPicker.svelte";
|
||||||
import TagChip from "$lib/components/TagChip.svelte";
|
import TagChip from "$lib/components/TagChip.svelte";
|
||||||
import IconSprite from "$lib/components/IconSprite.svelte";
|
import Icon from "$lib/components/Icon.svelte";
|
||||||
import { updateAdminTag, deleteAdminTag } from "$lib/api";
|
import { updateAdminTag, deleteAdminTag } from "$lib/api";
|
||||||
import { goto, invalidateAll } from "$app/navigation";
|
import { goto, invalidateAll } from "$app/navigation";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -28,49 +28,6 @@
|
|||||||
let color = $state<string | undefined>(data.tag.color);
|
let color = $state<string | undefined>(data.tag.color);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
// Preview icon SVG - starts with server-rendered, updates on icon change
|
|
||||||
// svelte-ignore state_referenced_locally
|
|
||||||
let previewIconSvg = $state(
|
|
||||||
data.tag.icon ? (data.icons[data.tag.icon] ?? "") : "",
|
|
||||||
);
|
|
||||||
let iconLoadTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// Watch for icon changes and fetch new preview
|
|
||||||
$effect(() => {
|
|
||||||
const currentIcon = icon;
|
|
||||||
|
|
||||||
// Clear pending timeout
|
|
||||||
if (iconLoadTimeout) {
|
|
||||||
clearTimeout(iconLoadTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentIcon) {
|
|
||||||
previewIconSvg = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if icon is already in sprite
|
|
||||||
if (data.icons[currentIcon]) {
|
|
||||||
previewIconSvg = data.icons[currentIcon];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce icon fetching for new icons
|
|
||||||
iconLoadTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/icons/${currentIcon.replace(":", "/")}`,
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
|
||||||
const iconData = await response.json();
|
|
||||||
previewIconSvg = iconData.svg ?? "";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep existing preview on error
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete state
|
// Delete state
|
||||||
let deleteModalOpen = $state(false);
|
let deleteModalOpen = $state(false);
|
||||||
let deleteConfirmReady = $state(false);
|
let deleteConfirmReady = $state(false);
|
||||||
@@ -145,8 +102,6 @@
|
|||||||
<title>Edit {data.tag.name} | Tags | Admin</title>
|
<title>Edit {data.tag.name} | Tags | Admin</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<IconSprite icons={data.icons} />
|
|
||||||
|
|
||||||
<div class="space-y-6 max-w-3xl">
|
<div class="space-y-6 max-w-3xl">
|
||||||
<!-- Back Link -->
|
<!-- Back Link -->
|
||||||
<a
|
<a
|
||||||
@@ -193,7 +148,7 @@
|
|||||||
<ColorPicker bind:selectedColor={color} />
|
<ColorPicker bind:selectedColor={color} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview - rendered inline with dynamic icon SVG -->
|
<!-- Preview -->
|
||||||
<div class="mt-6 pt-4 border-t border-admin-border">
|
<div class="mt-6 pt-4 border-t border-admin-border">
|
||||||
<span class="block text-sm font-medium text-admin-text mb-2">
|
<span class="block text-sm font-medium text-admin-text mb-2">
|
||||||
Preview
|
Preview
|
||||||
@@ -202,11 +157,8 @@
|
|||||||
class={tagBaseClasses}
|
class={tagBaseClasses}
|
||||||
style="border-left-color: #{color || '06b6d4'}"
|
style="border-left-color: #{color || '06b6d4'}"
|
||||||
>
|
>
|
||||||
{#if previewIconSvg}
|
{#if icon}
|
||||||
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
|
<Icon {icon} size="size-4.25 sm:size-3.75" />
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html previewIconSvg}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<span>{name || "Tag Name"}</span>
|
<span>{name || "Tag Name"}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { json, error } from "@sveltejs/kit";
|
import { json, error } from "@sveltejs/kit";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
import { requireAuth } from "$lib/server/auth";
|
|
||||||
import { getIconForApi } from "$lib/server/icons";
|
import { getIconForApi } from "$lib/server/icons";
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
// Require authentication
|
|
||||||
requireAuth(event);
|
|
||||||
|
|
||||||
const { collection, name } = event.params;
|
const { collection, name } = event.params;
|
||||||
const identifier = `${collection}:${name}`;
|
const identifier = `${collection}:${name}`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user