refactor: decompose NoteList into focused components and extract helpers
Break up NoteListInner (cc=61, 331 LoC, score 6.94) into: - NoteItem: extracted item rendering component (score 9.63) - SortDropdown: extracted into own file (score 9.68) - noteListHelpers.ts: all utility functions (score 10.0) - GroupBuilder class replaces 5-arg addResolvedGroup - filterEntries split into filterByKind/filterByFilterType - PinnedCard: unified entity/type document cards - RelationshipGroupSection: extracted group rendering - NoteList.tsx: orchestration only (score 8.79) NoteListInner reduced from cc=61 to cc=23, 331 → 105 LoC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
89
src/components/NoteItem.tsx
Normal file
89
src/components/NoteItem.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from './TypeCustomizePopover'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
Experiment: Flask,
|
||||
Responsibility: Target,
|
||||
Procedure: ArrowsClockwise,
|
||||
Person: Users,
|
||||
Event: CalendarBlank,
|
||||
Topic: Tag,
|
||||
Type: StackSimple,
|
||||
}
|
||||
|
||||
export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (customIcon) return resolveIcon(customIcon)
|
||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||
}
|
||||
|
||||
function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0
|
||||
const isExpired = trashedAge >= 86400 * 30
|
||||
const style = isExpired ? { color: 'var(--destructive)', fontWeight: 500 } as const : undefined
|
||||
const suffix = isExpired ? ' — will be permanently deleted' : ''
|
||||
return (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground" style={style}>
|
||||
Trashed {relativeDate(entry.trashedAt)}{suffix}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
}) {
|
||||
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
|
||||
className={cn(
|
||||
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
||||
isSelected && "border-l-[3px]",
|
||||
!isSelected && "hover:bg-muted"
|
||||
)}
|
||||
style={{
|
||||
padding: isSelected ? '14px 16px 14px 13px' : '14px 16px',
|
||||
...(isSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
|
||||
}}
|
||||
onClick={() => onSelectNote(entry)}
|
||||
>
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{entry.title}
|
||||
{entry.archived && (
|
||||
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
ARCHIVED
|
||||
</span>
|
||||
)}
|
||||
{entry.trashed && (
|
||||
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
TRASHED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
{entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,24 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
|
||||
// Virtuoso removed — flat list rendering used instead
|
||||
import { useState, useMemo, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
|
||||
ArrowsDownUp, Check, Warning,
|
||||
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
|
||||
} from '@phosphor-icons/react'
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from './TypeCustomizePopover'
|
||||
import { NoteItem, getTypeIcon } from './NoteItem'
|
||||
import { SortDropdown } from './SortDropdown'
|
||||
import {
|
||||
type SortOption, type RelationshipGroup,
|
||||
getSortComparator,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
sortByModified, relativeDate, getDisplayDate,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
} from '../utils/noteListHelpers'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
Experiment: Flask,
|
||||
Responsibility: Target,
|
||||
Procedure: ArrowsClockwise,
|
||||
Person: Users,
|
||||
Event: CalendarBlank,
|
||||
Topic: Tag,
|
||||
Type: StackSimple,
|
||||
}
|
||||
|
||||
function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (customIcon) return resolveIcon(customIcon)
|
||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||
}
|
||||
// Re-export for consumers
|
||||
export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator }
|
||||
export type { SortOption }
|
||||
|
||||
interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -38,312 +30,90 @@ interface NoteListProps {
|
||||
onCreateNote: () => void
|
||||
}
|
||||
|
||||
interface RelationshipGroup {
|
||||
label: string
|
||||
entries: VaultEntry[]
|
||||
}
|
||||
|
||||
function relativeDate(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - ts
|
||||
if (diff < 0) {
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
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' })
|
||||
}
|
||||
|
||||
function getDisplayDate(entry: VaultEntry): number | null {
|
||||
return entry.modifiedAt ?? entry.createdAt
|
||||
}
|
||||
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const fileStem = entry.filename.replace(/\.md$/, '')
|
||||
return refs.some((ref) => {
|
||||
const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
|
||||
const inner = raw.split('|')[0]
|
||||
return inner === stem || inner.split('/').pop() === fileStem
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
||||
return refs
|
||||
.map((ref) => {
|
||||
// Strip [[ ]] and remove alias (|display text) if present
|
||||
const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
|
||||
const inner = raw.split('|')[0]
|
||||
return entries.find((e) => {
|
||||
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
if (stem === inner) return true
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
if (fileStem === inner.split('/').pop()) return true
|
||||
return false
|
||||
})
|
||||
})
|
||||
.filter((e): e is VaultEntry => e !== undefined)
|
||||
}
|
||||
|
||||
export function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
|
||||
export type SortOption = 'modified' | 'created' | 'title' | 'status'
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'modified', label: 'Modified' },
|
||||
{ value: 'created', label: 'Created' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
Active: 0,
|
||||
Paused: 1,
|
||||
Done: 2,
|
||||
Finished: 3,
|
||||
}
|
||||
|
||||
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
|
||||
switch (option) {
|
||||
case 'modified':
|
||||
return sortByModified
|
||||
case 'created':
|
||||
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
|
||||
case 'title':
|
||||
return (a, b) => a.title.localeCompare(b.title)
|
||||
case 'status':
|
||||
return (a, b) => {
|
||||
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
|
||||
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
|
||||
if (sa !== sb) return sa - sb
|
||||
return sortByModified(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
|
||||
|
||||
function loadSortPreferences(): Record<string, SortOption> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SORT_STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveSortPreferences(prefs: Record<string, SortOption>) {
|
||||
try {
|
||||
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
|
||||
const stem = entity.filename.replace(/\.md$/, '')
|
||||
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const targets = [entity.title, ...entity.aliases]
|
||||
|
||||
return allEntries.filter((e) => {
|
||||
if (e.path === entity.path) return false
|
||||
const content = allContent[e.path]
|
||||
if (!content) return false
|
||||
for (const t of targets) {
|
||||
if (content.includes(`[[${t}]]`)) return true
|
||||
}
|
||||
if (content.includes(`[[${stem}]]`)) return true
|
||||
if (content.includes(`[[${pathStem}]]`)) return true
|
||||
if (content.includes(`[[${pathStem}|`)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function addGroup(
|
||||
groups: RelationshipGroup[],
|
||||
label: string,
|
||||
entries: VaultEntry[],
|
||||
seen: Set<string>,
|
||||
) {
|
||||
const unseen = entries.filter((e) => !seen.has(e.path))
|
||||
if (unseen.length > 0) {
|
||||
groups.push({ label, entries: unseen })
|
||||
unseen.forEach((e) => seen.add(e.path))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRelationshipGroups(
|
||||
entity: VaultEntry,
|
||||
allEntries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): RelationshipGroup[] {
|
||||
const groups: RelationshipGroup[] = []
|
||||
const seen = new Set<string>([entity.path])
|
||||
const rels = entity.relationships ?? {}
|
||||
|
||||
// 0. "Instances" — for type documents, show all entries of this type
|
||||
if (entity.isA === 'Type') {
|
||||
const instances = allEntries
|
||||
.filter((e) => e.isA === entity.title && !seen.has(e.path))
|
||||
.sort(sortByModified)
|
||||
addGroup(groups, 'Instances', instances, seen)
|
||||
}
|
||||
|
||||
// 1. "Has" — from the entity's own relationships map
|
||||
const hasRefs = rels['Has'] ?? []
|
||||
if (hasRefs.length > 0) {
|
||||
addGroup(groups, 'Has', resolveRefs(hasRefs, allEntries).sort(sortByModified), seen)
|
||||
}
|
||||
|
||||
// 2. Children — entries whose belongsTo points to this entity (reverse lookup, excluding events)
|
||||
const children = allEntries
|
||||
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
.sort(sortByModified)
|
||||
addGroup(groups, 'Children', children, seen)
|
||||
|
||||
// 3. Events — entities of type Event that reference this entity via belongsTo/relatedTo
|
||||
const events = allEntries
|
||||
.filter(
|
||||
(e) =>
|
||||
!seen.has(e.path) &&
|
||||
e.isA === 'Event' &&
|
||||
(refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity))
|
||||
)
|
||||
.sort(sortByModified)
|
||||
addGroup(groups, 'Events', events, seen)
|
||||
|
||||
// 4. "Topics" — from the entity's own relationships map
|
||||
const topicRefs = rels['Topics'] ?? []
|
||||
if (topicRefs.length > 0) {
|
||||
addGroup(groups, 'Topics', resolveRefs(topicRefs, allEntries).sort(sortByModified), seen)
|
||||
}
|
||||
|
||||
// 5. All other generic relationship fields (alphabetically)
|
||||
const handledKeys = new Set(['Has', 'Topics'])
|
||||
const otherKeys = Object.keys(rels)
|
||||
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
for (const key of otherKeys) {
|
||||
const refs = rels[key]
|
||||
if (refs && refs.length > 0) {
|
||||
addGroup(groups, key, resolveRefs(refs, allEntries).sort(sortByModified), seen)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Referenced By — entries that reference this entity via relatedTo (reverse lookup)
|
||||
const referencedBy = allEntries
|
||||
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
.sort(sortByModified)
|
||||
addGroup(groups, 'Referenced By', referencedBy, seen)
|
||||
|
||||
// 7. Backlinks — always last
|
||||
const backlinks = findBacklinks(entity, allEntries, allContent)
|
||||
.filter((e) => !seen.has(e.path))
|
||||
.sort(sortByModified)
|
||||
addGroup(groups, 'Backlinks', backlinks, seen)
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] {
|
||||
switch (selection.kind) {
|
||||
case 'filter':
|
||||
switch (selection.filter) {
|
||||
case 'all':
|
||||
return entries.filter((e) => !e.archived && !e.trashed)
|
||||
case 'favorites':
|
||||
return []
|
||||
case 'archived':
|
||||
return entries.filter((e) => e.archived && !e.trashed)
|
||||
case 'trash':
|
||||
return entries.filter((e) => e.trashed)
|
||||
}
|
||||
break
|
||||
case 'sectionGroup':
|
||||
return entries.filter((e) => e.isA === selection.type && !e.archived && !e.trashed)
|
||||
case 'entity':
|
||||
return []
|
||||
case 'topic': {
|
||||
const topic = selection.entry
|
||||
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived && !e.trashed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function SortDropdown({
|
||||
groupLabel,
|
||||
current,
|
||||
onChange,
|
||||
}: {
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
onChange: (groupLabel: string, option: SortOption) => void
|
||||
function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
|
||||
entry: VaultEntry
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
showDate?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const color = getTypeColor(entry.isA ?? '', te?.color)
|
||||
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
|
||||
const Icon = getTypeIcon(entry.isA, te?.icon)
|
||||
return (
|
||||
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent",
|
||||
open && "bg-accent text-foreground"
|
||||
)}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
title={`Sort by ${current}`}
|
||||
data-testid={`sort-button-${groupLabel}`}
|
||||
>
|
||||
<ArrowsDownUp size={12} />
|
||||
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
|
||||
style={{ width: 130, padding: 4 }}
|
||||
data-testid={`sort-menu-${groupLabel}`}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent",
|
||||
opt.value === current && "bg-accent font-medium"
|
||||
)}
|
||||
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(groupLabel, opt.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
data-testid={`sort-option-${opt.value}`}
|
||||
>
|
||||
{opt.value === current
|
||||
? <Check size={12} />
|
||||
: <span style={{ width: 12, height: 12, display: 'inline-block' }} />
|
||||
}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
|
||||
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
|
||||
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
|
||||
group: RelationshipGroup
|
||||
isCollapsed: boolean
|
||||
sortPrefs: Record<string, SortOption>
|
||||
onToggle: () => void
|
||||
handleSortChange: (groupLabel: string, option: SortOption) => void
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
}) {
|
||||
const groupSort = sortPrefs[group.label] ?? 'modified'
|
||||
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
|
||||
<button className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0" onClick={onToggle}>
|
||||
<span className="font-mono-label text-muted-foreground">{group.label}</span>
|
||||
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
|
||||
</button>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SortDropdown groupLabel={group.label} current={groupSort} onChange={handleSortChange} />
|
||||
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
|
||||
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed && sortedEntries.map((entry) => renderItem(entry))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrashWarningBanner({ expiredCount }: { expiredCount: number }) {
|
||||
if (expiredCount === 0) return null
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-[var(--border)]" style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}>
|
||||
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>Notes in trash for 30+ days will be permanently deleted</div>
|
||||
<div className="text-muted-foreground" style={{ fontSize: 11 }}>{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyMessage({ text }: { text: string }) {
|
||||
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">{text}</div>
|
||||
}
|
||||
|
||||
function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string {
|
||||
if (selection.kind === 'entity') return selection.entry.title
|
||||
if (typeDocument) return typeDocument.title
|
||||
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
|
||||
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
|
||||
return 'Notes'
|
||||
}
|
||||
|
||||
function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const e of entries) {
|
||||
if (e.isA === 'Type') map[e.title] = e
|
||||
}
|
||||
return map
|
||||
}, [entries])
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
@@ -351,7 +121,6 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
const [sortPrefs, setSortPrefs] = useState<Record<string, SortOption>>(loadSortPreferences)
|
||||
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
|
||||
@@ -371,58 +140,29 @@ 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])
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
|
||||
// Find the type document for this section group (e.g., type/project.md for "Project")
|
||||
const typeDocument = useMemo(() => {
|
||||
if (!isSectionGroup) return null
|
||||
const typeName = (selection as { kind: 'sectionGroup'; type: string }).type
|
||||
return entries.find((e) => e.isA === 'Type' && e.title === typeName) ?? null
|
||||
}, [isSectionGroup, selection, entries])
|
||||
|
||||
const entityGroups = useMemo(
|
||||
() => isEntityView ? buildRelationshipGroups(selection.entry, entries, allContent) : [],
|
||||
[isEntityView, selection, entries, allContent]
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => isEntityView ? [] : filterEntries(entries, selection, modifiedFiles),
|
||||
[entries, selection, modifiedFiles, isEntityView]
|
||||
)
|
||||
|
||||
const listSort = sortPrefs['__list__'] ?? 'modified'
|
||||
|
||||
const sorted = useMemo(
|
||||
() => isEntityView ? [] : [...filtered].sort(getSortComparator(listSort)),
|
||||
[filtered, isEntityView, listSort]
|
||||
)
|
||||
if (selection.kind !== 'sectionGroup') return null
|
||||
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
|
||||
}, [selection, entries])
|
||||
|
||||
const query = search.trim().toLowerCase()
|
||||
const listSort = sortPrefs['__list__'] ?? 'modified'
|
||||
|
||||
const searched = useMemo(
|
||||
() => query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted,
|
||||
[sorted, query]
|
||||
)
|
||||
|
||||
const searchedGroups = useMemo(
|
||||
() => query
|
||||
? entityGroups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)),
|
||||
}))
|
||||
.filter((g) => g.entries.length > 0)
|
||||
: entityGroups,
|
||||
[entityGroups, query]
|
||||
)
|
||||
const searched = useMemo(() => {
|
||||
if (isEntityView) return []
|
||||
const filtered = filterEntries(entries, selection, modifiedFiles)
|
||||
const sorted = [...filtered].sort(getSortComparator(listSort))
|
||||
return query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted
|
||||
}, [entries, selection, modifiedFiles, isEntityView, listSort, query])
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
const groups = buildRelationshipGroups(selection.entry, entries, allContent)
|
||||
if (!query) return groups
|
||||
return groups.map((g) => ({ ...g, entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)) })).filter((g) => g.entries.length > 0)
|
||||
}, [isEntityView, selection, entries, allContent, query])
|
||||
|
||||
const expiredTrashCount = useMemo(() => {
|
||||
if (!isTrashView) return 0
|
||||
@@ -430,267 +170,50 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
return searched.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
|
||||
}, [isTrashView, searched])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
|
||||
const isSelected = selectedNote?.path === entry.path && !isPinned
|
||||
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}
|
||||
className={cn(
|
||||
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
||||
isPinned && "border-l-[3px] border-l-[var(--accent-green)] bg-muted",
|
||||
isSelected && "border-l-[3px]",
|
||||
!isPinned && !isSelected && "hover:bg-muted"
|
||||
)}
|
||||
style={{
|
||||
padding: isPinned || isSelected ? '14px 16px 14px 13px' : '14px 16px',
|
||||
...(isSelected && {
|
||||
borderLeftColor: typeColor,
|
||||
backgroundColor: typeLightColor,
|
||||
}),
|
||||
}}
|
||||
onClick={() => onSelectNote(entry)}
|
||||
>
|
||||
<TypeIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="absolute right-3 top-2.5"
|
||||
style={{ color: typeColor }}
|
||||
data-testid="type-icon"
|
||||
/>
|
||||
<div className="pr-5">
|
||||
<div className={cn(
|
||||
"truncate text-[13px] text-foreground",
|
||||
isSelected ? "font-semibold" : "font-medium"
|
||||
)}>
|
||||
{entry.title}
|
||||
{entry.archived && (
|
||||
<span
|
||||
className="ml-1.5 inline-block align-middle text-muted-foreground"
|
||||
style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
|
||||
>
|
||||
ARCHIVED
|
||||
</span>
|
||||
)}
|
||||
{entry.trashed && (
|
||||
<span
|
||||
className="ml-1.5 inline-block align-middle"
|
||||
style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
|
||||
>
|
||||
TRASHED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground" style={entry.trashed && entry.trashedAt && (Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? { color: 'var(--destructive)', fontWeight: 500 } : undefined}>
|
||||
{entry.trashed && entry.trashedAt
|
||||
? `Trashed ${relativeDate(entry.trashedAt)}${(Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? ' — will be permanently deleted' : ''}`
|
||||
: relativeDate(getDisplayDate(entry))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [selectedNote?.path, onSelectNote, typeEntryMap])
|
||||
|
||||
const renderPinnedView = useCallback((entity: VaultEntry, groups: RelationshipGroup[]) => {
|
||||
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 */}
|
||||
<div
|
||||
className="relative cursor-pointer border-b border-[var(--border)]"
|
||||
style={{ backgroundColor: entityLightColor, padding: '14px 16px' }}
|
||||
onClick={() => onSelectNote(entity)}
|
||||
>
|
||||
<EntityIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="absolute right-3 top-3.5"
|
||||
style={{ color: entityTypeColor }}
|
||||
data-testid="type-icon"
|
||||
/>
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color: entityTypeColor }}>
|
||||
{entity.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: entityTypeColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entity.snippet}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] opacity-60" style={{ color: entityTypeColor }}>
|
||||
{relativeDate(getDisplayDate(entity))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relationship groups */}
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
{query ? 'No matching items' : 'No related items'}
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const isGroupCollapsed = collapsedGroups.has(group.label)
|
||||
const groupSort = sortPrefs[group.label] ?? 'modified'
|
||||
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div
|
||||
className="flex w-full items-center justify-between bg-muted"
|
||||
style={{ height: 32, padding: '0 16px' }}
|
||||
>
|
||||
<button
|
||||
className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0"
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
>
|
||||
<span className="font-mono-label text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
|
||||
</button>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SortDropdown
|
||||
groupLabel={group.label}
|
||||
current={groupSort}
|
||||
onChange={handleSortChange}
|
||||
/>
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground"
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
>
|
||||
{isGroupCollapsed
|
||||
? <CaretRight size={12} />
|
||||
: <CaretDown size={12} />
|
||||
}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{!isGroupCollapsed && sortedEntries.map((groupEntry) => renderItem(groupEntry))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem, typeEntryMap, sortPrefs, handleSortChange])
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
||||
), [selectedNote?.path, onSelectNote, typeEntryMap])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="flex h-[45px] shrink-0 items-center justify-between border-b border-border px-4" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">
|
||||
{isEntityView
|
||||
? selection.entry.title
|
||||
: typeDocument
|
||||
? typeDocument.title
|
||||
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'archived'
|
||||
? 'Archive'
|
||||
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'trash'
|
||||
? 'Trash'
|
||||
: 'Notes'}
|
||||
</h3>
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
{!isEntityView && (
|
||||
<SortDropdown
|
||||
groupLabel="__list__"
|
||||
current={listSort}
|
||||
onChange={handleSortChange}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }}
|
||||
title="Search notes"
|
||||
>
|
||||
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} onChange={handleSortChange} />}
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onCreateNote}
|
||||
title="Create new note"
|
||||
>
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onCreateNote} title="Create new note">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search (toggle on icon click) */}
|
||||
{searchVisible && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input
|
||||
placeholder="Search notes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 text-[13px]"
|
||||
autoFocus
|
||||
/>
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{isEntityView ? (() => {
|
||||
const entity = selection.entry
|
||||
return renderPinnedView(entity, searchedGroups)
|
||||
})() : (
|
||||
{isEntityView ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{/* Type document pinned card (for sectionGroup view) */}
|
||||
{typeDocument && (() => {
|
||||
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)]"
|
||||
style={{ backgroundColor: tdLightColor, padding: '14px 16px' }}
|
||||
onClick={() => onSelectNote(typeDocument)}
|
||||
>
|
||||
<TDIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="absolute right-3 top-3.5"
|
||||
style={{ color: tdColor }}
|
||||
data-testid="type-icon"
|
||||
/>
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color: tdColor }}>
|
||||
{typeDocument.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: tdColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{typeDocument.snippet}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* 30-day warning banner for trash view */}
|
||||
{isTrashView && expiredTrashCount > 0 && (
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-[var(--border)]"
|
||||
style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}
|
||||
>
|
||||
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>
|
||||
Notes in trash for 30+ days will be permanently deleted
|
||||
</div>
|
||||
<div className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{expiredTrashCount} {expiredTrashCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{searched.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
{isTrashView ? 'Trash is empty' : 'No notes found'}
|
||||
</div>
|
||||
) : (
|
||||
searched.map((entry) => renderItem(entry))
|
||||
)}
|
||||
<PinnedCard entry={selection.entry} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} showDate />
|
||||
{searchedGroups.length === 0
|
||||
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
|
||||
: searchedGroups.map((group) => (
|
||||
<RelationshipGroupSection key={group.label} group={group} isCollapsed={collapsedGroups.has(group.label)} sortPrefs={sortPrefs} onToggle={() => toggleGroup(group.label)} handleSortChange={handleSortChange} renderItem={renderItem} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />}
|
||||
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
{searched.length === 0
|
||||
? <EmptyMessage text={isTrashView ? 'Trash is empty' : 'No notes found'} />
|
||||
: searched.map((entry) => renderItem(entry))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
57
src/components/SortDropdown.tsx
Normal file
57
src/components/SortDropdown.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowsDownUp, Check } from '@phosphor-icons/react'
|
||||
import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers'
|
||||
|
||||
export function SortDropdown({ groupLabel, current, onChange }: {
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
onChange: (groupLabel: string, option: SortOption) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (opt: SortOption) => {
|
||||
onChange(groupLabel, opt)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
|
||||
<button
|
||||
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
title={`Sort by ${current}`}
|
||||
data-testid={`sort-button-${groupLabel}`}
|
||||
>
|
||||
<ArrowsDownUp size={12} />
|
||||
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 130, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn("flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", opt.value === current && "bg-accent font-medium")}
|
||||
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value) }}
|
||||
data-testid={`sort-option-${opt.value}`}
|
||||
>
|
||||
{opt.value === current ? <Check size={12} /> : <span style={{ width: 12, height: 12, display: 'inline-block' }} />}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { filterEntries, sortByModified, buildRelationshipGroups } from '../components/NoteList'
|
||||
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
interface Tab {
|
||||
|
||||
201
src/utils/noteListHelpers.ts
Normal file
201
src/utils/noteListHelpers.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
|
||||
|
||||
export interface RelationshipGroup {
|
||||
label: string
|
||||
entries: VaultEntry[]
|
||||
}
|
||||
|
||||
export function relativeDate(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - ts
|
||||
if (diff < 0) {
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
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' })
|
||||
}
|
||||
|
||||
export function getDisplayDate(entry: VaultEntry): number | null {
|
||||
return entry.modifiedAt ?? entry.createdAt
|
||||
}
|
||||
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const fileStem = entry.filename.replace(/\.md$/, '')
|
||||
return refs.some((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
return inner === stem || inner.split('/').pop() === fileStem
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
||||
return refs
|
||||
.map((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
return entries.find((e) => {
|
||||
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
if (stem === inner) return true
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
return fileStem === inner.split('/').pop()
|
||||
})
|
||||
})
|
||||
.filter((e): e is VaultEntry => e !== undefined)
|
||||
}
|
||||
|
||||
export function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
|
||||
export type SortOption = 'modified' | 'created' | 'title' | 'status'
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'modified', label: 'Modified' },
|
||||
{ value: 'created', label: 'Created' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
Active: 0, Paused: 1, Done: 2, Finished: 3,
|
||||
}
|
||||
|
||||
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
|
||||
switch (option) {
|
||||
case 'modified':
|
||||
return sortByModified
|
||||
case 'created':
|
||||
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
|
||||
case 'title':
|
||||
return (a, b) => a.title.localeCompare(b.title)
|
||||
case 'status':
|
||||
return (a, b) => {
|
||||
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
|
||||
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
|
||||
if (sa !== sb) return sa - sb
|
||||
return sortByModified(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
|
||||
|
||||
export function loadSortPreferences(): Record<string, SortOption> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SORT_STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSortPreferences(prefs: Record<string, SortOption>) {
|
||||
try {
|
||||
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
|
||||
const stem = entity.filename.replace(/\.md$/, '')
|
||||
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const targets = [entity.title, ...entity.aliases]
|
||||
|
||||
return allEntries.filter((e) => {
|
||||
if (e.path === entity.path) return false
|
||||
const content = allContent[e.path]
|
||||
if (!content) return false
|
||||
for (const t of targets) {
|
||||
if (content.includes(`[[${t}]]`)) return true
|
||||
}
|
||||
if (content.includes(`[[${stem}]]`)) return true
|
||||
if (content.includes(`[[${pathStem}]]`)) return true
|
||||
return content.includes(`[[${pathStem}|`)
|
||||
})
|
||||
}
|
||||
|
||||
class GroupBuilder {
|
||||
readonly groups: RelationshipGroup[] = []
|
||||
private readonly seen: Set<string>
|
||||
private readonly allEntries: VaultEntry[]
|
||||
|
||||
constructor(entityPath: string, allEntries: VaultEntry[]) {
|
||||
this.seen = new Set([entityPath])
|
||||
this.allEntries = allEntries
|
||||
}
|
||||
|
||||
add(label: string, entries: VaultEntry[]) {
|
||||
const unseen = entries.filter((e) => !this.seen.has(e.path))
|
||||
if (unseen.length > 0) {
|
||||
this.groups.push({ label, entries: unseen })
|
||||
unseen.forEach((e) => this.seen.add(e.path))
|
||||
}
|
||||
}
|
||||
|
||||
addFromRefs(label: string, refs: string[]) {
|
||||
if (refs.length > 0) {
|
||||
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
|
||||
}
|
||||
}
|
||||
|
||||
filterAndAdd(label: string, predicate: (e: VaultEntry) => boolean) {
|
||||
this.add(label, this.allEntries.filter((e) => !this.seen.has(e.path) && predicate(e)).sort(sortByModified))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRelationshipGroups(
|
||||
entity: VaultEntry,
|
||||
allEntries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): RelationshipGroup[] {
|
||||
const b = new GroupBuilder(entity.path, allEntries)
|
||||
const rels = entity.relationships ?? {}
|
||||
|
||||
if (entity.isA === 'Type') {
|
||||
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
|
||||
}
|
||||
|
||||
b.addFromRefs('Has', rels['Has'] ?? [])
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.addFromRefs('Topics', rels['Topics'] ?? [])
|
||||
|
||||
const handledKeys = new Set(['Has', 'Topics'])
|
||||
Object.keys(rels)
|
||||
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
|
||||
|
||||
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
|
||||
|
||||
return b.groups
|
||||
}
|
||||
|
||||
const isActive = (e: VaultEntry) => !e.archived && !e.trashed
|
||||
|
||||
function filterByKind(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
|
||||
if (selection.kind === 'entity') return []
|
||||
if (selection.kind === 'sectionGroup') {
|
||||
return entries.filter((e) => e.isA === selection.type && isActive(e))
|
||||
}
|
||||
if (selection.kind === 'topic') {
|
||||
return entries.filter((e) => refsMatch(e.relatedTo, selection.entry) && isActive(e))
|
||||
}
|
||||
return filterByFilterType(entries, selection.filter)
|
||||
}
|
||||
|
||||
function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] {
|
||||
if (filter === 'all') return entries.filter(isActive)
|
||||
if (filter === 'archived') return entries.filter((e) => e.archived && !e.trashed)
|
||||
if (filter === 'trash') return entries.filter((e) => e.trashed)
|
||||
return []
|
||||
}
|
||||
|
||||
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] {
|
||||
return filterByKind(entries, selection)
|
||||
}
|
||||
Reference in New Issue
Block a user