feat: pinned properties — inline bar in editor + values in note list

- PinnedPropertiesBar: horizontal bar below title with icon + label +
  editable value chips, overflow popover for hidden properties
- PinnedPropertyChip: inline-editable chip with status/relationship colors
- NoteListPinnedValues: compact value-only chips under note titles
- Pin/unpin context menu (right-click) in Properties panel with highlight
- Real-time sync: _pinned_properties changes propagate via frontmatterToEntryPatch
- Default pinned properties (status, belongs_to, related_to) for types without config
- Per-type config stored in type definition frontmatter as _pinned_properties

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-25 10:18:11 +01:00
parent caaa582ddd
commit d263e43025
11 changed files with 594 additions and 8 deletions

View File

@@ -1,3 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -8,6 +9,7 @@ import { TypeSelector } from './TypeSelector'
import { AddPropertyForm } from './AddPropertyForm'
import { countWords } from '../utils/wikilinks'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
import { PushPin } from '@phosphor-icons/react'
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
@@ -30,15 +32,46 @@ function formatFileSize(bytes: number): string {
return `${mb.toFixed(1)} MB`
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
function PropertyPinMenu({ x, y, isPinned, onPin, onUnpin, onClose }: {
x: number; y: number; isPinned: boolean
onPin: () => void; onUnpin: () => void; onClose: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [onClose])
return (
<div ref={ref} className="fixed z-50 flex flex-col rounded-lg border border-border bg-popover shadow-lg" style={{ left: x, top: y, minWidth: 160, padding: 4 }} data-testid="property-context-menu">
<button
className="flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-accent"
style={{ border: 'none', background: 'transparent', cursor: 'pointer' }}
onClick={() => { if (isPinned) onUnpin(); else onPin(); onClose() }}
>
<PushPin size={14} />
{isPinned ? 'Unpin from editor' : 'Pin to editor'}
</button>
</div>
)
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, isPinned, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange, onPin, onUnpin }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]; vaultTags: string[]
isPinned: boolean
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
onPin?: (key: string) => void; onUnpin?: (key: string) => void
}) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && editingKey !== propKey) {
e.preventDefault()
@@ -46,9 +79,21 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
}
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!onPin && !onUnpin) return
e.preventDefault()
setCtxMenu({ x: e.clientX, y: e.clientY })
}, [onPin, onUnpin])
return (
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<div
className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
style={isPinned ? { backgroundColor: 'color-mix(in srgb, var(--primary) 5%, transparent)' } : undefined}
tabIndex={0} onKeyDown={handleKeyDown} onContextMenu={handleContextMenu}
data-testid="editable-property"
>
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
{isPinned && <PushPin size={10} className="shrink-0 text-primary" style={{ opacity: 0.6 }} />}
<span className="truncate">{propKey}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
@@ -58,6 +103,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
<div className="min-w-0">
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
{ctxMenu && (
<PropertyPinMenu
x={ctxMenu.x} y={ctxMenu.y} isPinned={isPinned}
onPin={() => onPin?.(propKey)} onUnpin={() => onUnpin?.(propKey)}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
)
}
@@ -98,6 +150,7 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
isPinned, onPin, onUnpin,
}: {
entry: VaultEntry
content: string | null
@@ -107,6 +160,9 @@ export function DynamicPropertiesPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
isPinned?: (key: string) => boolean
onPin?: (key: string) => void
onUnpin?: (key: string) => void
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
@@ -126,10 +182,12 @@ export function DynamicPropertiesPanel({
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
isPinned={isPinned?.(key) ?? false}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
onPin={onPin} onUnpin={onUnpin}
/>
))}
</div>