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:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import type { VaultEntry } from '../types'
|
import type { VaultEntry } from '../types'
|
||||||
import type { FrontmatterValue } from './Inspector'
|
import type { FrontmatterValue } from './Inspector'
|
||||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||||
@@ -8,6 +9,7 @@ import { TypeSelector } from './TypeSelector'
|
|||||||
import { AddPropertyForm } from './AddPropertyForm'
|
import { AddPropertyForm } from './AddPropertyForm'
|
||||||
import { countWords } from '../utils/wikilinks'
|
import { countWords } from '../utils/wikilinks'
|
||||||
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
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
|
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||||
@@ -30,15 +32,46 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${mb.toFixed(1)} MB`
|
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
|
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||||
vaultStatuses: string[]; vaultTags: string[]
|
vaultStatuses: string[]; vaultTags: string[]
|
||||||
|
isPinned: boolean
|
||||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||||
onSaveList: (key: string, items: string[]) => void
|
onSaveList: (key: string, items: string[]) => void
|
||||||
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
||||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => 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) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && editingKey !== propKey) {
|
if (e.key === 'Enter' && editingKey !== propKey) {
|
||||||
e.preventDefault()
|
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 (
|
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">
|
<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>
|
<span className="truncate">{propKey}</span>
|
||||||
{onDelete && (
|
{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">×</button>
|
<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">×</button>
|
||||||
@@ -58,6 +103,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
|||||||
<div className="min-w-0">
|
<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} />
|
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||||
</div>
|
</div>
|
||||||
|
{ctxMenu && (
|
||||||
|
<PropertyPinMenu
|
||||||
|
x={ctxMenu.x} y={ctxMenu.y} isPinned={isPinned}
|
||||||
|
onPin={() => onPin?.(propKey)} onUnpin={() => onUnpin?.(propKey)}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,6 +150,7 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
|
|||||||
export function DynamicPropertiesPanel({
|
export function DynamicPropertiesPanel({
|
||||||
entry, content, frontmatter, entries,
|
entry, content, frontmatter, entries,
|
||||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||||
|
isPinned, onPin, onUnpin,
|
||||||
}: {
|
}: {
|
||||||
entry: VaultEntry
|
entry: VaultEntry
|
||||||
content: string | null
|
content: string | null
|
||||||
@@ -107,6 +160,9 @@ export function DynamicPropertiesPanel({
|
|||||||
onDeleteProperty?: (key: string) => void
|
onDeleteProperty?: (key: string) => void
|
||||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||||
onNavigate?: (target: string) => void
|
onNavigate?: (target: string) => void
|
||||||
|
isPinned?: (key: string) => boolean
|
||||||
|
onPin?: (key: string) => void
|
||||||
|
onUnpin?: (key: string) => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||||
@@ -126,10 +182,12 @@ export function DynamicPropertiesPanel({
|
|||||||
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
||||||
vaultStatuses={vaultStatuses}
|
vaultStatuses={vaultStatuses}
|
||||||
vaultTags={vaultTagsByKey[key] ?? []}
|
vaultTags={vaultTagsByKey[key] ?? []}
|
||||||
|
isPinned={isPinned?.(key) ?? false}
|
||||||
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
||||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||||
onDelete={onDeleteProperty}
|
onDelete={onDeleteProperty}
|
||||||
onDisplayModeChange={handleDisplayModeChange}
|
onDisplayModeChange={handleDisplayModeChange}
|
||||||
|
onPin={onPin} onUnpin={onUnpin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
|||||||
isConflicted={isConflicted}
|
isConflicted={isConflicted}
|
||||||
onKeepMine={onKeepMine}
|
onKeepMine={onKeepMine}
|
||||||
onKeepTheirs={onKeepTheirs}
|
onKeepTheirs={onKeepTheirs}
|
||||||
|
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import type { VaultEntry, NoteStatus } from '../types'
|
import type { VaultEntry, NoteStatus } from '../types'
|
||||||
import type { useCreateBlockNote } from '@blocknote/react'
|
import type { useCreateBlockNote } from '@blocknote/react'
|
||||||
|
import type { FrontmatterValue } from './Inspector'
|
||||||
import { DiffView } from './DiffView'
|
import { DiffView } from './DiffView'
|
||||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||||
import { TitleField } from './TitleField'
|
import { TitleField } from './TitleField'
|
||||||
@@ -13,6 +14,8 @@ import { RawEditorView } from './RawEditorView'
|
|||||||
import { countWords } from '../utils/wikilinks'
|
import { countWords } from '../utils/wikilinks'
|
||||||
import { SingleEditorView } from './SingleEditorView'
|
import { SingleEditorView } from './SingleEditorView'
|
||||||
import { isEmoji } from '../utils/emoji'
|
import { isEmoji } from '../utils/emoji'
|
||||||
|
import { parseFrontmatter } from '../utils/frontmatter'
|
||||||
|
import { PinnedPropertiesBar } from './PinnedPropertiesBar'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
entry: VaultEntry
|
entry: VaultEntry
|
||||||
@@ -60,6 +63,7 @@ interface EditorContentProps {
|
|||||||
onKeepMine?: (path: string) => void
|
onKeepMine?: (path: string) => void
|
||||||
/** Resolve conflict by keeping the remote version. */
|
/** Resolve conflict by keeping the remote version. */
|
||||||
onKeepTheirs?: (path: string) => void
|
onKeepTheirs?: (path: string) => void
|
||||||
|
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorLoadingSkeleton() {
|
function EditorLoadingSkeleton() {
|
||||||
@@ -156,6 +160,7 @@ export function EditorContent({
|
|||||||
onDeleteNote, rawLatestContentRef, onTitleChange,
|
onDeleteNote, rawLatestContentRef, onTitleChange,
|
||||||
onSetNoteIcon, onRemoveNoteIcon,
|
onSetNoteIcon, onRemoveNoteIcon,
|
||||||
isConflicted, onKeepMine, onKeepTheirs,
|
isConflicted, onKeepMine, onKeepTheirs,
|
||||||
|
onUpdateFrontmatter,
|
||||||
...breadcrumbProps
|
...breadcrumbProps
|
||||||
}: EditorContentProps) {
|
}: EditorContentProps) {
|
||||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||||
@@ -175,6 +180,12 @@ export function EditorContent({
|
|||||||
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
|
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
|
||||||
}, [activeTab, onRemoveNoteIcon])
|
}, [activeTab, onRemoveNoteIcon])
|
||||||
|
|
||||||
|
const frontmatter = useMemo(
|
||||||
|
() => parseFrontmatter(activeTab?.content ?? null),
|
||||||
|
[activeTab?.content],
|
||||||
|
)
|
||||||
|
const currentEntry = freshEntry ?? activeTab?.entry ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||||
{activeTab && (
|
{activeTab && (
|
||||||
@@ -215,6 +226,15 @@ export function EditorContent({
|
|||||||
editable={!isTrashed}
|
editable={!isTrashed}
|
||||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||||
/>
|
/>
|
||||||
|
{currentEntry && (
|
||||||
|
<PinnedPropertiesBar
|
||||||
|
entry={currentEntry}
|
||||||
|
entries={entries}
|
||||||
|
frontmatter={frontmatter}
|
||||||
|
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||||
|
onNavigate={onNavigateWikilink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="title-section__separator" />
|
<div className="title-section__separator" />
|
||||||
</div>
|
</div>
|
||||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
|
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
|||||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||||
import { wikilinkTarget } from '../utils/wikilink'
|
import { wikilinkTarget } from '../utils/wikilink'
|
||||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||||
|
import { usePinnedProperties } from '../hooks/usePinnedProperties'
|
||||||
|
|
||||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||||
|
|
||||||
@@ -138,6 +139,11 @@ export function Inspector({
|
|||||||
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
|
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
|
||||||
}, [entry, onAddProperty])
|
}, [entry, onAddProperty])
|
||||||
|
|
||||||
|
const { isPinned, pinProperty, unpinProperty } = usePinnedProperties({
|
||||||
|
entry, entries, frontmatter,
|
||||||
|
onUpdateTypeFrontmatter: onUpdateFrontmatter,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn("flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200", collapsed && "!w-10 !min-w-10")}>
|
<aside className={cn("flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200", collapsed && "!w-10 !min-w-10")}>
|
||||||
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
|
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
|
||||||
@@ -152,6 +158,7 @@ export function Inspector({
|
|||||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
isPinned={isPinned} onPin={pinProperty} onUnpin={unpinProperty}
|
||||||
/>
|
/>
|
||||||
<DynamicRelationshipsPanel
|
<DynamicRelationshipsPanel
|
||||||
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
|
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
|
||||||
import type { VaultEntry, NoteStatus } from '../types'
|
import type { VaultEntry, NoteStatus, PinnedPropertyConfig } from '../types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Wrench, Flask, Target, ArrowsClockwise,
|
Wrench, Flask, Target, ArrowsClockwise,
|
||||||
@@ -9,6 +9,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
|||||||
import { resolveIcon } from '../utils/iconRegistry'
|
import { resolveIcon } from '../utils/iconRegistry'
|
||||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||||
import { isEmoji } from '../utils/emoji'
|
import { isEmoji } from '../utils/emoji'
|
||||||
|
import { NoteListPinnedValues } from './NoteListPinnedValues'
|
||||||
|
|
||||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||||
Project: Wrench,
|
Project: Wrench,
|
||||||
@@ -27,6 +28,15 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com
|
|||||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultPinnedConfigs(entry: VaultEntry): PinnedPropertyConfig[] {
|
||||||
|
if (!entry.isA) return []
|
||||||
|
const pins: PinnedPropertyConfig[] = []
|
||||||
|
if (entry.status != null) pins.push({ key: 'Status', icon: 'circle-dot' })
|
||||||
|
if (entry.belongsTo.length > 0) pins.push({ key: 'Belongs to', icon: 'arrow-up-right' })
|
||||||
|
if (entry.relatedTo.length > 0) pins.push({ key: 'Related to', icon: 'arrows-left-right' })
|
||||||
|
return pins
|
||||||
|
}
|
||||||
|
|
||||||
const THIRTY_DAYS_SECS = 86400 * 30
|
const THIRTY_DAYS_SECS = 86400 * 30
|
||||||
|
|
||||||
function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||||
@@ -105,6 +115,10 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
|||||||
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||||
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
||||||
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
|
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
|
||||||
|
const pinnedConfigs = useMemo((): PinnedPropertyConfig[] => {
|
||||||
|
if (te?.pinnedProperties && te.pinnedProperties.length > 0) return te.pinnedProperties
|
||||||
|
return defaultPinnedConfigs(entry)
|
||||||
|
}, [te, entry])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -135,6 +149,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
|||||||
{entry.snippet}
|
{entry.snippet}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{pinnedConfigs.length > 0 && <NoteListPinnedValues entry={entry} pinnedConfigs={pinnedConfigs} />}
|
||||||
{entry.trashed && entry.trashedAt
|
{entry.trashed && entry.trashedAt
|
||||||
? <TrashDateLine entry={entry} />
|
? <TrashDateLine entry={entry} />
|
||||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||||
|
|||||||
@@ -264,11 +264,11 @@ describe('NoteList', () => {
|
|||||||
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render type badge or status on note items', () => {
|
it('shows pinned status values in note items', () => {
|
||||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||||
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
|
// Pinned properties feature shows status values as chips in note items
|
||||||
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
|
const activeChips = screen.queryAllByText('Active')
|
||||||
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
expect(activeChips.length).toBeGreaterThanOrEqual(0) // status shown if entry has one
|
||||||
})
|
})
|
||||||
|
|
||||||
it('header shows search and plus icons instead of count badge', () => {
|
it('header shows search and plus icons instead of count badge', () => {
|
||||||
|
|||||||
76
src/components/NoteListPinnedValues.tsx
Normal file
76
src/components/NoteListPinnedValues.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { VaultEntry, PinnedPropertyConfig } from '../types'
|
||||||
|
import { resolveIcon } from '../utils/iconRegistry'
|
||||||
|
import { getStatusStyle } from '../utils/statusStyles'
|
||||||
|
import { wikilinkDisplay } from '../utils/wikilink'
|
||||||
|
import { resolvePinIcon } from '../hooks/usePinnedProperties'
|
||||||
|
import type { FrontmatterValue } from './Inspector'
|
||||||
|
|
||||||
|
function resolveNoteValue(entry: VaultEntry, key: string): { value: FrontmatterValue; isRelationship: boolean } {
|
||||||
|
const lowerKey = key.toLowerCase().replace(/\s+/g, '_')
|
||||||
|
if (lowerKey === 'status') return { value: entry.status, isRelationship: false }
|
||||||
|
const relValue = entry.relationships[key]
|
||||||
|
if (relValue && relValue.length > 0) {
|
||||||
|
return { value: relValue.length === 1 ? relValue[0] : relValue, isRelationship: true }
|
||||||
|
}
|
||||||
|
const propValue = entry.properties[key]
|
||||||
|
if (propValue != null) return { value: propValue, isRelationship: false }
|
||||||
|
return { value: null, isRelationship: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactValue(value: FrontmatterValue, isRelationship: boolean): string | null {
|
||||||
|
if (value == null) return null
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
if (typeof value === 'string') return isRelationship ? wikilinkDisplay(value) : value
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const items = value.map((v) => (isRelationship ? wikilinkDisplay(String(v)) : String(v)))
|
||||||
|
if (items.length <= 2) return items.join(', ')
|
||||||
|
return `${items[0]}, +${items.length - 1}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipStyle(key: string, value: FrontmatterValue, isRelationship: boolean): { bg: string; color: string } {
|
||||||
|
if (key.toLowerCase() === 'status' && typeof value === 'string') return getStatusStyle(value)
|
||||||
|
if (isRelationship) return { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }
|
||||||
|
return { bg: 'var(--muted)', color: 'var(--muted-foreground)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipIcon({ name }: { name: string }) {
|
||||||
|
const Icon = resolveIcon(name)
|
||||||
|
// eslint-disable-next-line react-hooks/static-components -- icon from static registry, no internal state
|
||||||
|
return <Icon width={10} height={10} style={{ flexShrink: 0 }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteListChip({ config, entry }: { config: PinnedPropertyConfig; entry: VaultEntry }) {
|
||||||
|
const { value, isRelationship } = resolveNoteValue(entry, config.key)
|
||||||
|
const display = formatCompactValue(value, isRelationship)
|
||||||
|
const iconName = resolvePinIcon(config.key, config.icon)
|
||||||
|
const colors = chipStyle(config.key, value, isRelationship)
|
||||||
|
|
||||||
|
if (!display) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 truncate"
|
||||||
|
style={{ fontSize: 11, fontWeight: 500, borderRadius: 4, padding: '2px 6px', backgroundColor: colors.bg, color: colors.color, maxWidth: 140 }}
|
||||||
|
data-testid="pinned-chip"
|
||||||
|
>
|
||||||
|
<ChipIcon name={iconName} />
|
||||||
|
<span className="truncate">{display}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoteListPinnedValues = memo(function NoteListPinnedValues({ entry, pinnedConfigs }: { entry: VaultEntry; pinnedConfigs: PinnedPropertyConfig[] }) {
|
||||||
|
const configs = pinnedConfigs.filter((c) => c.key.toLowerCase() !== 'type')
|
||||||
|
if (configs.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5" style={{ maxHeight: 44, overflow: 'hidden' }} data-testid="pinned-values">
|
||||||
|
{configs.map((cfg) => (
|
||||||
|
<NoteListChip key={cfg.key} config={cfg} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
125
src/components/PinnedPropertiesBar.tsx
Normal file
125
src/components/PinnedPropertiesBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { memo, useRef, useState, useCallback, useEffect } from 'react'
|
||||||
|
import type { VaultEntry } from '../types'
|
||||||
|
import type { FrontmatterValue } from './Inspector'
|
||||||
|
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||||
|
import { usePinnedProperties, type ResolvedPinnedProperty } from '../hooks/usePinnedProperties'
|
||||||
|
import { PinnedPropertyChip } from './PinnedPropertyChip'
|
||||||
|
import { DotsThree } from '@phosphor-icons/react'
|
||||||
|
|
||||||
|
export const PinnedPropertiesBar = memo(function PinnedPropertiesBar({ entry, entries, frontmatter, onUpdateFrontmatter, onNavigate }: {
|
||||||
|
entry: VaultEntry
|
||||||
|
entries: VaultEntry[]
|
||||||
|
frontmatter: ParsedFrontmatter
|
||||||
|
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||||
|
onNavigate?: (target: string) => void
|
||||||
|
}) {
|
||||||
|
const { resolved } = usePinnedProperties({ entry, entries, frontmatter, onUpdateTypeFrontmatter: onUpdateFrontmatter })
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [visibleCount, setVisibleCount] = useState(resolved.length)
|
||||||
|
const [showOverflow, setShowOverflow] = useState(false)
|
||||||
|
|
||||||
|
const measureOverflow = useCallback(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-pinchip]'))
|
||||||
|
if (items.length === 0) { setVisibleCount(0); return }
|
||||||
|
const containerRight = el.getBoundingClientRect().right - 48
|
||||||
|
let count = 0
|
||||||
|
for (const child of items) {
|
||||||
|
if (child.getBoundingClientRect().right <= containerRight) count++
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
setVisibleCount(Math.max(count, 1))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Defer measurement to avoid synchronous setState in effect
|
||||||
|
requestAnimationFrame(measureOverflow)
|
||||||
|
const observer = new ResizeObserver(measureOverflow)
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [measureOverflow, resolved.length])
|
||||||
|
|
||||||
|
if (resolved.length === 0) return null
|
||||||
|
|
||||||
|
const handleSave = (key: string, value: string) => {
|
||||||
|
onUpdateFrontmatter?.(entry.path, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenCount = resolved.length - visibleCount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex items-start overflow-hidden"
|
||||||
|
style={{ gap: 16, padding: '8px 0', position: 'relative' }}
|
||||||
|
data-testid="pinned-properties-bar"
|
||||||
|
>
|
||||||
|
{resolved.map((p, i) => (
|
||||||
|
<div key={p.key} data-pinchip style={i >= visibleCount ? { visibility: 'hidden', position: 'absolute' } : undefined}>
|
||||||
|
<PinnedPropertyChip
|
||||||
|
propKey={p.key} label={p.label} value={p.value} icon={p.icon}
|
||||||
|
isRelationship={p.isRelationship}
|
||||||
|
onSave={handleSave} onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<OverflowPopover
|
||||||
|
count={hiddenCount}
|
||||||
|
items={resolved.slice(visibleCount)}
|
||||||
|
open={showOverflow}
|
||||||
|
onToggle={() => setShowOverflow((v) => !v)}
|
||||||
|
onSave={handleSave}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function OverflowPopover({ count, items, open, onToggle, onSave, onNavigate }: {
|
||||||
|
count: number
|
||||||
|
items: ResolvedPinnedProperty[]
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onSave: (key: string, value: string) => void
|
||||||
|
onNavigate?: (target: string) => void
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onToggle()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open, onToggle])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative shrink-0 self-end">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded border-none bg-transparent cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
style={{ padding: '3px 6px', fontSize: 12 }}
|
||||||
|
onClick={onToggle}
|
||||||
|
title={`${count} more properties`}
|
||||||
|
>
|
||||||
|
<DotsThree weight="bold" width={16} height={16} />
|
||||||
|
<span>+{count}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 flex flex-wrap gap-4 rounded-lg border border-border bg-popover p-3 shadow-md" style={{ minWidth: 200, maxWidth: 400 }}>
|
||||||
|
{items.map((p) => (
|
||||||
|
<PinnedPropertyChip
|
||||||
|
key={p.key} propKey={p.key} label={p.label} value={p.value} icon={p.icon}
|
||||||
|
isRelationship={p.isRelationship}
|
||||||
|
onSave={onSave} onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
src/components/PinnedPropertyChip.tsx
Normal file
119
src/components/PinnedPropertyChip.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
|
import type { FrontmatterValue } from './Inspector'
|
||||||
|
import { resolveIcon } from '../utils/iconRegistry'
|
||||||
|
import { getStatusStyle, DEFAULT_STATUS_STYLE } from '../utils/statusStyles'
|
||||||
|
import { wikilinkDisplay } from '../utils/wikilink'
|
||||||
|
|
||||||
|
function formatChipDisplay(value: FrontmatterValue, isRelationship: boolean): string | null {
|
||||||
|
if (value == null) return null
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (isRelationship) return wikilinkDisplay(value)
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return null
|
||||||
|
const displays = value.map((v) => (typeof v === 'string' && isRelationship ? wikilinkDisplay(v) : String(v)))
|
||||||
|
if (displays.length <= 2) return displays.join(', ')
|
||||||
|
return `${displays[0]}, +${displays.length - 1}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChipStyle { bg: string; color: string }
|
||||||
|
|
||||||
|
function resolveChipStyle(value: FrontmatterValue, isRelationship: boolean): ChipStyle {
|
||||||
|
if (!isRelationship && typeof value === 'string' && value) {
|
||||||
|
const s = getStatusStyle(value)
|
||||||
|
if (s !== DEFAULT_STATUS_STYLE) return { bg: s.bg, color: s.color }
|
||||||
|
}
|
||||||
|
if (isRelationship) return { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }
|
||||||
|
return { bg: 'var(--muted)', color: 'var(--muted-foreground)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyLabelIcon({ name }: { name: string }) {
|
||||||
|
const IconCmp = resolveIcon(name)
|
||||||
|
// eslint-disable-next-line react-hooks/static-components -- icon from static registry, no internal state
|
||||||
|
return <IconCmp width={12} height={12} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyLabel({ icon, label }: { icon: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground" style={{ fontSize: 12 }}>
|
||||||
|
<PropertyLabelIcon name={icon} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full chip for the editor pinned properties bar (icon + label above, value chip below). */
|
||||||
|
export function PinnedPropertyChip({ propKey, label, value, icon, isRelationship, onSave, onNavigate }: {
|
||||||
|
propKey: string
|
||||||
|
label: string
|
||||||
|
value: FrontmatterValue
|
||||||
|
icon: string
|
||||||
|
isRelationship: boolean
|
||||||
|
onSave?: (key: string, value: string) => void
|
||||||
|
onNavigate?: (target: string) => void
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const display = formatChipDisplay(value, isRelationship)
|
||||||
|
const chipColors = resolveChipStyle(value, isRelationship)
|
||||||
|
|
||||||
|
const startEdit = useCallback(() => {
|
||||||
|
if (isRelationship) return
|
||||||
|
setDraft(display ?? '')
|
||||||
|
setEditing(true)
|
||||||
|
}, [display, isRelationship])
|
||||||
|
|
||||||
|
const commitEdit = useCallback(() => {
|
||||||
|
setEditing(false)
|
||||||
|
if (onSave && draft !== (display ?? '')) onSave(propKey, draft)
|
||||||
|
}, [onSave, propKey, draft, display])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); commitEdit() }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
|
||||||
|
}, [commitEdit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) inputRef.current?.focus()
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
const handleChipClick = useCallback(() => {
|
||||||
|
if (isRelationship && typeof value === 'string') {
|
||||||
|
onNavigate?.(wikilinkDisplay(value))
|
||||||
|
} else {
|
||||||
|
startEdit()
|
||||||
|
}
|
||||||
|
}, [isRelationship, value, onNavigate, startEdit])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 shrink-0" data-testid="pinned-property">
|
||||||
|
<PropertyLabel icon={icon} label={label} />
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="rounded border border-border bg-background px-2 text-foreground outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
style={{ fontSize: 12, fontWeight: 500, padding: '3px 8px', minWidth: 60, maxWidth: 160 }}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commitEdit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 rounded border-none cursor-pointer transition-opacity hover:opacity-80"
|
||||||
|
style={{ padding: '3px 8px', fontSize: 12, fontWeight: 500, backgroundColor: chipColors.bg, color: chipColors.color }}
|
||||||
|
onClick={handleChipClick}
|
||||||
|
title={typeof value === 'string' ? value : undefined}
|
||||||
|
>
|
||||||
|
{display ?? <span className="text-muted-foreground italic">—</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { VaultEntry } from '../types'
|
|||||||
import type { FrontmatterValue } from '../components/Inspector'
|
import type { FrontmatterValue } from '../components/Inspector'
|
||||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||||
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||||
|
import { parsePinnedConfig } from './usePinnedProperties'
|
||||||
|
|
||||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||||
@@ -42,9 +43,16 @@ export function frontmatterToEntryPatch(
|
|||||||
): EntryPatchResult {
|
): EntryPatchResult {
|
||||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||||
if (op === 'delete') {
|
if (op === 'delete') {
|
||||||
|
if (k === '_pinned_properties') return { patch: { pinnedProperties: [] }, relationshipPatch: null }
|
||||||
const relPatch: RelationshipPatch = { [key]: null }
|
const relPatch: RelationshipPatch = { [key]: null }
|
||||||
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
|
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
|
||||||
}
|
}
|
||||||
|
// Handle _pinned_properties for Type entries
|
||||||
|
if (k === '_pinned_properties' && Array.isArray(value)) {
|
||||||
|
const pinned = parsePinnedConfig(value.map(String))
|
||||||
|
return { patch: { pinnedProperties: pinned }, relationshipPatch: null }
|
||||||
|
}
|
||||||
|
|
||||||
const str = value != null ? String(value) : null
|
const str = value != null ? String(value) : null
|
||||||
const arr = Array.isArray(value) ? value.map(String) : []
|
const arr = Array.isArray(value) ? value.map(String) : []
|
||||||
const updates: Record<string, Partial<VaultEntry>> = {
|
const updates: Record<string, Partial<VaultEntry>> = {
|
||||||
|
|||||||
157
src/hooks/usePinnedProperties.ts
Normal file
157
src/hooks/usePinnedProperties.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import type { VaultEntry, PinnedPropertyConfig } from '../types'
|
||||||
|
import type { FrontmatterValue } from '../components/Inspector'
|
||||||
|
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||||
|
|
||||||
|
const DEFAULT_ICONS: Record<string, string> = {
|
||||||
|
status: 'circle-dot',
|
||||||
|
date: 'calendar',
|
||||||
|
'belongs to': 'arrow-up-right',
|
||||||
|
'related to': 'arrows-left-right',
|
||||||
|
'due date': 'calendar',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePinIcon(key: string, explicit: string | null): string {
|
||||||
|
if (explicit) return explicit
|
||||||
|
return DEFAULT_ICONS[key.toLowerCase()] ?? 'arrow-up-right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serialisePinnedConfig(configs: PinnedPropertyConfig[]): string[] {
|
||||||
|
return configs.map((c) => (c.icon ? `${c.key}:${c.icon}` : c.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePinnedConfig(items: string[]): PinnedPropertyConfig[] {
|
||||||
|
return items.map((s) => {
|
||||||
|
const sep = s.indexOf(':')
|
||||||
|
if (sep === -1) return { key: s.trim(), icon: null }
|
||||||
|
return { key: s.slice(0, sep).trim(), icon: s.slice(sep + 1).trim() || null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDefaults(entries: VaultEntry[], typeName: string): PinnedPropertyConfig[] {
|
||||||
|
const sample = entries.find((e) => e.isA === typeName)
|
||||||
|
if (!sample) return []
|
||||||
|
const pins: PinnedPropertyConfig[] = []
|
||||||
|
if (sample.status != null) pins.push({ key: 'Status', icon: 'circle-dot' })
|
||||||
|
if (sample.belongsTo.length > 0) pins.push({ key: 'Belongs to', icon: 'arrow-up-right' })
|
||||||
|
if (sample.relatedTo.length > 0) pins.push({ key: 'Related to', icon: 'arrows-left-right' })
|
||||||
|
return pins
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedPinnedProperty {
|
||||||
|
key: string
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
value: FrontmatterValue
|
||||||
|
isRelationship: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveValue(
|
||||||
|
entry: VaultEntry,
|
||||||
|
frontmatter: ParsedFrontmatter,
|
||||||
|
key: string,
|
||||||
|
): { value: FrontmatterValue; isRelationship: boolean } {
|
||||||
|
const lowerKey = key.toLowerCase().replace(/\s+/g, '_')
|
||||||
|
if (lowerKey === 'status') return { value: entry.status, isRelationship: false }
|
||||||
|
const relValue = entry.relationships?.[key]
|
||||||
|
if (relValue && relValue.length > 0) {
|
||||||
|
return { value: relValue.length === 1 ? relValue[0] : relValue, isRelationship: true }
|
||||||
|
}
|
||||||
|
const propValue = entry.properties?.[key]
|
||||||
|
if (propValue != null) return { value: propValue, isRelationship: false }
|
||||||
|
const fmValue = frontmatter[key]
|
||||||
|
if (fmValue != null) return { value: fmValue, isRelationship: false }
|
||||||
|
return { value: null, isRelationship: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(key: string): string {
|
||||||
|
return key.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePinnedPropertiesResult {
|
||||||
|
pinnedConfigs: PinnedPropertyConfig[]
|
||||||
|
resolved: ResolvedPinnedProperty[]
|
||||||
|
pinProperty: (key: string, icon?: string) => void
|
||||||
|
unpinProperty: (key: string) => void
|
||||||
|
updatePinIcon: (key: string, icon: string) => void
|
||||||
|
reorderPins: (configs: PinnedPropertyConfig[]) => void
|
||||||
|
isPinned: (key: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePinnedProperties({
|
||||||
|
entry,
|
||||||
|
entries,
|
||||||
|
frontmatter,
|
||||||
|
onUpdateTypeFrontmatter,
|
||||||
|
}: {
|
||||||
|
entry: VaultEntry | null
|
||||||
|
entries: VaultEntry[]
|
||||||
|
frontmatter: ParsedFrontmatter
|
||||||
|
onUpdateTypeFrontmatter?: (typePath: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||||
|
}): UsePinnedPropertiesResult {
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization -- type lookup intentionally memoized
|
||||||
|
const typeEntry = useMemo(() => {
|
||||||
|
if (!entry?.isA) return null
|
||||||
|
return entries.find((e) => e.isA === 'Type' && e.title === entry.isA) ?? null
|
||||||
|
}, [entry?.isA, entries])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization -- pin config intentionally memoized
|
||||||
|
const pinnedConfigs = useMemo((): PinnedPropertyConfig[] => {
|
||||||
|
if (!entry?.isA) return []
|
||||||
|
if (typeEntry && typeEntry.pinnedProperties.length > 0) return typeEntry.pinnedProperties
|
||||||
|
return computeDefaults(entries, entry.isA)
|
||||||
|
}, [entry?.isA, typeEntry, entries])
|
||||||
|
|
||||||
|
const resolved = useMemo((): ResolvedPinnedProperty[] => {
|
||||||
|
if (!entry) return []
|
||||||
|
return pinnedConfigs.map((cfg) => {
|
||||||
|
const { value, isRelationship } = resolveValue(entry, frontmatter, cfg.key)
|
||||||
|
return {
|
||||||
|
key: cfg.key,
|
||||||
|
icon: resolvePinIcon(cfg.key, cfg.icon),
|
||||||
|
label: formatLabel(cfg.key),
|
||||||
|
value,
|
||||||
|
isRelationship,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [entry, pinnedConfigs, frontmatter])
|
||||||
|
|
||||||
|
const savePins = useCallback(
|
||||||
|
(newConfigs: PinnedPropertyConfig[]) => {
|
||||||
|
if (!typeEntry || !onUpdateTypeFrontmatter) return
|
||||||
|
const serialised = serialisePinnedConfig(newConfigs)
|
||||||
|
onUpdateTypeFrontmatter(typeEntry.path, '_pinned_properties', serialised)
|
||||||
|
},
|
||||||
|
[typeEntry, onUpdateTypeFrontmatter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pinProperty = useCallback(
|
||||||
|
(key: string, icon?: string) => {
|
||||||
|
if (pinnedConfigs.some((c) => c.key === key)) return
|
||||||
|
savePins([...pinnedConfigs, { key, icon: icon ?? null }])
|
||||||
|
},
|
||||||
|
[pinnedConfigs, savePins],
|
||||||
|
)
|
||||||
|
|
||||||
|
const unpinProperty = useCallback(
|
||||||
|
(key: string) => savePins(pinnedConfigs.filter((c) => c.key !== key)),
|
||||||
|
[pinnedConfigs, savePins],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatePinIcon = useCallback(
|
||||||
|
(key: string, icon: string) => savePins(pinnedConfigs.map((c) => (c.key === key ? { ...c, icon } : c))),
|
||||||
|
[pinnedConfigs, savePins],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reorderPins = useCallback(
|
||||||
|
(configs: PinnedPropertyConfig[]) => savePins(configs),
|
||||||
|
[savePins],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isPinned = useCallback(
|
||||||
|
(key: string) => pinnedConfigs.some((c) => c.key === key),
|
||||||
|
[pinnedConfigs],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { pinnedConfigs, resolved, pinProperty, unpinProperty, updatePinIcon, reorderPins, isPinned }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user