feat: add tag color customization with hex picker

- Add nullable color column to tags table with hex validation
- Build ColorPicker component with preset palette and custom hex input
- Apply tag colors to project cards via border styling
- Update all tag API endpoints to handle color field
This commit is contained in:
2026-01-06 18:15:26 -06:00
parent e32c776b6d
commit cacee9ba14
17 changed files with 392 additions and 71 deletions
+2
View File
@@ -6,6 +6,7 @@ export interface AdminTag {
id: string;
slug: string;
name: string;
color?: string;
createdAt: string;
}
@@ -48,6 +49,7 @@ export interface UpdateProjectData extends CreateProjectData {
export interface CreateTagData {
name: string;
slug?: string;
color?: string;
}
export interface UpdateTagData extends CreateTagData {
+2 -1
View File
@@ -56,7 +56,8 @@
{#each project.tags as tag (tag.name)}
<!-- TODO: Add link to project search with tag filtering -->
<span
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-300 border-l-3 border-l-cyan-500"
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-300 border-l-3"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
@@ -0,0 +1,152 @@
<script lang="ts">
import { cn } from "$lib/utils";
interface Props {
selectedColor: string | undefined;
label?: string;
class?: string;
}
let {
selectedColor = $bindable(),
label = "Color",
class: className,
}: Props = $props();
// Preset color palette (Tailwind-inspired)
const PRESET_COLORS = [
{ name: "Cyan", value: "06b6d4" },
{ name: "Blue", value: "3b82f6" },
{ name: "Indigo", value: "6366f1" },
{ name: "Purple", value: "a855f7" },
{ name: "Pink", value: "ec4899" },
{ name: "Rose", value: "f43f5e" },
{ name: "Orange", value: "f97316" },
{ name: "Amber", value: "f59e0b" },
{ name: "Yellow", value: "eab308" },
{ name: "Lime", value: "84cc16" },
{ name: "Green", value: "22c55e" },
{ name: "Emerald", value: "10b981" },
{ name: "Teal", value: "14b8a6" },
{ name: "Sky", value: "0ea5e9" },
{ name: "Zinc", value: "a1a1aa" },
];
let customHex = $state(selectedColor || "");
let validationError = $state<string | null>(null);
// Validate hex format (6 characters, no hash, no alpha)
function validateHexColor(hex: string): boolean {
return /^[0-9a-fA-F]{6}$/.test(hex);
}
function handleCustomInput(event: Event) {
const input = (event.target as HTMLInputElement).value.replace(
/[^0-9a-fA-F]/g,
"",
);
customHex = input.slice(0, 6);
if (customHex.length === 6) {
if (validateHexColor(customHex)) {
selectedColor = customHex.toLowerCase();
validationError = null;
} else {
validationError = "Invalid hex format";
}
} else if (customHex.length === 0) {
selectedColor = undefined;
validationError = null;
} else {
validationError = "Must be 6 characters";
}
}
function selectPreset(hex: string) {
selectedColor = hex;
customHex = hex;
validationError = null;
}
function clearColor() {
selectedColor = undefined;
customHex = "";
validationError = null;
}
</script>
<div class={cn("space-y-3", className)}>
{#if label}
<label class="block text-sm font-medium text-zinc-300">{label}</label>
{/if}
<!-- Preset Palette -->
<div class="grid grid-cols-8 gap-2">
{#each PRESET_COLORS as preset (preset.value)}
<button
type="button"
class={cn(
"size-8 rounded border-2 transition-all hover:scale-110",
selectedColor === preset.value
? "border-white ring-2 ring-white/20"
: "border-zinc-700 hover:border-zinc-500",
)}
style="background-color: #{preset.value}"
title={preset.name}
onclick={() => selectPreset(preset.value)}
/>
{/each}
<!-- Clear button -->
<button
type="button"
class={cn(
"size-8 rounded border-2 transition-all hover:scale-110 flex items-center justify-center",
!selectedColor
? "border-white ring-2 ring-white/20 bg-zinc-800"
: "border-zinc-700 hover:border-zinc-500 bg-zinc-900",
)}
title="No color"
onclick={clearColor}
>
<span class="text-zinc-500 text-xs"></span>
</button>
</div>
<!-- Custom Hex Input -->
<div class="flex items-start gap-2">
<div class="flex-1">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
>#</span
>
<input
type="text"
value={customHex}
oninput={handleCustomInput}
placeholder="3b82f6"
maxlength="6"
class={cn(
"w-full rounded-md border bg-zinc-900 px-3 py-2 pl-7 text-sm text-zinc-100",
"placeholder:text-zinc-600 focus:outline-none focus:ring-2",
validationError
? "border-red-500 focus:ring-red-500/20"
: "border-zinc-700 focus:border-zinc-600 focus:ring-zinc-500/20",
)}
/>
</div>
{#if validationError}
<p class="mt-1 text-xs text-red-400">{validationError}</p>
{/if}
</div>
<!-- Color Preview -->
{#if selectedColor && validateHexColor(selectedColor)}
<div
class="size-10 shrink-0 rounded-md border-2 border-zinc-700"
style="background-color: #{selectedColor}"
title="#{selectedColor}"
/>
{/if}
</div>
</div>
+20 -19
View File
@@ -1,6 +1,7 @@
export interface MockProjectTag {
name: string;
icon: string; // Icon identifier like "simple-icons:rust"
color?: string; // Hex color without hash
iconSvg?: string; // Pre-rendered SVG (populated server-side)
}
@@ -22,9 +23,9 @@ export const MOCK_PROJECTS: MockProject[] = [
"Personal portfolio showcasing projects and technical expertise. Built with Rust backend, SvelteKit frontend, and PostgreSQL.",
url: "https://github.com/Xevion/xevion.dev",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "SvelteKit", icon: "simple-icons:svelte" },
{ name: "PostgreSQL", icon: "cib:postgresql" },
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "SvelteKit", icon: "simple-icons:svelte", color: "f43f5e" },
{ name: "PostgreSQL", icon: "cib:postgresql", color: "3b82f6" },
],
updatedAt: "2026-01-06T22:12:37Z",
},
@@ -35,9 +36,9 @@ export const MOCK_PROJECTS: MockProject[] = [
"Powerful browser history analyzer for visualizing and understanding web browsing patterns across multiple browsers.",
url: "https://github.com/Xevion/historee",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "CLI", icon: "lucide:terminal" },
{ name: "Analytics", icon: "lucide:bar-chart-3" },
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "CLI", icon: "lucide:terminal", color: "a1a1aa" },
{ name: "Analytics", icon: "lucide:bar-chart-3", color: "10b981" },
],
updatedAt: "2026-01-06T06:01:27Z",
},
@@ -48,9 +49,9 @@ export const MOCK_PROJECTS: MockProject[] = [
"HTML adapter for Vercel's Satori library, enabling generation of beautiful social card images from HTML markup.",
url: "https://github.com/Xevion/satori-html",
tags: [
{ name: "TypeScript", icon: "simple-icons:typescript" },
{ name: "NPM", icon: "simple-icons:npm" },
{ name: "Graphics", icon: "lucide:image" },
{ name: "TypeScript", icon: "simple-icons:typescript", color: "3b82f6" },
{ name: "NPM", icon: "simple-icons:npm", color: "ec4899" },
{ name: "Graphics", icon: "lucide:image", color: "a855f7" },
],
updatedAt: "2026-01-05T20:23:07Z",
},
@@ -61,10 +62,10 @@ export const MOCK_PROJECTS: MockProject[] = [
"Cross-platform media bitrate visualizer with real-time analysis. Built with Tauri for native performance and modern UI.",
url: "https://github.com/Xevion/byte-me",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "Tauri", icon: "simple-icons:tauri" },
{ name: "Desktop", icon: "lucide:monitor" },
{ name: "Media", icon: "lucide:video" },
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "Tauri", icon: "simple-icons:tauri", color: "14b8a6" },
{ name: "Desktop", icon: "lucide:monitor", color: "6366f1" },
{ name: "Media", icon: "lucide:video", color: "f43f5e" },
],
updatedAt: "2026-01-05T05:09:09Z",
},
@@ -75,9 +76,9 @@ export const MOCK_PROJECTS: MockProject[] = [
"Modern RDAP query client for domain registration data lookup. Clean interface built with static Next.js for instant loads.",
url: "https://github.com/Xevion/rdap",
tags: [
{ name: "TypeScript", icon: "simple-icons:typescript" },
{ name: "Next.js", icon: "simple-icons:nextdotjs" },
{ name: "Networking", icon: "lucide:network" },
{ name: "TypeScript", icon: "simple-icons:typescript", color: "3b82f6" },
{ name: "Next.js", icon: "simple-icons:nextdotjs", color: "a1a1aa" },
{ name: "Networking", icon: "lucide:network", color: "0ea5e9" },
],
updatedAt: "2026-01-05T10:36:55Z",
},
@@ -88,9 +89,9 @@ export const MOCK_PROJECTS: MockProject[] = [
"Cross-platform key remapping daemon with per-application context awareness and intelligent stateful debouncing.",
url: "https://github.com/Xevion/rebinded",
tags: [
{ name: "Rust", icon: "simple-icons:rust" },
{ name: "System", icon: "lucide:settings-2" },
{ name: "Cross-platform", icon: "lucide:globe" },
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "System", icon: "lucide:settings-2", color: "a1a1aa" },
{ name: "Cross-platform", icon: "lucide:globe", color: "22c55e" },
],
updatedAt: "2026-01-01T00:34:09Z",
},
+40
View File
@@ -3,6 +3,7 @@
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 {
getAdminTags,
createAdminTag,
@@ -24,12 +25,14 @@
let showCreateForm = $state(false);
let createName = $state("");
let createSlug = $state("");
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 editColor = $state<string | undefined>(undefined);
let updating = $state(false);
// Delete state
@@ -60,11 +63,13 @@
const data: CreateTagData = {
name: createName,
slug: createSlug || undefined,
color: createColor,
};
await createAdminTag(data);
await loadTags();
createName = "";
createSlug = "";
createColor = undefined;
showCreateForm = false;
} catch (error) {
console.error("Failed to create tag:", error);
@@ -78,12 +83,14 @@
editingId = tag.id;
editName = tag.name;
editSlug = tag.slug;
editColor = tag.color;
}
function cancelEdit() {
editingId = null;
editName = "";
editSlug = "";
editColor = undefined;
}
async function handleUpdate() {
@@ -95,6 +102,7 @@
id: editingId,
name: editName,
slug: editSlug || undefined,
color: editColor,
};
await updateAdminTag(data);
await loadTags();
@@ -191,6 +199,9 @@
placeholder="Leave empty to auto-generate"
/>
</div>
<div class="mt-4">
<ColorPicker bind:selectedColor={createColor} />
</div>
<div class="mt-4 flex justify-end gap-2">
<Button variant="secondary" onclick={() => (showCreateForm = false)}>
Cancel
@@ -226,6 +237,9 @@
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
Slug
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
Color
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
Projects
</th>
@@ -253,6 +267,19 @@
placeholder="tag-slug"
/>
</td>
<td class="px-4 py-3">
{#if editColor}
<div class="flex items-center gap-2">
<div
class="size-6 rounded border border-zinc-700"
style="background-color: #{editColor}"
/>
<span class="text-xs text-zinc-500">#{editColor}</span>
</div>
{:else}
<span class="text-xs text-zinc-500">No color</span>
{/if}
</td>
<td class="px-4 py-3 text-admin-text">
{tag.projectCount}
</td>
@@ -284,6 +311,19 @@
<td class="px-4 py-3 text-zinc-500">
{tag.slug}
</td>
<td class="px-4 py-3">
{#if tag.color}
<div class="flex items-center gap-2">
<div
class="size-6 rounded border border-zinc-700"
style="background-color: #{tag.color}"
/>
<span class="text-xs text-zinc-500">#{tag.color}</span>
</div>
{:else}
<span class="text-xs text-zinc-500">No color</span>
{/if}
</td>
<td class="px-4 py-3 text-zinc-300">
{tag.projectCount}
</td>