mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user