import { Plus } from '@phosphor-icons/react' import { useMemo, useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import type { VaultEntry, WorkspaceIdentity } from '../types' import type { FrontmatterValue } from './Inspector' import type { ParsedFrontmatter } from '../utils/frontmatter' import { usePropertyPanelState } from '../hooks/usePropertyPanelState' import { getEffectiveDisplayMode, detectPropertyType, DISPLAY_MODE_ICONS } from '../utils/propertyTypes' import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCells' import { TypeSelector } from './TypeSelector' import { FormatSelector } from './FormatSelector' import { WorkspaceSelector } from './WorkspaceSelector' import { AddPropertyForm } from './AddPropertyForm' import type { PropertyDisplayMode } from '../utils/propertyTypes' import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents' import { PROPERTY_PANEL_GRID_STYLE, PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME, PROPERTY_PANEL_LABEL_CLASS_NAME, PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME, PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME, PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME, PROPERTY_PANEL_ROW_STYLE, } from './propertyPanelLayout' import { humanizePropertyKey } from '../utils/propertyLabels' import { translate, type AppLocale } from '../lib/i18n' import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata' import { noteFormatFromFrontmatter, type NoteFormat } from '../utils/noteFormat' // 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 } const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 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' function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, locale, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: { propKey: string; value: FrontmatterValue; editingKey: string | null displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode vaultStatuses: string[]; vaultTags: 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 locale: AppLocale }) { return (
{humanizePropertyKey(propKey)} {onDelete && ( )}
) } function AddPropertyButton({ locale, onClick, disabled }: { locale: AppLocale; onClick: () => void; disabled: boolean }) { return ( ) } const SUGGESTED_PROPERTIES = [ { key: 'Status', label: 'Status' }, { key: 'date', label: 'Date' }, { key: 'URL', label: 'URL' }, { key: 'icon', label: 'Icon' }, ] as const const SUGGESTED_PROPERTY_MODES: Record = { Status: 'status', date: 'date', URL: 'url', icon: 'text', } function getSuggestedDisplayMode(key: string): PropertyDisplayMode { return (Reflect.get(SUGGESTED_PROPERTY_MODES, key) as PropertyDisplayMode | undefined) ?? 'text' } function resolveMissingTypeName(entryIsA: string | null | undefined, availableTypes: string[]): string | null { const trimmed = entryIsA?.trim() if (!trimmed) return null return availableTypes.includes(trimmed) ? null : trimmed } function SuggestedPropertySlot({ label, displayMode, onAdd }: { label: string displayMode: PropertyDisplayMode onAdd: () => void }) { const SuggestedIcon = Reflect.get(DISPLAY_MODE_ICONS, displayMode) as typeof Plus return ( ) } function TypeDerivedPropertySlot({ propKey, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDisplayModeChange, locale, }: { propKey: string editingKey: string | null displayMode: PropertyDisplayMode autoMode: PropertyDisplayMode vaultStatuses: string[] vaultTags: string[] onStartEdit: (key: string | null) => void onSave: (key: string, value: string) => void onSaveList: (key: string, items: string[]) => void onUpdate?: (key: string, value: FrontmatterValue) => void onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void locale: AppLocale }) { if (editingKey === propKey) { return ( ) } const PlaceholderIcon = Reflect.get(DISPLAY_MODE_ICONS, displayMode) as typeof Plus return ( ) } function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set { const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase())) for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase()) if (hasSystemMetadataKey(keys, '_icon')) keys.add('icon') return keys } function getMissingSuggestedProperties(canAddProperty: boolean, existingKeys: Set, pendingSuggestedKey: string | null) { if (!canAddProperty) return [] return SUGGESTED_PROPERTIES.filter( ({ key }) => !existingKeys.has(key.toLowerCase()) && key !== pendingSuggestedKey, ) } function useFocusNoteIconProperty({ onAddProperty, setEditingKey, setPendingSuggestedKey, }: { onAddProperty?: (key: string, value: FrontmatterValue) => void setEditingKey: (key: string | null) => void setPendingSuggestedKey: (key: string | null) => void }) { useEffect(() => { const handleFocusNoteIcon = () => { if (!onAddProperty) return setPendingSuggestedKey('icon') setEditingKey('icon') } window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon) return () => window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon) }, [onAddProperty, setEditingKey, setPendingSuggestedKey]) } function useSuggestedPropertyActions({ onAddProperty, setEditingKey, setPendingSuggestedKey, }: { onAddProperty?: (key: string, value: FrontmatterValue) => void setEditingKey: (key: string | null) => void setPendingSuggestedKey: (key: string | null) => void }) { const handleSuggestedAdd = useCallback((key: string) => { if (!onAddProperty) return setPendingSuggestedKey(key) setEditingKey(key) }, [onAddProperty, setEditingKey, setPendingSuggestedKey]) const handlePendingSuggestedEdit = useCallback((key: string | null) => { setEditingKey(key) if (key === null) setPendingSuggestedKey(null) }, [setEditingKey, setPendingSuggestedKey]) const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => { setEditingKey(null) setPendingSuggestedKey(null) if (!onAddProperty) { return } const trimmed = newValue.trim() if (!trimmed) { return } onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed) }, [onAddProperty, setEditingKey, setPendingSuggestedKey]) return { handlePendingSuggestedEdit, handleSaveSuggestedValue, handleSuggestedAdd, } } function PropertyEntryRows({ source, entries, editingKey, displayOverrides, vaultStatuses, vaultTagsByKey, locale, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange, }: { source: 'frontmatter' | 'type-derived' entries: [string, FrontmatterValue][] editingKey: string | null displayOverrides: Record vaultStatuses: string[] vaultTagsByKey: Record locale: AppLocale 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 ( <> {entries.map(([key, value]) => ( source === 'type-derived' ? ( ) : ( ) ))} ) } function PendingSuggestedPropertyRow({ pendingSuggestedKey, editingKey, vaultStatuses, vaultTagsByKey, locale, onStartEdit, onSave, onSaveList, onDisplayModeChange, }: { pendingSuggestedKey: string | null editingKey: string | null vaultStatuses: string[] vaultTagsByKey: Record locale: AppLocale onStartEdit: (key: string | null) => void onSave: (key: string, value: string) => void onSaveList: (key: string, items: string[]) => void onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void }) { if (!pendingSuggestedKey || editingKey !== pendingSuggestedKey) { return null } return ( ) } function SuggestedPropertyRows({ properties, onAdd, }: { properties: Array<{ key: string; label: string }> onAdd: (key: string) => void }) { return ( <> {properties.map(({ key, label }) => ( onAdd(key)} /> ))} ) } type PropertyPanelState = ReturnType function MetadataSelectors({ availableTypes, customColorKey, entry, format, locale, missingTypeName, onChangeWorkspace, onCreateMissingType, onDeleteProperty, onNavigate, onUpdateProperty, typeColorKeys, typeIconKeys, workspaces, }: { availableTypes: string[] customColorKey?: string | null entry: VaultEntry format: NoteFormat locale: AppLocale missingTypeName?: string | null onChangeWorkspace?: (workspace: WorkspaceIdentity) => void | Promise onCreateMissingType?: (typeName: string) => boolean | void | Promise onDeleteProperty?: (key: string) => void onNavigate?: (target: string) => void onUpdateProperty?: (key: string, value: FrontmatterValue) => void typeColorKeys: Record typeIconKeys: Record workspaces?: WorkspaceIdentity[] }) { return ( <> ) } function DynamicPropertiesPanelContent({ entry, propertyState, pendingSuggestedKey, missingSuggested, missingTypeName, format, locale, workspaces, onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType, onChangeWorkspace, onStartPendingSuggestedEdit, onSaveSuggestedValue, onAddSuggestedProperty, }: { entry: VaultEntry propertyState: PropertyPanelState pendingSuggestedKey: string | null missingSuggested: Array<{ key: string; label: string }> missingTypeName: string | null format: NoteFormat locale: AppLocale workspaces?: WorkspaceIdentity[] onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void onNavigate?: (target: string) => void onCreateMissingType?: (typeName: string) => boolean | void | Promise onChangeWorkspace?: (workspace: WorkspaceIdentity) => void | Promise onStartPendingSuggestedEdit: (key: string | null) => void onSaveSuggestedValue: (key: string, newValue: string) => void onAddSuggestedProperty: (key: string) => void }) { const { editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides, availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries, typeDerivedPropertyEntries, handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange, } = propertyState return (
{!showAddDialog && ( setShowAddDialog(true)} disabled={!onAddProperty} /> )}
{showAddDialog && ( setShowAddDialog(false)} vaultStatuses={vaultStatuses} locale={locale} /> )}
) } interface DynamicPropertiesPanelProps { entry: VaultEntry content?: string | null frontmatter: ParsedFrontmatter entries?: VaultEntry[] workspaces?: WorkspaceIdentity[] onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void onNavigate?: (target: string) => void onCreateMissingType?: (typeName: string) => boolean | void | Promise onChangeWorkspace?: (workspace: WorkspaceIdentity) => void | Promise locale?: AppLocale } export function DynamicPropertiesPanel({ entry, frontmatter, entries, onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType, onChangeWorkspace, workspaces, locale = 'en', }: DynamicPropertiesPanelProps) { const propertyState = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty }) const [pendingSuggestedKey, setPendingSuggestedKey] = useState(null) const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, propertyState.availableTypes), [entry.isA, propertyState.availableTypes]) const format = useMemo(() => noteFormatFromFrontmatter(frontmatter), [frontmatter]) const existingKeys = useMemo( () => getExistingPropertyKeys([...propertyState.propertyEntries, ...propertyState.typeDerivedPropertyEntries], frontmatter), [frontmatter, propertyState.propertyEntries, propertyState.typeDerivedPropertyEntries], ) const missingSuggested = useMemo( () => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey), [existingKeys, onAddProperty, pendingSuggestedKey], ) const { handlePendingSuggestedEdit, handleSaveSuggestedValue, handleSuggestedAdd, } = useSuggestedPropertyActions({ onAddProperty, setEditingKey: propertyState.setEditingKey, setPendingSuggestedKey, }) useFocusNoteIconProperty({ onAddProperty, setEditingKey: propertyState.setEditingKey, setPendingSuggestedKey }) return ( ) }