diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx new file mode 100644 index 00000000..b3aab1d3 --- /dev/null +++ b/src/components/DynamicPropertiesPanel.tsx @@ -0,0 +1,266 @@ +import { useMemo, useState, useCallback } from 'react' +import type { VaultEntry } from '../types' +import type { FrontmatterValue } from './Inspector' +import type { ParsedFrontmatter } from '../utils/frontmatter' +import { EditableValue, EditableList } from './EditableValue' +import { Button } from '@/components/ui/button' + +const STATUS_STYLES: Record = { + Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, + Done: { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }, + Paused: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, + Archived: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, + Dropped: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, + Open: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, + Closed: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, + 'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, + Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, + Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, +} + +const DEFAULT_STATUS_STYLE = { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' } + +// 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 +const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace']) + +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 countWords(content: string | null): number { + if (!content) return 0 + const stripped = content.replace(/^---[\s\S]*?---\n?/, '') + const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0) + return words.length +} + +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' }) +} + +export function DynamicPropertiesPanel({ + entry, + content, + frontmatter, + onUpdateProperty, + onDeleteProperty, + onAddProperty, +}: { + entry: VaultEntry + content: string | null + frontmatter: ParsedFrontmatter + onUpdateProperty?: (key: string, value: FrontmatterValue) => void + onDeleteProperty?: (key: string) => void + onAddProperty?: (key: string, value: FrontmatterValue) => void +}) { + const [editingKey, setEditingKey] = useState(null) + const [showAddDialog, setShowAddDialog] = useState(false) + const [newKey, setNewKey] = useState('') + const [newValue, setNewValue] = useState('') + + const wordCount = countWords(content) + + const propertyEntries = useMemo(() => { + return Object.entries(frontmatter) + .filter(([key, value]) => { + if (SKIP_KEYS.has(key)) return false + if (RELATIONSHIP_KEYS.has(key)) return false + if (containsWikilinks(value)) return false + return true + }) + }, [frontmatter]) + + const handleSaveValue = useCallback((key: string, newValue: string) => { + setEditingKey(null) + if (onUpdateProperty) { + if (newValue.toLowerCase() === 'true') onUpdateProperty(key, true) + else if (newValue.toLowerCase() === 'false') onUpdateProperty(key, false) + else if (!isNaN(Number(newValue)) && newValue.trim() !== '') onUpdateProperty(key, Number(newValue)) + else onUpdateProperty(key, newValue) + } + }, [onUpdateProperty]) + + const handleSaveList = useCallback((key: string, newItems: string[]) => { + if (onUpdateProperty) { + if (newItems.length === 0) onDeleteProperty?.(key) + else if (newItems.length === 1) onUpdateProperty(key, newItems[0]) + else onUpdateProperty(key, newItems) + } + }, [onUpdateProperty, onDeleteProperty]) + + const handleAddProperty = useCallback(() => { + if (newKey.trim() && onAddProperty) { + if (newValue.includes(',')) { + const items = newValue.split(',').map(s => s.trim()).filter(s => s) + onAddProperty(newKey.trim(), items.length === 1 ? items[0] : items) + } else { + onAddProperty(newKey.trim(), newValue.trim() || '') + } + setNewKey('') + setNewValue('') + setShowAddDialog(false) + } + }, [newKey, newValue, onAddProperty]) + + const handleAddKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleAddProperty() + else if (e.key === 'Escape') { + setShowAddDialog(false) + setNewKey('') + setNewValue('') + } + } + + const renderEditableValue = (key: string, value: FrontmatterValue) => { + if (value === null || value === undefined) { + return ( + setEditingKey(key)} + onSave={(v) => handleSaveValue(key, v)} + onCancel={() => setEditingKey(null)} + /> + ) + } + + if (key === 'Status' || key.includes('Status')) { + const statusStr = String(value) + const style = STATUS_STYLES[statusStr] ?? DEFAULT_STATUS_STYLE + if (editingKey === key) { + return ( + { + if (e.key === 'Enter') handleSaveValue(key, (e.target as HTMLInputElement).value) + if (e.key === 'Escape') setEditingKey(null) + }} + onBlur={(e) => handleSaveValue(key, e.target.value)} + autoFocus + /> + ) + } + return ( + setEditingKey(key)} title="Click to edit" + > + {statusStr} + + ) + } + + if (Array.isArray(value)) { + return handleSaveList(key, items)} label={key} /> + } + + if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) { + return ( + setEditingKey(key)} + onSave={(v) => handleSaveValue(key, v)} + onCancel={() => setEditingKey(null)} + /> + ) + } + + if (typeof value === 'boolean') { + return ( + + ) + } + + return ( + setEditingKey(key)} + onSave={(v) => handleSaveValue(key, v)} + onCancel={() => setEditingKey(null)} + /> + ) + } + + return ( +
+

