2026-02-14 19:46:44 +01:00
|
|
|
import { useState } from 'react'
|
2026-02-14 19:44:39 +01:00
|
|
|
import type { VaultEntry, SidebarSelection } from '../types'
|
2026-02-14 18:22:42 +01:00
|
|
|
import './NoteList.css'
|
|
|
|
|
|
|
|
|
|
interface NoteListProps {
|
|
|
|
|
entries: VaultEntry[]
|
2026-02-14 19:44:39 +01:00
|
|
|
selection: SidebarSelection
|
2026-02-14 20:07:23 +01:00
|
|
|
selectedNote: VaultEntry | null
|
|
|
|
|
onSelectNote: (entry: VaultEntry) => void
|
2026-02-14 19:44:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
|
|
|
|
return refs.some((ref) => {
|
|
|
|
|
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
|
|
|
|
|
return inner === stem
|
|
|
|
|
})
|
2026-02-14 18:22:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 19:44:39 +01:00
|
|
|
function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
|
|
|
|
|
switch (selection.kind) {
|
|
|
|
|
case 'filter':
|
|
|
|
|
switch (selection.filter) {
|
|
|
|
|
case 'all':
|
|
|
|
|
return entries
|
|
|
|
|
case 'people':
|
|
|
|
|
return entries.filter((e) => e.isA === 'Person')
|
|
|
|
|
case 'events':
|
|
|
|
|
return entries.filter((e) => e.isA === 'Event')
|
|
|
|
|
case 'favorites':
|
|
|
|
|
// TODO: Implement favorites (needs a "favorite" field in frontmatter)
|
|
|
|
|
return []
|
|
|
|
|
case 'trash':
|
|
|
|
|
// TODO: Implement trash (needs deleted/archived status)
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
case 'sectionGroup':
|
|
|
|
|
return entries.filter((e) => e.isA === selection.type)
|
|
|
|
|
case 'entity': {
|
|
|
|
|
const pinned = selection.entry
|
|
|
|
|
const children = entries.filter(
|
|
|
|
|
(e) => e.path !== pinned.path && refsMatch(e.belongsTo, pinned)
|
|
|
|
|
)
|
|
|
|
|
return [pinned, ...children]
|
|
|
|
|
}
|
|
|
|
|
case 'topic': {
|
|
|
|
|
const topic = selection.entry
|
|
|
|
|
return entries.filter((e) => refsMatch(e.relatedTo, topic))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 19:46:44 +01:00
|
|
|
function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
|
|
|
|
return (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 19:48:59 +01:00
|
|
|
const TYPE_PILLS = [
|
|
|
|
|
{ label: 'All', type: null },
|
|
|
|
|
{ label: 'Projects', type: 'Project' },
|
|
|
|
|
{ label: 'Notes', type: 'Note' },
|
|
|
|
|
{ label: 'Events', type: 'Event' },
|
|
|
|
|
{ label: 'People', type: 'Person' },
|
|
|
|
|
{ label: 'Experiments', type: 'Experiment' },
|
|
|
|
|
{ label: 'Procedures', type: 'Procedure' },
|
|
|
|
|
{ label: 'Responsibilities', type: 'Responsibility' },
|
|
|
|
|
] as const
|
|
|
|
|
|
2026-02-14 20:07:23 +01:00
|
|
|
export function NoteList({ entries, selection, selectedNote, onSelectNote }: NoteListProps) {
|
2026-02-14 19:46:44 +01:00
|
|
|
const [search, setSearch] = useState('')
|
2026-02-14 19:48:59 +01:00
|
|
|
const [typeFilter, setTypeFilter] = useState<string | null>(null)
|
2026-02-14 19:46:44 +01:00
|
|
|
|
2026-02-14 19:44:39 +01:00
|
|
|
const filtered = filterEntries(entries, selection)
|
|
|
|
|
|
2026-02-14 19:46:44 +01:00
|
|
|
// Sort: for entity view, keep pinned first, sort children; otherwise sort all
|
|
|
|
|
let sorted: VaultEntry[]
|
|
|
|
|
if (selection.kind === 'entity' && filtered.length > 0) {
|
|
|
|
|
const [pinned, ...children] = filtered
|
|
|
|
|
sorted = [pinned, ...children.sort(sortByModified)]
|
|
|
|
|
} else {
|
|
|
|
|
sorted = [...filtered].sort(sortByModified)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Search filter (title substring, case-insensitive)
|
|
|
|
|
const query = search.trim().toLowerCase()
|
2026-02-14 19:48:59 +01:00
|
|
|
const searched = query
|
2026-02-14 19:46:44 +01:00
|
|
|
? sorted.filter((e) => e.title.toLowerCase().includes(query))
|
|
|
|
|
: sorted
|
|
|
|
|
|
2026-02-14 19:48:59 +01:00
|
|
|
// Type filter pills
|
|
|
|
|
const displayed = typeFilter
|
|
|
|
|
? searched.filter((e) => e.isA === typeFilter)
|
|
|
|
|
: searched
|
|
|
|
|
|
2026-02-14 18:22:42 +01:00
|
|
|
return (
|
|
|
|
|
<div className="note-list">
|
|
|
|
|
<div className="note-list__header">
|
|
|
|
|
<h3>Notes</h3>
|
2026-02-14 19:46:44 +01:00
|
|
|
<span className="note-list__count">{displayed.length}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="note-list__search">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="note-list__search-input"
|
|
|
|
|
placeholder="Search notes..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
/>
|
2026-02-14 18:22:42 +01:00
|
|
|
</div>
|
2026-02-14 19:48:59 +01:00
|
|
|
<div className="note-list__pills">
|
|
|
|
|
{TYPE_PILLS.map(({ label, type }) => (
|
|
|
|
|
<button
|
|
|
|
|
key={label}
|
|
|
|
|
className={`note-list__pill${typeFilter === type ? ' note-list__pill--active' : ''}`}
|
|
|
|
|
onClick={() => setTypeFilter(type)}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-02-14 18:22:42 +01:00
|
|
|
<div className="note-list__items">
|
2026-02-14 19:46:44 +01:00
|
|
|
{displayed.length === 0 ? (
|
2026-02-14 19:44:39 +01:00
|
|
|
<div className="note-list__empty">No notes found</div>
|
2026-02-14 18:22:42 +01:00
|
|
|
) : (
|
2026-02-14 19:46:44 +01:00
|
|
|
displayed.map((entry, i) => (
|
2026-02-14 19:44:39 +01:00
|
|
|
<div
|
|
|
|
|
key={entry.path}
|
|
|
|
|
className={`note-list__item${
|
|
|
|
|
selection.kind === 'entity' && i === 0 ? ' note-list__item--pinned' : ''
|
2026-02-14 20:07:23 +01:00
|
|
|
}${selectedNote?.path === entry.path ? ' note-list__item--selected' : ''}`}
|
|
|
|
|
onClick={() => onSelectNote(entry)}
|
2026-02-14 19:44:39 +01:00
|
|
|
>
|
2026-02-14 18:22:42 +01:00
|
|
|
<div className="note-list__title">{entry.title}</div>
|
|
|
|
|
<div className="note-list__meta">
|
|
|
|
|
{entry.isA && <span className="note-list__type">{entry.isA}</span>}
|
|
|
|
|
{entry.status && <span className="note-list__status">{entry.status}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|