feat: color and icon customization for sidebar type sections

This commit is contained in:
lucaronin
2026-02-21 16:27:43 +01:00
16 changed files with 5838 additions and 28 deletions

View File

@@ -49,6 +49,8 @@ const mockEntry: VaultEntry = {
fileSize: 1024,
snippet: '',
relationships: {},
icon: null,
color: null,
}
const mockContent = `---

View File

@@ -19,6 +19,8 @@ const mockEntry: VaultEntry = {
fileSize: 1024,
snippet: '',
relationships: {},
icon: null,
color: null,
}
const mockContent = `---
@@ -54,6 +56,8 @@ const referrerEntry: VaultEntry = {
fileSize: 200,
snippet: '',
relationships: {},
icon: null,
color: null,
}
const allContent: Record<string, string> = {

View File

@@ -25,6 +25,8 @@ const mockEntries: VaultEntry[] = [
relationships: {
'Related to': ['[[topic/software-development]]'],
},
icon: null,
color: null,
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
@@ -45,6 +47,8 @@ const mockEntries: VaultEntry[] = [
'Belongs to': ['[[project/26q1-laputa-app]]'],
'Related to': ['[[topic/growth]]'],
},
icon: null,
color: null,
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
@@ -62,6 +66,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 320,
snippet: 'Sponsorship manager.',
relationships: {},
icon: null,
color: null,
},
{
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
@@ -79,6 +85,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 512,
snippet: 'Project kickoff meeting notes.',
relationships: {},
icon: null,
color: null,
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
@@ -96,6 +104,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 256,
snippet: 'Frontend, backend, and systems programming.',
relationships: {},
icon: null,
color: null,
},
]

View File

