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
+26 -14
View File
@@ -37,6 +37,7 @@ pub struct DbTag {
pub id: Uuid,
pub slug: String,
pub name: String,
pub color: Option<String>,
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<String>,
}
#[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<DbTag, sqlx::Error> {
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<Option<DbTag>, 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<Option<DbTag>,
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<Vec<DbTag>, 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<Vec<(DbTag, i32)>
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<Vec<(DbTag, i32)>
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<DbTag, sqlx::Error> {
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)
+52 -2
View File
@@ -610,10 +610,16 @@ async fn list_tags_handler(State(state): State<Arc<AppState>>) -> 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<String>,
color: Option<String>,
}
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<String>,
color: Option<String>,
}
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,