mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -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;
|
projectCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagWithIcon extends AdminTag {
|
||||||
|
iconSvg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminProject {
|
export interface AdminProject {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<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>
|
||||||
Reference in New Issue
Block a user