@@ -9,6 +9,7 @@ import {
} from '@phosphor-icons/react'
import type { ComponentType, SVGAttributes } from 'react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from './TypeCustomizePopover'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -21,7 +22,8 @@ const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>>
Type: StackSimple,
}
function getTypeIcon(isA: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (customIcon) return resolveIcon(customIcon)
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
@@ -234,6 +236,15 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
})
}, [])
// Build type entry map for custom icon/color lookup
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) {
if (e.isA === 'Type') map[e.title] = e
}
return map
}, [entries])
// Find the type document for this section group (e.g., type/project.md for "Project")
const typeDocument = useMemo(() => {
if (!isSectionGroup) return null
@@ -278,9 +289,10 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
const isSelected = selectedNote?.path === entry.path && !isPinned
const typeColor = getTypeColor(entry.isA ?? 'Note')
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note')
const TypeIcon = getTypeIcon(entry.isA)
const te = typeEntryMap[entry.isA ?? '']
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = getTypeIcon(entry.isA, te?.icon)
return (
<div
key={entry.path}
@@ -322,12 +334,13 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
</div>
</div>
)
}, [selectedNote?.path, onSelectNote])
}, [selectedNote?.path, onSelectNote, typeEntryMap])
const renderPinnedView = useCallback((entity: VaultEntry, groups: RelationshipGroup[]) => {
const entityTypeColor = getTypeColor(entity.isA ?? '')
const entityLightColor = getTypeLightColor(entity.isA ?? '')
const EntityIcon = getTypeIcon(entity.isA)
const ete = typeEntryMap[entity.isA ?? '']
const entityTypeColor = getTypeColor(entity.isA ?? '', ete?.color)
const entityLightColor = getTypeLightColor(entity.isA ?? '', ete?.color)
const EntityIcon = getTypeIcon(entity.isA, ete?.icon)
return (
<div className="h-full overflow-y-auto">
{/* Prominent card */}
@@ -387,7 +400,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
)}
</div>
)
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem])
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem, typeEntryMap])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
@@ -436,9 +449,10 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
<div className="h-full overflow-y-auto">
{/* Type document pinned card (for sectionGroup view) */}
{typeDocument && (() => {
const tdColor = getTypeColor(typeDocument.isA ?? 'Type')
const tdLightColor = getTypeLightColor(typeDocument.isA ?? 'Type')
const TDIcon = getTypeIcon(typeDocument.isA)
const tde = typeEntryMap[typeDocument.title] ?? typeDocument
const tdColor = getTypeColor(typeDocument.isA ?? 'Type', tde?.color)
const tdLightColor = getTypeLightColor(typeDocument.isA ?? 'Type', tde?.color)
const TDIcon = getTypeIcon(typeDocument.isA, tde?.icon)
return (
<div
className="relative cursor-pointer border-b border-[var(--border)]"

View File

@@ -20,6 +20,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 1024,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/responsibility/grow-newsletter.md',
@@ -37,6 +39,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 512,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/experiment/stock-screener.md',
@@ -54,6 +58,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 256,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/procedure/weekly-essays.md',
@@ -71,6 +77,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 128,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/topic/software-development.md',
@@ -88,6 +96,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 256,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/topic/trading.md',
@@ -105,6 +115,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 180,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/person/alice.md',
@@ -122,6 +134,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 100,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/event/kickoff.md',
@@ -139,6 +153,8 @@ const mockEntries: VaultEntry[] = [
fileSize: 200,
snippet: '',
relationships: {},
icon: null,
color: null,
},
]
@@ -283,6 +299,8 @@ describe('Sidebar', () => {
fileSize: 200,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/type/book.md',
@@ -300,6 +318,8 @@ describe('Sidebar', () => {
fileSize: 200,
snippet: '',
relationships: {},
icon: null,
color: null,
},
{
path: '/vault/recipe/pasta.md',
@@ -317,6 +337,8 @@ describe('Sidebar', () => {
fileSize: 300,
snippet: '',
relationships: {},
icon: null,
color: null,
},
]
@@ -362,6 +384,8 @@ describe('Sidebar', () => {
fileSize: 200,
snippet: '',
relationships: {},
icon: null,
color: null,
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
// "Projects" should appear once (the built-in section), not twice

View File

@@ -1,8 +1,9 @@
import { useState, useMemo, memo, type ComponentType } from 'react'
import { useState, useMemo, useRef, useEffect, useCallback, memo, type ComponentType } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover'
import {
FileText,
Star,
@@ -26,6 +27,7 @@ interface SidebarProps {
onSelectNote?: (entry: VaultEntry) => void
onCreateType?: (type: string) => void
onCreateNewType?: () => void
onCustomizeType?: (typeName: string, icon: string, color: string) => void
modifiedCount?: number
onCommitPush?: () => void
}
@@ -39,6 +41,7 @@ interface SectionGroup {
label: string
type: string
Icon: ComponentType<IconProps>
customColor?: string | null
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
@@ -54,13 +57,26 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, modifiedCount = 0, onCommitPush }: SidebarProps) {
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, modifiedCount = 0, onCommitPush }: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const toggleSection = (type: string) => {
setCollapsed((prev) => ({ ...prev, [type]: !prev[type] }))
}
const getSectionColor = (entry: VaultEntry) => getTypeColor(entry.isA ?? 'Note')
// Build a map of type name → type entry for quick lookup of icon/color
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) {
if (e.isA === 'Type') map[e.title] = e
}
return map
}, [entries])
const isActive = (sel: SidebarSelection): boolean => {
if (selection.kind !== sel.kind) return false
@@ -79,20 +95,90 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
.map((e) => ({
label: e.title + 's',
type: e.title,
Icon: FileText,
Icon: resolveIcon(e.icon),
customColor: e.color,
}))
}, [entries])
// For built-in types, check if they have custom icon/color overrides from their type entry
const builtInWithOverrides: SectionGroup[] = useMemo(() => {
return BUILT_IN_SECTION_GROUPS.map((sg) => {
const typeEntry = typeEntryMap[sg.type]
if (!typeEntry?.icon && !typeEntry?.color) return sg
return {
...sg,
Icon: typeEntry?.icon ? resolveIcon(typeEntry.icon) : sg.Icon,
customColor: typeEntry?.color ?? null,
}
})
}, [typeEntryMap])
const allSectionGroups = useMemo(
() => [...BUILT_IN_SECTION_GROUPS, ...customSectionGroups],
[customSectionGroups],
() => [...builtInWithOverrides, ...customSectionGroups],
[builtInWithOverrides, customSectionGroups],
)
const renderSection = ({ label, type, Icon }: SectionGroup) => {
// Close context menu on outside click
useEffect(() => {
if (!contextMenuPos) return
const handler = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuPos(null)
setContextMenuType(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [contextMenuPos])
// Close customize popover on outside click
useEffect(() => {
if (!customizeTarget) return
const handler = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setCustomizeTarget(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [customizeTarget])
const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPos({ x: e.clientX, y: e.clientY })
setContextMenuType(type)
}, [])
const openCustomizePopover = useCallback((type: string) => {
setContextMenuPos(null)
setContextMenuType(null)
setCustomizeTarget(type)
}, [])
const handleCustomizeIcon = useCallback((icon: string) => {
if (!customizeTarget) return
const typeEntry = typeEntryMap[customizeTarget]
if (typeEntry && onCustomizeType) {
onCustomizeType(customizeTarget, icon, typeEntry.color ?? 'blue')
}
}, [customizeTarget, typeEntryMap, onCustomizeType])
const handleCustomizeColor = useCallback((color: string) => {
if (!customizeTarget) return
const typeEntry = typeEntryMap[customizeTarget]
if (typeEntry && onCustomizeType) {
onCustomizeType(customizeTarget, typeEntry.icon ?? 'file-text', color)
}
}, [customizeTarget, typeEntryMap, onCustomizeType])
const renderSection = ({ label, type, Icon, customColor }: SectionGroup) => {
const items = entries.filter((e) => e.isA === type)
const isCollapsed = collapsed[type] ?? false
const isTopic = type === 'Topic'
const isTypeSection = type === 'Type'
const sectionColor = getTypeColor(type, customColor)
const sectionLightColor = getTypeLightColor(type, customColor)
const handlePlusClick = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -115,9 +201,10 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
)}
style={{ padding: '6px 16px', borderRadius: 4, gap: 8 }}
onClick={() => onSelect({ kind: 'sectionGroup', type })}
onContextMenu={(e) => handleContextMenu(e, type)}
>
<div className="flex items-center" style={{ gap: 8 }}>
<Icon size={16} style={{ color: getTypeColor(type) }} />
<Icon size={16} style={{ color: sectionColor }} />
<span className="text-[13px] font-medium text-foreground">{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
@@ -160,8 +247,8 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
style={{
padding: '4px 16px 4px 28px',
...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && {
backgroundColor: getTypeLightColor(entry.isA ?? ''),
color: getSectionColor(entry),
backgroundColor: sectionLightColor,
color: sectionColor,
}),
}}
onClick={() => {
@@ -257,6 +344,39 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
</button>
</div>
)}
{/* Context menu (right-click on section header) */}
{contextMenuPos && contextMenuType && (
<div
ref={contextMenuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: contextMenuPos.x, top: contextMenuPos.y, minWidth: 180 }}
>
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left"
onClick={() => openCustomizePopover(contextMenuType)}
>
Customize icon & color
</button>
</div>
)}
{/* Customize popover */}
{customizeTarget && (
<div
ref={popoverRef}
className="fixed z-50"
style={{ left: 20, top: 100 }}
>
<TypeCustomizePopover
currentIcon={typeEntryMap[customizeTarget]?.icon ?? null}
currentColor={typeEntryMap[customizeTarget]?.color ?? null}
onChangeIcon={handleCustomizeIcon}
onChangeColor={handleCustomizeColor}
onClose={() => setCustomizeTarget(null)}
/>
</div>
)}
</aside>
)
})

View File

@@ -0,0 +1,145 @@
import { useState, type ComponentType } from 'react'
import {
FileText, Wrench, Flask, Target, ArrowsClockwise, Users, CalendarBlank,
Tag, StackSimple, BookOpen, CookingPot, Heart, Star, House, Lightbulb,
Briefcase, Gear, Cube, Leaf, MusicNote, Camera, Airplane, GameController,
PaintBrush, ShoppingCart, GraduationCap, Trophy, ChatCircle, Notebook,
MapPin, Code, Barbell, PawPrint, Pill, Knife,
type IconProps,
} from '@phosphor-icons/react'
import { ACCENT_COLORS } from '../utils/typeColors'
import { cn } from '@/lib/utils'
/** Curated Phosphor icons (normal weight) for type customization */
export const ICON_OPTIONS: { name: string; Icon: ComponentType<IconProps> }[] = [
{ name: 'file-text', Icon: FileText },
{ name: 'wrench', Icon: Wrench },
{ name: 'flask', Icon: Flask },
{ name: 'target', Icon: Target },
{ name: 'arrows-clockwise', Icon: ArrowsClockwise },
{ name: 'users', Icon: Users },
{ name: 'calendar-blank', Icon: CalendarBlank },
{ name: 'tag', Icon: Tag },
{ name: 'stack-simple', Icon: StackSimple },
{ name: 'book-open', Icon: BookOpen },
{ name: 'cooking-pot', Icon: CookingPot },
{ name: 'heart', Icon: Heart },
{ name: 'star', Icon: Star },
{ name: 'house', Icon: House },
{ name: 'lightbulb', Icon: Lightbulb },
{ name: 'briefcase', Icon: Briefcase },
{ name: 'gear', Icon: Gear },
{ name: 'cube', Icon: Cube },
{ name: 'leaf', Icon: Leaf },
{ name: 'music-note', Icon: MusicNote },
{ name: 'camera', Icon: Camera },
{ name: 'airplane', Icon: Airplane },
{ name: 'game-controller', Icon: GameController },
{ name: 'paint-brush', Icon: PaintBrush },
{ name: 'shopping-cart', Icon: ShoppingCart },
{ name: 'graduation-cap', Icon: GraduationCap },
{ name: 'trophy', Icon: Trophy },
{ name: 'chat-circle', Icon: ChatCircle },
{ name: 'notebook', Icon: Notebook },
{ name: 'map-pin', Icon: MapPin },
{ name: 'code', Icon: Code },
{ name: 'barbell', Icon: Barbell },
{ name: 'paw-print', Icon: PawPrint },
{ name: 'pill', Icon: Pill },
{ name: 'knife', Icon: Knife },
]
const ICON_MAP: Record<string, ComponentType<IconProps>> = Object.fromEntries(
ICON_OPTIONS.map((o) => [o.name, o.Icon]),
)
/** Resolves a Phosphor icon name to its component, with fallback to FileText */
export function resolveIcon(name: string | null): ComponentType<IconProps> {
return (name && ICON_MAP[name]) || FileText
}
interface TypeCustomizePopoverProps {
currentIcon: string | null
currentColor: string | null
onChangeIcon: (icon: string) => void
onChangeColor: (color: string) => void
onClose: () => void
}
export function TypeCustomizePopover({
currentIcon,
currentColor,
onChangeIcon,
onChangeColor,
onClose,
}: TypeCustomizePopoverProps) {
const [selectedColor, setSelectedColor] = useState(currentColor)
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
const handleColorClick = (key: string) => {
setSelectedColor(key)
onChangeColor(key)
}
const handleIconClick = (name: string) => {
setSelectedIcon(name)
onChangeIcon(name)
}
return (
<div
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
style={{ width: 264, padding: 12 }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
{/* Color section */}
<div className="font-mono-overline mb-2 text-muted-foreground">COLOR</div>
<div className="flex gap-2 mb-3">
{ACCENT_COLORS.map((c) => (
<button
key={c.key}
className={cn(
"flex items-center justify-center rounded-full border-2 cursor-pointer transition-all",
selectedColor === c.key ? "border-foreground scale-110" : "border-transparent hover:scale-105",
)}
style={{ width: 28, height: 28, backgroundColor: c.css, border: selectedColor === c.key ? '2px solid var(--foreground)' : '2px solid transparent' }}
onClick={() => handleColorClick(c.key)}
title={c.label}
/>
))}
</div>
{/* Icon section */}
<div className="font-mono-overline mb-2 text-muted-foreground">ICON</div>
<div className="flex flex-wrap gap-1" style={{ maxHeight: 200, overflowY: 'auto' }}>
{ICON_OPTIONS.map(({ name, Icon }) => (
<button
key={name}
className={cn(
"flex items-center justify-center rounded cursor-pointer transition-colors",
selectedIcon === name
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
style={{ width: 30, height: 30 }}
onClick={() => handleIconClick(name)}
title={name}
>
<Icon size={16} />
</button>
))}
</div>
{/* Done button */}
<div className="mt-3 flex justify-end">
<button
className="rounded px-3 py-1 text-[12px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent cursor-pointer transition-colors border-none bg-transparent"
onClick={onClose}
>
Done
</button>
</div>
</div>
)
}