diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts index cdc1f16..113988f 100644 --- a/web/src/lib/admin-types.ts +++ b/web/src/lib/admin-types.ts @@ -15,9 +15,8 @@ export interface AdminTagWithCount extends AdminTag { projectCount: number; } -export interface TagWithIcon extends AdminTag { - iconSvg?: string; -} +// TagWithIcon is now just an alias for AdminTag since icons are rendered via sprite +export type TagWithIcon = AdminTag; // Media types for project carousel export type MediaType = "image" | "video"; diff --git a/web/src/lib/components/IconSprite.svelte b/web/src/lib/components/IconSprite.svelte new file mode 100644 index 0000000..549f15d --- /dev/null +++ b/web/src/lib/components/IconSprite.svelte @@ -0,0 +1,50 @@ + + + + + + diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte index 34e66a7..4030981 100644 --- a/web/src/lib/components/ProjectCard.svelte +++ b/web/src/lib/components/ProjectCard.svelte @@ -4,14 +4,8 @@ import TagList from "./TagList.svelte"; 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 { - project: AdminProject & { - tags: ProjectTag[]; - clockIconSvg?: string; - }; + project: AdminProject; class?: string; } diff --git a/web/src/lib/components/TagChip.svelte b/web/src/lib/components/TagChip.svelte index 921f24a..afbfd47 100644 --- a/web/src/lib/components/TagChip.svelte +++ b/web/src/lib/components/TagChip.svelte @@ -1,15 +1,16 @@ {#snippet iconAndName()} - {#if iconSvg} - - - {@html iconSvg} + {#if icon} + + {/if} {name} diff --git a/web/src/lib/components/TagList.svelte b/web/src/lib/components/TagList.svelte index e70cf00..f42a70e 100644 --- a/web/src/lib/components/TagList.svelte +++ b/web/src/lib/components/TagList.svelte @@ -2,7 +2,7 @@ import TagChip from "./TagChip.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 { tags: Tag[]; @@ -134,7 +134,7 @@ {style} > {#each visibleTags as tag (tag.name)} - + {/each} {#if hiddenTags.length > 0} diff --git a/web/src/lib/components/admin/TagPicker.svelte b/web/src/lib/components/admin/TagPicker.svelte index a0605e8..cdc9b26 100644 --- a/web/src/lib/components/admin/TagPicker.svelte +++ b/web/src/lib/components/admin/TagPicker.svelte @@ -86,7 +86,7 @@ addTag(tag.id)} > - + {/each} diff --git a/web/src/lib/server/tag-icons.ts b/web/src/lib/server/tag-icons.ts index 27c4f52..bc14913 100644 --- a/web/src/lib/server/tag-icons.ts +++ b/web/src/lib/server/tag-icons.ts @@ -1,18 +1,17 @@ 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 options - Render options (size, etc.) - * @returns Array of tags with iconSvg property populated + * @param tags - Array of tags to extract icons from + * @returns Record of icon identifier to SVG string */ -export async function addIconsToTags( +export async function collectTagIcons( tags: AdminTag[], - options?: { size?: number }, -): Promise { - // Collect all icon identifiers +): Promise> { + // Collect unique icon identifiers const iconIds = new Set(); for (const tag of tags) { 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) { - return tags.map((tag) => ({ ...tag, iconSvg: undefined })); + return {}; } // Batch render all icons - const icons = await renderIconsBatch([...iconIds], { - size: options?.size ?? 12, - }); + const iconsMap = await renderIconsBatch([...iconIds]); - // Map icons back to tags - return tags.map((tag) => ({ - ...tag, - iconSvg: tag.icon ? (icons.get(tag.icon) ?? undefined) : undefined, - })); + // Convert Map to plain object for serialization + const icons: Record = {}; + for (const [id, svg] of iconsMap) { + icons[id] = svg; + } + + return icons; } diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts index f4bafaa..aff28d0 100644 --- a/web/src/routes/+page.server.ts +++ b/web/src/routes/+page.server.ts @@ -3,8 +3,6 @@ import { apiFetch } from "$lib/api.server"; import { renderIconsBatch } from "$lib/server/icons"; import type { AdminProject } from "$lib/admin-types"; -const CLOCK_ICON = "lucide:clock"; - export const load: PageServerLoad = async ({ fetch, parent }) => { // Get settings from parent layout const parentData = await parent(); @@ -12,53 +10,37 @@ export const load: PageServerLoad = async ({ fetch, parent }) => { const projects = await apiFetch("/api/projects", { fetch }); - // Collect all icon identifiers for batch rendering - const smallIconIds = new Set(); - const largeIconIds = new Set(); + // Collect all unique icon identifiers for batch rendering + const iconIds = new Set(); - // Add static icons - smallIconIds.add(CLOCK_ICON); - - // Collect tag icons (size 12) + // Collect tag icons for (const project of projects) { for (const tag of project.tags) { 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) { if (link.icon) { - largeIconIds.add(link.icon); + iconIds.add(link.icon); } } - // Batch render all icons (two batches for different sizes) - const [smallIcons, largeIcons] = await Promise.all([ - renderIconsBatch([...smallIconIds], { size: 12 }), - renderIconsBatch([...largeIconIds], { size: 16 }), - ]); + // Batch render all icons (single size, CSS handles scaling) + const iconsMap = await renderIconsBatch([...iconIds]); - // Map icons back to projects - const projectsWithIcons = projects.map((project) => ({ - ...project, - tags: project.tags.map((tag) => ({ - ...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) ?? "", - })); + // Convert Map to plain object for serialization + const icons: Record = {}; + for (const [id, svg] of iconsMap) { + icons[id] = svg; + } return { - projects: projectsWithIcons, - socialLinksWithIcons, + projects, + icons, + socialLinksWithIcons: settings.socialLinks, }; }; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 0a63895..1410cd6 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -3,30 +3,18 @@ import { page } from "$app/state"; import ProjectCard from "$lib/components/ProjectCard.svelte"; import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte"; + import IconSprite, { toSymbolId } from "$lib/components/IconSprite.svelte"; import { telemetry } from "$lib/telemetry"; import type { PageData } from "./$types"; import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key"; - interface ExtendedPageData extends PageData { - socialLinksWithIcons: Array<{ - id: string; - platform: string; - label: string; - value: string; - icon: string; - iconSvg: string; - visible: boolean; - displayOrder: number; - }>; - } - - let { data }: { data: ExtendedPageData } = $props(); + let { data }: { data: PageData } = $props(); const projects = $derived(data.projects); - const socialLinksWithIcons = $derived(data.socialLinksWithIcons); + const socialLinks = $derived(data.socialLinksWithIcons); // Filter visible social links const visibleSocialLinks = $derived( - socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible), + socialLinks.filter((link) => link.visible), ); function openDiscordModal(username: string) { @@ -38,6 +26,9 @@ } + + +
- - {@html link.iconSvg} + - - {@html link.iconSvg} + - - {@html link.iconSvg} + { - tags: TagWithIcon[]; -} +import { collectTagIcons } from "$lib/server/tag-icons"; +import type { AdminProject } from "$lib/admin-types"; export const load: PageServerLoad = async ({ fetch }) => { const projects = await apiFetch("/api/projects", { fetch }); - // Collect all tag icon identifiers for batch rendering - const iconIds = new Set(); - for (const project of projects) { - 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, - })), - })); + // Collect all tag icons across all projects + const allTags = projects.flatMap((project) => project.tags); + const icons = await collectTagIcons(allTags); return { - projects: projectsWithIcons, + projects, + icons, }; }; diff --git a/web/src/routes/admin/projects/+page.svelte b/web/src/routes/admin/projects/+page.svelte index 8a8b6d2..4405e96 100644 --- a/web/src/routes/admin/projects/+page.svelte +++ b/web/src/routes/admin/projects/+page.svelte @@ -2,8 +2,9 @@ import Button from "$lib/components/admin/Button.svelte"; import Table from "$lib/components/admin/Table.svelte"; import TagChip from "$lib/components/TagChip.svelte"; + import IconSprite from "$lib/components/IconSprite.svelte"; import { goto } from "$app/navigation"; - import type { ProjectWithTagIcons } from "./+page.server"; + import type { PageData } from "./$types"; import type { ProjectStatus } from "$lib/admin-types"; import IconPlus from "~icons/lucide/plus"; @@ -16,13 +17,7 @@ hidden: { color: "52525b", label: "Hidden" }, }; - interface Props { - data: { - projects: ProjectWithTagIcons[]; - }; - } - - let { data }: Props = $props(); + let { data }: { data: PageData } = $props(); function formatDate(dateStr: string): string { const date = new Date(dateStr); @@ -68,6 +63,8 @@ Projects | Admin + +
@@ -157,7 +154,7 @@ {/each} diff --git a/web/src/routes/admin/projects/[id]/+page.server.ts b/web/src/routes/admin/projects/[id]/+page.server.ts index d7ae194..3807287 100644 --- a/web/src/routes/admin/projects/[id]/+page.server.ts +++ b/web/src/routes/admin/projects/[id]/+page.server.ts @@ -1,22 +1,31 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; -import { addIconsToTags } from "$lib/server/tag-icons"; -import type { AdminProject, AdminTagWithCount } from "$lib/admin-types"; +import { collectTagIcons } from "$lib/server/tag-icons"; +import type { + AdminProject, + AdminTagWithCount, + AdminTag, +} from "$lib/admin-types"; export const load: PageServerLoad = async ({ params, fetch }) => { const { id } = params; // Fetch project and tags in parallel - const [project, tagsWithCounts] = await Promise.all([ + const [project, availableTags] = await Promise.all([ apiFetch(`/api/projects/${id}`, { fetch }).catch(() => null), apiFetch("/api/tags", { fetch }), ]); - // Add icons to tags - const availableTags = await addIconsToTags(tagsWithCounts); + // 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 { project, availableTags, + icons, }; }; diff --git a/web/src/routes/admin/projects/[id]/+page.svelte b/web/src/routes/admin/projects/[id]/+page.svelte index edfaf77..e572ba3 100644 --- a/web/src/routes/admin/projects/[id]/+page.svelte +++ b/web/src/routes/admin/projects/[id]/+page.svelte @@ -3,24 +3,15 @@ import { resolve } from "$app/paths"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; import Modal from "$lib/components/admin/Modal.svelte"; + import IconSprite from "$lib/components/IconSprite.svelte"; import { updateAdminProject, deleteAdminProject } from "$lib/api"; - import type { - UpdateProjectData, - CreateProjectData, - TagWithIcon, - } from "$lib/admin-types"; + import type { UpdateProjectData, CreateProjectData } from "$lib/admin-types"; + import type { PageData } from "./$types"; import { getLogger } from "@logtape/logtape"; const logger = getLogger(["admin", "projects", "edit"]); - interface Props { - data: { - project: import("$lib/admin-types").AdminProject | null; - availableTags: TagWithIcon[]; - }; - } - - let { data }: Props = $props(); + let { data }: { data: PageData } = $props(); // Delete modal state let deleteModalOpen = $state(false); @@ -70,6 +61,8 @@ Edit Project | Admin + +
diff --git a/web/src/routes/admin/projects/new/+page.server.ts b/web/src/routes/admin/projects/new/+page.server.ts index 5972695..18430f4 100644 --- a/web/src/routes/admin/projects/new/+page.server.ts +++ b/web/src/routes/admin/projects/new/+page.server.ts @@ -1,17 +1,18 @@ import type { PageServerLoad } from "./$types"; 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"; export const load: PageServerLoad = async ({ fetch }) => { - const tagsWithCounts = await apiFetch("/api/tags", { + const availableTags = await apiFetch("/api/tags", { fetch, }); - // Add icons to tags - const availableTags = await addIconsToTags(tagsWithCounts); + // Collect icons for sprite + const icons = await collectTagIcons(availableTags); return { availableTags, + icons, }; }; diff --git a/web/src/routes/admin/projects/new/+page.svelte b/web/src/routes/admin/projects/new/+page.svelte index e010741..3df8af7 100644 --- a/web/src/routes/admin/projects/new/+page.svelte +++ b/web/src/routes/admin/projects/new/+page.svelte @@ -2,16 +2,12 @@ import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; + import IconSprite from "$lib/components/IconSprite.svelte"; 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 { - data: { - availableTags: TagWithIcon[]; - }; - } - - let { data }: Props = $props(); + let { data }: { data: PageData } = $props(); async function handleSubmit(formData: CreateProjectData) { await createAdminProject(formData); @@ -23,6 +19,8 @@ New Project | Admin + +
diff --git a/web/src/routes/admin/tags/+page.server.ts b/web/src/routes/admin/tags/+page.server.ts index d67439e..62b6f45 100644 --- a/web/src/routes/admin/tags/+page.server.ts +++ b/web/src/routes/admin/tags/+page.server.ts @@ -1,11 +1,7 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; -import { addIconsToTags } from "$lib/server/tag-icons"; -import type { AdminTagWithCount, TagWithIcon } from "$lib/admin-types"; - -export interface TagWithIconAndCount extends TagWithIcon { - projectCount: number; -} +import { collectTagIcons } from "$lib/server/tag-icons"; +import type { AdminTagWithCount } from "$lib/admin-types"; export const load: PageServerLoad = async ({ fetch }) => { const tags = await apiFetch("/api/tags", { fetch }); @@ -13,12 +9,11 @@ export const load: PageServerLoad = async ({ fetch }) => { // Sort by project count descending (popularity) const sortedTags = [...tags].sort((a, b) => b.projectCount - a.projectCount); - // Add icons to tags (type assertion safe - addIconsToTags preserves all properties) - const tagsWithIcons = (await addIconsToTags( - sortedTags, - )) as TagWithIconAndCount[]; + // Collect icons for sprite + const icons = await collectTagIcons(sortedTags); return { - tags: tagsWithIcons, + tags: sortedTags, + icons, }; }; diff --git a/web/src/routes/admin/tags/+page.svelte b/web/src/routes/admin/tags/+page.svelte index aa95d6e..100e277 100644 --- a/web/src/routes/admin/tags/+page.svelte +++ b/web/src/routes/admin/tags/+page.svelte @@ -5,9 +5,10 @@ import ColorPicker from "$lib/components/admin/ColorPicker.svelte"; import IconPicker from "$lib/components/admin/IconPicker.svelte"; import TagChip from "$lib/components/TagChip.svelte"; + import IconSprite from "$lib/components/IconSprite.svelte"; import { createAdminTag, deleteAdminTag } from "$lib/api"; - import type { CreateTagData } from "$lib/admin-types"; - import type { TagWithIconAndCount } from "./+page.server"; + import type { CreateTagData, AdminTagWithCount } from "$lib/admin-types"; + import type { PageData } from "./$types"; import IconPlus from "~icons/lucide/plus"; import IconX from "~icons/lucide/x"; import IconInfo from "~icons/lucide/info"; @@ -16,13 +17,7 @@ const logger = getLogger(["admin", "tags"]); - interface Props { - data: { - tags: TagWithIconAndCount[]; - }; - } - - let { data }: Props = $props(); + let { data }: { data: PageData } = $props(); // Create form state let showCreateForm = $state(false); @@ -61,7 +56,7 @@ // Delete state let deleteModalOpen = $state(false); - let deleteTarget = $state(null); + let deleteTarget = $state(null); let deleteConfirmReady = $state(false); let deleteTimeout: ReturnType | null = null; @@ -93,7 +88,7 @@ } } - function handleTagClick(tag: TagWithIconAndCount, event: MouseEvent) { + function handleTagClick(tag: AdminTagWithCount, event: MouseEvent) { if (deleteMode) { event.preventDefault(); event.stopPropagation(); @@ -102,14 +97,14 @@ // 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 === " ")) { event.preventDefault(); initiateDelete(tag); } } - function initiateDelete(tag: TagWithIconAndCount) { + function initiateDelete(tag: AdminTagWithCount) { deleteTarget = tag; deleteConfirmReady = false; @@ -151,6 +146,8 @@ Tags | Admin + +
@@ -261,7 +258,7 @@ ; -} - export const load: PageServerLoad = async ({ params, fetch }) => { const { slug } = params; @@ -44,30 +37,30 @@ export const load: PageServerLoad = async ({ params, fetch }) => { // 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(); if (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 = { - ...tagData.tag, - iconSvg: tagData.tag.icon - ? (icons.get(tagData.tag.icon) ?? undefined) - : undefined, - }; + // Batch render all icons + const iconsMap = await renderIconsBatch([...iconIds]); - // Add icons to related tags using helper (preserving cooccurrenceCount) - const relatedTagsWithIconsBase = await addIconsToTags(relatedTags); - const relatedTagsWithIcons = relatedTags.map((tag, i) => ({ - ...relatedTagsWithIconsBase[i], - cooccurrenceCount: tag.cooccurrenceCount, - })); + // Convert Map to plain object for serialization + const icons: Record = {}; + for (const [id, svg] of iconsMap) { + icons[id] = svg; + } return { - tag: tagWithIcon, + tag: tagData.tag, projects: tagData.projects, - relatedTags: relatedTagsWithIcons, - } satisfies TagPageData; + relatedTags, + icons, + }; }; diff --git a/web/src/routes/admin/tags/[slug]/+page.svelte b/web/src/routes/admin/tags/[slug]/+page.svelte index 38b6c8e..45435a0 100644 --- a/web/src/routes/admin/tags/[slug]/+page.svelte +++ b/web/src/routes/admin/tags/[slug]/+page.svelte @@ -5,20 +5,17 @@ import ColorPicker from "$lib/components/admin/ColorPicker.svelte"; import IconPicker from "$lib/components/admin/IconPicker.svelte"; import TagChip from "$lib/components/TagChip.svelte"; + import IconSprite from "$lib/components/IconSprite.svelte"; import { updateAdminTag, deleteAdminTag } from "$lib/api"; 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 IconExternalLink from "~icons/lucide/external-link"; import { getLogger } from "@logtape/logtape"; const logger = getLogger(["admin", "tags", "edit"]); - interface Props { - data: TagPageData; - } - - let { data }: Props = $props(); + let { data }: { data: PageData } = $props(); // Form state - initialize from loaded data (intentionally captures initial values) // svelte-ignore state_referenced_locally @@ -33,7 +30,9 @@ // Preview icon SVG - starts with server-rendered, updates on icon change // 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 | null = null; // Watch for icon changes and fetch new preview @@ -50,7 +49,13 @@ 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 () => { try { const response = await fetch( @@ -130,12 +135,18 @@ 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"; Edit {data.tag.name} | Tags | Admin + + - +
Preview - + + {#if previewIconSvg} + + + {@html previewIconSvg} + + {/if} + {name || "Tag Name"} +
@@ -248,7 +270,7 @@ {/each}