@@ -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}