refactor: decompose Inspector.tsx into EditableValue, DynamicPropertiesPanel, and frontmatter utils
This commit is contained in:
266
src/components/DynamicPropertiesPanel.tsx
Normal file
266
src/components/DynamicPropertiesPanel.tsx
Normal file
@@ -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<string, { bg: string; color: string }> = {
|
||||
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<string | null>(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 (
|
||||
<EditableValue
|
||||
value="" isEditing={editingKey === key}
|
||||
onStartEdit={() => 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 (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text" defaultValue={statusStr}
|
||||
onKeyDown={(e) => {
|
||||
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 (
|
||||
<span
|
||||
className="inline-block cursor-pointer transition-opacity hover:opacity-80"
|
||||
style={{ backgroundColor: style.bg, color: style.color, borderRadius: 9999, padding: '1px 6px', fontSize: 10, fontWeight: 500 }}
|
||||
onClick={() => setEditingKey(key)} title="Click to edit"
|
||||
>
|
||||
{statusStr}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return <EditableList items={value.map(String)} onSave={(items) => handleSaveList(key, items)} label={key} />
|
||||
}
|
||||
|
||||
if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) {
|
||||
return (
|
||||
<EditableValue
|
||||
value={String(value)} isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<button
|
||||
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => onUpdateProperty?.(key, !value)}
|
||||
>
|
||||
{value ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableValue
|
||||
value={String(value)} isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{entry.isA && (
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Type</span>
|
||||
<span className="text-right text-secondary-foreground">{entry.isA}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} className="group/prop flex items-center justify-between text-[13px]">
|
||||
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{key}
|
||||
{onDeleteProperty && (
|
||||
<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={() => onDeleteProperty(key)} title="Delete property"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{renderEditableValue(key, value)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Modified</span>
|
||||
<span className="text-right text-secondary-foreground">{formatDate(entry.modifiedAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Words</span>
|
||||
<span className="text-right text-secondary-foreground">{wordCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddDialog ? (
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Property name" value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleAddKeyDown} autoFocus
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Value" value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)} onKeyDown={handleAddKeyDown}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="xs" onClick={handleAddProperty} disabled={!newKey.trim()}>Add</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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={() => setShowAddDialog(true)} disabled={!onAddProperty}
|
||||
>
|
||||
+ Add property
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
src/components/EditableValue.tsx
Normal file
167
src/components/EditableValue.tsx
Normal file
@@ -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 (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSave(editValue)}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer rounded px-1 py-0.5 text-right text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title="Click to edit"
|
||||
>
|
||||
{value || '\u2014'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditableList({
|
||||
items,
|
||||
onSave,
|
||||
label,
|
||||
}: {
|
||||
items: string[]
|
||||
onSave: (newItems: string[]) => void
|
||||
label: string
|
||||
}) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(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 (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="group/item flex items-center gap-1">
|
||||
{editingIndex === idx ? (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'edit')}
|
||||
onBlur={handleSaveEdit}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="flex-1 cursor-pointer truncate rounded px-1 py-0.5 text-[13px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<button
|
||||
className="border-none bg-transparent p-0 px-1 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/item:opacity-100"
|
||||
onClick={() => handleDeleteItem(idx)}
|
||||
title="Remove item"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isAddingNew ? (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'add')}
|
||||
onBlur={handleAddNew}
|
||||
placeholder={`New ${label.toLowerCase()}...`}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="border-none bg-transparent p-0 py-1 text-left text-xs text-muted-foreground hover:text-secondary-foreground"
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
>
|
||||
+ Add item
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, { bg: string; color: string }> = {
|
||||
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 (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSave(editValue)}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer rounded px-1 py-0.5 text-right text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title="Click to edit"
|
||||
>
|
||||
{value || '\u2014'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Editable list component
|
||||
function EditableList({
|
||||
items,
|
||||
onSave,
|
||||
label,
|
||||
}: {
|
||||
items: string[]
|
||||
onSave: (newItems: string[]) => void
|
||||
label: string
|
||||
}) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(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 (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="group/item flex items-center gap-1">
|
||||
{editingIndex === idx ? (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'edit')}
|
||||
onBlur={handleSaveEdit}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="flex-1 cursor-pointer truncate rounded px-1 py-0.5 text-[13px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<button
|
||||
className="border-none bg-transparent p-0 px-1 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/item:opacity-100"
|
||||
onClick={() => handleDeleteItem(idx)}
|
||||
title="Remove item"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isAddingNew ? (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'add')}
|
||||
onBlur={handleAddNew}
|
||||
placeholder={`New ${label.toLowerCase()}...`}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="border-none bg-transparent p-0 py-1 text-left text-xs text-muted-foreground hover:text-secondary-foreground"
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
>
|
||||
+ Add item
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
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
|
||||
<button
|
||||
key={`${ref}-${idx}`}
|
||||
className="border-none text-left text-primary cursor-pointer hover:opacity-80"
|
||||
style={{
|
||||
background: 'var(--accent-blue-light)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
style={{ background: 'var(--accent-blue-light)', borderRadius: 6, padding: '6px 10px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(wikilinkTarget(ref))}
|
||||
>
|
||||
{wikilinkDisplay(ref)}
|
||||
@@ -380,57 +63,33 @@ function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: s
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicRelationshipsPanel({
|
||||
frontmatter,
|
||||
onNavigate
|
||||
}: {
|
||||
frontmatter: ParsedFrontmatter
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
// Find all keys that contain wikilinks
|
||||
function DynamicRelationshipsPanel({ frontmatter, onNavigate }: { frontmatter: ParsedFrontmatter; onNavigate: (target: string) => void }) {
|
||||
const relationshipEntries = useMemo(() => {
|
||||
return Object.entries(frontmatter)
|
||||
.filter(([key, value]) => {
|
||||
// Check if this key typically contains relationships or has wikilinks
|
||||
if (RELATIONSHIP_KEYS.has(key)) return true
|
||||
return containsWikilinks(value)
|
||||
})
|
||||
.filter(([key, value]) => RELATIONSHIP_KEYS.has(key) || containsWikilinks(value))
|
||||
.map(([key, value]) => {
|
||||
const refs: string[] = []
|
||||
if (typeof value === 'string' && isWikilink(value)) {
|
||||
refs.push(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
if (typeof v === 'string' && isWikilink(v)) {
|
||||
refs.push(v)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
|
||||
else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) })
|
||||
return { key, refs }
|
||||
})
|
||||
.filter(({ refs }) => refs.length > 0)
|
||||
}, [frontmatter])
|
||||
|
||||
if (relationshipEntries.length === 0) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
{relationshipEntries.map(({ key, refs }) => (
|
||||
<RelationshipGroup key={key} label={key} refs={refs} onNavigate={onNavigate} />
|
||||
))}
|
||||
{relationshipEntries.length === 0 ? (
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
|
||||
) : (
|
||||
relationshipEntries.map(({ key, refs }) => (
|
||||
<RelationshipGroup key={key} label={key} refs={refs} onNavigate={onNavigate} />
|
||||
))
|
||||
)}
|
||||
<button
|
||||
className="mt-2 w-full cursor-not-allowed border border-border bg-transparent text-center text-muted-foreground"
|
||||
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.6 }}
|
||||
disabled
|
||||
title="Coming soon"
|
||||
disabled title="Coming soon"
|
||||
>
|
||||
+ Link existing
|
||||
</button>
|
||||
@@ -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<string | null>(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 (
|
||||
<EditableValue
|
||||
value=""
|
||||
isEditing={editingKey === key}
|
||||
onStartEdit={() => 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 (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
defaultValue={statusStr}
|
||||
onKeyDown={(e) => {
|
||||
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 (
|
||||
<span
|
||||
className="inline-block cursor-pointer transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: style.bg,
|
||||
color: style.color,
|
||||
borderRadius: 9999,
|
||||
padding: '1px 6px',
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onClick={() => setEditingKey(key)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{statusStr}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Arrays get list editor
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<EditableList
|
||||
items={value.map(String)}
|
||||
onSave={(items) => 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 (
|
||||
<EditableValue
|
||||
value={String(value)}
|
||||
isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<button
|
||||
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => onUpdateProperty?.(key, !value)}
|
||||
>
|
||||
{value ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: editable string
|
||||
return (
|
||||
<EditableValue
|
||||
value={String(value)}
|
||||
isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Always show Type from entry */}
|
||||
{entry.isA && (
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Type</span>
|
||||
<span className="text-right text-secondary-foreground">{entry.isA}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic properties from frontmatter */}
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} className="group/prop flex items-center justify-between text-[13px]">
|
||||
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{key}
|
||||
{onDeleteProperty && (
|
||||
<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={() => onDeleteProperty(key)}
|
||||
title="Delete property"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{renderEditableValue(key, value)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Always show Modified and Words (read-only) */}
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Modified</span>
|
||||
<span className="text-right text-secondary-foreground">{formatDate(entry.modifiedAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Words</span>
|
||||
<span className="text-right text-secondary-foreground">{wordCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add property UI */}
|
||||
{showAddDialog ? (
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
placeholder="Property name"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="xs" onClick={handleAddProperty} disabled={!newKey.trim()}>Add</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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={() => setShowAddDialog(true)}
|
||||
disabled={!onAddProperty}
|
||||
>
|
||||
+ Add property
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Find all entries whose content contains a wikilink to the current note */
|
||||
function useBacklinks(
|
||||
entry: VaultEntry | null,
|
||||
entries: VaultEntry[],
|
||||
allContent: Record<string, string>
|
||||
): VaultEntry[] {
|
||||
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allContent: Record<string, string>): 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 (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No note selected</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backlinks</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
)}>
|
||||
<div
|
||||
className="flex items-center border-b border-border"
|
||||
style={{ height: 45, padding: '0 12px', gap: 8 }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }} data-tauri-drag-region>
|
||||
{collapsed ? (
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>Properties</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
@@ -874,9 +253,7 @@ export function Inspector({
|
||||
{entry ? (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
content={content}
|
||||
frontmatter={frontmatter}
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
@@ -886,24 +263,7 @@ export function Inspector({
|
||||
<GitHistoryPanel commits={gitHistory} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No note selected</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backlinks</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
|
||||
</div>
|
||||
</>
|
||||
<EmptyInspector />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
72
src/utils/frontmatter.ts
Normal file
72
src/utils/frontmatter.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user