feat: add icon picker for tags with Iconify integration

- Add icon field to tag creation/update API and handlers
- Install @iconify packages (json, types, utils) as production deps
- Build IconPicker component for tag admin UI
- Fix apiFetch lazy initialization for build-time safety
- Update Docker to install production dependencies for SSR runtime
This commit is contained in:
2026-01-07 20:57:12 -06:00
parent dd1ce186d2
commit 462b510e14
21 changed files with 123 additions and 45 deletions
+1
View File
@@ -49,6 +49,7 @@ export interface UpdateProjectData extends CreateProjectData {
export interface CreateTagData {
name: string;
slug?: string;
icon?: string;
color?: string;
}
+18 -9
View File
@@ -15,13 +15,7 @@ interface BunFetchOptions extends RequestInit {
* Create a socket-aware fetch function
* Automatically handles Unix socket vs TCP based on UPSTREAM_URL
*/
function createSmartFetch(upstreamUrl: string | undefined) {
if (!upstreamUrl) {
const error = "UPSTREAM_URL environment variable not set";
logger.error(error);
throw new Error(error);
}
function createSmartFetch(upstreamUrl: string) {
const isUnixSocket =
upstreamUrl.startsWith("/") || upstreamUrl.startsWith("./");
const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl;
@@ -84,5 +78,20 @@ function createSmartFetch(upstreamUrl: string | undefined) {
};
}
// Export the configured smart fetch function
export const apiFetch = createSmartFetch(env.UPSTREAM_URL);
// Lazy-initialized fetch function (only throws if UPSTREAM_URL is missing when actually used)
let cachedFetch: ReturnType<typeof createSmartFetch> | null = null;
export async function apiFetch<T>(
path: string,
options?: FetchOptions,
): Promise<T> {
if (!cachedFetch) {
if (!env.UPSTREAM_URL) {
const error = "UPSTREAM_URL environment variable not set";
logger.error(error);
throw new Error(error);
}
cachedFetch = createSmartFetch(env.UPSTREAM_URL);
}
return cachedFetch(path, options);
}
+1 -1
View File
@@ -30,7 +30,7 @@
primary:
"bg-admin-accent text-white hover:bg-admin-accent-hover focus-visible:ring-admin-accent shadow-sm hover:shadow",
secondary:
"bg-transparent text-admin-text border border-admin-border hover:border-admin-border-hover hover:bg-admin-surface-hover/50 focus-visible:ring-admin-accent",
"bg-zinc-200/60 dark:bg-zinc-600/50 text-admin-text border border-zinc-400/50 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-300/70 dark:hover:bg-zinc-500/60 focus-visible:ring-admin-accent",
danger:
"bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500 shadow-sm hover:shadow",
ghost:
+1 -1
View File
@@ -10,7 +10,7 @@
</script>
<div
class={cn("overflow-x-auto rounded-lg border border-admin-border", className)}
class={cn("overflow-x-auto rounded-lg border border-admin-border bg-admin-surface", className)}
>
<table class="w-full text-sm">
{@render children?.()}
+21 -3
View File
@@ -2,16 +2,34 @@ import type { LayoutServerLoad } from "./$types";
import { getOGImageUrl } from "$lib/og-types";
import { apiFetch } from "$lib/api.server";
import type { SiteSettings } from "$lib/admin-types";
import { building } from "$app/environment";
const DEFAULT_SETTINGS: SiteSettings = {
identity: {
siteTitle: "xevion.dev",
displayName: "Ryan Walters",
occupation: "Software Engineer",
bio: "Software engineer and developer",
},
socialLinks: [],
};
export const load: LayoutServerLoad = async ({ url, fetch }) => {
// Fetch site settings for all pages
const settings = await apiFetch<SiteSettings>("/api/settings", { fetch });
let settings: SiteSettings;
if (building) {
// During prerendering, use default settings (API isn't available)
settings = DEFAULT_SETTINGS;
} else {
// At runtime, fetch from API
settings = await apiFetch<SiteSettings>("/api/settings", { fetch });
}
return {
settings,
metadata: {
title: settings.identity.siteTitle,
description: settings.identity.bio.split("\n")[0], // First line of bio
description: settings.identity.bio.split("\n")[0],
ogImage: getOGImageUrl({ type: "index" }),
url: url.toString(),
},
+2 -2
View File
@@ -58,10 +58,10 @@
<!-- Recent Events -->
<div
class="rounded-xl border border-admin-border bg-admin-surface/50 overflow-hidden shadow-sm shadow-black/10 dark:shadow-black/20"
class="rounded-xl border border-admin-border bg-admin-surface overflow-hidden shadow-sm shadow-black/10 dark:shadow-black/20"
>
<div
class="flex items-center justify-between px-6 py-3.5 bg-admin-surface-hover/30 border-b border-admin-border"
class="flex items-center justify-between px-6 py-3.5 bg-admin-surface-hover border-b border-admin-border"
>
<h2 class="text-sm font-medium text-admin-text-secondary">
Recent Events
+3 -3
View File
@@ -108,7 +108,7 @@
</div>
{:else}
<Table>
<thead class="bg-admin-surface/50">
<thead class="bg-admin-surface-hover">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
@@ -137,9 +137,9 @@
</th>
</tr>
</thead>
<tbody class="divide-y divide-admin-border/50">
<tbody class="divide-y divide-admin-border">
{#each projects as project (project.id)}
<tr class="hover:bg-admin-surface-hover/30 transition-colors">
<tr class="hover:bg-admin-surface-hover/50 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div>
+41 -3
View File
@@ -4,6 +4,7 @@
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,
@@ -25,6 +26,7 @@
let showCreateForm = $state(false);
let createName = $state("");
let createSlug = $state("");
let createIcon = $state<string>("");
let createColor = $state<string | undefined>(undefined);
let creating = $state(false);
@@ -32,6 +34,7 @@
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);
@@ -63,12 +66,14 @@
const data: CreateTagData = {
name: createName,
slug: createSlug || undefined,
icon: createIcon || undefined,
color: createColor,
};
await createAdminTag(data);
await loadTags();
createName = "";
createSlug = "";
createIcon = "";
createColor = undefined;
showCreateForm = false;
} catch (error) {
@@ -83,6 +88,7 @@
editingId = tag.id;
editName = tag.name;
editSlug = tag.slug;
editIcon = tag.icon || "";
editColor = tag.color;
}
@@ -90,6 +96,7 @@
editingId = null;
editName = "";
editSlug = "";
editIcon = "";
editColor = undefined;
}
@@ -103,6 +110,7 @@
name: editName,
slug: editSlug || undefined,
color: editColor,
icon: editIcon || undefined,
};
await updateAdminTag(data);
await loadTags();
@@ -199,6 +207,9 @@
placeholder="Leave empty to auto-generate"
/>
</div>
<div class="mt-4">
<IconPicker bind:selectedIcon={createIcon} label="Icon (optional)" />
</div>
<div class="mt-4">
<ColorPicker bind:selectedColor={createColor} />
</div>
@@ -229,7 +240,7 @@
</div>
{:else}
<Table>
<thead class="bg-admin-surface/50">
<thead class="bg-admin-surface-hover">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
@@ -241,6 +252,11 @@
>
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"
>
@@ -258,9 +274,9 @@
</th>
</tr>
</thead>
<tbody class="divide-y divide-admin-border/50">
<tbody class="divide-y divide-admin-border">
{#each tags as tag (tag.id)}
<tr class="hover:bg-admin-surface-hover/30 transition-colors">
<tr class="hover:bg-admin-surface-hover/50 transition-colors">
{#if editingId === tag.id}
<!-- Edit mode -->
<td class="px-4 py-3">
@@ -277,6 +293,17 @@
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">
@@ -323,6 +350,17 @@
<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">