feat: color and icon customization for sidebar type sections
This commit is contained in:
5381
design/color-icon-for-types.pen
Normal file
5381
design/color-icon-for-types.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,10 @@ pub struct VaultEntry {
|
||||
/// Generic relationship fields: any frontmatter key whose value contains wikilinks.
|
||||
/// Key is the original frontmatter field name (e.g. "Has", "Topics", "Events").
|
||||
pub relationships: HashMap<String, Vec<String>>,
|
||||
/// Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot".
|
||||
pub icon: Option<String>,
|
||||
/// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange".
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
@@ -57,6 +61,10 @@ struct Frontmatter {
|
||||
created_at: Option<String>,
|
||||
#[serde(rename = "Created time")]
|
||||
created_time: Option<String>,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -203,6 +211,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
/// Only skip keys that can never contain wikilinks.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"is a", "aliases", "status", "cadence", "created at", "created time",
|
||||
"icon", "color",
|
||||
];
|
||||
|
||||
/// Check if a string contains a wikilink pattern `[[...]]`.
|
||||
@@ -352,6 +361,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
owner: frontmatter.owner,
|
||||
cadence: frontmatter.cadence,
|
||||
modified_at, created_at, file_size,
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -102,6 +102,16 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes, setToastMessage])
|
||||
|
||||
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
|
||||
const typeEntry = vault.entries.find((e) => e.isA === 'Type' && e.title === typeName)
|
||||
if (!typeEntry) return
|
||||
// Update icon and color in frontmatter (two separate calls)
|
||||
notes.handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
notes.handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
// Also update the entry in-memory for instant UI feedback
|
||||
vault.updateEntry(typeEntry.path, { icon, color })
|
||||
}, [vault, notes])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: () => setShowQuickOpen(true),
|
||||
onCreateNote: openCreateDialog,
|
||||
@@ -151,7 +161,7 @@ function App() {
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
<div className="app__sidebar" style={{ width: sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} onCreateNewType={openCreateTypeDialog} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} onCreateNewType={openCreateTypeDialog} onCustomizeType={handleCustomizeType} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
|
||||
</div>
|
||||
<ResizeHandle onResize={handleSidebarResize} />
|
||||
<div className="app__note-list" style={{ width: noteListWidth }}>
|
||||
|
||||
@@ -49,6 +49,8 @@ const mockEntry: VaultEntry = {
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
}
|
||||
|
||||
const mockContent = `---
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
145
src/components/TypeCustomizePopover.tsx
Normal file
145
src/components/TypeCustomizePopover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ export function useNoteActions(
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: noStatusTypes.has(type) ? null : 'Active',
|
||||
owner: null, cadence: null, modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', relationships: {},
|
||||
snippet: '', relationships: {}, icon: null, color: null,
|
||||
}
|
||||
|
||||
const frontmatter = [
|
||||
@@ -242,7 +242,7 @@ export function useNoteActions(
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', relationships: {},
|
||||
snippet: '', relationships: {}, icon: null, color: null,
|
||||
}
|
||||
|
||||
const content = `---\nIs A: Type\n---\n\n# ${typeName}\n\n`
|
||||
|
||||
@@ -67,6 +67,10 @@ export function useVaultLoader(vaultPath: string) {
|
||||
setAllContent((prev) => ({ ...prev, [path]: content }))
|
||||
}, [])
|
||||
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e))
|
||||
}, [])
|
||||
|
||||
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
|
||||
try {
|
||||
if (isTauri()) {
|
||||
@@ -113,6 +117,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
allContent,
|
||||
modifiedFiles,
|
||||
addEntry,
|
||||
updateEntry,
|
||||
updateContent,
|
||||
loadModifiedFiles,
|
||||
loadGitHistory,
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
--accent-blue-hover: #0D4AD6;
|
||||
--accent-green: #38A169;
|
||||
--accent-orange: #D9730D;
|
||||
--accent-orange-light: rgba(217, 115, 13, 0.1);
|
||||
--accent-red: #E53E3E;
|
||||
--accent-purple: #805AD5;
|
||||
--accent-yellow: #D69E2E;
|
||||
|
||||
@@ -552,6 +552,8 @@ A **general-purpose document** — research notes, meeting notes, strategy docs.
|
||||
// --- Custom type documents (user-created types) ---
|
||||
'/Users/luca/Laputa/type/recipe.md': `---
|
||||
Is A: Type
|
||||
icon: cooking-pot
|
||||
color: orange
|
||||
---
|
||||
|
||||
# Recipe
|
||||
@@ -565,6 +567,8 @@ A **recipe** for cooking or baking. Recipes have ingredients, steps, and serving
|
||||
`,
|
||||
'/Users/luca/Laputa/type/book.md': `---
|
||||
Is A: Type
|
||||
icon: book-open
|
||||
color: green
|
||||
---
|
||||
|
||||
# Book
|
||||
@@ -631,6 +635,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
'Type': ['[[type/project]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility/grow-newsletter.md',
|
||||
@@ -657,6 +663,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Related to': ['[[topic/growth]]'],
|
||||
'Type': ['[[type/responsibility]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md',
|
||||
@@ -677,6 +685,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Owner': ['[[person/matteo-cellini|Matteo Cellini]]'],
|
||||
'Type': ['[[type/responsibility]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure/write-weekly-essays.md',
|
||||
@@ -697,6 +707,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/procedure]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure/run-sponsorships.md',
|
||||
@@ -717,6 +729,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Belongs to': ['[[responsibility/manage-sponsorships]]'],
|
||||
'Type': ['[[type/procedure]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/experiment/stock-screener.md',
|
||||
@@ -738,6 +752,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Has Data': ['[[data/ema200-backtest-results]]'],
|
||||
'Type': ['[[type/experiment]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
||||
@@ -759,6 +775,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Related to': ['[[topic/growth]]', '[[topic/ads]]'],
|
||||
'Type': ['[[type/note]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/budget-allocation.md',
|
||||
@@ -779,6 +797,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Type': ['[[type/note]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
||||
@@ -798,6 +818,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md',
|
||||
@@ -818,6 +840,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Related to': ['[[project/26q1-laputa-app]]', '[[person/matteo-cellini]]'],
|
||||
'Type': ['[[type/event]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/software-development.md',
|
||||
@@ -838,6 +862,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Notes': ['[[note/facebook-ads-strategy]]', '[[note/budget-allocation]]'],
|
||||
'Type': ['[[type/topic]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/trading.md',
|
||||
@@ -858,6 +884,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Notes': ['[[experiment/stock-screener]]'],
|
||||
'Type': ['[[type/topic]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/on-writing-well.md',
|
||||
@@ -878,6 +906,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/essay]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/engineering-leadership-101.md',
|
||||
@@ -899,6 +929,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
'Type': ['[[type/essay]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
|
||||
@@ -919,6 +951,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/essay]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
// --- Type documents ---
|
||||
{
|
||||
@@ -937,6 +971,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 320,
|
||||
snippet: 'A time-bound initiative that advances a Responsibility. Projects have a clear start, end, and deliverables.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/responsibility.md',
|
||||
@@ -954,6 +990,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 280,
|
||||
snippet: 'An ongoing area of ownership — something you\'re accountable for indefinitely.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/procedure.md',
|
||||
@@ -971,6 +1009,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 310,
|
||||
snippet: 'A recurring process tied to a Responsibility. Procedures have a cadence and describe how to do something.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/experiment.md',
|
||||
@@ -988,6 +1028,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 290,
|
||||
snippet: 'A hypothesis-driven investigation with a clear test and measurable outcome.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/person.md',
|
||||
@@ -1005,6 +1047,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 200,
|
||||
snippet: 'A person you interact with — team members, collaborators, contacts.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/event.md',
|
||||
@@ -1022,6 +1066,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 180,
|
||||
snippet: 'A point-in-time occurrence — meetings, launches, milestones.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/topic.md',
|
||||
@@ -1039,6 +1085,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 170,
|
||||
snippet: 'A subject area for categorization. Topics group related notes, projects, and resources by theme.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/essay.md',
|
||||
@@ -1056,6 +1104,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 200,
|
||||
snippet: 'A published piece of writing — newsletter essays, blog posts, articles.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/note.md',
|
||||
@@ -1073,6 +1123,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 190,
|
||||
snippet: 'A general-purpose document — research notes, meeting notes, strategy docs.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
// --- Custom type documents (user-created types) ---
|
||||
{
|
||||
@@ -1091,6 +1143,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 250,
|
||||
snippet: 'A recipe for cooking or baking. Recipes have ingredients, steps, and serving info.',
|
||||
relationships: {},
|
||||
icon: 'cooking-pot',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/book.md',
|
||||
@@ -1108,6 +1162,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
fileSize: 220,
|
||||
snippet: 'A book you\'re reading or have read. Track reading progress, notes, and key takeaways.',
|
||||
relationships: {},
|
||||
icon: 'book-open',
|
||||
color: 'green',
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
{
|
||||
@@ -1128,6 +1184,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
relationships: {
|
||||
'Type': ['[[type/recipe]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/book/designing-data-intensive-applications.md',
|
||||
@@ -1147,6 +1205,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
relationships: {
|
||||
'Type': ['[[type/book]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface VaultEntry {
|
||||
snippet: string
|
||||
/** Generic relationship fields: any frontmatter key whose value contains wikilinks. */
|
||||
relationships: Record<string, string[]>
|
||||
/** Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot" */
|
||||
icon: string | null
|
||||
/** Accent color key for Type entries: "red" | "purple" | "blue" | "green" | "yellow" | "orange" */
|
||||
color: string | null
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
|
||||
@@ -28,12 +28,31 @@ const TYPE_LIGHT_COLOR_MAP: Record<string, string> = {
|
||||
const DEFAULT_COLOR = 'var(--accent-blue)'
|
||||
const DEFAULT_LIGHT_COLOR = 'var(--accent-blue-light)'
|
||||
|
||||
/** Returns the CSS variable for the accent color of a given note type */
|
||||
export function getTypeColor(isA: string | null): string {
|
||||
/** Color key → CSS variable mapping for the design system accent palette */
|
||||
export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight: string }[] = [
|
||||
{ key: 'red', label: 'Red', css: 'var(--accent-red)', cssLight: 'var(--accent-red-light)' },
|
||||
{ key: 'orange', label: 'Orange', css: 'var(--accent-orange)', cssLight: 'var(--accent-orange-light)' },
|
||||
{ key: 'yellow', label: 'Yellow', css: 'var(--accent-yellow)', cssLight: 'var(--accent-yellow-light)' },
|
||||
{ key: 'green', label: 'Green', css: 'var(--accent-green)', cssLight: 'var(--accent-green-light)' },
|
||||
{ key: 'blue', label: 'Blue', css: 'var(--accent-blue)', cssLight: 'var(--accent-blue-light)' },
|
||||
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
|
||||
]
|
||||
|
||||
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(
|
||||
ACCENT_COLORS.map((c) => [c.key, c.css]),
|
||||
)
|
||||
const COLOR_KEY_TO_CSS_LIGHT: Record<string, string> = Object.fromEntries(
|
||||
ACCENT_COLORS.map((c) => [c.key, c.cssLight]),
|
||||
)
|
||||
|
||||
/** Returns the CSS variable for the accent color of a given note type, with optional custom override */
|
||||
export function getTypeColor(isA: string | null, customColorKey?: string | null): string {
|
||||
if (customColorKey && COLOR_KEY_TO_CSS[customColorKey]) return COLOR_KEY_TO_CSS[customColorKey]
|
||||
return (isA && TYPE_COLOR_MAP[isA]) ?? DEFAULT_COLOR
|
||||
}
|
||||
|
||||
/** Returns the CSS variable for the light/background variant of a given note type's color */
|
||||
export function getTypeLightColor(isA: string | null): string {
|
||||
export function getTypeLightColor(isA: string | null, customColorKey?: string | null): string {
|
||||
if (customColorKey && COLOR_KEY_TO_CSS_LIGHT[customColorKey]) return COLOR_KEY_TO_CSS_LIGHT[customColorKey]
|
||||
return (isA && TYPE_LIGHT_COLOR_MAP[isA]) ?? DEFAULT_LIGHT_COLOR
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user