feat: brain colored folders/tags — color + icon fields, mobile pills

Backend:
- Added color (hex) and icon (lucide name) columns to folders and tags
- Default folders seeded with colors: Home=green, Work=indigo, Travel=blue, etc.
- API returns color/icon in sidebar and CRUD responses
- Create/update endpoints accept color and icon

Frontend:
- Mobile: horizontal scrollable pill tabs with colored dots
- Desktop sidebar: colored dots next to folder names
- Active pill gets tinted border matching folder color

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 22:32:40 -05:00
parent 8f3afd46c3
commit 3531360827
4 changed files with 646 additions and 642 deletions

View File

@@ -22,11 +22,15 @@ router = APIRouter(prefix="/api/taxonomy", tags=["taxonomy"])
class FolderIn(BaseModel):
name: str
color: Optional[str] = None
icon: Optional[str] = None
class FolderOut(BaseModel):
id: str
name: str
slug: str
color: Optional[str] = None
icon: Optional[str] = None
is_active: bool
sort_order: int
item_count: int = 0
@@ -34,11 +38,15 @@ class FolderOut(BaseModel):
class TagIn(BaseModel):
name: str
color: Optional[str] = None
icon: Optional[str] = None
class TagOut(BaseModel):
id: str
name: str
slug: str
color: Optional[str] = None
icon: Optional[str] = None
is_active: bool
sort_order: int
item_count: int = 0
@@ -77,8 +85,8 @@ async def get_sidebar(
folder_counts[row[0]] = row[1]
folders = [FolderOut(
id=f.id, name=f.name, slug=f.slug, is_active=f.is_active,
sort_order=f.sort_order, item_count=folder_counts.get(f.id, 0)
id=f.id, name=f.name, slug=f.slug, color=f.color, icon=f.icon,
is_active=f.is_active, sort_order=f.sort_order, item_count=folder_counts.get(f.id, 0)
) for f in folder_rows]
# Tags with counts
@@ -95,8 +103,8 @@ async def get_sidebar(
tag_counts[row[0]] = row[1]
tags = [TagOut(
id=t.id, name=t.name, slug=t.slug, is_active=t.is_active,
sort_order=t.sort_order, item_count=tag_counts.get(t.id, 0)
id=t.id, name=t.name, slug=t.slug, color=t.color, icon=t.icon,
is_active=t.is_active, sort_order=t.sort_order, item_count=tag_counts.get(t.id, 0)
) for t in tag_rows]
# Total items
@@ -118,7 +126,7 @@ async def list_folders(
rows = (await db.execute(
select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name)
)).scalars().all()
return [FolderOut(id=f.id, name=f.name, slug=f.slug, is_active=f.is_active, sort_order=f.sort_order) for f in rows]
return [FolderOut(id=f.id, name=f.name, slug=f.slug, color=f.color, icon=f.icon, is_active=f.is_active, sort_order=f.sort_order) for f in rows]
@router.post("/folders", response_model=FolderOut, status_code=201)
@@ -138,7 +146,8 @@ async def create_folder(
select(func.max(Folder.sort_order)).where(Folder.user_id == user_id)
)).scalar() or 0
folder = Folder(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug, sort_order=max_order + 1)
folder = Folder(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug,
color=body.color, icon=body.icon, sort_order=max_order + 1)
db.add(folder)
await db.commit()
return FolderOut(id=folder.id, name=folder.name, slug=folder.slug, is_active=folder.is_active, sort_order=folder.sort_order)
@@ -159,6 +168,8 @@ async def update_folder(
folder.name = body.name.strip()
folder.slug = slugify(body.name)
if body.color is not None: folder.color = body.color
if body.icon is not None: folder.icon = body.icon
folder.updated_at = datetime.utcnow()
# Update denormalized folder name on items
@@ -211,7 +222,7 @@ async def list_tags(
rows = (await db.execute(
select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name)
)).scalars().all()
return [TagOut(id=t.id, name=t.name, slug=t.slug, is_active=t.is_active, sort_order=t.sort_order) for t in rows]
return [TagOut(id=t.id, name=t.name, slug=t.slug, color=t.color, icon=t.icon, is_active=t.is_active, sort_order=t.sort_order) for t in rows]
@router.post("/tags", response_model=TagOut, status_code=201)
@@ -231,7 +242,8 @@ async def create_tag(
select(func.max(Tag.sort_order)).where(Tag.user_id == user_id)
)).scalar() or 0
tag = Tag(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug, sort_order=max_order + 1)
tag = Tag(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug,
color=body.color, icon=body.icon, sort_order=max_order + 1)
db.add(tag)
await db.commit()
return TagOut(id=tag.id, name=tag.name, slug=tag.slug, is_active=tag.is_active, sort_order=tag.sort_order)
@@ -253,6 +265,8 @@ async def update_tag(
old_name = tag.name
tag.name = body.name.strip()
tag.slug = slugify(body.name)
if body.color is not None: tag.color = body.color
if body.icon is not None: tag.icon = body.icon
tag.updated_at = datetime.utcnow()
# Update denormalized tags array on items that had the old name

View File

@@ -29,6 +29,8 @@ class Folder(Base):
user_id = Column(String(64), nullable=False, index=True)
name = Column(String(128), nullable=False)
slug = Column(String(128), nullable=False)
color = Column(String(7), nullable=True) # hex like #4F46E5
icon = Column(String(32), nullable=True) # lucide icon name like "home"
is_active = Column(Boolean, default=True, nullable=False)
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
@@ -46,6 +48,8 @@ class Tag(Base):
user_id = Column(String(64), nullable=False, index=True)
name = Column(String(128), nullable=False)
slug = Column(String(128), nullable=False)
color = Column(String(7), nullable=True)
icon = Column(String(32), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
@@ -64,8 +68,16 @@ class ItemTag(Base):
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Default folders to seed for new users
DEFAULT_FOLDERS = ["Home", "Family", "Work", "Travel", "Knowledge", "Faith", "Projects"]
# Default folders with colors and icons
DEFAULT_FOLDERS = [
{"name": "Home", "color": "#059669", "icon": "home"},
{"name": "Family", "color": "#D97706", "icon": "heart"},
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
{"name": "Knowledge", "color": "#8B5CF6", "icon": "book-open"},
{"name": "Faith", "color": "#10B981", "icon": "moon"},
{"name": "Projects", "color": "#F43F5E", "icon": "folder"},
]
# Default tags to seed for new users
DEFAULT_TAGS = [
@@ -87,8 +99,9 @@ async def ensure_user_taxonomy(db, user_id: str):
)).scalar() or 0
if folder_count == 0:
for i, name in enumerate(DEFAULT_FOLDERS):
db.add(Folder(id=new_id(), user_id=user_id, name=name, slug=slugify(name), sort_order=i))
for i, f in enumerate(DEFAULT_FOLDERS):
db.add(Folder(id=new_id(), user_id=user_id, name=f["name"], slug=slugify(f["name"]),
color=f.get("color"), icon=f.get("icon"), sort_order=i))
await db.flush()
tag_count = (await db.execute(