From 08c5dcda3bc0b6f92a525949182b964a48d0792c Mon Sep 17 00:00:00 2001 From: Xevion Date: Wed, 14 Jan 2026 11:44:46 -0600 Subject: [PATCH] refactor: use common TagChip, switch to SSR for admin pages, better error & logger handling --- web/src/lib/admin-types.ts | 4 + web/src/lib/api.server.ts | 3 +- web/src/lib/api.ts | 32 +- web/src/lib/components/TagChip.svelte | 35 +- .../lib/components/admin/ProjectForm.svelte | 11 +- web/src/lib/components/admin/TagPicker.svelte | 41 +- web/src/lib/errors.ts | 37 ++ web/src/lib/server/tag-icons.ts | 38 ++ web/src/routes/admin/projects/+page.server.ts | 66 ++++ web/src/routes/admin/projects/+page.svelte | 85 +++-- .../admin/projects/[id]/+page.server.ts | 22 ++ .../routes/admin/projects/[id]/+page.svelte | 65 +--- .../routes/admin/projects/new/+page.server.ts | 17 + .../routes/admin/projects/new/+page.svelte | 57 +-- web/src/routes/admin/tags/+page.server.ts | 24 ++ web/src/routes/admin/tags/+page.svelte | 354 +++++------------- .../routes/admin/tags/[slug]/+page.server.ts | 73 ++++ web/src/routes/admin/tags/[slug]/+page.svelte | 275 ++++++++++++++ 18 files changed, 831 insertions(+), 408 deletions(-) create mode 100644 web/src/lib/errors.ts create mode 100644 web/src/lib/server/tag-icons.ts create mode 100644 web/src/routes/admin/projects/+page.server.ts create mode 100644 web/src/routes/admin/projects/[id]/+page.server.ts create mode 100644 web/src/routes/admin/projects/new/+page.server.ts create mode 100644 web/src/routes/admin/tags/+page.server.ts create mode 100644 web/src/routes/admin/tags/[slug]/+page.server.ts create mode 100644 web/src/routes/admin/tags/[slug]/+page.svelte 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"} +

+
+