2026-03-25 10:18:11 +01:00
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
2026-02-17 12:12:32 +01:00
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
import type { FrontmatterValue } from './Inspector'
|
|
|
|
|
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
2026-03-05 07:11:33 +01:00
|
|
|
import { usePropertyPanelState } from '../hooks/usePropertyPanelState'
|
2026-03-16 07:25:40 +01:00
|
|
|
import { getEffectiveDisplayMode, detectPropertyType } from '../utils/propertyTypes'
|
|
|
|
|
import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCells'
|
|
|
|
|
import { TypeSelector } from './TypeSelector'
|
|
|
|
|
import { AddPropertyForm } from './AddPropertyForm'
|
2026-02-24 12:19:21 +01:00
|
|
|
import { countWords } from '../utils/wikilinks'
|
2026-03-16 07:25:40 +01:00
|
|
|
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
2026-03-25 10:18:11 +01:00
|
|
|
import { PushPin } from '@phosphor-icons/react'
|
2026-02-17 12:12:32 +01:00
|
|
|
|
2026-02-22 13:11:38 +01:00
|
|
|
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
2026-02-17 12:12:32 +01:00
|
|
|
export function containsWikilinks(value: FrontmatterValue): boolean {
|
|
|
|
|
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
|
|
|
|
|
if (Array.isArray(value)) return value.some(v => typeof v === 'string' && /^\[\[.*\]\]$/.test(v))
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(timestamp: number | null): string {
|
|
|
|
|
if (!timestamp) return '\u2014'
|
|
|
|
|
const d = new Date(timestamp * 1000)
|
|
|
|
|
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 15:19:57 +01:00
|
|
|
function formatFileSize(bytes: number): string {
|
|
|
|
|
if (bytes < 1024) return `${bytes} B`
|
|
|
|
|
const kb = bytes / 1024
|
|
|
|
|
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
|
|
|
|
const mb = kb / 1024
|
|
|
|
|
return `${mb.toFixed(1)} MB`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 10:18:11 +01:00
|
|
|
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 (
|
2026-03-25 10:32:11 +01:00
|
|
|
<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"
|
|
|
|
|
>
|
2026-03-25 10:18:11 +01:00
|
|
|
<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' }}
|
2026-03-25 10:32:11 +01:00
|
|
|
onClick={() => { if (isPinned) { onUnpin() } else { onPin() } onClose() }}
|
2026-03-25 10:18:11 +01:00
|
|
|
>
|
|
|
|
|
<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 }: {
|
2026-02-22 10:55:45 +01:00
|
|
|
propKey: string; value: FrontmatterValue; editingKey: string | null
|
feat: smart property display — type-aware rendering with display mode override (#83)
* design: add smart property display wireframes
Frames: date picker, boolean toggle, status badge, display mode override dropdown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: smart property display — type-aware rendering with display mode override
- Add property type auto-detection (date, boolean, status, url, text) based on value content and property name
- Date properties (ISO strings) show as friendly format (e.g. "Mar 31, 2026") with native date picker on click
- Boolean properties show as toggle buttons
- Status properties auto-detected from key name or known status values, rendered as colored badges
- Each property gets a display mode override dropdown (visible on hover) to force a specific display type
- Display mode overrides persist across sessions via localStorage
- Add mock data with date/boolean fields for testing
- 36 new tests covering type detection, date formatting, localStorage persistence, and UI rendering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:12:40 +01:00
|
|
|
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
2026-02-28 12:34:01 +01:00
|
|
|
vaultStatuses: string[]; vaultTags: string[]
|
2026-03-25 10:18:11 +01:00
|
|
|
isPinned: boolean
|
2026-02-22 10:55:45 +01:00
|
|
|
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
|
feat: smart property display — type-aware rendering with display mode override (#83)
* design: add smart property display wireframes
Frames: date picker, boolean toggle, status badge, display mode override dropdown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: smart property display — type-aware rendering with display mode override
- Add property type auto-detection (date, boolean, status, url, text) based on value content and property name
- Date properties (ISO strings) show as friendly format (e.g. "Mar 31, 2026") with native date picker on click
- Boolean properties show as toggle buttons
- Status properties auto-detected from key name or known status values, rendered as colored badges
- Each property gets a display mode override dropdown (visible on hover) to force a specific display type
- Display mode overrides persist across sessions via localStorage
- Add mock data with date/boolean fields for testing
- 36 new tests covering type detection, date formatting, localStorage persistence, and UI rendering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:12:40 +01:00
|
|
|
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
2026-03-25 10:18:11 +01:00
|
|
|
onPin?: (key: string) => void; onUnpin?: (key: string) => void
|
2026-02-22 10:55:45 +01:00
|
|
|
}) {
|
2026-03-25 10:18:11 +01:00
|
|
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null)
|
|
|
|
|
|
feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter (#158)
* feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter
- Add missing menu bar items: Archive Note (⌘E), Trash Note (⌘⌫),
Find in Vault (⌘⇧F), Go Back (⌘[), Go Forward (⌘])
- Wire new menu events through useMenuEvents → useAppCommands
- Note list: ↑↓ moves highlight, Enter opens selected note (useNoteListKeyboard hook)
- Inspector properties: Tab between rows, Enter to start editing
- Settings panel: auto-focus first input on open
- Extract StatusDot, StateBadge, noteItemStyle from NoteItem (reduces complexity)
- Extract dispatchActiveTabEvent/dispatchOptionalEvent from useMenuEvents
- 21 new tests (useNoteListKeyboard + useMenuEvents for new events)
- All 1275 frontend tests pass, 319 Rust tests pass
- CodeScene quality gates: passed (NoteItem improved from 22→18 complexity)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: keyboard-first-nav wireframes (note list highlight, menu shortcuts, inspector tab nav)
* test: add search.rs coverage for fallback branches (qmd_uri_to_vault_path, extract_clean_snippet)
* chore: exclude search.rs and lib.rs from coverage (qmd integration + Tauri setup code)
---------
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:32:15 +01:00
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Enter' && editingKey !== propKey) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
onStartEdit(propKey)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 10:18:11 +01:00
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
|
|
|
if (!onPin && !onUnpin) return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
setCtxMenu({ x: e.clientX, y: e.clientY })
|
|
|
|
|
}, [onPin, onUnpin])
|
|
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
return (
|
2026-03-25 10:18:11 +01:00
|
|
|
<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"
|
|
|
|
|
>
|
2026-03-20 20:27:36 +01:00
|
|
|
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
|
2026-03-25 10:18:11 +01:00
|
|
|
{isPinned && <PushPin size={10} className="shrink-0 text-primary" style={{ opacity: 0.6 }} />}
|
2026-02-28 10:54:37 +01:00
|
|
|
<span className="truncate">{propKey}</span>
|
2026-02-22 10:55:45 +01:00
|
|
|
{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>
|
|
|
|
|
)}
|
feat: smart property display — type-aware rendering with display mode override (#83)
* design: add smart property display wireframes
Frames: date picker, boolean toggle, status badge, display mode override dropdown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: smart property display — type-aware rendering with display mode override
- Add property type auto-detection (date, boolean, status, url, text) based on value content and property name
- Date properties (ISO strings) show as friendly format (e.g. "Mar 31, 2026") with native date picker on click
- Boolean properties show as toggle buttons
- Status properties auto-detected from key name or known status values, rendered as colored badges
- Each property gets a display mode override dropdown (visible on hover) to force a specific display type
- Display mode overrides persist across sessions via localStorage
- Add mock data with date/boolean fields for testing
- 36 new tests covering type detection, date formatting, localStorage persistence, and UI rendering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:12:40 +01:00
|
|
|
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
2026-02-22 10:55:45 +01:00
|
|
|
</span>
|
2026-03-20 20:27:36 +01:00
|
|
|
<div className="min-w-0">
|
2026-03-19 06:57:11 +01:00
|
|
|
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
|
|
|
|
</div>
|
2026-03-25 10:18:11 +01:00
|
|
|
{ctxMenu && (
|
|
|
|
|
<PropertyPinMenu
|
|
|
|
|
x={ctxMenu.x} y={ctxMenu.y} isPinned={isPinned}
|
|
|
|
|
onPin={() => onPin?.(propKey)} onUnpin={() => onUnpin?.(propKey)}
|
|
|
|
|
onClose={() => setCtxMenu(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-22 10:55:45 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 15:19:57 +01:00
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
2026-02-22 10:55:45 +01:00
|
|
|
return (
|
2026-03-20 20:27:36 +01:00
|
|
|
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
|
|
|
|
|
<span className="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
|
|
|
|
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
2026-02-22 10:55:45 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
className="mt-3 w-full cursor-pointer border border-border bg-transparent text-center text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12 }}
|
|
|
|
|
onClick={onClick} disabled={disabled}
|
|
|
|
|
>+ Add property</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 15:02:33 +01:00
|
|
|
function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="border-t border-border pt-3">
|
|
|
|
|
<h4 className="font-mono-overline mb-2 text-muted-foreground">Info</h4>
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
|
|
|
|
|
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
|
|
|
|
|
<InfoRow label="Words" value={String(wordCount)} />
|
|
|
|
|
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 12:59:23 +01:00
|
|
|
export function DynamicPropertiesPanel({
|
2026-03-08 22:15:08 +01:00
|
|
|
entry, content, frontmatter, entries,
|
2026-02-27 12:59:23 +01:00
|
|
|
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
2026-03-25 10:18:11 +01:00
|
|
|
isPinned, onPin, onUnpin,
|
2026-02-27 12:59:23 +01:00
|
|
|
}: {
|
|
|
|
|
entry: VaultEntry
|
|
|
|
|
content: string | null
|
|
|
|
|
frontmatter: ParsedFrontmatter
|
|
|
|
|
entries?: VaultEntry[]
|
|
|
|
|
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
|
|
|
|
onDeleteProperty?: (key: string) => void
|
|
|
|
|
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
|
|
|
|
onNavigate?: (target: string) => void
|
2026-03-25 10:18:11 +01:00
|
|
|
isPinned?: (key: string) => boolean
|
|
|
|
|
onPin?: (key: string) => void
|
|
|
|
|
onUnpin?: (key: string) => void
|
2026-02-27 12:59:23 +01:00
|
|
|
}) {
|
|
|
|
|
const {
|
|
|
|
|
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
2026-02-28 20:01:56 +01:00
|
|
|
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
2026-02-27 12:59:23 +01:00
|
|
|
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
2026-03-08 22:15:08 +01:00
|
|
|
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
2026-02-27 12:59:23 +01:00
|
|
|
|
|
|
|
|
const wordCount = countWords(content ?? '')
|
|
|
|
|
|
2026-02-17 12:12:32 +01:00
|
|
|
return (
|
2026-02-23 15:19:57 +01:00
|
|
|
<div className="flex flex-col gap-3">
|
2026-03-19 07:56:26 +01:00
|
|
|
<div className="flex flex-col gap-1.5">
|
2026-02-28 20:01:56 +01:00
|
|
|
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
2026-02-27 12:59:23 +01:00
|
|
|
{propertyEntries.map(([key, value]) => (
|
|
|
|
|
<PropertyRow
|
|
|
|
|
key={key} propKey={key} value={value}
|
|
|
|
|
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
|
|
|
|
vaultStatuses={vaultStatuses}
|
2026-02-28 12:34:01 +01:00
|
|
|
vaultTags={vaultTagsByKey[key] ?? []}
|
2026-03-25 10:18:11 +01:00
|
|
|
isPinned={isPinned?.(key) ?? false}
|
2026-02-27 12:59:23 +01:00
|
|
|
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
|
|
|
|
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
|
|
|
|
onDelete={onDeleteProperty}
|
|
|
|
|
onDisplayModeChange={handleDisplayModeChange}
|
2026-03-25 10:18:11 +01:00
|
|
|
onPin={onPin} onUnpin={onUnpin}
|
2026-02-27 12:59:23 +01:00
|
|
|
/>
|
|
|
|
|
))}
|
2026-02-17 12:12:32 +01:00
|
|
|
</div>
|
2026-02-22 10:55:45 +01:00
|
|
|
{showAddDialog
|
2026-02-27 12:59:23 +01:00
|
|
|
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} vaultStatuses={vaultStatuses} />
|
2026-02-22 10:55:45 +01:00
|
|
|
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
|
|
|
|
|
}
|
2026-02-25 15:02:33 +01:00
|
|
|
<NoteInfoSection entry={entry} wordCount={wordCount} />
|
2026-02-17 12:12:32 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|