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:
@@ -23,8 +23,8 @@
|
|||||||
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
|
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarFolder { id: string; name: string; slug: string; is_active: boolean; item_count: number; }
|
interface SidebarFolder { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
|
||||||
interface SidebarTag { id: string; name: string; slug: string; is_active: boolean; item_count: number; }
|
interface SidebarTag { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let items = $state<BrainItem[]>([]);
|
let items = $state<BrainItem[]>([]);
|
||||||
@@ -366,6 +366,7 @@
|
|||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
||||||
<button class="nav-item" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
|
<button class="nav-item" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
|
||||||
|
{#if folder.color}<span class="nav-dot" style="background: {folder.color}"></span>{/if}
|
||||||
<span class="nav-label">{folder.name}</span>
|
<span class="nav-label">{folder.name}</span>
|
||||||
{#if showManage}
|
{#if showManage}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions --><span class="nav-delete" onclick={(e) => { e.stopPropagation(); if (confirm(`Delete "${folder.name}"? Items will be moved.`)) deleteTaxonomy(folder.id); }}>×</span>
|
<!-- svelte-ignore a11y_no_static_element_interactions --><span class="nav-delete" onclick={(e) => { e.stopPropagation(); if (confirm(`Delete "${folder.name}"? Items will be moved.`)) deleteTaxonomy(folder.id); }}>×</span>
|
||||||
@@ -437,11 +438,20 @@
|
|||||||
{#if uploading}<div class="upload-status">Uploading...</div>{/if}
|
{#if uploading}<div class="upload-status">Uploading...</div>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Mobile sidebar toggle -->
|
<!-- Mobile: horizontal pill filters -->
|
||||||
<button class="mobile-filter-btn" onclick={() => mobileSidebarOpen = true}>
|
<div class="mobile-pills">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/></svg>
|
<button class="pill" class:active={!activeFolder && !activeTag} onclick={() => { activeFolder = null; activeTag = null; activeFolderId = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
|
||||||
Folders & Tags
|
All
|
||||||
</button>
|
</button>
|
||||||
|
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
||||||
|
<button class="pill" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; loadItems(); }}
|
||||||
|
style={folder.color ? `--pill-color: ${folder.color}` : ''}>
|
||||||
|
{#if folder.color}<span class="pill-dot" style="background: {folder.color}"></span>{/if}
|
||||||
|
{folder.name}
|
||||||
|
{#if folder.item_count > 0}<span class="pill-count">{folder.item_count}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Active filter indicator -->
|
<!-- Active filter indicator -->
|
||||||
{#if activeFolder || activeTag}
|
{#if activeFolder || activeTag}
|
||||||
@@ -867,26 +877,56 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ Mobile filter button ═══ */
|
/* ═══ Mobile pills ═══ */
|
||||||
.mobile-filter-btn {
|
.mobile-pills {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 14px;
|
overflow-x: auto;
|
||||||
border-radius: 10px;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.mobile-pills::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(35,26,17,0.1);
|
border: 1px solid rgba(35,26,17,0.1);
|
||||||
background: rgba(255,252,248,0.7);
|
background: rgba(255,252,248,0.7);
|
||||||
color: #5c5046;
|
color: #5c5046;
|
||||||
font-size: 0.82rem;
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 12px;
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
transition: all 160ms;
|
transition: all 160ms;
|
||||||
}
|
}
|
||||||
.mobile-filter-btn:hover { background: rgba(255,248,241,0.9); color: #1e1812; }
|
.pill:hover { background: rgba(255,248,241,0.9); }
|
||||||
|
.pill.active {
|
||||||
|
background: rgba(255,248,241,0.95);
|
||||||
|
color: #1e1812;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--pill-color, rgba(35,26,17,0.2));
|
||||||
|
box-shadow: inset 0 0 0 1px var(--pill-color, transparent);
|
||||||
|
}
|
||||||
|
.pill-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pill-count {
|
||||||
|
font-size: 0.65rem; font-family: var(--mono); color: #8c7b69;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mobile-filter-btn { display: flex; }
|
.mobile-pills { display: flex; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ Active filter ═══ */
|
/* ═══ Active filter ═══ */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,15 @@ router = APIRouter(prefix="/api/taxonomy", tags=["taxonomy"])
|
|||||||
|
|
||||||
class FolderIn(BaseModel):
|
class FolderIn(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
|
||||||
class FolderOut(BaseModel):
|
class FolderOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
slug: str
|
slug: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
sort_order: int
|
sort_order: int
|
||||||
item_count: int = 0
|
item_count: int = 0
|
||||||
@@ -34,11 +38,15 @@ class FolderOut(BaseModel):
|
|||||||
|
|
||||||
class TagIn(BaseModel):
|
class TagIn(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
|
||||||
class TagOut(BaseModel):
|
class TagOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
slug: str
|
slug: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
sort_order: int
|
sort_order: int
|
||||||
item_count: int = 0
|
item_count: int = 0
|
||||||
@@ -77,8 +85,8 @@ async def get_sidebar(
|
|||||||
folder_counts[row[0]] = row[1]
|
folder_counts[row[0]] = row[1]
|
||||||
|
|
||||||
folders = [FolderOut(
|
folders = [FolderOut(
|
||||||
id=f.id, name=f.name, slug=f.slug, is_active=f.is_active,
|
id=f.id, name=f.name, slug=f.slug, color=f.color, icon=f.icon,
|
||||||
sort_order=f.sort_order, item_count=folder_counts.get(f.id, 0)
|
is_active=f.is_active, sort_order=f.sort_order, item_count=folder_counts.get(f.id, 0)
|
||||||
) for f in folder_rows]
|
) for f in folder_rows]
|
||||||
|
|
||||||
# Tags with counts
|
# Tags with counts
|
||||||
@@ -95,8 +103,8 @@ async def get_sidebar(
|
|||||||
tag_counts[row[0]] = row[1]
|
tag_counts[row[0]] = row[1]
|
||||||
|
|
||||||
tags = [TagOut(
|
tags = [TagOut(
|
||||||
id=t.id, name=t.name, slug=t.slug, is_active=t.is_active,
|
id=t.id, name=t.name, slug=t.slug, color=t.color, icon=t.icon,
|
||||||
sort_order=t.sort_order, item_count=tag_counts.get(t.id, 0)
|
is_active=t.is_active, sort_order=t.sort_order, item_count=tag_counts.get(t.id, 0)
|
||||||
) for t in tag_rows]
|
) for t in tag_rows]
|
||||||
|
|
||||||
# Total items
|
# Total items
|
||||||
@@ -118,7 +126,7 @@ async def list_folders(
|
|||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name)
|
select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name)
|
||||||
)).scalars().all()
|
)).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)
|
@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)
|
select(func.max(Folder.sort_order)).where(Folder.user_id == user_id)
|
||||||
)).scalar() or 0
|
)).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)
|
db.add(folder)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return FolderOut(id=folder.id, name=folder.name, slug=folder.slug, is_active=folder.is_active, sort_order=folder.sort_order)
|
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.name = body.name.strip()
|
||||||
folder.slug = slugify(body.name)
|
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()
|
folder.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
# Update denormalized folder name on items
|
# Update denormalized folder name on items
|
||||||
@@ -211,7 +222,7 @@ async def list_tags(
|
|||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name)
|
select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name)
|
||||||
)).scalars().all()
|
)).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)
|
@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)
|
select(func.max(Tag.sort_order)).where(Tag.user_id == user_id)
|
||||||
)).scalar() or 0
|
)).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)
|
db.add(tag)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return TagOut(id=tag.id, name=tag.name, slug=tag.slug, is_active=tag.is_active, sort_order=tag.sort_order)
|
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
|
old_name = tag.name
|
||||||
tag.name = body.name.strip()
|
tag.name = body.name.strip()
|
||||||
tag.slug = slugify(body.name)
|
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()
|
tag.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
# Update denormalized tags array on items that had the old name
|
# Update denormalized tags array on items that had the old name
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class Folder(Base):
|
|||||||
user_id = Column(String(64), nullable=False, index=True)
|
user_id = Column(String(64), nullable=False, index=True)
|
||||||
name = Column(String(128), nullable=False)
|
name = Column(String(128), nullable=False)
|
||||||
slug = 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)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, 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)
|
user_id = Column(String(64), nullable=False, index=True)
|
||||||
name = Column(String(128), nullable=False)
|
name = Column(String(128), nullable=False)
|
||||||
slug = 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)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, 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)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# Default folders to seed for new users
|
# Default folders with colors and icons
|
||||||
DEFAULT_FOLDERS = ["Home", "Family", "Work", "Travel", "Knowledge", "Faith", "Projects"]
|
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 to seed for new users
|
||||||
DEFAULT_TAGS = [
|
DEFAULT_TAGS = [
|
||||||
@@ -87,8 +99,9 @@ async def ensure_user_taxonomy(db, user_id: str):
|
|||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
|
||||||
if folder_count == 0:
|
if folder_count == 0:
|
||||||
for i, name in enumerate(DEFAULT_FOLDERS):
|
for i, f in enumerate(DEFAULT_FOLDERS):
|
||||||
db.add(Folder(id=new_id(), user_id=user_id, name=name, slug=slugify(name), sort_order=i))
|
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()
|
await db.flush()
|
||||||
|
|
||||||
tag_count = (await db.execute(
|
tag_count = (await db.execute(
|
||||||
|
|||||||
Reference in New Issue
Block a user