mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -06:00
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:
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user