refactor: use common TagChip, switch to SSR for admin pages, better error & logger handling

This commit is contained in:
2026-01-14 11:44:46 -06:00
parent cba0cbb704
commit 08c5dcda3b
18 changed files with 831 additions and 408 deletions
+4
View File
@@ -15,6 +15,10 @@ export interface AdminTagWithCount extends AdminTag {
projectCount: number; projectCount: number;
} }
export interface TagWithIcon extends AdminTag {
iconSvg?: string;
}
export interface AdminProject { export interface AdminProject {
id: string; id: string;
slug: string; slug: string;
+2 -1
View File
@@ -1,6 +1,7 @@
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
import { requestContext } from "$lib/server/context"; import { requestContext } from "$lib/server/context";
import { ApiError } from "$lib/errors";
const logger = getLogger(["ssr", "lib", "api"]); const logger = getLogger(["ssr", "lib", "api"]);
@@ -71,7 +72,7 @@ function createSmartFetch(upstreamUrl: string) {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
}); });
throw new Error(`API error: ${response.status} ${response.statusText}`); throw new ApiError(response.status, response.statusText);
} }
const data = await response.json(); const data = await response.json();
+29 -3
View File
@@ -10,6 +10,7 @@ import type {
UpdateTagData, UpdateTagData,
SiteSettings, SiteSettings,
} from "./admin-types"; } from "./admin-types";
import { ApiError } from "./errors";
// ============================================================================ // ============================================================================
// CLIENT-SIDE API FUNCTIONS // CLIENT-SIDE API FUNCTIONS
@@ -23,7 +24,7 @@ async function clientApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`); throw new ApiError(response.status, response.statusText);
} }
return response.json(); return response.json();
@@ -44,8 +45,7 @@ export async function getAdminProject(
try { try {
return await clientApiFetch<AdminProject>(`/api/projects/${id}`); return await clientApiFetch<AdminProject>(`/api/projects/${id}`);
} catch (error) { } catch (error) {
// 404 errors should return null if (ApiError.isNotFound(error)) {
if (error instanceof Error && error.message.includes("404")) {
return null; return null;
} }
throw error; throw error;
@@ -108,6 +108,32 @@ export async function deleteAdminTag(id: string): Promise<void> {
}); });
} }
export interface TagWithProjects {
tag: AdminTag;
projects: AdminProject[];
}
export async function getAdminTagBySlug(
slug: string,
): Promise<TagWithProjects | null> {
try {
return await clientApiFetch<TagWithProjects>(`/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<RelatedTag[]> {
return clientApiFetch<RelatedTag[]>(`/api/tags/${slug}/related`);
}
// Admin Events API (currently mocked - no backend implementation yet) // Admin Events API (currently mocked - no backend implementation yet)
export async function getAdminEvents(): Promise<AdminEvent[]> { export async function getAdminEvents(): Promise<AdminEvent[]> {
// TODO: Implement when events table is added to backend // TODO: Implement when events table is added to backend
+25 -8
View File
@@ -5,19 +5,19 @@
name: string; name: string;
color?: string; color?: string;
iconSvg?: string; iconSvg?: string;
href?: string;
class?: 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";
</script> </script>
<span {#snippet iconAndName()}
class={cn(
"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",
className,
)}
style="border-left-color: #{color || '06b6d4'}"
>
{#if iconSvg} {#if iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full"> <span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
@@ -25,4 +25,21 @@
</span> </span>
{/if} {/if}
<span>{name}</span> <span>{name}</span>
{/snippet}
{#if href}
<a
{href}
class={cn(baseClasses, linkClasses, className)}
style="border-left-color: #{color || '06b6d4'}"
>
{@render iconAndName()}
</a>
{:else}
<span
class={cn(baseClasses, className)}
style="border-left-color: #{color || '06b6d4'}"
>
{@render iconAndName()}
</span> </span>
{/if}
@@ -4,14 +4,17 @@
import TagPicker from "./TagPicker.svelte"; import TagPicker from "./TagPicker.svelte";
import type { import type {
AdminProject, AdminProject,
AdminTag,
CreateProjectData, CreateProjectData,
ProjectStatus, ProjectStatus,
TagWithIcon,
} from "$lib/admin-types"; } from "$lib/admin-types";
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["admin", "components", "ProjectForm"]);
interface Props { interface Props {
project?: AdminProject | null; project?: AdminProject | null;
availableTags: AdminTag[]; availableTags: TagWithIcon[];
onsubmit: (data: CreateProjectData) => Promise<void>; onsubmit: (data: CreateProjectData) => Promise<void>;
submitLabel?: string; submitLabel?: string;
} }
@@ -85,7 +88,9 @@
tagIds: selectedTagIds, tagIds: selectedTagIds,
}); });
} catch (error) { } 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"); alert("Failed to save project");
} finally { } finally {
submitting = false; submitting = false;
+19 -14
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { AdminTag } from "$lib/admin-types"; import TagChip from "$lib/components/TagChip.svelte";
import IconX from "~icons/lucide/x"; import type { TagWithIcon } from "$lib/admin-types";
interface Props { interface Props {
label?: string; label?: string;
availableTags: AdminTag[]; availableTags: TagWithIcon[];
selectedTagIds: string[]; selectedTagIds: string[];
placeholder?: string; placeholder?: string;
class?: string; class?: string;
@@ -22,6 +22,7 @@
let searchTerm = $state(""); let searchTerm = $state("");
let dropdownOpen = $state(false); let dropdownOpen = $state(false);
let inputRef: HTMLInputElement | undefined = $state(); let inputRef: HTMLInputElement | undefined = $state();
let hoveredTagId = $state<string | null>(null);
// Generate unique ID for accessibility // Generate unique ID for accessibility
const inputId = `tagpicker-${Math.random().toString(36).substring(2, 11)}`; const inputId = `tagpicker-${Math.random().toString(36).substring(2, 11)}`;
@@ -72,21 +73,25 @@
<div <div
class="min-h-[42px] w-full rounded-md border border-admin-border bg-admin-bg-secondary px-3 py-2" class="min-h-[42px] w-full rounded-md border border-admin-border bg-admin-bg-secondary px-3 py-2"
> >
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1.5 items-center">
{#each selectedTags as tag (tag.id)} {#each selectedTags as tag (tag.id)}
<span
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-500/20"
>
{tag.name}
<button <button
type="button" type="button"
onclick={() => removeTag(tag.id)} onclick={() => removeTag(tag.id)}
class="hover:text-blue-300" onmouseenter={() => (hoveredTagId = tag.id)}
aria-label="Remove tag" onmouseleave={() => (hoveredTagId = null)}
class="cursor-pointer"
aria-label="Remove {tag.name}"
> >
<IconX class="w-3 h-3" /> <TagChip
name={tag.name}
color={hoveredTagId === tag.id ? "ef4444" : tag.color}
iconSvg={tag.iconSvg}
class="transition-all duration-150 {hoveredTagId === tag.id
? 'bg-red-100/80 dark:bg-red-900/40'
: ''}"
/>
</button> </button>
</span>
{/each} {/each}
<!-- Search input --> <!-- Search input -->
@@ -111,10 +116,10 @@
{#each filteredTags as tag (tag.id)} {#each filteredTags as tag (tag.id)}
<button <button
type="button" type="button"
class="w-full px-3 py-2 text-left text-sm text-admin-text hover:bg-admin-surface-hover transition-colors" 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)}
> >
{tag.name} <TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
</button> </button>
{/each} {/each}
</div> </div>
+37
View File
@@ -0,0 +1,37 @@
/**
* Custom error class for API requests that preserves HTTP status codes
*/
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
message?: string,
) {
super(message || `API error: ${status} ${statusText}`);
this.name = "ApiError";
}
/**
* Check if an error is a 404 Not Found error
*/
static isNotFound(error: unknown): boolean {
return error instanceof ApiError && error.status === 404;
}
/**
* Check if an error is an authentication error (401/403)
*/
static isAuthError(error: unknown): boolean {
return (
error instanceof ApiError &&
(error.status === 401 || error.status === 403)
);
}
/**
* Check if an error is a server error (5xx)
*/
static isServerError(error: unknown): boolean {
return error instanceof ApiError && error.status >= 500 && error.status < 600;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { renderIconsBatch } from "./icons";
import type { AdminTag, TagWithIcon } from "$lib/admin-types";
/**
* Add rendered icon SVG strings to tags by batch-rendering all icons
*
* @param tags - Array of tags to add icons to
* @param options - Render options (size, etc.)
* @returns Array of tags with iconSvg property populated
*/
export async function addIconsToTags(
tags: AdminTag[],
options?: { size?: number },
): Promise<TagWithIcon[]> {
// Collect all icon identifiers
const iconIds = new Set<string>();
for (const tag of tags) {
if (tag.icon) {
iconIds.add(tag.icon);
}
}
// Return early if no icons to render
if (iconIds.size === 0) {
return tags.map((tag) => ({ ...tag, iconSvg: undefined }));
}
// Batch render all icons
const icons = await renderIconsBatch([...iconIds], {
size: options?.size ?? 12,
});
// Map icons back to tags
return tags.map((tag) => ({
...tag,
iconSvg: tag.icon ? (icons.get(tag.icon) ?? undefined) : undefined,
}));
}
@@ -0,0 +1,66 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { renderIconsBatch } from "$lib/server/icons";
import type {
AdminProject,
ProjectStatus,
TagWithIcon,
} from "$lib/admin-types";
export interface ProjectWithTagIcons extends Omit<AdminProject, "tags"> {
tags: TagWithIcon[];
}
// Status display configuration (icons for server-side rendering)
const STATUS_ICONS: Record<ProjectStatus, string> = {
active: "lucide:circle-check",
maintained: "lucide:wrench",
archived: "lucide:archive",
hidden: "lucide:eye-off",
};
export const load: PageServerLoad = async ({ fetch }) => {
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
// Collect all icon identifiers for batch rendering
const iconIds = new Set<string>();
// Add status icons
for (const icon of Object.values(STATUS_ICONS)) {
iconIds.add(icon);
}
// Add tag icons
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 });
// Build status icons map
const statusIcons: Record<ProjectStatus, string> = {
active: icons.get(STATUS_ICONS.active) ?? "",
maintained: icons.get(STATUS_ICONS.maintained) ?? "",
archived: icons.get(STATUS_ICONS.archived) ?? "",
hidden: icons.get(STATUS_ICONS.hidden) ?? "",
};
// 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 {
projects: projectsWithIcons,
statusIcons,
};
};
+50 -35
View File
@@ -1,35 +1,41 @@
<script lang="ts"> <script lang="ts">
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 Badge from "$lib/components/admin/Badge.svelte";
import Modal from "$lib/components/admin/Modal.svelte"; import Modal from "$lib/components/admin/Modal.svelte";
import { getAdminProjects, deleteAdminProject } from "$lib/api"; import TagChip from "$lib/components/TagChip.svelte";
import type { AdminProject } from "$lib/admin-types"; import { deleteAdminProject } from "$lib/api";
import { invalidateAll } from "$app/navigation";
import type { ProjectWithTagIcons } from "./+page.server";
import type { ProjectStatus } from "$lib/admin-types";
import IconPlus from "~icons/lucide/plus"; import IconPlus from "~icons/lucide/plus";
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["admin", "projects"]);
// Status display configuration (colors match Badge component)
const STATUS_CONFIG: Record<ProjectStatus, { color: string; label: string }> =
{
active: { color: "10b981", label: "Active" },
maintained: { color: "6366f1", label: "Maintained" },
archived: { color: "71717a", label: "Archived" },
hidden: { color: "52525b", label: "Hidden" },
};
interface Props {
data: {
projects: ProjectWithTagIcons[];
statusIcons: Record<ProjectStatus, string>;
};
}
let { data }: Props = $props();
let projects = $state<AdminProject[]>([]);
let loading = $state(true);
let deleteModalOpen = $state(false); let deleteModalOpen = $state(false);
let deleteTarget = $state<AdminProject | null>(null); let deleteTarget = $state<ProjectWithTagIcons | null>(null);
let deleteConfirmReady = $state(false); let deleteConfirmReady = $state(false);
let deleteTimeout: ReturnType<typeof setTimeout> | null = null; let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
async function loadProjects() { function initiateDelete(project: ProjectWithTagIcons) {
try {
projects = await getAdminProjects();
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
loading = false;
}
}
// Load projects on mount
$effect(() => {
loadProjects();
});
function initiateDelete(project: AdminProject) {
deleteTarget = project; deleteTarget = project;
deleteConfirmReady = false; deleteConfirmReady = false;
@@ -55,12 +61,14 @@
try { try {
await deleteAdminProject(deleteTarget.id); await deleteAdminProject(deleteTarget.id);
projects = projects.filter((p) => p.id !== deleteTarget!.id); await invalidateAll();
deleteModalOpen = false; deleteModalOpen = false;
deleteTarget = null; deleteTarget = null;
deleteConfirmReady = false; deleteConfirmReady = false;
} catch (error) { } catch (error) {
console.error("Failed to delete project:", error); logger.error("Failed to delete project", {
error: error instanceof Error ? error.message : String(error),
});
alert("Failed to delete project"); alert("Failed to delete project");
} }
} }
@@ -95,11 +103,7 @@
</div> </div>
<!-- Projects Table --> <!-- Projects Table -->
{#if loading} {#if data.projects.length === 0}
<div class="text-center py-12 text-admin-text-muted">
Loading projects...
</div>
{:else if projects.length === 0}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-admin-text-muted mb-4">No projects yet</p> <p class="text-admin-text-muted mb-4">No projects yet</p>
<Button variant="primary" href="/admin/projects/new" <Button variant="primary" href="/admin/projects/new"
@@ -138,7 +142,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-admin-border"> <tbody class="divide-y divide-admin-border">
{#each projects as project (project.id)} {#each data.projects as project (project.id)}
<tr class="hover:bg-admin-surface-hover/50 transition-colors"> <tr class="hover:bg-admin-surface-hover/50 transition-colors">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -153,17 +157,28 @@
</div> </div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<Badge variant={project.status}> <TagChip
{project.status} name={STATUS_CONFIG[project.status].label}
</Badge> color={STATUS_CONFIG[project.status].color}
iconSvg={data.statusIcons[project.status]}
/>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each project.tags.slice(0, 3) as tag (tag.id)} {#each project.tags.slice(0, 3) as tag (tag.id)}
<Badge variant="default">{tag.name}</Badge> <TagChip
name={tag.name}
color={tag.color}
iconSvg={tag.iconSvg}
href={`/admin/tags/${tag.slug}`}
/>
{/each} {/each}
{#if project.tags.length > 3} {#if project.tags.length > 3}
<Badge variant="default">+{project.tags.length - 3}</Badge> <span
class="inline-flex items-center px-2 py-1 text-xs text-admin-text-muted bg-admin-surface-hover rounded"
>
+{project.tags.length - 3}
</span>
{/if} {/if}
</div> </div>
</td> </td>
@@ -0,0 +1,22 @@
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";
export const load: PageServerLoad = async ({ params, fetch }) => {
const { id } = params;
// Fetch project and tags in parallel
const [project, tagsWithCounts] = await Promise.all([
apiFetch<AdminProject>(`/api/projects/${id}`, { fetch }).catch(() => null),
apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }),
]);
// Add icons to tags
const availableTags = await addIconsToTags(tagsWithCounts);
return {
project,
availableTags,
};
};
+18 -47
View File
@@ -1,56 +1,29 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores";
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 { getAdminProject, getAdminTags, updateAdminProject } from "$lib/api"; import { updateAdminProject } from "$lib/api";
import type { import type {
AdminProject,
AdminTag,
AdminTagWithCount,
CreateProjectData,
UpdateProjectData, UpdateProjectData,
CreateProjectData,
TagWithIcon,
} from "$lib/admin-types"; } from "$lib/admin-types";
const projectId = $derived(($page.params as { id: string }).id); interface Props {
data: {
let project = $state<AdminProject | null>(null); project: import("$lib/admin-types").AdminProject | null;
let tags = $state<AdminTag[]>([]); availableTags: TagWithIcon[];
let loading = $state(true); };
async function loadData() {
try {
const [projectData, tagsWithCounts] = await Promise.all([
getAdminProject(projectId),
getAdminTags(),
]);
project = projectData;
tags = tagsWithCounts.map(
(t: AdminTagWithCount): AdminTag => ({
id: t.id,
slug: t.slug,
name: t.name,
createdAt: t.createdAt,
}),
);
} catch (error) {
console.error("Failed to load data:", error);
alert("Failed to load project");
goto(resolve("/admin/projects"));
} finally {
loading = false;
}
} }
$effect(() => { let { data }: Props = $props();
loadData();
}); async function handleSubmit(formData: CreateProjectData) {
if (!data.project) return;
async function handleSubmit(data: CreateProjectData) {
const updateData: UpdateProjectData = { const updateData: UpdateProjectData = {
...data, ...formData,
id: projectId, id: data.project.id,
}; };
await updateAdminProject(updateData); await updateAdminProject(updateData);
goto(resolve("/admin/projects")); goto(resolve("/admin/projects"));
@@ -71,23 +44,21 @@
</div> </div>
<!-- Form --> <!-- Form -->
{#if loading} {#if !data.project}
<div class="text-center py-12 text-admin-text-muted">Loading...</div>
{:else if !project}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-admin-text-muted mb-4">Project not found</p> <p class="text-admin-text-muted mb-4">Project not found</p>
<a <a
href={resolve("/admin/projects")} href={resolve("/admin/projects")}
class="text-admin-accent hover:text-admin-accent-hover" class="text-admin-accent hover:text-admin-accent-hover"
> >
Back to projects Back to projects
</a> </a>
</div> </div>
{:else} {:else}
<div class="rounded-lg border border-admin-border bg-admin-surface p-6"> <div class="rounded-lg border border-admin-border bg-admin-surface p-6">
<ProjectForm <ProjectForm
{project} project={data.project}
availableTags={tags} availableTags={data.availableTags}
onsubmit={handleSubmit} onsubmit={handleSubmit}
submitLabel="Update Project" submitLabel="Update Project"
/> />
@@ -0,0 +1,17 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { addIconsToTags } from "$lib/server/tag-icons";
import type { AdminTagWithCount } from "$lib/admin-types";
export const load: PageServerLoad = async ({ fetch }) => {
const tagsWithCounts = await apiFetch<AdminTagWithCount[]>("/api/tags", {
fetch,
});
// Add icons to tags
const availableTags = await addIconsToTags(tagsWithCounts);
return {
availableTags,
};
};
+10 -35
View File
@@ -2,40 +2,19 @@
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 { getAdminTags, createAdminProject } from "$lib/api"; import { createAdminProject } from "$lib/api";
import type { import type { CreateProjectData, TagWithIcon } from "$lib/admin-types";
AdminTag,
AdminTagWithCount,
CreateProjectData,
} from "$lib/admin-types";
let tags = $state<AdminTag[]>([]); interface Props {
let loading = $state(true); data: {
availableTags: TagWithIcon[];
async function loadTags() { };
try {
const tagsWithCounts = await getAdminTags();
tags = tagsWithCounts.map(
(t: AdminTagWithCount): AdminTag => ({
id: t.id,
slug: t.slug,
name: t.name,
createdAt: t.createdAt,
}),
);
} catch (error) {
console.error("Failed to load tags:", error);
} finally {
loading = false;
}
} }
$effect(() => { let { data }: Props = $props();
loadTags();
});
async function handleSubmit(data: CreateProjectData) { async function handleSubmit(formData: CreateProjectData) {
await createAdminProject(data); await createAdminProject(formData);
goto(resolve("/admin/projects")); goto(resolve("/admin/projects"));
} }
</script> </script>
@@ -54,15 +33,11 @@
</div> </div>
<!-- Form --> <!-- Form -->
{#if loading}
<div class="text-center py-12 text-admin-text-muted">Loading...</div>
{:else}
<div class="rounded-lg border border-admin-border bg-admin-surface p-6"> <div class="rounded-lg border border-admin-border bg-admin-surface p-6">
<ProjectForm <ProjectForm
availableTags={tags} availableTags={data.availableTags}
onsubmit={handleSubmit} onsubmit={handleSubmit}
submitLabel="Create Project" submitLabel="Create Project"
/> />
</div> </div>
{/if}
</div> </div>
+24
View File
@@ -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<AdminTagWithCount[]>("/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,
};
};
+99 -247
View File
@@ -1,26 +1,28 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/admin/Button.svelte"; import Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte"; import Input from "$lib/components/admin/Input.svelte";
import Table from "$lib/components/admin/Table.svelte";
import Modal from "$lib/components/admin/Modal.svelte"; import Modal from "$lib/components/admin/Modal.svelte";
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 { import TagChip from "$lib/components/TagChip.svelte";
getAdminTags, import { createAdminTag, deleteAdminTag } from "$lib/api";
createAdminTag, import type { CreateTagData } from "$lib/admin-types";
updateAdminTag, import type { TagWithIconAndCount } from "./+page.server";
deleteAdminTag,
} from "$lib/api";
import type {
AdminTagWithCount,
CreateTagData,
UpdateTagData,
} from "$lib/admin-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 { invalidateAll } from "$app/navigation";
import { getLogger } from "@logtape/logtape";
let tags = $state<AdminTagWithCount[]>([]); const logger = getLogger(["admin", "tags"]);
let loading = $state(true);
interface Props {
data: {
tags: TagWithIconAndCount[];
};
}
let { data }: Props = $props();
// Create form state // Create form state
let showCreateForm = $state(false); let showCreateForm = $state(false);
@@ -30,104 +32,80 @@
let createColor = $state<string | undefined>(undefined); let createColor = $state<string | undefined>(undefined);
let creating = $state(false); let creating = $state(false);
// Edit state // Delete mode state (activated by holding Shift)
let editingId = $state<string | null>(null); let deleteMode = $state(false);
let editName = $state("");
let editSlug = $state(""); // Track shift key
let editIcon = $state<string>(""); $effect(() => {
let editColor = $state<string | undefined>(undefined); const handleKeyDown = (e: KeyboardEvent) => {
let updating = $state(false); if (e.key === "Shift") deleteMode = true;
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift") deleteMode = false;
};
const handleBlur = () => {
// Reset delete mode if window loses focus
deleteMode = false;
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
});
// Delete state // Delete state
let deleteModalOpen = $state(false); let deleteModalOpen = $state(false);
let deleteTarget = $state<AdminTagWithCount | null>(null); let deleteTarget = $state<TagWithIconAndCount | null>(null);
let deleteConfirmReady = $state(false); let deleteConfirmReady = $state(false);
let deleteTimeout: ReturnType<typeof setTimeout> | null = null; let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
async function loadTags() {
try {
tags = await getAdminTags();
} catch (error) {
console.error("Failed to load tags:", error);
} finally {
loading = false;
}
}
$effect(() => {
loadTags();
});
async function handleCreate() { async function handleCreate() {
if (!createName.trim()) return; if (!createName.trim()) return;
creating = true; creating = true;
try { try {
const data: CreateTagData = { const createData: CreateTagData = {
name: createName, name: createName,
slug: createSlug || undefined, slug: createSlug || undefined,
icon: createIcon || undefined, icon: createIcon || undefined,
color: createColor, color: createColor,
}; };
await createAdminTag(data); await createAdminTag(createData);
await loadTags(); await invalidateAll();
createName = ""; createName = "";
createSlug = ""; createSlug = "";
createIcon = ""; createIcon = "";
createColor = undefined; createColor = undefined;
showCreateForm = false; showCreateForm = false;
} catch (error) { } catch (error) {
console.error("Failed to create tag:", error); logger.error("Failed to create tag", {
error: error instanceof Error ? error.message : String(error),
});
alert("Failed to create tag"); alert("Failed to create tag");
} finally { } finally {
creating = false; creating = false;
} }
} }
function startEdit(tag: AdminTagWithCount) { function handleTagClick(tag: TagWithIconAndCount, event: MouseEvent) {
editingId = tag.id; if (deleteMode) {
editName = tag.name; event.preventDefault();
editSlug = tag.slug; event.stopPropagation();
editIcon = tag.icon || ""; initiateDelete(tag);
editColor = tag.color; }
// Otherwise, let the link navigate normally
} }
function cancelEdit() { function initiateDelete(tag: TagWithIconAndCount) {
editingId = null;
editName = "";
editSlug = "";
editIcon = "";
editColor = undefined;
}
async function handleUpdate() {
if (!editingId || !editName.trim()) return;
updating = true;
try {
const data: UpdateTagData = {
id: editingId,
name: editName,
slug: editSlug || undefined,
color: editColor,
icon: editIcon || undefined,
};
await updateAdminTag(data);
await loadTags();
cancelEdit();
} catch (error) {
console.error("Failed to update tag:", error);
alert("Failed to update tag");
} finally {
updating = false;
}
}
function initiateDelete(tag: AdminTagWithCount) {
deleteTarget = tag; deleteTarget = tag;
deleteConfirmReady = false; deleteConfirmReady = false;
// Enable confirm button after delay
deleteTimeout = setTimeout(() => { deleteTimeout = setTimeout(() => {
deleteConfirmReady = true; deleteConfirmReady = true;
}, 2000); }, 2000);
@@ -149,12 +127,14 @@
try { try {
await deleteAdminTag(deleteTarget.id); await deleteAdminTag(deleteTarget.id);
await loadTags(); await invalidateAll();
deleteModalOpen = false; deleteModalOpen = false;
deleteTarget = null; deleteTarget = null;
deleteConfirmReady = false; deleteConfirmReady = false;
} catch (error) { } catch (error) {
console.error("Failed to delete tag:", error); logger.error("Failed to delete tag", {
error: error instanceof Error ? error.message : String(error),
});
alert("Failed to delete tag"); alert("Failed to delete tag");
} }
} }
@@ -168,7 +148,15 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="flex items-center gap-2">
<h1 class="text-xl font-semibold text-admin-text">Tags</h1> <h1 class="text-xl font-semibold text-admin-text">Tags</h1>
<span
class="text-admin-text-muted hover:text-admin-text cursor-help transition-colors"
title="Hold Shift and click a tag to delete it"
>
<IconInfo class="w-4 h-4" />
</span>
</div>
<p class="mt-1 text-sm text-admin-text-muted"> <p class="mt-1 text-sm text-admin-text-muted">
Manage project tags and categories Manage project tags and categories
</p> </p>
@@ -228,10 +216,8 @@
</div> </div>
{/if} {/if}
<!-- Tags Table --> <!-- Tags Grid -->
{#if loading} {#if data.tags.length === 0}
<div class="text-center py-12 text-admin-text-muted">Loading tags...</div>
{:else if tags.length === 0}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-admin-text-muted mb-4">No tags yet</p> <p class="text-admin-text-muted mb-4">No tags yet</p>
<Button variant="primary" onclick={() => (showCreateForm = true)}> <Button variant="primary" onclick={() => (showCreateForm = true)}>
@@ -239,173 +225,39 @@
</Button> </Button>
</div> </div>
{:else} {:else}
<Table> <div class="space-y-3">
<thead class="bg-admin-surface-hover"> <!-- Delete mode indicator -->
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
>
Name
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
>
Slug
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
>
Icon
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
>
Color
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
>
Projects
</th>
<th
class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-admin-border">
{#each tags as tag (tag.id)}
<tr class="hover:bg-admin-surface-hover/50 transition-colors">
{#if editingId === tag.id}
<!-- Edit mode -->
<td class="px-4 py-3">
<Input
type="text"
bind:value={editName}
placeholder="Tag name"
/>
</td>
<td class="px-4 py-3">
<Input
type="text"
bind:value={editSlug}
placeholder="tag-slug"
/>
</td>
<td class="px-4 py-3">
{#if editIcon}
<div class="flex items-center gap-2">
<div class="text-admin-text">
<span class="text-xs text-admin-text-muted"
>{editIcon}</span
>
</div>
</div>
{:else}
<span class="text-xs text-admin-text-muted">No icon</span>
{/if}
</td>
<td class="px-4 py-3">
{#if editColor}
<div class="flex items-center gap-2">
<div <div
class="size-6 rounded border border-admin-border" class="h-6 flex items-center transition-opacity duration-150"
style="background-color: #{editColor}" class:opacity-100={deleteMode}
></div> class:opacity-0={!deleteMode}
<span class="text-xs text-admin-text-muted"
>#{editColor}</span
> >
<span
class="text-sm text-red-500 dark:text-red-400 font-medium flex items-center gap-1.5"
>
<IconX class="w-4 h-4" />
Click a tag to delete it
</span>
</div> </div>
{:else}
<span class="text-xs text-admin-text-muted">No color</span> <!-- Tags -->
{/if} <div class="flex flex-wrap gap-2 max-w-3xl">
</td> {#each data.tags as tag (tag.id)}
<td class="px-4 py-3 text-admin-text"> <!-- svelte-ignore a11y_no_static_element_interactions -->
{tag.projectCount} <div onclick={(e) => handleTagClick(tag, e)} class="contents">
</td> <TagChip
<td class="px-4 py-3 text-right"> name={tag.name}
<div class="flex justify-end gap-2"> color={deleteMode ? "ef4444" : tag.color}
<Button iconSvg={tag.iconSvg}
variant="secondary" href={`/admin/tags/${tag.slug}`}
size="sm" class="transition-all duration-150 {deleteMode
onclick={cancelEdit} ? 'bg-red-100/80 dark:bg-red-900/40 cursor-pointer'
disabled={updating} : ''}"
> />
Cancel
</Button>
<Button
variant="primary"
size="sm"
onclick={handleUpdate}
disabled={updating || !editName.trim()}
>
{updating ? "Saving..." : "Save"}
</Button>
</div> </div>
</td>
{:else}
<!-- View mode -->
<td class="px-4 py-3 font-medium text-admin-text">
{tag.name}
</td>
<td class="px-4 py-3 text-admin-text-secondary">
{tag.slug}
</td>
<td class="px-4 py-3">
{#if tag.icon}
<div class="flex items-center gap-2">
<div class="text-admin-text">
<span class="text-xs text-admin-text-muted"
>{tag.icon}</span
>
</div>
</div>
{:else}
<span class="text-xs text-admin-text-muted">No icon</span>
{/if}
</td>
<td class="px-4 py-3">
{#if tag.color}
<div class="flex items-center gap-2">
<div
class="size-6 rounded border border-admin-border"
style="background-color: #{tag.color}"
></div>
<span class="text-xs text-admin-text-muted"
>#{tag.color}</span
>
</div>
{:else}
<span class="text-xs text-admin-text-muted">No color</span>
{/if}
</td>
<td class="px-4 py-3 text-admin-text">
{tag.projectCount}
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2">
<Button
variant="secondary"
size="sm"
onclick={() => startEdit(tag)}
>
Edit
</Button>
<Button
variant="danger"
size="sm"
onclick={() => initiateDelete(tag)}
>
Delete
</Button>
</div>
</td>
{/if}
</tr>
{/each} {/each}
</tbody> </div>
</Table> </div>
{/if} {/if}
</div> </div>
@@ -0,0 +1,73 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { renderIconsBatch } from "$lib/server/icons";
import { addIconsToTags } from "$lib/server/tag-icons";
import { error } from "@sveltejs/kit";
import type { AdminTag, AdminProject } from "$lib/admin-types";
interface TagWithProjectsResponse {
tag: AdminTag;
projects: AdminProject[];
}
interface RelatedTagResponse extends AdminTag {
cooccurrenceCount: number;
}
export interface TagPageData {
tag: AdminTag & { iconSvg?: string };
projects: AdminProject[];
relatedTags: Array<RelatedTagResponse & { iconSvg?: string }>;
}
export const load: PageServerLoad = async ({ params, fetch }) => {
const { slug } = params;
// Fetch tag with projects
let tagData: TagWithProjectsResponse;
try {
tagData = await apiFetch<TagWithProjectsResponse>(`/api/tags/${slug}`, {
fetch,
});
} catch {
throw error(404, "Tag not found");
}
// Fetch related tags
let relatedTags: RelatedTagResponse[] = [];
try {
relatedTags = await apiFetch<RelatedTagResponse[]>(
`/api/tags/${slug}/related`,
{ fetch },
);
} catch (err) {
// Non-fatal - just show empty related tags
}
// Render main tag icon (single icon, just use renderIconsBatch directly)
const iconIds = new Set<string>();
if (tagData.tag.icon) {
iconIds.add(tagData.tag.icon);
}
const icons = await renderIconsBatch([...iconIds], { size: 12 });
const tagWithIcon = {
...tagData.tag,
iconSvg: tagData.tag.icon
? (icons.get(tagData.tag.icon) ?? undefined)
: undefined,
};
// 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,
}));
return {
tag: tagWithIcon,
projects: tagData.projects,
relatedTags: relatedTagsWithIcons,
} satisfies TagPageData;
};
@@ -0,0 +1,275 @@
<script lang="ts">
import Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte";
import Modal from "$lib/components/admin/Modal.svelte";
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
import IconPicker from "$lib/components/admin/IconPicker.svelte";
import TagChip from "$lib/components/TagChip.svelte";
import { updateAdminTag, deleteAdminTag } from "$lib/api";
import { goto, invalidateAll } from "$app/navigation";
import type { TagPageData } from "./+page.server";
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();
// Form state - initialize from loaded data
let name = $state(data.tag.name);
let slug = $state(data.tag.slug);
let icon = $state(data.tag.icon ?? "");
let color = $state<string | undefined>(data.tag.color);
let saving = $state(false);
// Preview icon SVG - starts with server-rendered, updates on icon change
let previewIconSvg = $state(data.tag.iconSvg ?? "");
let iconLoadTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for icon changes and fetch new preview
$effect(() => {
const currentIcon = icon;
// Clear pending timeout
if (iconLoadTimeout) {
clearTimeout(iconLoadTimeout);
}
if (!currentIcon) {
previewIconSvg = "";
return;
}
// Debounce icon fetching
iconLoadTimeout = setTimeout(async () => {
try {
const response = await fetch(
`/api/icons/${currentIcon.replace(":", "/")}`,
);
if (response.ok) {
const iconData = await response.json();
previewIconSvg = iconData.svg ?? "";
}
} catch {
// Keep existing preview on error
}
}, 200);
});
// Delete state
let deleteModalOpen = $state(false);
let deleteConfirmReady = $state(false);
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
async function handleSave() {
if (!name.trim()) return;
saving = true;
try {
await updateAdminTag({
id: data.tag.id,
name: name.trim(),
slug: slug.trim() || undefined,
icon: icon || undefined,
color: color,
});
// If slug changed, navigate to new URL
const newSlug = slug.trim() || data.tag.slug;
if (newSlug !== data.tag.slug) {
await goto(`/admin/tags/${newSlug}`, { replaceState: true });
} else {
await invalidateAll();
}
} catch (error) {
logger.error("Failed to update tag", {
error: error instanceof Error ? error.message : String(error),
});
alert("Failed to update tag");
} finally {
saving = false;
}
}
function initiateDelete() {
deleteConfirmReady = false;
deleteTimeout = setTimeout(() => {
deleteConfirmReady = true;
}, 2000);
deleteModalOpen = true;
}
function cancelDelete() {
if (deleteTimeout) {
clearTimeout(deleteTimeout);
}
deleteModalOpen = false;
deleteConfirmReady = false;
}
async function confirmDelete() {
if (!deleteConfirmReady) return;
try {
await deleteAdminTag(data.tag.id);
await goto("/admin/tags");
} catch (error) {
logger.error("Failed to delete tag", {
error: error instanceof Error ? error.message : String(error),
});
alert("Failed to delete tag");
}
}
</script>
<svelte:head>
<title>Edit {data.tag.name} | Tags | Admin</title>
</svelte:head>
<div class="space-y-6 max-w-3xl">
<!-- Back Link -->
<a
href="/admin/tags"
class="inline-flex items-center gap-1.5 text-sm text-admin-text-muted hover:text-admin-text transition-colors"
>
<IconArrowLeft class="w-4 h-4" />
Back to Tags
</a>
<!-- Header -->
<div>
<h1 class="text-xl font-semibold text-admin-text">Edit Tag</h1>
<p class="mt-1 text-sm text-admin-text-muted">
Modify tag details and view associated projects
</p>
</div>
<!-- Edit Form -->
<div
class="rounded-xl border border-admin-border bg-admin-surface p-6 shadow-sm shadow-black/10 dark:shadow-black/20"
>
<div class="grid gap-4 md:grid-cols-2">
<Input
label="Name"
type="text"
bind:value={name}
placeholder="TypeScript"
required
/>
<Input
label="Slug"
type="text"
bind:value={slug}
placeholder="Leave empty to keep current"
/>
</div>
<div class="mt-4">
<IconPicker bind:selectedIcon={icon} label="Icon" />
</div>
<div class="mt-4">
<ColorPicker bind:selectedColor={color} />
</div>
<!-- Preview -->
<div class="mt-6 pt-4 border-t border-admin-border">
<label class="block text-sm font-medium text-admin-text mb-2">
Preview
</label>
<TagChip name={name || "Tag Name"} {color} iconSvg={previewIconSvg} />
</div>
<!-- Actions -->
<div class="mt-6 pt-4 border-t border-admin-border flex justify-between">
<Button variant="danger" onclick={initiateDelete}>Delete Tag</Button>
<div class="flex gap-2">
<Button variant="secondary" href="/admin/tags">Cancel</Button>
<Button
variant="primary"
onclick={handleSave}
disabled={saving || !name.trim()}
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</div>
<!-- Projects using this tag -->
{#if data.projects.length > 0}
<div
class="rounded-xl border border-admin-border bg-admin-surface p-6 shadow-sm shadow-black/10 dark:shadow-black/20"
>
<h2 class="text-base font-medium text-admin-text mb-4">
Projects using this tag ({data.projects.length})
</h2>
<ul class="space-y-2">
{#each data.projects as project (project.id)}
<li>
<a
href={`/admin/projects/${project.id}`}
class="flex items-center justify-between p-2 -mx-2 rounded-lg hover:bg-admin-surface-hover transition-colors group"
>
<span class="text-admin-text group-hover:text-admin-primary">
{project.name}
</span>
<IconExternalLink
class="w-4 h-4 text-admin-text-muted opacity-0 group-hover:opacity-100 transition-opacity"
/>
</a>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Related Tags -->
{#if data.relatedTags.length > 0}
<div
class="rounded-xl border border-admin-border bg-admin-surface p-6 shadow-sm shadow-black/10 dark:shadow-black/20"
>
<h2 class="text-base font-medium text-admin-text mb-4">Related Tags</h2>
<p class="text-sm text-admin-text-muted mb-4">
Tags that frequently appear alongside this one
</p>
<div class="flex flex-wrap gap-2">
{#each data.relatedTags as tag (tag.id)}
<TagChip
name={tag.name}
color={tag.color}
iconSvg={tag.iconSvg}
href={`/admin/tags/${tag.slug}`}
/>
{/each}
</div>
</div>
{/if}
</div>
<!-- Delete Confirmation Modal -->
<Modal
bind:open={deleteModalOpen}
title="Delete Tag"
description="Are you sure you want to delete this tag? This will remove it from all projects."
confirmText={deleteConfirmReady ? "Delete" : "Wait 2s..."}
confirmVariant="danger"
onconfirm={confirmDelete}
oncancel={cancelDelete}
>
<div
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
>
<p class="font-medium text-admin-text">{data.tag.name}</p>
<p class="text-sm text-admin-text-secondary">
Used in {data.projects.length} project{data.projects.length === 1
? ""
: "s"}
</p>
</div>
</Modal>