Properties

+
+ {entry.isA && ( +
+ Type + {entry.isA} +
+ )} + + {propertyEntries.map(([key, value]) => ( +
+ + {key} + {onDeleteProperty && ( + + )} + + {renderEditableValue(key, value)} +
+ ))} + +
+ Modified + {formatDate(entry.modifiedAt)} +
+
+ Words + {wordCount} +
+
+ + {showAddDialog ? ( +
+ setNewKey(e.target.value)} onKeyDown={handleAddKeyDown} autoFocus + /> + setNewValue(e.target.value)} onKeyDown={handleAddKeyDown} + /> +
+ + +
+
+ ) : ( + + )} +
+ ) +} diff --git a/src/components/EditableValue.tsx b/src/components/EditableValue.tsx new file mode 100644 index 00000000..5f082f15 --- /dev/null +++ b/src/components/EditableValue.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react' + +export function EditableValue({ + value, + onSave, + onCancel, + isEditing, + onStartEdit +}: { + value: string + onSave: (newValue: string) => void + onCancel: () => void + isEditing: boolean + onStartEdit: () => void +}) { + const [editValue, setEditValue] = useState(value) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSave(editValue) + } else if (e.key === 'Escape') { + setEditValue(value) + onCancel() + } + } + + if (isEditing) { + return ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => onSave(editValue)} + autoFocus + /> + ) + } + + return ( + + {value || '\u2014'} + + ) +} + +export function EditableList({ + items, + onSave, + label, +}: { + items: string[] + onSave: (newItems: string[]) => void + label: string +}) { + const [editingIndex, setEditingIndex] = useState(null) + const [editValue, setEditValue] = useState('') + const [isAddingNew, setIsAddingNew] = useState(false) + const [newValue, setNewValue] = useState('') + + const handleStartEdit = (index: number) => { + setEditingIndex(index) + setEditValue(items[index]) + } + + const handleSaveEdit = () => { + if (editingIndex !== null) { + const newItems = [...items] + if (editValue.trim()) { + newItems[editingIndex] = editValue.trim() + } else { + newItems.splice(editingIndex, 1) + } + onSave(newItems) + setEditingIndex(null) + } + } + + const handleDeleteItem = (index: number) => { + const newItems = items.filter((_, i) => i !== index) + onSave(newItems) + } + + const handleAddNew = () => { + if (newValue.trim()) { + onSave([...items, newValue.trim()]) + setNewValue('') + setIsAddingNew(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent, action: 'edit' | 'add') => { + if (e.key === 'Enter') { + if (action === 'edit') handleSaveEdit() + else handleAddNew() + } else if (e.key === 'Escape') { + if (action === 'edit') { + setEditingIndex(null) + setEditValue('') + } else { + setIsAddingNew(false) + setNewValue('') + } + } + } + + return ( +
+ {items.map((item, idx) => ( +
+ {editingIndex === idx ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, 'edit')} + onBlur={handleSaveEdit} + autoFocus + /> + ) : ( + <> + handleStartEdit(idx)} + title="Click to edit" + > + {item} + + + + )} +
+ ))} + {isAddingNew ? ( + setNewValue(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, 'add')} + onBlur={handleAddNew} + placeholder={`New ${label.toLowerCase()}...`} + autoFocus + /> + ) : ( + + )} +
+ ) +} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index c1838aa3..aff8d48c 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState, useCallback } from 'react' +import { useMemo, useCallback } from 'react' import type { VaultEntry, GitCommit } from '../types' import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' import { SlidersHorizontal, X } from '@phosphor-icons/react' +import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter' +import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel' interface InspectorProps { collapsed: boolean @@ -20,156 +21,16 @@ interface InspectorProps { export type FrontmatterValue = string | number | boolean | string[] | null -interface ParsedFrontmatter { - [key: string]: FrontmatterValue -} - -const STATUS_STYLES: Record = { - Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, - Done: { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }, - Paused: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, - Archived: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, - Dropped: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, - Open: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, - Closed: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, - 'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, - Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, - Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, -} - -const DEFAULT_STATUS_STYLE = { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' } - -// Keys that are relationships (contain wikilinks) -const RELATIONSHIP_KEYS = new Set([ - 'Belongs to', - 'Related to', - 'Events', - 'Has Data', - 'Owner', - 'Advances', - 'Parent', - 'Children', - 'Has', - 'Notes', -]) - -// Keys to skip showing in Properties (shown elsewhere or internal) -const SKIP_KEYS = new Set([ - 'aliases', - 'notion_id', - 'workspace', -]) - -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 countWords(content: string | null): number { - if (!content) return 0 - // Strip YAML frontmatter - const stripped = content.replace(/^---[\s\S]*?---\n?/, '') - const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0) - return words.length -} - -/** Parse YAML frontmatter from content */ -function parseFrontmatter(content: string | null): ParsedFrontmatter { - if (!content) return {} - - const match = content.match(/^---\n([\s\S]*?)\n---/) - if (!match) return {} - - const yaml = match[1] - const result: ParsedFrontmatter = {} - - let currentKey: string | null = null - let currentList: string[] = [] - let inList = false - - const lines = yaml.split('\n') - - for (const line of lines) { - // Check for list item - const listMatch = line.match(/^ - (.*)$/) - if (listMatch && currentKey) { - inList = true - currentList.push(listMatch[1].replace(/^["']|["']$/g, '')) - continue - } - - // If we were in a list and hit a non-list line, save it - if (inList && currentKey) { - result[currentKey] = currentList.length === 1 ? currentList[0] : currentList - currentList = [] - inList = false - } - - // Check for key: value - const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/) - if (kvMatch) { - currentKey = kvMatch[1].trim() - const value = kvMatch[2].trim() - - if (value === '' || value === '|' || value === '>') { - // Empty value or multiline - wait for list items or next key - continue - } - - // Handle inline list like [item1, item2] - if (value.startsWith('[') && value.endsWith(']')) { - const items = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')) - result[currentKey] = items.length === 1 ? items[0] : items - continue - } - - // Handle quoted string - const unquoted = value.replace(/^["']|["']$/g, '') - - // Handle boolean - if (unquoted.toLowerCase() === 'true') { - result[currentKey] = true - continue - } - if (unquoted.toLowerCase() === 'false') { - result[currentKey] = false - continue - } - - result[currentKey] = unquoted - } - } - - // Don't forget last list if any - if (inList && currentKey) { - result[currentKey] = currentList.length === 1 ? currentList[0] : currentList - } - - return result -} - /** Check if a string is a wikilink */ function isWikilink(value: string): boolean { return /^\[\[.*\]\]$/.test(value) } -/** Check if a value contains wikilinks */ -function containsWikilinks(value: FrontmatterValue): boolean { - if (typeof value === 'string') return isWikilink(value) - if (Array.isArray(value)) return value.some(v => typeof v === 'string' && isWikilink(v)) - return false -} - -/** Extract display name from a wikilink like "[[responsibility/grow-newsletter|Grow Newsletter]]" */ +/** Extract display name from a wikilink */ function wikilinkDisplay(ref: string): string { const inner = ref.replace(/^\[\[|\]\]$/g, '') - // Check for pipe alias: [[path|Display Name]] const pipeIdx = inner.indexOf('|') - if (pipeIdx !== -1) { - return inner.slice(pipeIdx + 1) - } - // Take last path segment and convert to title case + if (pipeIdx !== -1) return inner.slice(pipeIdx + 1) const last = inner.split('/').pop() ?? inner return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } @@ -177,180 +38,8 @@ function wikilinkDisplay(ref: string): string { /** Extract the target path for navigation from a wikilink ref */ function wikilinkTarget(ref: string): string { const inner = ref.replace(/^\[\[|\]\]$/g, '') - // Check for pipe alias: [[path|Display Name]] — use path part const pipeIdx = inner.indexOf('|') - const path = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner - // Return the full path for matching (not just last segment) - return path -} - -// Editable value component for inline editing -function EditableValue({ - value, - onSave, - onCancel, - isEditing, - onStartEdit -}: { - value: string - onSave: (newValue: string) => void - onCancel: () => void - isEditing: boolean - onStartEdit: () => void -}) { - const [editValue, setEditValue] = useState(value) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - onSave(editValue) - } else if (e.key === 'Escape') { - setEditValue(value) - onCancel() - } - } - - if (isEditing) { - return ( - setEditValue(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={() => onSave(editValue)} - autoFocus - /> - ) - } - - return ( - - {value || '\u2014'} - - ) -} - -// Editable list component -function EditableList({ - items, - onSave, - label, -}: { - items: string[] - onSave: (newItems: string[]) => void - label: string -}) { - const [editingIndex, setEditingIndex] = useState(null) - const [editValue, setEditValue] = useState('') - const [isAddingNew, setIsAddingNew] = useState(false) - const [newValue, setNewValue] = useState('') - - const handleStartEdit = (index: number) => { - setEditingIndex(index) - setEditValue(items[index]) - } - - const handleSaveEdit = () => { - if (editingIndex !== null) { - const newItems = [...items] - if (editValue.trim()) { - newItems[editingIndex] = editValue.trim() - } else { - // Remove empty items - newItems.splice(editingIndex, 1) - } - onSave(newItems) - setEditingIndex(null) - } - } - - const handleDeleteItem = (index: number) => { - const newItems = items.filter((_, i) => i !== index) - onSave(newItems) - } - - const handleAddNew = () => { - if (newValue.trim()) { - onSave([...items, newValue.trim()]) - setNewValue('') - setIsAddingNew(false) - } - } - - const handleKeyDown = (e: React.KeyboardEvent, action: 'edit' | 'add') => { - if (e.key === 'Enter') { - if (action === 'edit') handleSaveEdit() - else handleAddNew() - } else if (e.key === 'Escape') { - if (action === 'edit') { - setEditingIndex(null) - setEditValue('') - } else { - setIsAddingNew(false) - setNewValue('') - } - } - } - - return ( -
- {items.map((item, idx) => ( -
- {editingIndex === idx ? ( - setEditValue(e.target.value)} - onKeyDown={(e) => handleKeyDown(e, 'edit')} - onBlur={handleSaveEdit} - autoFocus - /> - ) : ( - <> - handleStartEdit(idx)} - title="Click to edit" - > - {item} - - - - )} -
- ))} - {isAddingNew ? ( - setNewValue(e.target.value)} - onKeyDown={(e) => handleKeyDown(e, 'add')} - onBlur={handleAddNew} - placeholder={`New ${label.toLowerCase()}...`} - autoFocus - /> - ) : ( - - )} -
- ) + return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner } function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: string[]; onNavigate: (target: string) => void }) { @@ -363,13 +52,7 @@ function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: s @@ -438,299 +97,23 @@ function DynamicRelationshipsPanel({ ) } -function DynamicPropertiesPanel({ - entry, - content, - frontmatter, - onUpdateProperty, - onDeleteProperty, - onAddProperty, -}: { - entry: VaultEntry - content: string | null - frontmatter: ParsedFrontmatter - onUpdateProperty?: (key: string, value: FrontmatterValue) => void - onDeleteProperty?: (key: string) => void - onAddProperty?: (key: string, value: FrontmatterValue) => void -}) { - const [editingKey, setEditingKey] = useState(null) - const [showAddDialog, setShowAddDialog] = useState(false) - const [newKey, setNewKey] = useState('') - const [newValue, setNewValue] = useState('') - - const wordCount = countWords(content) - - // Filter out relationship keys and skipped keys - const propertyEntries = useMemo(() => { - return Object.entries(frontmatter) - .filter(([key, value]) => { - if (SKIP_KEYS.has(key)) return false - if (RELATIONSHIP_KEYS.has(key)) return false - if (containsWikilinks(value)) return false - return true - }) - }, [frontmatter]) - - const handleSaveValue = useCallback((key: string, newValue: string) => { - setEditingKey(null) - if (onUpdateProperty) { - // Try to preserve type - if (newValue.toLowerCase() === 'true') { - onUpdateProperty(key, true) - } else if (newValue.toLowerCase() === 'false') { - onUpdateProperty(key, false) - } else if (!isNaN(Number(newValue)) && newValue.trim() !== '') { - onUpdateProperty(key, Number(newValue)) - } else { - onUpdateProperty(key, newValue) - } - } - }, [onUpdateProperty]) - - const handleSaveList = useCallback((key: string, newItems: string[]) => { - if (onUpdateProperty) { - if (newItems.length === 0) { - onDeleteProperty?.(key) - } else if (newItems.length === 1) { - onUpdateProperty(key, newItems[0]) - } else { - onUpdateProperty(key, newItems) - } - } - }, [onUpdateProperty, onDeleteProperty]) - - const handleAddProperty = useCallback(() => { - if (newKey.trim() && onAddProperty) { - // Check if it looks like a list - if (newValue.includes(',')) { - const items = newValue.split(',').map(s => s.trim()).filter(s => s) - onAddProperty(newKey.trim(), items.length === 1 ? items[0] : items) - } else { - onAddProperty(newKey.trim(), newValue.trim() || '') - } - setNewKey('') - setNewValue('') - setShowAddDialog(false) - } - }, [newKey, newValue, onAddProperty]) - - const handleAddKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleAddProperty() - } else if (e.key === 'Escape') { - setShowAddDialog(false) - setNewKey('') - setNewValue('') - } - } - - const renderEditableValue = (key: string, value: FrontmatterValue) => { - if (value === null || value === undefined) { - return ( - setEditingKey(key)} - onSave={(v) => handleSaveValue(key, v)} - onCancel={() => setEditingKey(null)} - /> - ) - } - - // Status gets special rendering but is still editable - if (key === 'Status' || key.includes('Status')) { - const statusStr = String(value) - const style = STATUS_STYLES[statusStr] ?? DEFAULT_STATUS_STYLE - if (editingKey === key) { - return ( - { - if (e.key === 'Enter') handleSaveValue(key, (e.target as HTMLInputElement).value) - if (e.key === 'Escape') setEditingKey(null) - }} - onBlur={(e) => handleSaveValue(key, e.target.value)} - autoFocus - /> - ) - } - return ( - setEditingKey(key)} - title="Click to edit" - > - {statusStr} - - ) - } - - // Arrays get list editor - if (Array.isArray(value)) { - return ( - handleSaveList(key, items)} - label={key} - /> - ) - } - - // Date fields - still editable but formatted - if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) { - return ( - setEditingKey(key)} - onSave={(v) => handleSaveValue(key, v)} - onCancel={() => setEditingKey(null)} - /> - ) - } - - // Boolean - if (typeof value === 'boolean') { - return ( - - ) - } - - // Default: editable string - return ( - setEditingKey(key)} - onSave={(v) => handleSaveValue(key, v)} - onCancel={() => setEditingKey(null)} - /> - ) - } - - return ( -
-

Properties

-
- {/* Always show Type from entry */} - {entry.isA && ( -
- Type - {entry.isA} -
- )} - - {/* Dynamic properties from frontmatter */} - {propertyEntries.map(([key, value]) => ( -
- - {key} - {onDeleteProperty && ( - - )} - - {renderEditableValue(key, value)} -
- ))} - - {/* Always show Modified and Words (read-only) */} -
- Modified - {formatDate(entry.modifiedAt)} -
-
- Words - {wordCount} -
-
- - {/* Add property UI */} - {showAddDialog ? ( -
- setNewKey(e.target.value)} - onKeyDown={handleAddKeyDown} - autoFocus - /> - setNewValue(e.target.value)} - onKeyDown={handleAddKeyDown} - /> -
- - -
-
- ) : ( - - )} -
- ) -} - -/** Find all entries whose content contains a wikilink to the current note */ -function useBacklinks( - entry: VaultEntry | null, - entries: VaultEntry[], - allContent: Record -): VaultEntry[] { +function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allContent: Record): VaultEntry[] { return useMemo(() => { if (!entry) return [] - // Build patterns to match: [[title]], [[filename-without-ext]], [[path-segment/filename-without-ext]] const title = entry.title const stem = entry.filename.replace(/\.md$/, '') - // Also match by aliases const targets = [title, ...entry.aliases] - // Also match path-based links like [[project/26q1-laputa-app]] const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') return entries.filter((e) => { if (e.path === entry.path) return false const content = allContent[e.path] if (!content) return false - // Check for any [[target]] pattern in the content for (const t of targets) { if (content.includes(`[[${t}]]`)) return true } if (content.includes(`[[${stem}]]`)) return true if (content.includes(`[[${pathStem}]]`)) return true - // Also check with pipe aliases if (content.includes(`[[${pathStem}|`)) return true return false }) @@ -803,38 +186,46 @@ function GitHistoryPanel({ commits }: { commits: GitCommit[] }) { ) } +function EmptyInspector() { + return ( + <> +
+

Properties

+

No note selected

+
+
+

Relationships

+

No relationships

+
+
+

Backlinks

+

No backlinks

+
+
+

History

+

No revision history

+
+ + ) +} + export function Inspector({ - collapsed, - onToggle, - entry, - content, - entries, - allContent, - gitHistory, - onNavigate, - onUpdateFrontmatter, - onDeleteProperty, - onAddProperty, + collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate, + onUpdateFrontmatter, onDeleteProperty, onAddProperty, }: InspectorProps) { const backlinks = useBacklinks(entry, entries, allContent) const frontmatter = useMemo(() => parseFrontmatter(content), [content]) const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => { - if (entry && onUpdateFrontmatter) { - onUpdateFrontmatter(entry.path, key, value) - } + if (entry && onUpdateFrontmatter) onUpdateFrontmatter(entry.path, key, value) }, [entry, onUpdateFrontmatter]) const handleDeleteProperty = useCallback((key: string) => { - if (entry && onDeleteProperty) { - onDeleteProperty(entry.path, key) - } + if (entry && onDeleteProperty) onDeleteProperty(entry.path, key) }, [entry, onDeleteProperty]) const handleAddProperty = useCallback((key: string, value: FrontmatterValue) => { - if (entry && onAddProperty) { - onAddProperty(entry.path, key, value) - } + if (entry && onAddProperty) onAddProperty(entry.path, key, value) }, [entry, onAddProperty]) return ( @@ -842,28 +233,16 @@ export function Inspector({ "flex flex-col overflow-y-auto border-l border-border bg-background text-foreground transition-[width] duration-200", collapsed && "!w-10 !min-w-10" )}> -
+
{collapsed ? ( - ) : ( <> Properties - @@ -874,9 +253,7 @@ export function Inspector({ {entry ? ( <> ) : ( - <> -
-

Properties

-

No note selected

-
-
-

Relationships

-

No relationships

-
-
-

Backlinks

-

No backlinks

-
-
-

History

-

No revision history

-
- + )}
)} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts new file mode 100644 index 00000000..e1ec8dc7 --- /dev/null +++ b/src/utils/frontmatter.ts @@ -0,0 +1,72 @@ +import type { FrontmatterValue } from '../components/Inspector' + +export interface ParsedFrontmatter { + [key: string]: FrontmatterValue +} + +/** Parse YAML frontmatter from content */ +export function parseFrontmatter(content: string | null): ParsedFrontmatter { + if (!content) return {} + + const match = content.match(/^---\n([\s\S]*?)\n---/) + if (!match) return {} + + const yaml = match[1] + const result: ParsedFrontmatter = {} + + let currentKey: string | null = null + let currentList: string[] = [] + let inList = false + + const lines = yaml.split('\n') + + for (const line of lines) { + const listMatch = line.match(/^ - (.*)$/) + if (listMatch && currentKey) { + inList = true + currentList.push(listMatch[1].replace(/^["']|["']$/g, '')) + continue + } + + if (inList && currentKey) { + result[currentKey] = currentList.length === 1 ? currentList[0] : currentList + currentList = [] + inList = false + } + + const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/) + if (kvMatch) { + currentKey = kvMatch[1].trim() + const value = kvMatch[2].trim() + + if (value === '' || value === '|' || value === '>') { + continue + } + + if (value.startsWith('[') && value.endsWith(']')) { + const items = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')) + result[currentKey] = items.length === 1 ? items[0] : items + continue + } + + const unquoted = value.replace(/^["']|["']$/g, '') + + if (unquoted.toLowerCase() === 'true') { + result[currentKey] = true + continue + } + if (unquoted.toLowerCase() === 'false') { + result[currentKey] = false + continue + } + + result[currentKey] = unquoted + } + } + + if (inList && currentKey) { + result[currentKey] = currentList.length === 1 ? currentList[0] : currentList + } + + return result +}