import { ArrowUpRight, X as XIcon } from '@phosphor-icons/react' import { useState, useCallback, useEffect, useRef, type ReactNode, type RefObject } from 'react' import { createPortal } from 'react-dom' import { Button } from '@/components/ui/button' import { Calendar } from '@/components/ui/calendar' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { trackDatePropertyDirectEntrySaved } from '../lib/productAnalytics' import { translate, type AppLocale } from '../lib/i18n' import { isValidCssColor } from '../utils/colorUtils' import { isUrlValue } from '../utils/url' import { type PropertyDisplayMode, formatDateValue, toISODate, DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS, } from '../utils/propertyTypes' import { StatusDropdown } from './StatusDropdown' import type { FrontmatterValue } from './Inspector' import { EditableValue, TagPillList, UrlValue } from './EditableValue' import { getStatusStyle } from '../utils/statusStyles' import { TagsDropdown } from './TagsDropdown' import { getTagStyle } from '../utils/tagStyles' import { ColorEditableValue } from './ColorInput' import { IconEditableValue } from './IconEditableValue' import { PROPERTY_CHIP_STYLE } from './propertyChipStyles' import { canonicalSystemMetadataKey } from '../utils/systemMetadata' import { useDateDisplayFormat } from '../hooks/useAppPreferences' const ISO_DATE_INPUT_RE = /^(\d{4})-(\d{2})-(\d{2})$/ const DEFAULT_DATE_PICKER_START_YEAR = 1800 const DEFAULT_DATE_PICKER_END_YEAR = 2200 function localDate(year: number, monthIndex: number, day: number): Date { const date = new Date(0) date.setFullYear(year, monthIndex, day) date.setHours(0, 0, 0, 0) return date } function dateToISO(day: Date): string { const yyyy = String(day.getFullYear()).padStart(4, '0') const mm = String(day.getMonth() + 1).padStart(2, '0') const dd = String(day.getDate()).padStart(2, '0') return `${yyyy}-${mm}-${dd}` } function parseDateInput(value: string): string | null { const trimmed = value.trim() const match = ISO_DATE_INPUT_RE.exec(trimmed) if (!match) return null const year = Number(match[1]) if (year < 1) return null const date = localDate(year, Number(match[2]) - 1, Number(match[3])) return dateToISO(date) === trimmed ? trimmed : null } function dateFromISO(value: string): Date | undefined { const iso = parseDateInput(value) if (!iso) return undefined const match = ISO_DATE_INPUT_RE.exec(iso) if (!match) return undefined return localDate(Number(match[1]), Number(match[2]) - 1, Number(match[3])) } function parseDateValue(value: string): Date | undefined { return dateFromISO(toISODate(value)) } function datePickerStartMonth(selectedDate: Date | undefined): Date { const selectedYear = selectedDate?.getFullYear() const year = selectedYear && selectedYear < DEFAULT_DATE_PICKER_START_YEAR ? selectedYear : DEFAULT_DATE_PICKER_START_YEAR return localDate(year, 0, 1) } function datePickerEndMonth(selectedDate: Date | undefined): Date { const selectedYear = selectedDate?.getFullYear() const year = selectedYear && selectedYear > DEFAULT_DATE_PICKER_END_YEAR ? selectedYear : DEFAULT_DATE_PICKER_END_YEAR return localDate(year, 11, 31) } const RELATIONSHIP_PROPERTY_KEYS = new Set(['belongs_to', 'related_to', 'has']) function normalizePropertyKey(propKey: string): string { return propKey.trim().toLowerCase().replace(/[\s-]+/g, '_') } function showsRelationshipPropertyIcon(propKey: string): boolean { return RELATIONSHIP_PROPERTY_KEYS.has(normalizePropertyKey(propKey)) } 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) const style = getStatusStyle(statusStr) return ( {isEditing && ( onSave(propKey, newValue)} onCancel={() => onStartEdit(null)} /> )} ) } function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: { propKey: string; value: string[]; isEditing: boolean; vaultTags: string[] onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void }) { const handleToggle = useCallback((tag: string) => { const idx = value.indexOf(tag) const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag] onSave(propKey, next) }, [propKey, value, onSave]) const handleRemove = useCallback((tag: string) => { onSave(propKey, value.filter(t => t !== tag)) }, [propKey, value, onSave]) return ( {value.map(tag => { const style = getTagStyle(tag) return ( {tag} ) })} {isEditing && ( onStartEdit(null)} /> )} ) } function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) { return ( ) } function NumberValue({ value, onSave, onCancel, isEditing, onStartEdit, }: ScalarEditProps) { const [editValue, setEditValue] = useState(value) const restoreValue = useCallback(() => { setEditValue(value) }, [value]) const commitValue = useCallback(() => { const trimmed = editValue.trim() if (trimmed === '') { onSave('') return } const parsed = Number(trimmed) if (Number.isFinite(parsed)) { onSave(trimmed) return } restoreValue() onCancel() }, [editValue, onCancel, onSave, restoreValue]) const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter') { commitValue() return } if (event.key === 'Escape') { restoreValue() onCancel() } }, [commitValue, onCancel, restoreValue]) if (isEditing) { return ( setEditValue(event.target.value)} onKeyDown={handleKeyDown} onBlur={commitValue} autoFocus data-testid="number-input" /> ) } return ( ) } function DateValue({ value, onSave, locale = 'en', autoOpen = false, onCancel }: { value: string onSave: (newValue: string) => void locale?: AppLocale autoOpen?: boolean onCancel?: () => void }) { const dateDisplayFormat = useDateDisplayFormat() const [open, setOpen] = useState(autoOpen) const formatted = formatDateValue(value, dateDisplayFormat) const pickDateLabel = translate(locale, 'inspector.properties.pickDate') const selectedDate = parseDateValue(value) const selectedIso = selectedDate ? dateToISO(selectedDate) : '' const [draftValue, setDraftValue] = useState(selectedIso) const [invalidDraft, setInvalidDraft] = useState(false) const resetDraft = () => { setDraftValue(selectedIso) setInvalidDraft(false) } const closeWithoutSave = () => { resetDraft() setOpen(false) onCancel?.() } const commitDraftValue = () => { if (draftValue.trim() === '') { if (value) onSave('') else closeWithoutSave() setOpen(false) return } const parsed = parseDateInput(draftValue) if (!parsed) { setInvalidDraft(true) return } onSave(parsed) trackDatePropertyDirectEntrySaved() setOpen(false) } const handleSelect = (day: Date | undefined) => { if (day) onSave(dateToISO(day)) setOpen(false) } const handleClear = (e: React.MouseEvent) => { e.stopPropagation() onSave('') setOpen(false) } const handleInputChange = (event: React.ChangeEvent) => { setDraftValue(event.target.value) if (invalidDraft) setInvalidDraft(false) } const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault() commitDraftValue() return } if (event.key === 'Escape') { event.preventDefault() closeWithoutSave() } } return ( { if (nextOpen) resetDraft() setOpen(nextOpen) if (!nextOpen) onCancel?.() }} >
{selectedDate && (
)}
) } export 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 CurrentIcon = Reflect.get(DISPLAY_MODE_ICONS, currentMode) as typeof DISPLAY_MODE_ICONS.text const showRelationshipIcon = showsRelationshipPropertyIcon(propKey) const handleSelect = (mode: PropertyDisplayMode) => { onSelect(propKey, mode === autoMode ? null : mode) setOpen(false) } return (
{open && ( setOpen(false)} onSelect={handleSelect} triggerRef={triggerRef} /> )}
) } function DisplayModeMenu({ autoMode, currentMode, onClose, onSelect, triggerRef, }: { autoMode: PropertyDisplayMode currentMode: PropertyDisplayMode onClose: () => void onSelect: (mode: PropertyDisplayMode) => void triggerRef: RefObject }) { const backdropRef = 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 const left = Math.max(rect.right - menuW, 8) node.style.top = `${rect.bottom + 4}px` node.style.left = `${left}px` }, [triggerRef]) useEffect(() => { const backdrop = backdropRef.current if (!backdrop) return backdrop.addEventListener('click', onClose) return () => backdrop.removeEventListener('click', onClose) }, [onClose]) return createPortal( <>
{DISPLAY_MODE_OPTIONS.map((opt) => ( ))}
, document.body, ) } function DisplayModeOption({ autoMode, currentMode, onSelect, option, }: { autoMode: PropertyDisplayMode currentMode: PropertyDisplayMode onSelect: (mode: PropertyDisplayMode) => void option: typeof DISPLAY_MODE_OPTIONS[number] }) { const OptIcon = DISPLAY_MODE_ICONS[option.value] return ( ) } function toBooleanValue(value: FrontmatterValue): boolean { if (typeof value === 'boolean') return value if (typeof value === 'string') return value.toLowerCase() === 'true' return false } function autoDetectFromValue(propKey: string, value: FrontmatterValue): PropertyDisplayMode { if (canonicalSystemMetadataKey(propKey) === '_icon') return 'text' if (typeof value === 'boolean') return 'boolean' if (typeof value === 'string' && isUrlValue(value)) return 'url' if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color' return 'text' } type SmartCellProps = { propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean locale?: AppLocale 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 } interface ScalarEditProps { value: string isEditing: boolean onStartEdit: () => void onSave: (nextValue: string) => void onCancel: () => void } function createScalarEditProps({ propKey, value, isEditing, onStartEdit, onSave, }: { propKey: string value: FrontmatterValue isEditing: boolean onStartEdit: (key: string | null) => void onSave: (key: string, value: string) => void }): ScalarEditProps { return { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (nextValue: string) => onSave(propKey, nextValue), onCancel: () => onStartEdit(null), } } type ScalarRendererProps = SmartCellProps & { editProps: ScalarEditProps } type ScalarDisplayRenderer = (props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) => ReactNode const SCALAR_DISPLAY_RENDERERS: readonly [PropertyDisplayMode, ScalarDisplayRenderer][] = [ ['status', (props) => ( )], ['tags', (props) => ( )], ['date', (props) => ( props.onSave(props.propKey, nextValue)} autoOpen={props.isEditing} onCancel={() => props.onStartEdit(null)} /> )], ['number', (props) => ], ['boolean', (props) => { const boolVal = toBooleanValue(props.value) return props.onUpdate?.(props.propKey, !boolVal)} /> }], ['url', (props) => ], ['color', (props) => ], ] function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) { const renderer = SCALAR_DISPLAY_RENDERERS.find(([mode]) => mode === props.resolvedMode)?.[1] return renderer ? renderer(props) : } function ScalarValueCell(props: SmartCellProps) { const { propKey, value, displayMode, isEditing, onStartEdit, onSave } = props const editProps = createScalarEditProps({ propKey, value, isEditing, onStartEdit, onSave, }) if (canonicalSystemMetadataKey(propKey) === '_icon') { return } const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode return renderScalarDisplayMode({ ...props, resolvedMode, editProps }) } export function SmartPropertyValueCell(props: SmartCellProps) { const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props if (Array.isArray(value)) { if (displayMode === 'tags') { return } return onSaveList(propKey, items)} label={propKey} /> } return }