import { useMemo, useState, useCallback, useRef } from 'react' import { createPortal } from 'react-dom' import type { VaultEntry } from '../types' import type { FrontmatterValue } from './Inspector' import type { ParsedFrontmatter } from '../utils/frontmatter' import { EditableValue, TagPillList, UrlValue } from './EditableValue' import { isUrlValue } from '../utils/url' import { Button } from '@/components/ui/button' import { Calendar } from '@/components/ui/calendar' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link } from 'lucide-react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { countWords } from '../utils/wikilinks' import { type PropertyDisplayMode, getEffectiveDisplayMode, formatDateValue, toISODate, loadDisplayModeOverrides, saveDisplayModeOverride, removeDisplayModeOverride, detectPropertyType, } from '../utils/propertyTypes' import { StatusPill, StatusDropdown } from './StatusDropdown' // Keys that are relationships (contain wikilinks) export const RELATIONSHIP_KEYS = new Set([ 'Belongs to', 'Related to', 'Events', 'Has Data', 'Owner', 'Advances', 'Parent', 'Children', 'Has', 'Notes', ]) // Keys to skip showing in Properties (handled by dedicated UI or internal) const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A']) // eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component 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' }) } function coerceValue(raw: string): FrontmatterValue { if (raw.toLowerCase() === 'true') return true if (raw.toLowerCase() === 'false') return false if (!isNaN(Number(raw)) && raw.trim() !== '') return Number(raw) return raw } function parseNewValue(rawValue: string): FrontmatterValue { if (!rawValue.includes(',')) return rawValue.trim() || '' const items = rawValue.split(',').map(s => s.trim()).filter(s => s) return items.length === 1 ? items[0] : items } 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` } function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }: { propKey: string; value: FrontmatterValue; isEditing: boolean; vaultStatuses: string[] onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void }) { const statusStr = String(value) return ( onStartEdit(propKey)} data-testid="status-badge" > {isEditing && ( onSave(propKey, newValue)} onCancel={() => onStartEdit(null)} /> )} ) } function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) { return ( ) } function parseDateValue(value: string): Date | undefined { const iso = toISODate(value) const d = new Date(iso + 'T00:00:00') return isNaN(d.getTime()) ? undefined : d } function dateToISO(day: Date): string { const yyyy = day.getFullYear() const mm = String(day.getMonth() + 1).padStart(2, '0') const dd = String(day.getDate()).padStart(2, '0') return `${yyyy}-${mm}-${dd}` } function DateValue({ value, onSave }: { value: string; onSave: (newValue: string) => void }) { const [open, setOpen] = useState(false) const formatted = formatDateValue(value) const selectedDate = parseDateValue(value) const handleSelect = (day: Date | undefined) => { if (day) onSave(dateToISO(day)) setOpen(false) } const handleClear = (e: React.MouseEvent) => { e.stopPropagation() onSave('') setOpen(false) } return ( {selectedDate && (
)}
) } const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [ { value: 'text', label: 'Text' }, { value: 'date', label: 'Date' }, { value: 'boolean', label: 'Boolean' }, { value: 'status', label: 'Status' }, { value: 'url', label: 'URL' }, ] function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: { propKey: string; currentMode: PropertyDisplayMode; autoMode: PropertyDisplayMode onSelect: (key: string, mode: PropertyDisplayMode | null) => void }) { const [open, setOpen] = useState(false) const triggerRef = useRef(null) const positionMenu = useCallback((node: HTMLDivElement | null) => { if (!node) return const el = triggerRef.current if (!el) return const rect = el.getBoundingClientRect() const menuW = 140 let left = rect.right - menuW if (left < 8) left = 8 node.style.top = `${rect.bottom + 4}px` node.style.left = `${left}px` }, []) const handleSelect = (mode: PropertyDisplayMode) => { if (mode === autoMode) { onSelect(propKey, null) } else { onSelect(propKey, mode) } setOpen(false) } return (
{open && createPortal( <>
setOpen(false)} />
{DISPLAY_MODE_OPTIONS.map(opt => { const OptIcon = DISPLAY_MODE_ICONS[opt.value] return ( ) })}
, document.body )}
) } const DISPLAY_MODE_ICONS: Record = { text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, } const ADD_INPUT_CLASS = "h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary" function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { const boolVal = value.toLowerCase() === 'true' return ( ) } function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { const selectedDate = value ? parseDateValue(value) : undefined const formatted = value ? formatDateValue(value) : '' return ( { if (day) onChange(dateToISO(day)) }} defaultMonth={selectedDate} /> ) } function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) { const [showDropdown, setShowDropdown] = useState(false) return ( {showDropdown && ( { onChange(v); setShowDropdown(false) }} onCancel={() => setShowDropdown(false)} /> )} ) } function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: { displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[] }) { switch (displayMode) { case 'boolean': return case 'date': return case 'status': return default: return ( onChange(e.target.value)} onKeyDown={onKeyDown} /> ) } } function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: { onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void vaultStatuses: string[] }) { const [newKey, setNewKey] = useState('') const [newValue, setNewValue] = useState('') const [displayMode, setDisplayMode] = useState('text') const handleModeChange = (mode: PropertyDisplayMode) => { setDisplayMode(mode) if (mode === 'boolean') setNewValue('false') else if (mode !== displayMode) setNewValue('') } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode) else if (e.key === 'Escape') onCancel() } return (
setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus />
) } const TYPE_NONE = '__none__' function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) { if (!isA) return null return (
Type {onNavigate ? ( ) : ( {isA} )}
) } function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, onUpdateProperty, onNavigate }: { isA?: string | null; customColorKey?: string | null; availableTypes: string[] typeColorKeys: Record onUpdateProperty?: (key: string, value: FrontmatterValue) => void onNavigate?: (target: string) => void }) { if (!onUpdateProperty) return const currentValue = isA || TYPE_NONE const options = isA && !availableTypes.includes(isA) ? [...availableTypes, isA].sort((a, b) => a.localeCompare(b)) : availableTypes return (
Type
) } function toBooleanValue(value: FrontmatterValue): boolean { if (typeof value === 'boolean') return value if (typeof value === 'string') return value.toLowerCase() === 'true' return false } function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode { if (typeof value === 'boolean') return 'boolean' if (typeof value === 'string' && isUrlValue(value)) return 'url' return 'text' } function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate }: { propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean vaultStatuses: string[] onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void }) { const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) } if (Array.isArray(value)) return onSaveList(propKey, items)} label={propKey} /> const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode switch (resolvedMode) { case 'status': return case 'date': return onSave(propKey, v)} /> case 'boolean': { const boolVal = toBooleanValue(value) return onUpdate?.(propKey, !boolVal)} /> } case 'url': return default: return } } function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: { propKey: string; value: FrontmatterValue; editingKey: string | null displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode vaultStatuses: string[] 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 }) { return (
{propKey} {onDelete && ( )}
) } function InfoRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
) } function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) { return ( ) } function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: number }) { return (

Info

) } function reconcileListUpdate( newItems: string[], onUpdate: (key: string, value: FrontmatterValue) => void, onDelete: ((key: string) => void) | undefined, key: string, ) { if (newItems.length === 0) onDelete?.(key) else if (newItems.length === 1) onUpdate(key, newItems[0]) else onUpdate(key, newItems) } function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) { const typeEntries = (entries ?? []).filter(e => e.isA === 'Type') const typeColorKeys: Record = {} for (const e of typeEntries) { typeColorKeys[e.title] = e.color ?? null } return { availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)), customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null, typeColorKeys, } } function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] { const seen = new Set() for (const e of entries ?? []) { if (e.status) seen.add(e.status) } return Array.from(seen).sort((a, b) => a.localeCompare(b)) } function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean { return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value) } function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue { return mode === 'boolean' ? rawValue.toLowerCase() === 'true' : parseNewValue(rawValue) } function persistModeOverride(key: string, mode: PropertyDisplayMode | null) { if (mode === null) removeDisplayModeOverride(key) else saveDisplayModeOverride(key, mode) } interface PropertyPanelDeps { entries: VaultEntry[] | undefined entryIsA: string | null frontmatter: ParsedFrontmatter onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void } function usePropertyPanelState(deps: PropertyPanelDeps) { const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps const [editingKey, setEditingKey] = useState(null) const [showAddDialog, setShowAddDialog] = useState(false) const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides()) const { availableTypes, customColorKey, typeColorKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA]) const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries]) const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter]) const handleSaveValue = useCallback((key: string, newValue: string) => { setEditingKey(null) if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue)) }, [onUpdateProperty]) const handleSaveList = useCallback((key: string, newItems: string[]) => { if (!onUpdateProperty) return reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key) }, [onUpdateProperty, onDeleteProperty]) const handleAdd = useCallback((rawKey: string, rawValue: string, mode: PropertyDisplayMode) => { if (!rawKey.trim() || !onAddProperty) return onAddProperty(rawKey.trim(), parseAddedValue(rawValue, mode)) if (mode !== 'text') { persistModeOverride(rawKey.trim(), mode) setDisplayOverrides(loadDisplayModeOverrides()) } setShowAddDialog(false) }, [onAddProperty]) const handleDisplayModeChange = useCallback((key: string, mode: PropertyDisplayMode | null) => { persistModeOverride(key, mode) setDisplayOverrides(loadDisplayModeOverrides()) }, []) return { editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides, availableTypes, customColorKey, typeColorKeys, vaultStatuses, propertyEntries, handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange, } } export function DynamicPropertiesPanel({ entry, content, frontmatter, entries, onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, }: { 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 }) { const { editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides, availableTypes, customColorKey, typeColorKeys, vaultStatuses, propertyEntries, handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange, } = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty }) const wordCount = countWords(content ?? '') return (
{propertyEntries.map(([key, value]) => ( ))}
{showAddDialog ? setShowAddDialog(false)} vaultStatuses={vaultStatuses} /> : setShowAddDialog(true)} disabled={!onAddProperty} /> }
) }