diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts index d3a8451..a4f6cbe 100644 --- a/web/src/lib/admin-types.ts +++ b/web/src/lib/admin-types.ts @@ -15,6 +15,10 @@ export interface AdminTagWithCount extends AdminTag { projectCount: number; } +export interface TagWithIcon extends AdminTag { + iconSvg?: string; +} + export interface AdminProject { id: string; slug: string; diff --git a/web/src/lib/api.server.ts b/web/src/lib/api.server.ts index b7ea758..a4ea934 100644 --- a/web/src/lib/api.server.ts +++ b/web/src/lib/api.server.ts @@ -1,6 +1,7 @@ import { getLogger } from "@logtape/logtape"; import { env } from "$env/dynamic/private"; import { requestContext } from "$lib/server/context"; +import { ApiError } from "$lib/errors"; const logger = getLogger(["ssr", "lib", "api"]); @@ -71,7 +72,7 @@ function createSmartFetch(upstreamUrl: string) { status: response.status, statusText: response.statusText, }); - throw new Error(`API error: ${response.status} ${response.statusText}`); + throw new ApiError(response.status, response.statusText); } const data = await response.json(); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b50630f..8627a82 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -10,6 +10,7 @@ import type { UpdateTagData, SiteSettings, } from "./admin-types"; +import { ApiError } from "./errors"; // ============================================================================ // CLIENT-SIDE API FUNCTIONS @@ -23,7 +24,7 @@ async function clientApiFetch(path: string, init?: RequestInit): Promise { }); if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); + throw new ApiError(response.status, response.statusText); } return response.json(); @@ -44,8 +45,7 @@ export async function getAdminProject( try { return await clientApiFetch(`/api/projects/${id}`); } catch (error) { - // 404 errors should return null - if (error instanceof Error && error.message.includes("404")) { + if (ApiError.isNotFound(error)) { return null; } throw error; @@ -108,6 +108,32 @@ export async function deleteAdminTag(id: string): Promise { }); } +export interface TagWithProjects { + tag: AdminTag; + projects: AdminProject[]; +} + +export async function getAdminTagBySlug( + slug: string, +): Promise { + try { + return await clientApiFetch(`/api/tags/${slug}`); + } catch (error) { + if (ApiError.isNotFound(error)) { + return null; + } + throw error; + } +} + +export interface RelatedTag extends AdminTag { + cooccurrenceCount: number; +} + +export async function getRelatedTags(slug: string): Promise { + return clientApiFetch(`/api/tags/${slug}/related`); +} + // Admin Events API (currently mocked - no backend implementation yet) export async function getAdminEvents(): Promise { // TODO: Implement when events table is added to backend diff --git a/web/src/lib/components/TagChip.svelte b/web/src/lib/components/TagChip.svelte index 2cc3215..6d59bd3 100644 --- a/web/src/lib/components/TagChip.svelte +++ b/web/src/lib/components/TagChip.svelte @@ -5,19 +5,19 @@ name: string; color?: string; iconSvg?: string; + href?: string; class?: string; } - let { name, color, iconSvg, class: className }: Props = $props(); + let { name, color, iconSvg, href, class: className }: Props = $props(); + + 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"; + const linkClasses = + "hover:bg-zinc-300/80 dark:hover:bg-zinc-600/50 transition-colors"; - +{#snippet iconAndName()} {#if iconSvg} @@ -25,4 +25,21 @@ {/if} {name} - +{/snippet} + +{#if href} + + {@render iconAndName()} + +{:else} + + {@render iconAndName()} + +{/if} diff --git a/web/src/lib/components/admin/ProjectForm.svelte b/web/src/lib/components/admin/ProjectForm.svelte index 36e7903..efc2b35 100644 --- a/web/src/lib/components/admin/ProjectForm.svelte +++ b/web/src/lib/components/admin/ProjectForm.svelte @@ -4,14 +4,17 @@ import TagPicker from "./TagPicker.svelte"; import type { AdminProject, - AdminTag, CreateProjectData, ProjectStatus, + TagWithIcon, } from "$lib/admin-types"; + import { getLogger } from "@logtape/logtape"; + + const logger = getLogger(["admin", "components", "ProjectForm"]); interface Props { project?: AdminProject | null; - availableTags: AdminTag[]; + availableTags: TagWithIcon[]; onsubmit: (data: CreateProjectData) => Promise; submitLabel?: string; } @@ -85,7 +88,9 @@ tagIds: selectedTagIds, }); } catch (error) { - console.error("Failed to submit project:", error); + logger.error("Failed to submit project", { + error: error instanceof Error ? error.message : String(error), + }); alert("Failed to save project"); } finally { submitting = false; diff --git a/web/src/lib/components/admin/TagPicker.svelte b/web/src/lib/components/admin/TagPicker.svelte index 7312e61..a0605e8 100644 --- a/web/src/lib/components/admin/TagPicker.svelte +++ b/web/src/lib/components/admin/TagPicker.svelte @@ -1,11 +1,11 @@ @@ -54,15 +33,11 @@ - {#if loading} -
Loading...
- {:else} -
- -
- {/if} +
+ +
diff --git a/web/src/routes/admin/tags/+page.server.ts b/web/src/routes/admin/tags/+page.server.ts new file mode 100644 index 0000000..d67439e --- /dev/null +++ b/web/src/routes/admin/tags/+page.server.ts @@ -0,0 +1,24 @@ +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; +} + +export const load: PageServerLoad = async ({ fetch }) => { + const tags = await apiFetch("/api/tags", { 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[]; + + return { + tags: tagsWithIcons, + }; +}; diff --git a/web/src/routes/admin/tags/+page.svelte b/web/src/routes/admin/tags/+page.svelte index e388cd1..11273c4 100644 --- a/web/src/routes/admin/tags/+page.svelte +++ b/web/src/routes/admin/tags/+page.svelte @@ -1,26 +1,28 @@ + + + Edit {data.tag.name} | Tags | Admin + + +
+ + + + Back to Tags + + + +
+

Edit Tag

+

+ Modify tag details and view associated projects +

+
+ + +
+
+ + +
+ +
+ +
+ +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+
+
+ + + {#if data.projects.length > 0} +
+

+ Projects using this tag ({data.projects.length}) +

+ +
+ {/if} + + + {#if data.relatedTags.length > 0} +
+

Related Tags

+

+ Tags that frequently appear alongside this one +

+
+ {#each data.relatedTags as tag (tag.id)} + + {/each} +
+
+ {/if} +
+ + + +
+

{data.tag.name}

+

+ Used in {data.projects.length} project{data.projects.length === 1 + ? "" + : "s"} +

+
+