Add visual polish: snippets, dates, colored badges, improved states (M5 Task 4)
Note list items now show preview snippets and relative dates. Type badges have distinct colors (blue=Project, purple=Responsibility, etc.). Selected state uses a blue left border. Active pill uses blue highlight. Hover effects improved across sidebar and note list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -263,7 +263,7 @@ function App() {
|
||||
</div>
|
||||
<ResizeHandle onResize={handleSidebarResize} />
|
||||
<div className="app__note-list" style={{ width: noteListWidth }}>
|
||||
<NoteList entries={entries} selection={selection} selectedNote={activeTab?.entry ?? null} onSelectNote={handleSelectNote} onCreateNote={() => setShowCreateDialog(true)} />
|
||||
<NoteList entries={entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={allContent} onSelectNote={handleSelectNote} onCreateNote={() => setShowCreateDialog(true)} />
|
||||
</div>
|
||||
<ResizeHandle onResize={handleNoteListResize} />
|
||||
<div className="app__editor">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
.note-list__header {
|
||||
padding: 16px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -28,8 +28,8 @@
|
||||
}
|
||||
|
||||
.note-list__count {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
background: #2a2a4a;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
.note-list__search-input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
padding: 7px 10px;
|
||||
background: #1e1e3a;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 6px;
|
||||
@@ -73,14 +73,15 @@
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.note-list__search-input::placeholder {
|
||||
color: #666;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.note-list__search-input:focus {
|
||||
border-color: #4a4a7a;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* Type filter pills */
|
||||
@@ -98,72 +99,157 @@
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.note-list__pill:hover {
|
||||
background: #2a2a4a;
|
||||
color: #e0e0e0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.note-list__pill--active {
|
||||
background: #3a3a5a;
|
||||
background: #4a9eff;
|
||||
color: #ffffff;
|
||||
border-color: #4a4a7a;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.note-list__items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-list__empty {
|
||||
padding: 24px 16px;
|
||||
color: #666;
|
||||
padding: 32px 16px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Note items */
|
||||
.note-list__item {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #1e1e3a;
|
||||
border-bottom: 1px solid rgba(42, 42, 74, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.note-list__item:hover {
|
||||
background: #1e1e3a;
|
||||
}
|
||||
|
||||
.note-list__item-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.note-list__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.note-list__date {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-list__snippet {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.note-list__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Type badges with distinct colors */
|
||||
.note-list__type {
|
||||
background: #2a2a4a;
|
||||
padding: 1px 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.note-list__type--project {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.note-list__type--responsibility {
|
||||
background: rgba(156, 114, 255, 0.15);
|
||||
color: #9c72ff;
|
||||
}
|
||||
|
||||
.note-list__type--procedure {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.note-list__type--experiment {
|
||||
background: rgba(0, 200, 150, 0.15);
|
||||
color: #00c896;
|
||||
}
|
||||
|
||||
.note-list__type--note {
|
||||
background: rgba(200, 200, 200, 0.1);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.note-list__type--person {
|
||||
background: rgba(255, 100, 130, 0.15);
|
||||
color: #ff6482;
|
||||
}
|
||||
|
||||
.note-list__type--event {
|
||||
background: rgba(255, 200, 50, 0.15);
|
||||
color: #e6b800;
|
||||
}
|
||||
|
||||
.note-list__type--topic {
|
||||
background: rgba(100, 220, 200, 0.15);
|
||||
color: #64dcc8;
|
||||
}
|
||||
|
||||
.note-list__status {
|
||||
color: #6a6;
|
||||
color: #4caf50;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Selected & pinned states */
|
||||
.note-list__item--selected {
|
||||
background: #2a2a4a;
|
||||
background: #24244a;
|
||||
border-left: 3px solid #4a9eff;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.note-list__item--selected:hover {
|
||||
background: #28284e;
|
||||
}
|
||||
|
||||
.note-list__item--pinned {
|
||||
background: #1e1e3a;
|
||||
border-left: 3px solid #6a6;
|
||||
border-left: 3px solid #4caf50;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
@@ -81,13 +81,13 @@ const mockEntries: VaultEntry[] = [
|
||||
|
||||
describe('NoteList', () => {
|
||||
it('shows empty state when no entries', () => {
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all entries with All Notes filter', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
@@ -95,20 +95,20 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('filters by People', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'people' }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'people' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by Events', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'events' }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'events' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by section group type', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
@@ -116,7 +116,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('shows entity pinned at top with children', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Pinned entity + child (Facebook Ads Strategy belongsTo this project)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -127,7 +127,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('filters by topic (relatedTo references)', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Build Laputa App has relatedTo: [[topic/software-development]]
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -135,12 +135,12 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('renders a search bar', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by search query (case-insensitive substring)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
const input = screen.getByPlaceholderText('Search notes...')
|
||||
fireEvent.change(input, { target: { value: 'facebook' } })
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -154,14 +154,14 @@ describe('NoteList', () => {
|
||||
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
||||
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
||||
]
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
|
||||
const titleTexts = titles.map((el) => el.textContent)
|
||||
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
||||
})
|
||||
|
||||
it('renders type filter pills', () => {
|
||||
const { container } = render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
const { container } = render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
const pills = container.querySelectorAll('.note-list__pill')
|
||||
const labels = Array.from(pills).map((p) => p.textContent)
|
||||
expect(labels).toContain('All')
|
||||
@@ -172,7 +172,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('filters by type pill', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
@@ -180,7 +180,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('clicking All pill resets type filter', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('People'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('All'))
|
||||
|
||||
@@ -6,10 +6,39 @@ interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
selectedNote: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
}
|
||||
|
||||
/** Extract first ~80 chars of content after the title heading */
|
||||
function getSnippet(content: string | undefined): string {
|
||||
if (!content) return ''
|
||||
// Remove frontmatter
|
||||
const withoutFm = content.replace(/^---[\s\S]*?---\s*/, '')
|
||||
// Remove the first heading
|
||||
const withoutH1 = withoutFm.replace(/^#\s+.*\n+/, '')
|
||||
// Clean markdown syntax and collapse whitespace
|
||||
const clean = withoutH1
|
||||
.replace(/[#*_`\[\]]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
return clean.slice(0, 80) + (clean.length > 80 ? '...' : '')
|
||||
}
|
||||
|
||||
/** Format a relative date string */
|
||||
function relativeDate(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - ts
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
// Extract the path stem: /Users/luca/Laputa/project/26q1-laputa-app.md → project/26q1-laputa-app
|
||||
@@ -69,7 +98,7 @@ const TYPE_PILLS = [
|
||||
{ label: 'Responsibilities', type: 'Responsibility' },
|
||||
] as const
|
||||
|
||||
export function NoteList({ entries, selection, selectedNote, onSelectNote, onCreateNote }: NoteListProps) {
|
||||
export function NoteList({ entries, selection, selectedNote, allContent, onSelectNote, onCreateNote }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null)
|
||||
|
||||
@@ -138,9 +167,13 @@ export function NoteList({ entries, selection, selectedNote, onSelectNote, onCre
|
||||
}${selectedNote?.path === entry.path ? ' note-list__item--selected' : ''}`}
|
||||
onClick={() => onSelectNote(entry)}
|
||||
>
|
||||
<div className="note-list__title">{entry.title}</div>
|
||||
<div className="note-list__item-top">
|
||||
<div className="note-list__title">{entry.title}</div>
|
||||
<span className="note-list__date">{relativeDate(entry.modifiedAt)}</span>
|
||||
</div>
|
||||
<div className="note-list__snippet">{getSnippet(allContent[entry.path])}</div>
|
||||
<div className="note-list__meta">
|
||||
{entry.isA && <span className="note-list__type">{entry.isA}</span>}
|
||||
{entry.isA && <span className={`note-list__type note-list__type--${entry.isA.toLowerCase()}`}>{entry.isA}</span>}
|
||||
{entry.status && <span className="note-list__status">{entry.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
padding: 16px;
|
||||
padding: 20px 16px 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
@@ -37,17 +38,21 @@
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
margin: 1px 4px;
|
||||
border-radius: 6px;
|
||||
margin: 1px 6px;
|
||||
transition: all 0.12s;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.sidebar__filter-item:hover {
|
||||
background: #2a2a4a;
|
||||
background: #252545;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.sidebar__filter-item--active {
|
||||
background: #2a2a4a;
|
||||
color: #ffffff;
|
||||
background: #4a9eff18;
|
||||
color: #4a9eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Section Groups */
|
||||
@@ -132,20 +137,24 @@
|
||||
padding: 5px 16px 5px 28px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
margin: 1px 4px;
|
||||
border-radius: 6px;
|
||||
margin: 1px 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #bbb;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.sidebar__item:hover {
|
||||
background: #2a2a4a;
|
||||
background: #252545;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.sidebar__item--active {
|
||||
background: #2a2a4a;
|
||||
color: #ffffff;
|
||||
background: #4a9eff18;
|
||||
color: #4a9eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Topics */
|
||||
|
||||
Reference in New Issue
Block a user