refactor: migrate icon rendering to SVG sprite pattern

Replace per-icon inline SVG rendering with a centralized sprite system. Icons are now collected once per page and rendered as <symbol> elements, referenced via <use> tags. Eliminates redundant icon fetching, reduces HTML size, and simplifies icon management across components.
This commit is contained in:
2026-01-15 01:59:02 -06:00
parent 95cb98b084
commit 935c5e6475
19 changed files with 226 additions and 222 deletions
+2 -3
View File
@@ -15,9 +15,8 @@ export interface AdminTagWithCount extends AdminTag {
projectCount: number; projectCount: number;
} }
export interface TagWithIcon extends AdminTag { // TagWithIcon is now just an alias for AdminTag since icons are rendered via sprite
iconSvg?: string; export type TagWithIcon = AdminTag;
}
// Media types for project carousel // Media types for project carousel
export type MediaType = "image" | "video"; export type MediaType = "image" | "video";
+50
View File
@@ -0,0 +1,50 @@
<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 -7
View File
@@ -4,14 +4,8 @@
import TagList from "./TagList.svelte"; import TagList from "./TagList.svelte";
import type { AdminProject } from "$lib/admin-types"; import type { AdminProject } from "$lib/admin-types";
// Extended tag type with icon SVG for display
type ProjectTag = { iconSvg?: string; name: string; color?: string };
interface Props { interface Props {
project: AdminProject & { project: AdminProject;
tags: ProjectTag[];
clockIconSvg?: string;
};
class?: string; class?: string;
} }
+8 -6
View File
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { toSymbolId } from "./IconSprite.svelte";
interface Props { interface Props {
name: string; name: string;
color?: string; color?: string;
iconSvg?: string; icon?: string;
href?: string; href?: string;
class?: string; class?: string;
} }
let { name, color, iconSvg, href, class: className }: Props = $props(); let { name, color, icon, href, class: className }: Props = $props();
const baseClasses = const baseClasses =
"inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3 shadow-sm"; "inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3 shadow-sm";
@@ -18,10 +19,11 @@
</script> </script>
{#snippet iconAndName()} {#snippet iconAndName()}
{#if iconSvg} {#if icon}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full"> <span class="size-4.25 sm:size-3.75">
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <svg class="w-full h-full" aria-hidden="true">
{@html iconSvg} <use href="#{toSymbolId(icon)}" />
</svg>
</span> </span>
{/if} {/if}
<span>{name}</span> <span>{name}</span>
+2 -2
View File
@@ -2,7 +2,7 @@
import TagChip from "./TagChip.svelte"; import TagChip from "./TagChip.svelte";
import OverflowPill from "./OverflowPill.svelte"; import OverflowPill from "./OverflowPill.svelte";
export type Tag = { iconSvg?: string; name: string; color?: string }; export type Tag = { icon?: string; name: string; color?: string };
interface Props { interface Props {
tags: Tag[]; tags: Tag[];
@@ -134,7 +134,7 @@
{style} {style}
> >
{#each visibleTags as tag (tag.name)} {#each visibleTags as tag (tag.name)}
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} /> <TagChip name={tag.name} color={tag.color} icon={tag.icon} />
{/each} {/each}
{#if hiddenTags.length > 0} {#if hiddenTags.length > 0}
<OverflowPill count={hiddenTags.length} {hiddenTagNames} /> <OverflowPill count={hiddenTags.length} {hiddenTagNames} />
@@ -86,7 +86,7 @@
<TagChip <TagChip
name={tag.name} name={tag.name}
color={hoveredTagId === tag.id ? "ef4444" : tag.color} color={hoveredTagId === tag.id ? "ef4444" : tag.color}
iconSvg={tag.iconSvg} icon={tag.icon}
class="transition-all duration-150 {hoveredTagId === tag.id class="transition-all duration-150 {hoveredTagId === tag.id
? 'bg-red-100/80 dark:bg-red-900/40' ? 'bg-red-100/80 dark:bg-red-900/40'
: ''}" : ''}"
@@ -119,7 +119,7 @@
class="w-full px-3 py-1.5 text-left hover:bg-admin-surface-hover transition-colors flex items-center" class="w-full px-3 py-1.5 text-left hover:bg-admin-surface-hover transition-colors flex items-center"
onclick={() => addTag(tag.id)} onclick={() => addTag(tag.id)}
> >
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} /> <TagChip name={tag.name} color={tag.color} icon={tag.icon} />
</button> </button>
{/each} {/each}
</div> </div>
+18 -19
View File
@@ -1,18 +1,17 @@
import { renderIconsBatch } from "./icons"; import { renderIconsBatch } from "./icons";
import type { AdminTag, TagWithIcon } from "$lib/admin-types"; import type { AdminTag } from "$lib/admin-types";
/** /**
* Add rendered icon SVG strings to tags by batch-rendering all icons * 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 add icons to * @param tags - Array of tags to extract icons from
* @param options - Render options (size, etc.) * @returns Record of icon identifier to SVG string
* @returns Array of tags with iconSvg property populated
*/ */
export async function addIconsToTags( export async function collectTagIcons(
tags: AdminTag[], tags: AdminTag[],
options?: { size?: number }, ): Promise<Record<string, string>> {
): Promise<TagWithIcon[]> { // Collect unique icon identifiers
// Collect all icon identifiers
const iconIds = new Set<string>(); const iconIds = new Set<string>();
for (const tag of tags) { for (const tag of tags) {
if (tag.icon) { if (tag.icon) {
@@ -20,19 +19,19 @@ export async function addIconsToTags(
} }
} }
// Return early if no icons to render // Return early if no icons
if (iconIds.size === 0) { if (iconIds.size === 0) {
return tags.map((tag) => ({ ...tag, iconSvg: undefined })); return {};
} }
// Batch render all icons // Batch render all icons
const icons = await renderIconsBatch([...iconIds], { const iconsMap = await renderIconsBatch([...iconIds]);
size: options?.size ?? 12,
});
// Map icons back to tags // Convert Map to plain object for serialization
return tags.map((tag) => ({ const icons: Record<string, string> = {};
...tag, for (const [id, svg] of iconsMap) {
iconSvg: tag.icon ? (icons.get(tag.icon) ?? undefined) : undefined, icons[id] = svg;
})); }
return icons;
} }
+16 -34
View File
@@ -3,8 +3,6 @@ import { apiFetch } from "$lib/api.server";
import { renderIconsBatch } from "$lib/server/icons"; import { renderIconsBatch } from "$lib/server/icons";
import type { AdminProject } from "$lib/admin-types"; import type { AdminProject } from "$lib/admin-types";
const CLOCK_ICON = "lucide:clock";
export const load: PageServerLoad = async ({ fetch, parent }) => { export const load: PageServerLoad = async ({ fetch, parent }) => {
// Get settings from parent layout // Get settings from parent layout
const parentData = await parent(); const parentData = await parent();
@@ -12,53 +10,37 @@ 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 icon identifiers for batch rendering // Collect all unique icon identifiers for batch rendering
const smallIconIds = new Set<string>(); const iconIds = new Set<string>();
const largeIconIds = new Set<string>();
// Add static icons // Collect tag icons
smallIconIds.add(CLOCK_ICON);
// Collect tag icons (size 12)
for (const project of projects) { for (const project of projects) {
for (const tag of project.tags) { for (const tag of project.tags) {
if (tag.icon) { if (tag.icon) {
smallIconIds.add(tag.icon); iconIds.add(tag.icon);
} }
} }
} }
// Collect social link icons (size 16) // Collect social link icons
for (const link of settings.socialLinks) { for (const link of settings.socialLinks) {
if (link.icon) { if (link.icon) {
largeIconIds.add(link.icon); iconIds.add(link.icon);
} }
} }
// Batch render all icons (two batches for different sizes) // Batch render all icons (single size, CSS handles scaling)
const [smallIcons, largeIcons] = await Promise.all([ const iconsMap = await renderIconsBatch([...iconIds]);
renderIconsBatch([...smallIconIds], { size: 12 }),
renderIconsBatch([...largeIconIds], { size: 16 }),
]);
// Map icons back to projects // Convert Map to plain object for serialization
const projectsWithIcons = projects.map((project) => ({ const icons: Record<string, string> = {};
...project, for (const [id, svg] of iconsMap) {
tags: project.tags.map((tag) => ({ icons[id] = svg;
...tag, }
iconSvg: tag.icon ? (smallIcons.get(tag.icon) ?? "") : "",
})),
clockIconSvg: smallIcons.get(CLOCK_ICON) ?? "",
}));
// Map icons back to social links
const socialLinksWithIcons = settings.socialLinks.map((link) => ({
...link,
iconSvg: largeIcons.get(link.icon) ?? "",
}));
return { return {
projects: projectsWithIcons, projects,
socialLinksWithIcons, icons,
socialLinksWithIcons: settings.socialLinks,
}; };
}; };
+16 -22
View File
@@ -3,30 +3,18 @@
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 { 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";
interface ExtendedPageData extends PageData { let { data }: { data: PageData } = $props();
socialLinksWithIcons: Array<{
id: string;
platform: string;
label: string;
value: string;
icon: string;
iconSvg: string;
visible: boolean;
displayOrder: number;
}>;
}
let { data }: { data: ExtendedPageData } = $props();
const projects = $derived(data.projects); const projects = $derived(data.projects);
const socialLinksWithIcons = $derived(data.socialLinksWithIcons); const socialLinks = $derived(data.socialLinksWithIcons);
// Filter visible social links // Filter visible social links
const visibleSocialLinks = $derived( const visibleSocialLinks = $derived(
socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible), socialLinks.filter((link) => link.visible),
); );
function openDiscordModal(username: string) { function openDiscordModal(username: string) {
@@ -38,6 +26,9 @@
} }
</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
@@ -73,8 +64,9 @@
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"> <span class="size-4 text-zinc-600 dark:text-zinc-300">
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <svg class="w-full h-full" aria-hidden="true">
{@html link.iconSvg} <use href="#{toSymbolId(link.icon)}" />
</svg>
</span> </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"
@@ -92,8 +84,9 @@
}} }}
> >
<span class="size-4 text-zinc-600 dark:text-zinc-300"> <span class="size-4 text-zinc-600 dark:text-zinc-300">
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <svg class="w-full h-full" aria-hidden="true">
{@html link.iconSvg} <use href="#{toSymbolId(link.icon)}" />
</svg>
</span> </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"
@@ -108,8 +101,9 @@
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"> <span class="size-4.5 text-zinc-600 dark:text-zinc-300">
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <svg class="w-full h-full" aria-hidden="true">
{@html link.iconSvg} <use href="#{toSymbolId(link.icon)}" />
</svg>
</span> </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"
+7 -28
View File
@@ -1,38 +1,17 @@
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 { collectTagIcons } from "$lib/server/tag-icons";
import type { AdminProject, TagWithIcon } from "$lib/admin-types"; import type { AdminProject } from "$lib/admin-types";
export interface ProjectWithTagIcons extends Omit<AdminProject, "tags"> {
tags: TagWithIcon[];
}
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 icon identifiers for batch rendering // Collect all tag icons across all projects
const iconIds = new Set<string>(); const allTags = projects.flatMap((project) => project.tags);
for (const project of projects) { const icons = await collectTagIcons(allTags);
for (const tag of project.tags) {
if (tag.icon) {
iconIds.add(tag.icon);
}
}
}
// Batch render all icons
const icons = await renderIconsBatch([...iconIds], { size: 12 });
// Map icons back to project tags
const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({
...project,
tags: project.tags.map((tag) => ({
...tag,
iconSvg: tag.icon ? (icons.get(tag.icon) ?? undefined) : undefined,
})),
}));
return { return {
projects: projectsWithIcons, projects,
icons,
}; };
}; };
+6 -9
View File
@@ -2,8 +2,9 @@
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 { ProjectWithTagIcons } from "./+page.server"; import type { PageData } from "./$types";
import type { ProjectStatus } from "$lib/admin-types"; import type { ProjectStatus } from "$lib/admin-types";
import IconPlus from "~icons/lucide/plus"; import IconPlus from "~icons/lucide/plus";
@@ -16,13 +17,7 @@
hidden: { color: "52525b", label: "Hidden" }, hidden: { color: "52525b", label: "Hidden" },
}; };
interface Props { let { data }: { data: PageData } = $props();
data: {
projects: ProjectWithTagIcons[];
};
}
let { data }: Props = $props();
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -68,6 +63,8 @@
<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">
@@ -157,7 +154,7 @@
<TagChip <TagChip
name={tag.name} name={tag.name}
color={tag.color} color={tag.color}
iconSvg={tag.iconSvg} icon={tag.icon}
href={`/admin/tags/${tag.slug}`} href={`/admin/tags/${tag.slug}`}
/> />
{/each} {/each}
@@ -1,22 +1,31 @@
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server"; import { apiFetch } from "$lib/api.server";
import { addIconsToTags } from "$lib/server/tag-icons"; 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;
// Fetch project and tags in parallel // Fetch project and tags in parallel
const [project, tagsWithCounts] = await Promise.all([ const [project, availableTags] = await Promise.all([
apiFetch<AdminProject>(`/api/projects/${id}`, { fetch }).catch(() => null), apiFetch<AdminProject>(`/api/projects/${id}`, { fetch }).catch(() => null),
apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }), apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }),
]); ]);
// Add icons to tags // Collect icons for sprite (from available tags + project tags)
const availableTags = await addIconsToTags(tagsWithCounts); const allTags: AdminTag[] = [...availableTags];
if (project) {
allTags.push(...project.tags);
}
const icons = await collectTagIcons(allTags);
return { return {
project, project,
availableTags, availableTags,
icons,
}; };
}; };
@@ -3,24 +3,15 @@
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 { import type { UpdateProjectData, CreateProjectData } from "$lib/admin-types";
UpdateProjectData, import type { PageData } from "./$types";
CreateProjectData,
TagWithIcon,
} from "$lib/admin-types";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
const logger = getLogger(["admin", "projects", "edit"]); const logger = getLogger(["admin", "projects", "edit"]);
interface Props { let { data }: { data: PageData } = $props();
data: {
project: import("$lib/admin-types").AdminProject | null;
availableTags: TagWithIcon[];
};
}
let { data }: Props = $props();
// Delete modal state // Delete modal state
let deleteModalOpen = $state(false); let deleteModalOpen = $state(false);
@@ -70,6 +61,8 @@
<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,17 +1,18 @@
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server"; import { apiFetch } from "$lib/api.server";
import { addIconsToTags } from "$lib/server/tag-icons"; 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 }) => {
const tagsWithCounts = await apiFetch<AdminTagWithCount[]>("/api/tags", { const availableTags = await apiFetch<AdminTagWithCount[]>("/api/tags", {
fetch, fetch,
}); });
// Add icons to tags // Collect icons for sprite
const availableTags = await addIconsToTags(tagsWithCounts); const icons = await collectTagIcons(availableTags);
return { return {
availableTags, availableTags,
icons,
}; };
}; };
@@ -2,16 +2,12 @@
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, TagWithIcon } from "$lib/admin-types"; import type { CreateProjectData } from "$lib/admin-types";
import type { PageData } from "./$types";
interface Props { let { data }: { data: PageData } = $props();
data: {
availableTags: TagWithIcon[];
};
}
let { data }: Props = $props();
async function handleSubmit(formData: CreateProjectData) { async function handleSubmit(formData: CreateProjectData) {
await createAdminProject(formData); await createAdminProject(formData);
@@ -23,6 +19,8 @@
<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>
+6 -11
View File
@@ -1,11 +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 { addIconsToTags } from "$lib/server/tag-icons"; import { collectTagIcons } from "$lib/server/tag-icons";
import type { AdminTagWithCount, TagWithIcon } from "$lib/admin-types"; import type { AdminTagWithCount } from "$lib/admin-types";
export interface TagWithIconAndCount extends TagWithIcon {
projectCount: number;
}
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
const tags = await apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }); const tags = await apiFetch<AdminTagWithCount[]>("/api/tags", { fetch });
@@ -13,12 +9,11 @@ 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);
// Add icons to tags (type assertion safe - addIconsToTags preserves all properties) // Collect icons for sprite
const tagsWithIcons = (await addIconsToTags( const icons = await collectTagIcons(sortedTags);
sortedTags,
)) as TagWithIconAndCount[];
return { return {
tags: tagsWithIcons, tags: sortedTags,
icons,
}; };
}; };
+11 -14
View File
@@ -5,9 +5,10 @@
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 } from "$lib/admin-types"; import type { CreateTagData, AdminTagWithCount } from "$lib/admin-types";
import type { TagWithIconAndCount } from "./+page.server"; import type { PageData } from "./$types";
import IconPlus from "~icons/lucide/plus"; import IconPlus from "~icons/lucide/plus";
import IconX from "~icons/lucide/x"; import IconX from "~icons/lucide/x";
import IconInfo from "~icons/lucide/info"; import IconInfo from "~icons/lucide/info";
@@ -16,13 +17,7 @@
const logger = getLogger(["admin", "tags"]); const logger = getLogger(["admin", "tags"]);
interface Props { let { data }: { data: PageData } = $props();
data: {
tags: TagWithIconAndCount[];
};
}
let { data }: Props = $props();
// Create form state // Create form state
let showCreateForm = $state(false); let showCreateForm = $state(false);
@@ -61,7 +56,7 @@
// Delete state // Delete state
let deleteModalOpen = $state(false); let deleteModalOpen = $state(false);
let deleteTarget = $state<TagWithIconAndCount | null>(null); let deleteTarget = $state<AdminTagWithCount | null>(null);
let deleteConfirmReady = $state(false); let deleteConfirmReady = $state(false);
let deleteTimeout: ReturnType<typeof setTimeout> | null = null; let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -93,7 +88,7 @@
} }
} }
function handleTagClick(tag: TagWithIconAndCount, event: MouseEvent) { function handleTagClick(tag: AdminTagWithCount, event: MouseEvent) {
if (deleteMode) { if (deleteMode) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -102,14 +97,14 @@
// Otherwise, let the link navigate normally // Otherwise, let the link navigate normally
} }
function handleTagKeyDown(tag: TagWithIconAndCount, event: KeyboardEvent) { function handleTagKeyDown(tag: AdminTagWithCount, event: KeyboardEvent) {
if (deleteMode && (event.key === "Enter" || event.key === " ")) { if (deleteMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault(); event.preventDefault();
initiateDelete(tag); initiateDelete(tag);
} }
} }
function initiateDelete(tag: TagWithIconAndCount) { function initiateDelete(tag: AdminTagWithCount) {
deleteTarget = tag; deleteTarget = tag;
deleteConfirmReady = false; deleteConfirmReady = false;
@@ -151,6 +146,8 @@
<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">
@@ -261,7 +258,7 @@
<TagChip <TagChip
name={tag.name} name={tag.name}
color={deleteMode ? "ef4444" : tag.color} color={deleteMode ? "ef4444" : tag.color}
iconSvg={tag.iconSvg} icon={tag.icon}
href={`/admin/tags/${tag.slug}`} href={`/admin/tags/${tag.slug}`}
class="transition-all duration-150 {deleteMode class="transition-all duration-150 {deleteMode
? 'bg-red-100/80 dark:bg-red-900/40 cursor-pointer' ? 'bg-red-100/80 dark:bg-red-900/40 cursor-pointer'
@@ -1,7 +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 { renderIconsBatch } from "$lib/server/icons"; import { renderIconsBatch } from "$lib/server/icons";
import { addIconsToTags } from "$lib/server/tag-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";
@@ -14,12 +13,6 @@ interface RelatedTagResponse extends AdminTag {
cooccurrenceCount: number; cooccurrenceCount: number;
} }
export interface TagPageData {
tag: AdminTag & { iconSvg?: string };
projects: AdminProject[];
relatedTags: Array<RelatedTagResponse & { iconSvg?: string }>;
}
export const load: PageServerLoad = async ({ params, fetch }) => { export const load: PageServerLoad = async ({ params, fetch }) => {
const { slug } = params; const { slug } = params;
@@ -44,30 +37,30 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
// Non-fatal - just show empty related tags // Non-fatal - just show empty related tags
} }
// Render main tag icon (single icon, just use renderIconsBatch directly) // Collect all unique icons
const iconIds = new Set<string>(); const iconIds = new Set<string>();
if (tagData.tag.icon) { if (tagData.tag.icon) {
iconIds.add(tagData.tag.icon); iconIds.add(tagData.tag.icon);
} }
const icons = await renderIconsBatch([...iconIds], { size: 12 }); for (const tag of relatedTags) {
if (tag.icon) {
iconIds.add(tag.icon);
}
}
const tagWithIcon = { // Batch render all icons
...tagData.tag, const iconsMap = await renderIconsBatch([...iconIds]);
iconSvg: tagData.tag.icon
? (icons.get(tagData.tag.icon) ?? undefined)
: undefined,
};
// Add icons to related tags using helper (preserving cooccurrenceCount) // Convert Map to plain object for serialization
const relatedTagsWithIconsBase = await addIconsToTags(relatedTags); const icons: Record<string, string> = {};
const relatedTagsWithIcons = relatedTags.map((tag, i) => ({ for (const [id, svg] of iconsMap) {
...relatedTagsWithIconsBase[i], icons[id] = svg;
cooccurrenceCount: tag.cooccurrenceCount, }
}));
return { return {
tag: tagWithIcon, tag: tagData.tag,
projects: tagData.projects, projects: tagData.projects,
relatedTags: relatedTagsWithIcons, relatedTags,
} satisfies TagPageData; icons,
};
}; };
+33 -11
View File
@@ -5,20 +5,17 @@
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 { 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 { TagPageData } from "./+page.server"; import type { PageData } from "./$types";
import IconArrowLeft from "~icons/lucide/arrow-left"; import IconArrowLeft from "~icons/lucide/arrow-left";
import IconExternalLink from "~icons/lucide/external-link"; import IconExternalLink from "~icons/lucide/external-link";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
const logger = getLogger(["admin", "tags", "edit"]); const logger = getLogger(["admin", "tags", "edit"]);
interface Props { let { data }: { data: PageData } = $props();
data: TagPageData;
}
let { data }: Props = $props();
// Form state - initialize from loaded data (intentionally captures initial values) // Form state - initialize from loaded data (intentionally captures initial values)
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
@@ -33,7 +30,9 @@
// Preview icon SVG - starts with server-rendered, updates on icon change // Preview icon SVG - starts with server-rendered, updates on icon change
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let previewIconSvg = $state(data.tag.iconSvg ?? ""); let previewIconSvg = $state(
data.tag.icon ? (data.icons[data.tag.icon] ?? "") : "",
);
let iconLoadTimeout: ReturnType<typeof setTimeout> | null = null; let iconLoadTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for icon changes and fetch new preview // Watch for icon changes and fetch new preview
@@ -50,7 +49,13 @@
return; return;
} }
// Debounce icon fetching // 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 () => { iconLoadTimeout = setTimeout(async () => {
try { try {
const response = await fetch( const response = await fetch(
@@ -130,12 +135,18 @@
alert("Failed to delete tag"); alert("Failed to delete tag");
} }
} }
// Base classes for tag chip styling (matches TagChip component)
const tagBaseClasses =
"inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3 shadow-sm";
</script> </script>
<svelte:head> <svelte:head>
<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
@@ -182,12 +193,23 @@
<ColorPicker bind:selectedColor={color} /> <ColorPicker bind:selectedColor={color} />
</div> </div>
<!-- Preview --> <!-- Preview - rendered inline with dynamic icon SVG -->
<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
</span> </span>
<TagChip name={name || "Tag Name"} {color} iconSvg={previewIconSvg} /> <span
class={tagBaseClasses}
style="border-left-color: #{color || '06b6d4'}"
>
{#if previewIconSvg}
<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 previewIconSvg}
</span>
{/if}
<span>{name || "Tag Name"}</span>
</span>
</div> </div>
<!-- Actions --> <!-- Actions -->
@@ -248,7 +270,7 @@
<TagChip <TagChip
name={tag.name} name={tag.name}
color={tag.color} color={tag.color}
iconSvg={tag.iconSvg} icon={tag.icon}
href={`/admin/tags/${tag.slug}`} href={`/admin/tags/${tag.slug}`}
/> />
{/each} {/each}