mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
refactor: use common TagChip, switch to SSR for admin pages, better error & logger handling
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+29
-3
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
|
||||
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<AdminProject>(`/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<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)
|
||||
export async function getAdminEvents(): Promise<AdminEvent[]> {
|
||||
// TODO: Implement when events table is added to backend
|
||||
|
||||
@@ -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";
|
||||
</script>
|
||||
|
||||
<span
|
||||
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'}"
|
||||
>
|
||||
{#snippet iconAndName()}
|
||||
{#if iconSvg}
|
||||
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
@@ -25,4 +25,21 @@
|
||||
</span>
|
||||
{/if}
|
||||
<span>{name}</span>
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
@@ -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<void>;
|
||||
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;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import type { AdminTag } from "$lib/admin-types";
|
||||
import IconX from "~icons/lucide/x";
|
||||
import TagChip from "$lib/components/TagChip.svelte";
|
||||
import type { TagWithIcon } from "$lib/admin-types";
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
availableTags: AdminTag[];
|
||||
availableTags: TagWithIcon[];
|
||||
selectedTagIds: string[];
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
@@ -22,6 +22,7 @@
|
||||
let searchTerm = $state("");
|
||||
let dropdownOpen = $state(false);
|
||||
let inputRef: HTMLInputElement | undefined = $state();
|
||||
let hoveredTagId = $state<string | null>(null);
|
||||
|
||||
// Generate unique ID for accessibility
|
||||
const inputId = `tagpicker-${Math.random().toString(36).substring(2, 11)}`;
|
||||
@@ -72,21 +73,25 @@
|
||||
<div
|
||||
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)}
|
||||
<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"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(tag.id)}
|
||||
onmouseenter={() => (hoveredTagId = tag.id)}
|
||||
onmouseleave={() => (hoveredTagId = null)}
|
||||
class="cursor-pointer"
|
||||
aria-label="Remove {tag.name}"
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(tag.id)}
|
||||
class="hover:text-blue-300"
|
||||
aria-label="Remove tag"
|
||||
>
|
||||
<IconX class="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
<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>
|
||||
{/each}
|
||||
|
||||
<!-- Search input -->
|
||||
@@ -111,10 +116,10 @@
|
||||
{#each filteredTags as tag (tag.id)}
|
||||
<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)}
|
||||
>
|
||||
{tag.name}
|
||||
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,35 +1,41 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/admin/Button.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 { getAdminProjects, deleteAdminProject } from "$lib/api";
|
||||
import type { AdminProject } from "$lib/admin-types";
|
||||
import TagChip from "$lib/components/TagChip.svelte";
|
||||
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 { 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 deleteTarget = $state<AdminProject | null>(null);
|
||||
let deleteTarget = $state<ProjectWithTagIcons | null>(null);
|
||||
let deleteConfirmReady = $state(false);
|
||||
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function loadProjects() {
|
||||
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) {
|
||||
function initiateDelete(project: ProjectWithTagIcons) {
|
||||
deleteTarget = project;
|
||||
deleteConfirmReady = false;
|
||||
|
||||
@@ -55,12 +61,14 @@
|
||||
|
||||
try {
|
||||
await deleteAdminProject(deleteTarget.id);
|
||||
projects = projects.filter((p) => p.id !== deleteTarget!.id);
|
||||
await invalidateAll();
|
||||
deleteModalOpen = false;
|
||||
deleteTarget = null;
|
||||
deleteConfirmReady = false;
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
@@ -95,11 +103,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Projects Table -->
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-admin-text-muted">
|
||||
Loading projects...
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
{#if data.projects.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-admin-text-muted mb-4">No projects yet</p>
|
||||
<Button variant="primary" href="/admin/projects/new"
|
||||
@@ -138,7 +142,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -153,17 +157,28 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge variant={project.status}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
<TagChip
|
||||
name={STATUS_CONFIG[project.status].label}
|
||||
color={STATUS_CONFIG[project.status].color}
|
||||
iconSvg={data.statusIcons[project.status]}
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#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}
|
||||
{#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}
|
||||
</div>
|
||||
</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,
|
||||
};
|
||||
};
|
||||
@@ -1,56 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||
import { getAdminProject, getAdminTags, updateAdminProject } from "$lib/api";
|
||||
import { updateAdminProject } from "$lib/api";
|
||||
import type {
|
||||
AdminProject,
|
||||
AdminTag,
|
||||
AdminTagWithCount,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
CreateProjectData,
|
||||
TagWithIcon,
|
||||
} from "$lib/admin-types";
|
||||
|
||||
const projectId = $derived(($page.params as { id: string }).id);
|
||||
|
||||
let project = $state<AdminProject | null>(null);
|
||||
let tags = $state<AdminTag[]>([]);
|
||||
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;
|
||||
}
|
||||
interface Props {
|
||||
data: {
|
||||
project: import("$lib/admin-types").AdminProject | null;
|
||||
availableTags: TagWithIcon[];
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadData();
|
||||
});
|
||||
let { data }: Props = $props();
|
||||
|
||||
async function handleSubmit(formData: CreateProjectData) {
|
||||
if (!data.project) return;
|
||||
|
||||
async function handleSubmit(data: CreateProjectData) {
|
||||
const updateData: UpdateProjectData = {
|
||||
...data,
|
||||
id: projectId,
|
||||
...formData,
|
||||
id: data.project.id,
|
||||
};
|
||||
await updateAdminProject(updateData);
|
||||
goto(resolve("/admin/projects"));
|
||||
@@ -71,23 +44,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-admin-text-muted">Loading...</div>
|
||||
{:else if !project}
|
||||
{#if !data.project}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-admin-text-muted mb-4">Project not found</p>
|
||||
<a
|
||||
href={resolve("/admin/projects")}
|
||||
class="text-admin-accent hover:text-admin-accent-hover"
|
||||
>
|
||||
← Back to projects
|
||||
Back to projects
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-admin-border bg-admin-surface p-6">
|
||||
<ProjectForm
|
||||
{project}
|
||||
availableTags={tags}
|
||||
project={data.project}
|
||||
availableTags={data.availableTags}
|
||||
onsubmit={handleSubmit}
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -2,40 +2,19 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||
import { getAdminTags, createAdminProject } from "$lib/api";
|
||||
import type {
|
||||
AdminTag,
|
||||
AdminTagWithCount,
|
||||
CreateProjectData,
|
||||
} from "$lib/admin-types";
|
||||
import { createAdminProject } from "$lib/api";
|
||||
import type { CreateProjectData, TagWithIcon } from "$lib/admin-types";
|
||||
|
||||
let tags = $state<AdminTag[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
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;
|
||||
}
|
||||
interface Props {
|
||||
data: {
|
||||
availableTags: TagWithIcon[];
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTags();
|
||||
});
|
||||
let { data }: Props = $props();
|
||||
|
||||
async function handleSubmit(data: CreateProjectData) {
|
||||
await createAdminProject(data);
|
||||
async function handleSubmit(formData: CreateProjectData) {
|
||||
await createAdminProject(formData);
|
||||
goto(resolve("/admin/projects"));
|
||||
}
|
||||
</script>
|
||||
@@ -54,15 +33,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<ProjectForm
|
||||
availableTags={tags}
|
||||
onsubmit={handleSubmit}
|
||||
submitLabel="Create Project"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rounded-lg border border-admin-border bg-admin-surface p-6">
|
||||
<ProjectForm
|
||||
availableTags={data.availableTags}
|
||||
onsubmit={handleSubmit}
|
||||
submitLabel="Create Project"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,26 +1,28 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/admin/Button.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 ColorPicker from "$lib/components/admin/ColorPicker.svelte";
|
||||
import IconPicker from "$lib/components/admin/IconPicker.svelte";
|
||||
import {
|
||||
getAdminTags,
|
||||
createAdminTag,
|
||||
updateAdminTag,
|
||||
deleteAdminTag,
|
||||
} from "$lib/api";
|
||||
import type {
|
||||
AdminTagWithCount,
|
||||
CreateTagData,
|
||||
UpdateTagData,
|
||||
} from "$lib/admin-types";
|
||||
import TagChip from "$lib/components/TagChip.svelte";
|
||||
import { createAdminTag, deleteAdminTag } from "$lib/api";
|
||||
import type { CreateTagData } from "$lib/admin-types";
|
||||
import type { TagWithIconAndCount } from "./+page.server";
|
||||
import IconPlus from "~icons/lucide/plus";
|
||||
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[]>([]);
|
||||
let loading = $state(true);
|
||||
const logger = getLogger(["admin", "tags"]);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
tags: TagWithIconAndCount[];
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
// Create form state
|
||||
let showCreateForm = $state(false);
|
||||
@@ -30,104 +32,80 @@
|
||||
let createColor = $state<string | undefined>(undefined);
|
||||
let creating = $state(false);
|
||||
|
||||
// Edit state
|
||||
let editingId = $state<string | null>(null);
|
||||
let editName = $state("");
|
||||
let editSlug = $state("");
|
||||
let editIcon = $state<string>("");
|
||||
let editColor = $state<string | undefined>(undefined);
|
||||
let updating = $state(false);
|
||||
// Delete mode state (activated by holding Shift)
|
||||
let deleteMode = $state(false);
|
||||
|
||||
// Track shift key
|
||||
$effect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
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
|
||||
let deleteModalOpen = $state(false);
|
||||
let deleteTarget = $state<AdminTagWithCount | null>(null);
|
||||
let deleteTarget = $state<TagWithIconAndCount | null>(null);
|
||||
let deleteConfirmReady = $state(false);
|
||||
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() {
|
||||
if (!createName.trim()) return;
|
||||
|
||||
creating = true;
|
||||
try {
|
||||
const data: CreateTagData = {
|
||||
const createData: CreateTagData = {
|
||||
name: createName,
|
||||
slug: createSlug || undefined,
|
||||
icon: createIcon || undefined,
|
||||
color: createColor,
|
||||
};
|
||||
await createAdminTag(data);
|
||||
await loadTags();
|
||||
await createAdminTag(createData);
|
||||
await invalidateAll();
|
||||
createName = "";
|
||||
createSlug = "";
|
||||
createIcon = "";
|
||||
createColor = undefined;
|
||||
showCreateForm = false;
|
||||
} 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");
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(tag: AdminTagWithCount) {
|
||||
editingId = tag.id;
|
||||
editName = tag.name;
|
||||
editSlug = tag.slug;
|
||||
editIcon = tag.icon || "";
|
||||
editColor = tag.color;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
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 handleTagClick(tag: TagWithIconAndCount, event: MouseEvent) {
|
||||
if (deleteMode) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
initiateDelete(tag);
|
||||
}
|
||||
// Otherwise, let the link navigate normally
|
||||
}
|
||||
|
||||
function initiateDelete(tag: AdminTagWithCount) {
|
||||
function initiateDelete(tag: TagWithIconAndCount) {
|
||||
deleteTarget = tag;
|
||||
deleteConfirmReady = false;
|
||||
|
||||
// Enable confirm button after delay
|
||||
deleteTimeout = setTimeout(() => {
|
||||
deleteConfirmReady = true;
|
||||
}, 2000);
|
||||
@@ -149,12 +127,14 @@
|
||||
|
||||
try {
|
||||
await deleteAdminTag(deleteTarget.id);
|
||||
await loadTags();
|
||||
await invalidateAll();
|
||||
deleteModalOpen = false;
|
||||
deleteTarget = null;
|
||||
deleteConfirmReady = false;
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
@@ -168,7 +148,15 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-admin-text">Tags</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<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">
|
||||
Manage project tags and categories
|
||||
</p>
|
||||
@@ -228,10 +216,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags Table -->
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-admin-text-muted">Loading tags...</div>
|
||||
{:else if tags.length === 0}
|
||||
<!-- Tags Grid -->
|
||||
{#if data.tags.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-admin-text-muted mb-4">No tags yet</p>
|
||||
<Button variant="primary" onclick={() => (showCreateForm = true)}>
|
||||
@@ -239,173 +225,39 @@
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Table>
|
||||
<thead class="bg-admin-surface-hover">
|
||||
<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
|
||||
class="size-6 rounded border border-admin-border"
|
||||
style="background-color: #{editColor}"
|
||||
></div>
|
||||
<span class="text-xs text-admin-text-muted"
|
||||
>#{editColor}</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={cancelEdit}
|
||||
disabled={updating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onclick={handleUpdate}
|
||||
disabled={updating || !editName.trim()}
|
||||
>
|
||||
{updating ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</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>
|
||||
<div class="space-y-3">
|
||||
<!-- Delete mode indicator -->
|
||||
<div
|
||||
class="h-6 flex items-center transition-opacity duration-150"
|
||||
class:opacity-100={deleteMode}
|
||||
class:opacity-0={!deleteMode}
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-2 max-w-3xl">
|
||||
{#each data.tags as tag (tag.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onclick={(e) => handleTagClick(tag, e)} class="contents">
|
||||
<TagChip
|
||||
name={tag.name}
|
||||
color={deleteMode ? "ef4444" : tag.color}
|
||||
iconSvg={tag.iconSvg}
|
||||
href={`/admin/tags/${tag.slug}`}
|
||||
class="transition-all duration-150 {deleteMode
|
||||
? 'bg-red-100/80 dark:bg-red-900/40 cursor-pointer'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
Reference in New Issue
Block a user