From cacee9ba14bd0f0acd851d2effa0466d0f82756f Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 18:15:26 -0600 Subject: [PATCH] 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 --- ...2b997f96562334359531d163bfeefe184a68.json} | 10 +- ...6a07d08b0e44a129e209cdfead695453245e.json} | 10 +- ...268d143568ef811d3d4e17fcef6e42f26985.json} | 12 +- ...70018494868bd177d77c4262255e2bffddd7.json} | 13 +- ...b6a527fad52bc964e9904cfa965c7cbd7d12.json} | 12 +- ...7945b7052370f0fa9d6d5709978d3fbe76cd.json} | 10 +- ...e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json} | 15 +- ...d2f63eb81886f7f73e989807d6e4d64a01ea.json} | 12 +- Justfile | 30 ++-- migrations/20260107000447_add_tag_color.sql | 9 ++ src/db.rs | 40 +++-- src/main.rs | 54 ++++++- web/src/lib/admin-types.ts | 2 + web/src/lib/components/ProjectCard.svelte | 3 +- .../lib/components/admin/ColorPicker.svelte | 152 ++++++++++++++++++ web/src/lib/mock-data/projects.ts | 39 ++--- web/src/routes/admin/tags/+page.svelte | 40 +++++ 17 files changed, 392 insertions(+), 71 deletions(-) rename .sqlx/{query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json => query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json} (59%) rename .sqlx/{query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json => query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json} (66%) rename .sqlx/{query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json => query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json} (69%) rename .sqlx/{query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json => query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json} (61%) rename .sqlx/{query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json => query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json} (59%) rename .sqlx/{query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json => query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json} (65%) rename .sqlx/{query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json => query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json} (57%) rename .sqlx/{query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json => query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json} (60%) create mode 100644 migrations/20260107000447_add_tag_color.sql create mode 100644 web/src/lib/components/admin/ColorPicker.svelte diff --git a/.sqlx/query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json b/.sqlx/query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json similarity index 59% rename from .sqlx/query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json rename to .sqlx/query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json index b835d9b..8874408 100644 --- a/.sqlx/query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json +++ b/.sqlx/query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT t.id, t.slug, t.name, t.created_at\n FROM tags t\n JOIN project_tags pt ON t.id = pt.tag_id\n WHERE pt.project_id = $1\n ORDER BY t.name ASC\n ", + "query": "\n SELECT t.id, t.slug, t.name, t.color, t.created_at\n FROM tags t\n JOIN project_tags pt ON t.id = pt.tag_id\n WHERE pt.project_id = $1\n ORDER BY t.name ASC\n ", "describe": { "columns": [ { @@ -20,6 +20,11 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" } @@ -33,8 +38,9 @@ false, false, false, + true, false ] }, - "hash": "31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28" + "hash": "0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68" } diff --git a/.sqlx/query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json b/.sqlx/query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json similarity index 66% rename from .sqlx/query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json rename to .sqlx/query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json index 8f363a3..f597701 100644 --- a/.sqlx/query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json +++ b/.sqlx/query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, created_at\n FROM tags\n WHERE id = $1\n ", + "query": "\n SELECT id, slug, name, color, created_at\n FROM tags\n WHERE id = $1\n ", "describe": { "columns": [ { @@ -20,6 +20,11 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" } @@ -33,8 +38,9 @@ false, false, false, + true, false ] }, - "hash": "300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807" + "hash": "120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e" } diff --git a/.sqlx/query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json b/.sqlx/query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json similarity index 69% rename from .sqlx/query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json rename to .sqlx/query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json index 4b4ef2e..200b2af 100644 --- a/.sqlx/query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json +++ b/.sqlx/query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE tags\n SET slug = $2, name = $3\n WHERE id = $1\n RETURNING id, slug, name, created_at\n ", + "query": "\n SELECT id, slug, name, color, created_at\n FROM tags\n WHERE slug = $1\n ", "describe": { "columns": [ { @@ -20,14 +20,17 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Uuid", - "Text", "Text" ] }, @@ -35,8 +38,9 @@ false, false, false, + true, false ] }, - "hash": "2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08" + "hash": "19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985" } diff --git a/.sqlx/query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json b/.sqlx/query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json similarity index 61% rename from .sqlx/query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json rename to .sqlx/query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json index 85ba3cc..c9799d6 100644 --- a/.sqlx/query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json +++ b/.sqlx/query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO tags (slug, name)\n VALUES ($1, $2)\n RETURNING id, slug, name, created_at\n ", + "query": "\n INSERT INTO tags (slug, name, color)\n VALUES ($1, $2, $3)\n RETURNING id, slug, name, color, created_at\n ", "describe": { "columns": [ { @@ -20,6 +20,11 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" } @@ -27,15 +32,17 @@ "parameters": { "Left": [ "Text", - "Text" + "Text", + "Varchar" ] }, "nullable": [ false, false, false, + true, false ] }, - "hash": "de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f" + "hash": "5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7" } diff --git a/.sqlx/query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json b/.sqlx/query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json similarity index 59% rename from .sqlx/query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json rename to .sqlx/query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json index 48b2ad1..5e70dee 100644 --- a/.sqlx/query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json +++ b/.sqlx/query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n t.id, \n t.slug, \n t.name, \n t.created_at,\n COUNT(pt.project_id)::int as \"project_count!\"\n FROM tags t\n LEFT JOIN project_tags pt ON t.id = pt.tag_id\n GROUP BY t.id, t.slug, t.name, t.created_at\n ORDER BY t.name ASC\n ", + "query": "\n SELECT \n t.id, \n t.slug, \n t.name,\n t.color,\n t.created_at,\n COUNT(pt.project_id)::int as \"project_count!\"\n FROM tags t\n LEFT JOIN project_tags pt ON t.id = pt.tag_id\n GROUP BY t.id, t.slug, t.name, t.color, t.created_at\n ORDER BY t.name ASC\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 5, "name": "project_count!", "type_info": "Int4" } @@ -36,9 +41,10 @@ false, false, false, + true, false, null ] }, - "hash": "4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879" + "hash": "5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12" } diff --git a/.sqlx/query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json b/.sqlx/query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json similarity index 65% rename from .sqlx/query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json rename to .sqlx/query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json index b3ac759..d6277a5 100644 --- a/.sqlx/query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json +++ b/.sqlx/query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, created_at\n FROM tags\n ORDER BY name ASC\n ", + "query": "\n SELECT id, slug, name, color, created_at\n FROM tags\n ORDER BY name ASC\n ", "describe": { "columns": [ { @@ -20,6 +20,11 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" } @@ -31,8 +36,9 @@ false, false, false, + true, false ] }, - "hash": "df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff" + "hash": "9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd" } diff --git a/.sqlx/query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json b/.sqlx/query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json similarity index 57% rename from .sqlx/query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json rename to .sqlx/query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json index 6d1849b..9071c49 100644 --- a/.sqlx/query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json +++ b/.sqlx/query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, created_at\n FROM tags\n WHERE slug = $1\n ", + "query": "\n UPDATE tags\n SET slug = $2, name = $3, color = $4\n WHERE id = $1\n RETURNING id, slug, name, color, created_at\n ", "describe": { "columns": [ { @@ -20,21 +20,30 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Text" + "Uuid", + "Text", + "Text", + "Varchar" ] }, "nullable": [ false, false, false, + true, false ] }, - "hash": "baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120" + "hash": "cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5" } diff --git a/.sqlx/query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json b/.sqlx/query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json similarity index 60% rename from .sqlx/query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json rename to .sqlx/query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json index a45df42..68a5123 100644 --- a/.sqlx/query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json +++ b/.sqlx/query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n t.id, \n t.slug, \n t.name, \n t.created_at,\n tc.count\n FROM tag_cooccurrence tc\n JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)\n WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1\n ORDER BY tc.count DESC, t.name ASC\n LIMIT $2\n ", + "query": "\n SELECT \n t.id, \n t.slug, \n t.name,\n t.color,\n t.created_at,\n tc.count\n FROM tag_cooccurrence tc\n JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)\n WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1\n ORDER BY tc.count DESC, t.name ASC\n LIMIT $2\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 5, "name": "count", "type_info": "Int4" } @@ -39,9 +44,10 @@ false, false, false, + true, false, false ] }, - "hash": "5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32" + "hash": "e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea" } diff --git a/Justfile b/Justfile index db83ae2..f716b5b 100644 --- a/Justfile +++ b/Justfile @@ -32,7 +32,7 @@ check: await proc.exited; const elapsed = ((Date.now() - start) / 1000).toFixed(1); - + return { ...check, stdout, stderr, exitCode: proc.exitCode, elapsed }; }); @@ -65,6 +65,10 @@ check: process.stderr.write(`\r\x1b[K`); process.exit(anyFailed ? 1 : 0); +format: + bun run --cwd web format --list-different + cargo fmt --all + build: bun run --cwd web build cargo build --release @@ -99,26 +103,26 @@ docker-run-json port="8080": [script("bun")] seed: const { spawnSync } = await import("child_process"); - + // Ensure DB is running const db = spawnSync("just", ["db"], { stdio: "inherit" }); if (db.status !== 0) process.exit(db.status); - + // Run migrations const migrate = spawnSync("sqlx", ["migrate", "run"], { stdio: "inherit" }); if (migrate.status !== 0) process.exit(migrate.status); - + // Seed data const seed = spawnSync("cargo", ["run", "--bin", "seed"], { stdio: "inherit" }); if (seed.status !== 0) process.exit(seed.status); - + console.log("✅ Database ready with seed data"); [script("bun")] db cmd="start": const fs = await import("fs/promises"); const { spawnSync } = await import("child_process"); - + const NAME = "xevion-postgres"; const USER = "xevion"; const PASS = "dev"; @@ -126,18 +130,18 @@ db cmd="start": const PORT = "5432"; const ENV_FILE = ".env"; const CMD = "{{cmd}}"; - + const run = (args) => spawnSync("docker", args, { encoding: "utf8" }); const getContainer = () => { const res = run(["ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json"]); return res.stdout.trim() ? JSON.parse(res.stdout) : null; }; - + const updateEnv = async () => { const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`; try { let content = await fs.readFile(ENV_FILE, "utf8"); - content = content.includes("DATABASE_URL=") + content = content.includes("DATABASE_URL=") ? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`) : content.trim() + `\nDATABASE_URL=${url}\n`; await fs.writeFile(ENV_FILE, content); @@ -145,16 +149,16 @@ db cmd="start": await fs.writeFile(ENV_FILE, `DATABASE_URL=${url}\n`); } }; - + const create = () => { - run(["run", "-d", "--name", NAME, "-e", `POSTGRES_USER=${USER}`, + run(["run", "-d", "--name", NAME, "-e", `POSTGRES_USER=${USER}`, "-e", `POSTGRES_PASSWORD=${PASS}`, "-e", `POSTGRES_DB=${DB}`, "-p", `${PORT}:5432`, "postgres:16-alpine"]); console.log("✅ created"); }; - + const container = getContainer(); - + if (CMD === "rm") { if (!container) process.exit(0); run(["stop", NAME]); diff --git a/migrations/20260107000447_add_tag_color.sql b/migrations/20260107000447_add_tag_color.sql new file mode 100644 index 0000000..dccc6d3 --- /dev/null +++ b/migrations/20260107000447_add_tag_color.sql @@ -0,0 +1,9 @@ +-- Add color column to tags table (nullable hex color without hash) +ALTER TABLE tags ADD COLUMN color VARCHAR(6) DEFAULT NULL; + +-- Add check constraint for valid hex format (6 characters, 0-9a-fA-F) +ALTER TABLE tags ADD CONSTRAINT tags_color_hex_format + CHECK (color IS NULL OR color ~ '^[0-9a-fA-F]{6}$'); + +-- Create index for color lookups (optional, for future filtering) +CREATE INDEX idx_tags_color ON tags(color) WHERE color IS NOT NULL; diff --git a/src/db.rs b/src/db.rs index 36d1dbc..0524fbb 100644 --- a/src/db.rs +++ b/src/db.rs @@ -37,6 +37,7 @@ pub struct DbTag { pub id: Uuid, pub slug: String, pub name: String, + pub color: Option, pub created_at: OffsetDateTime, } @@ -78,6 +79,8 @@ pub struct ApiTag { pub id: String, pub slug: String, pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -108,6 +111,7 @@ impl DbTag { id: self.id.to_string(), slug: self.slug.clone(), name: self.name.clone(), + color: self.color.clone(), } } } @@ -211,6 +215,7 @@ pub async fn create_tag( pool: &PgPool, name: &str, slug_override: Option<&str>, + color: Option<&str>, ) -> Result { let slug = slug_override .map(|s| slugify(s)) @@ -219,12 +224,13 @@ pub async fn create_tag( sqlx::query_as!( DbTag, r#" - INSERT INTO tags (slug, name) - VALUES ($1, $2) - RETURNING id, slug, name, created_at + INSERT INTO tags (slug, name, color) + VALUES ($1, $2, $3) + RETURNING id, slug, name, color, created_at "#, slug, - name + name, + color ) .fetch_one(pool) .await @@ -234,7 +240,7 @@ pub async fn get_tag_by_id(pool: &PgPool, id: Uuid) -> Result, sql sqlx::query_as!( DbTag, r#" - SELECT id, slug, name, created_at + SELECT id, slug, name, color, created_at FROM tags WHERE id = $1 "#, @@ -248,7 +254,7 @@ pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::query_as!( DbTag, r#" - SELECT id, slug, name, created_at + SELECT id, slug, name, color, created_at FROM tags WHERE slug = $1 "#, @@ -262,7 +268,7 @@ pub async fn get_all_tags(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as!( DbTag, r#" - SELECT id, slug, name, created_at + SELECT id, slug, name, color, created_at FROM tags ORDER BY name ASC "# @@ -277,12 +283,13 @@ pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result SELECT t.id, t.slug, - t.name, + t.name, + t.color, t.created_at, COUNT(pt.project_id)::int as "project_count!" FROM tags t LEFT JOIN project_tags pt ON t.id = pt.tag_id - GROUP BY t.id, t.slug, t.name, t.created_at + GROUP BY t.id, t.slug, t.name, t.color, t.created_at ORDER BY t.name ASC "# ) @@ -296,6 +303,7 @@ pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result id: row.id, slug: row.slug, name: row.name, + color: row.color, created_at: row.created_at, }; (tag, row.project_count) @@ -308,6 +316,7 @@ pub async fn update_tag( id: Uuid, name: &str, slug_override: Option<&str>, + color: Option<&str>, ) -> Result { let slug = slug_override .map(|s| slugify(s)) @@ -317,13 +326,14 @@ pub async fn update_tag( DbTag, r#" UPDATE tags - SET slug = $2, name = $3 + SET slug = $2, name = $3, color = $4 WHERE id = $1 - RETURNING id, slug, name, created_at + RETURNING id, slug, name, color, created_at "#, id, slug, - name + name, + color ) .fetch_one(pool) .await @@ -405,7 +415,7 @@ pub async fn get_tags_for_project( sqlx::query_as!( DbTag, r#" - SELECT t.id, t.slug, t.name, t.created_at + SELECT t.id, t.slug, t.name, t.color, t.created_at FROM tags t JOIN project_tags pt ON t.id = pt.tag_id WHERE pt.project_id = $1 @@ -487,7 +497,8 @@ pub async fn get_related_tags( SELECT t.id, t.slug, - t.name, + t.name, + t.color, t.created_at, tc.count FROM tag_cooccurrence tc @@ -509,6 +520,7 @@ pub async fn get_related_tags( id: row.id, slug: row.slug, name: row.name, + color: row.color, created_at: row.created_at, }; (tag, row.count) diff --git a/src/main.rs b/src/main.rs index d261c2b..13f131d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -610,10 +610,16 @@ async fn list_tags_handler(State(state): State>) -> impl IntoRespo } } +/// Validate hex color format (6 characters, no hash, no alpha) +fn validate_hex_color(color: &str) -> bool { + color.len() == 6 && color.chars().all(|c| c.is_ascii_hexdigit()) +} + #[derive(serde::Deserialize)] struct CreateTagRequest { name: String, slug: Option, + color: Option, } async fn create_tag_handler( @@ -635,7 +641,28 @@ async fn create_tag_handler( .into_response(); } - match db::create_tag(&state.pool, &payload.name, payload.slug.as_deref()).await { + // Validate color if provided + if let Some(ref color) = payload.color { + if !validate_hex_color(color) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Invalid color format. Must be 6-character hex (e.g., '3b82f6')" + })), + ) + .into_response(); + } + } + + match db::create_tag( + &state.pool, + &payload.name, + payload.slug.as_deref(), + payload.color.as_deref(), + ) + .await + { Ok(tag) => (StatusCode::CREATED, Json(tag.to_api_tag())).into_response(), Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => ( StatusCode::CONFLICT, @@ -710,6 +737,7 @@ async fn get_tag_handler( struct UpdateTagRequest { name: String, slug: Option, + color: Option, } async fn update_tag_handler( @@ -732,6 +760,20 @@ async fn update_tag_handler( .into_response(); } + // Validate color if provided + if let Some(ref color) = payload.color { + if !validate_hex_color(color) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Invalid color format. Must be 6-character hex (e.g., '3b82f6')" + })), + ) + .into_response(); + } + } + let tag = match db::get_tag_by_slug(&state.pool, &slug).await { Ok(Some(tag)) => tag, Ok(None) => { @@ -757,7 +799,15 @@ async fn update_tag_handler( } }; - match db::update_tag(&state.pool, tag.id, &payload.name, payload.slug.as_deref()).await { + match db::update_tag( + &state.pool, + tag.id, + &payload.name, + payload.slug.as_deref(), + payload.color.as_deref(), + ) + .await + { Ok(updated_tag) => Json(updated_tag.to_api_tag()).into_response(), Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => ( StatusCode::CONFLICT, diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts index 914c9ac..f0c3d5b 100644 --- a/web/src/lib/admin-types.ts +++ b/web/src/lib/admin-types.ts @@ -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 { diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte index cbe6067..f9c4bda 100644 --- a/web/src/lib/components/ProjectCard.svelte +++ b/web/src/lib/components/ProjectCard.svelte @@ -56,7 +56,8 @@ {#each project.tags as tag (tag.name)} {#if tag.iconSvg} diff --git a/web/src/lib/components/admin/ColorPicker.svelte b/web/src/lib/components/admin/ColorPicker.svelte new file mode 100644 index 0000000..7f624bb --- /dev/null +++ b/web/src/lib/components/admin/ColorPicker.svelte @@ -0,0 +1,152 @@ + + +
+ {#if label} + + {/if} + + +
+ {#each PRESET_COLORS as preset (preset.value)} + +
+ + +
+
+
+ # + +
+ {#if validationError} +

{validationError}

+ {/if} +
+ + + {#if selectedColor && validateHexColor(selectedColor)} +
+ {/if} +
+
diff --git a/web/src/lib/mock-data/projects.ts b/web/src/lib/mock-data/projects.ts index 63532f1..3b08d7d 100644 --- a/web/src/lib/mock-data/projects.ts +++ b/web/src/lib/mock-data/projects.ts @@ -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", }, diff --git a/web/src/routes/admin/tags/+page.svelte b/web/src/routes/admin/tags/+page.svelte index ae1e0ea..4c374c7 100644 --- a/web/src/routes/admin/tags/+page.svelte +++ b/web/src/routes/admin/tags/+page.svelte @@ -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(undefined); let creating = $state(false); // Edit state let editingId = $state(null); let editName = $state(""); let editSlug = $state(""); + let editColor = $state(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" />
+
+ +