diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index 87f1f234..439b4d5d 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -1,538 +1,6 @@ -import { useMemo, useCallback, useState, useRef } from 'react' -import type { ComponentType, SVGAttributes } from 'react' -import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink' -import type { VaultEntry, GitCommit } from '../types' -import { CaretRight, Trash, X } from '@phosphor-icons/react' -import type { ParsedFrontmatter } from '../utils/frontmatter' -import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel' -import { getTypeColor, getTypeLightColor } from '../utils/typeColors' -import { getTypeIcon } from './NoteItem' -import { findEntryByTarget } from '../utils/wikilinkColors' -import type { FrontmatterValue } from './Inspector' -import { NoteSearchList } from './NoteSearchList' -import { useNoteSearch } from '../hooks/useNoteSearch' - -function isWikilink(value: string): boolean { - return /^\[\[.*\]\]$/.test(value) -} - -function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined { - const target = wikilinkTarget(ref) - const byTitle = findEntryByTarget(entries, target) - if (byTitle) return byTitle - const lastSegment = target.split('/').pop() - return entries.find((e) => { - const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - if (stem === target) return true - return e.filename.replace(/\.md$/, '') === lastSegment - }) -} - -function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashed: boolean }) { - if (isTrashed) return (trashed) - if (isArchived) return (archived) - return null -} - -function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: { - label: string - typeColor: string - bgColor?: string - isArchived: boolean - isTrashed: boolean - onClick: () => void - onRemove?: () => void - title?: string - TypeIcon: ComponentType> -}) { - const isDimmed = isArchived || isTrashed - const color = isDimmed ? 'var(--muted-foreground)' : typeColor - return ( - - ) -} - -function entryStatusTitle(entry: VaultEntry | undefined): string | undefined { - if (entry?.trashed) return 'Trashed' - if (entry?.archived) return 'Archived' - return undefined -} - -function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Record) { - const resolved = resolveRef(ref, entries) - const refType = resolved?.isA ?? null - const te = typeEntryMap[refType ?? ''] - return { - label: wikilinkDisplay(ref), - typeColor: getTypeColor(refType, te?.color), - bgColor: getTypeLightColor(refType, te?.color), - isArchived: resolved?.archived ?? false, - isTrashed: resolved?.trashed ?? false, - target: wikilinkTarget(ref), - title: entryStatusTitle(resolved), - TypeIcon: getTypeIcon(refType, te?.icon), - } -} - -function SearchDropdown({ search, onSelect }: { - search: ReturnType - onSelect: (title: string) => void -}) { - return ( -
- item.entry.path} - onItemClick={(item) => onSelect(item.entry.title)} - onItemHover={(i) => search.setSelectedIndex(i)} - className="max-h-[160px] overflow-y-auto" - /> -
- ) -} - -function InlineAddNote({ entries, onAdd }: { - entries: VaultEntry[] - onAdd: (noteTitle: string) => void -}) { - const [active, setActive] = useState(false) - const [query, setQuery] = useState('') - const inputRef = useRef(null) - const search = useNoteSearch(entries, query, 8) - - const selectAndClose = useCallback((title: string) => { - onAdd(title) - setQuery('') - setActive(false) - }, [onAdd]) - - const handleConfirm = useCallback(() => { - const title = search.selectedEntry?.title ?? query.trim() - if (title) selectAndClose(title) - }, [search.selectedEntry, query, selectAndClose]) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - search.handleKeyDown(e) - if (e.key === 'Enter') { e.preventDefault(); handleConfirm() } - else if (e.key === 'Escape') { setQuery(''); setActive(false) } - }, [search, handleConfirm]) - - if (!active) { - return ( - - ) - } - - return ( -
-
- setQuery(e.target.value)} - onKeyDown={handleKeyDown} - data-testid="add-relation-ref-input" - /> - -
- {query.trim() && search.results.length > 0 && ( - - )} -
- ) -} - -function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: { - label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record - onNavigate: (target: string) => void - onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void -}) { - if (refs.length === 0) return null - return ( -
- {label} -
- {refs.map((ref, idx) => { - const props = resolveRefProps(ref, entries, typeEntryMap) - return ( - onNavigate(props.target)} - onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined} - /> - ) - })} -
- {onAddRef && } -
- ) -} - -function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] { - return Object.entries(frontmatter) - .filter(([key, value]) => key !== 'Type' && (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) }) - return { key, refs } - }) - .filter(({ refs }) => refs.length > 0) -} - -function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: { - entries: VaultEntry[] - value: string - onChange: (v: string) => void - onSubmit?: () => void - onCancel?: () => void -}) { - const [focused, setFocused] = useState(false) - const search = useNoteSearch(entries, value, 8) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - search.handleKeyDown(e) - if (e.key === 'Enter') { - e.preventDefault() - if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) } - else onSubmit?.() - } else if (e.key === 'Escape') { onCancel?.() } - }, [search, onChange, onSubmit, onCancel]) - - const showDropdown = focused && value.trim() && search.results.length > 0 - - return ( -
- onChange(e.target.value)} - onFocus={() => setFocused(true)} - onBlur={() => setTimeout(() => setFocused(false), 150)} - onKeyDown={handleKeyDown} - /> - {showDropdown && ( - { onChange(title); setFocused(false) }} /> - )} -
- ) -} - -function AddRelationshipForm({ entries, onAddProperty }: { - entries: VaultEntry[] - onAddProperty: (key: string, value: FrontmatterValue) => void -}) { - const [relKey, setRelKey] = useState('') - const [relTarget, setRelTarget] = useState('') - const [showForm, setShowForm] = useState(false) - const keyInputRef = useRef(null) - - const submitForm = useCallback((targetOverride?: string) => { - const key = relKey.trim() - const target = (targetOverride ?? relTarget).trim() - if (!key || !target) return - onAddProperty(key, `[[${target}]]`) - setRelKey(''); setRelTarget(''); setShowForm(false) - }, [relKey, relTarget, onAddProperty]) - - const resetForm = useCallback(() => { - setShowForm(false); setRelKey(''); setRelTarget('') - }, []) - - if (!showForm) { - return ( - - ) - } - - return ( -
- setRelKey(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }} - /> - -
- - -
-
- ) -} - -function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null { - const remaining = refs.filter(r => r !== refToRemove) - if (remaining.length === 0) return null - return remaining.length === 1 ? remaining[0] : remaining -} - -function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false { - const newRef = `[[${noteTitle}]]` - if (refs.includes(newRef)) return false - const updated = [...refs, newRef] - return updated.length === 1 ? updated[0] : updated -} - -export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: { - frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record - onNavigate: (target: string) => void - onAddProperty?: (key: string, value: FrontmatterValue) => void - onUpdateProperty?: (key: string, value: FrontmatterValue) => void - onDeleteProperty?: (key: string) => void -}) { - const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter]) - - const handleRemoveRef = useCallback((key: string, refToRemove: string) => { - if (!onUpdateProperty || !onDeleteProperty) return - const group = relationshipEntries.find(g => g.key === key) - if (!group) return - const result = updateRefsForRemoval(group.refs, refToRemove) - if (result === null) onDeleteProperty(key) - else onUpdateProperty(key, result) - }, [relationshipEntries, onUpdateProperty, onDeleteProperty]) - - const handleAddRef = useCallback((key: string, noteTitle: string) => { - if (!onUpdateProperty) return - const existing = relationshipEntries.find(g => g.key === key)?.refs ?? [] - const result = updateRefsForAddition(existing, noteTitle) - if (result !== false) onUpdateProperty(key, result) - }, [relationshipEntries, onUpdateProperty]) - - const canEdit = !!onUpdateProperty && !!onDeleteProperty - - return ( -
- {relationshipEntries.map(({ key, refs }) => ( - handleRemoveRef(key, ref) : undefined} - onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined} - /> - ))} - {onAddProperty - ? - : - } -
- ) -} - -export interface BacklinkItem { - entry: VaultEntry - context: string | null -} - -function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: { - entry: VaultEntry - context: string | null - typeEntryMap: Record - onNavigate: (target: string) => void -}) { - const te = typeEntryMap[entry.isA ?? ''] - const isDimmed = entry.archived || entry.trashed - return ( - - ) -} - -export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { - backlinks: BacklinkItem[] - typeEntryMap: Record - onNavigate: (target: string) => void -}) { - const [expanded, setExpanded] = useState(false) - - if (backlinks.length === 0) return null - - return ( -
- - {expanded && ( -
- {backlinks.map(({ entry, context }) => ( - - ))} -
- )} -
- ) -} - -export interface ReferencedByItem { - entry: VaultEntry - viaKey: string -} - -export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: { - items: ReferencedByItem[] - typeEntryMap: Record - onNavigate: (target: string) => void -}) { - const grouped = useMemo(() => { - const map = new Map() - for (const item of items) { - const existing = map.get(item.viaKey) - if (existing) existing.push(item.entry) - else map.set(item.viaKey, [item.entry]) - } - return Array.from(map.entries()) - }, [items]) - - if (items.length === 0) return null - - return ( -
-
- {grouped.map(([viaKey, groupEntries]) => ( -
- - ← {viaKey} - -
- {groupEntries.map((e) => { - const te = typeEntryMap[e.isA ?? ''] - return ( - onNavigate(e.title)} - title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} - TypeIcon={getTypeIcon(e.isA, te?.icon)} - /> - ) - })} -
-
- ))} -
-
- ) -} - -function formatRelativeDate(timestamp: number): string { - const now = Math.floor(Date.now() / 1000) - const days = Math.floor((now - timestamp) / 86400) - if (days < 1) return 'today' - if (days === 1) return 'yesterday' - if (days < 30) return `${days}d ago` - const months = Math.floor(days / 30) - return months === 1 ? '1mo ago' : `${months}mo ago` -} - -export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) { - return ( -
-

History

- {commits.length === 0 - ?

No revision history

- : ( -
- {commits.map((c) => ( -
-
- - {formatRelativeDate(c.date)} -
-
{c.message}
- {c.author &&
{c.author}
} -
- ))} -
- ) - } -
- ) -} +export { DynamicRelationshipsPanel } from './inspector/RelationshipsPanel' +export { BacklinksPanel } from './inspector/BacklinksPanel' +export type { BacklinkItem } from './inspector/BacklinksPanel' +export { ReferencedByPanel } from './inspector/ReferencedByPanel' +export type { ReferencedByItem } from './inspector/ReferencedByPanel' +export { GitHistoryPanel } from './inspector/GitHistoryPanel' diff --git a/src/components/inspector/BacklinksPanel.tsx b/src/components/inspector/BacklinksPanel.tsx new file mode 100644 index 00000000..147b6818 --- /dev/null +++ b/src/components/inspector/BacklinksPanel.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import type { VaultEntry } from '../../types' +import { CaretRight, Trash } from '@phosphor-icons/react' +import { getTypeColor } from '../../utils/typeColors' +import { entryStatusTitle } from './shared' +import { StatusSuffix } from './LinkButton' + +export interface BacklinkItem { + entry: VaultEntry + context: string | null +} + +function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: { + entry: VaultEntry + context: string | null + typeEntryMap: Record + onNavigate: (target: string) => void +}) { + const te = typeEntryMap[entry.isA ?? ''] + const isDimmed = entry.archived || entry.trashed + return ( + + ) +} + +export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { + backlinks: BacklinkItem[] + typeEntryMap: Record + onNavigate: (target: string) => void +}) { + const [expanded, setExpanded] = useState(false) + + if (backlinks.length === 0) return null + + return ( +
+ + {expanded && ( +
+ {backlinks.map(({ entry, context }) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/inspector/GitHistoryPanel.tsx b/src/components/inspector/GitHistoryPanel.tsx new file mode 100644 index 00000000..8ecf23ce --- /dev/null +++ b/src/components/inspector/GitHistoryPanel.tsx @@ -0,0 +1,36 @@ +import type { GitCommit } from '../../types' + +function formatRelativeDate(timestamp: number): string { + const now = Math.floor(Date.now() / 1000) + const days = Math.floor((now - timestamp) / 86400) + if (days < 1) return 'today' + if (days === 1) return 'yesterday' + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + return months === 1 ? '1mo ago' : `${months}mo ago` +} + +export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) { + return ( +
+

History

+ {commits.length === 0 + ?

No revision history

+ : ( +
+ {commits.map((c) => ( +
+
+ + {formatRelativeDate(c.date)} +
+
{c.message}
+ {c.author &&
{c.author}
} +
+ ))} +
+ ) + } +
+ ) +} diff --git a/src/components/inspector/LinkButton.tsx b/src/components/inspector/LinkButton.tsx new file mode 100644 index 00000000..6ccc7df4 --- /dev/null +++ b/src/components/inspector/LinkButton.tsx @@ -0,0 +1,55 @@ +import type { ComponentType, SVGAttributes } from 'react' +import { Trash, X } from '@phosphor-icons/react' + +export function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashed: boolean }) { + if (isTrashed) return (trashed) + if (isArchived) return (archived) + return null +} + +export function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: { + label: string + typeColor: string + bgColor?: string + isArchived: boolean + isTrashed: boolean + onClick: () => void + onRemove?: () => void + title?: string + TypeIcon: ComponentType> +}) { + const isDimmed = isArchived || isTrashed + const color = isDimmed ? 'var(--muted-foreground)' : typeColor + return ( + + ) +} diff --git a/src/components/inspector/ReferencedByPanel.tsx b/src/components/inspector/ReferencedByPanel.tsx new file mode 100644 index 00000000..a5e8c99a --- /dev/null +++ b/src/components/inspector/ReferencedByPanel.tsx @@ -0,0 +1,59 @@ +import { useMemo } from 'react' +import type { VaultEntry } from '../../types' +import { getTypeColor } from '../../utils/typeColors' +import { getTypeIcon } from '../NoteItem' +import { LinkButton } from './LinkButton' + +export interface ReferencedByItem { + entry: VaultEntry + viaKey: string +} + +export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: { + items: ReferencedByItem[] + typeEntryMap: Record + onNavigate: (target: string) => void +}) { + const grouped = useMemo(() => { + const map = new Map() + for (const item of items) { + const existing = map.get(item.viaKey) + if (existing) existing.push(item.entry) + else map.set(item.viaKey, [item.entry]) + } + return Array.from(map.entries()) + }, [items]) + + if (items.length === 0) return null + + return ( +
+
+ {grouped.map(([viaKey, groupEntries]) => ( +
+ + ← {viaKey} + +
+ {groupEntries.map((e) => { + const te = typeEntryMap[e.isA ?? ''] + return ( + onNavigate(e.title)} + title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} + TypeIcon={getTypeIcon(e.isA, te?.icon)} + /> + ) + })} +
+
+ ))} +
+
+ ) +} diff --git a/src/components/inspector/RelationshipsPanel.tsx b/src/components/inspector/RelationshipsPanel.tsx new file mode 100644 index 00000000..d189e185 --- /dev/null +++ b/src/components/inspector/RelationshipsPanel.tsx @@ -0,0 +1,279 @@ +import { useMemo, useCallback, useState, useRef } from 'react' +import type { VaultEntry } from '../../types' +import { X } from '@phosphor-icons/react' +import type { ParsedFrontmatter } from '../../utils/frontmatter' +import { RELATIONSHIP_KEYS, containsWikilinks } from '../DynamicPropertiesPanel' +import type { FrontmatterValue } from '../Inspector' +import { NoteSearchList } from '../NoteSearchList' +import { useNoteSearch } from '../../hooks/useNoteSearch' +import { isWikilink, resolveRefProps } from './shared' +import { LinkButton } from './LinkButton' + +function SearchDropdown({ search, onSelect }: { + search: ReturnType + onSelect: (title: string) => void +}) { + return ( +
+ item.entry.path} + onItemClick={(item) => onSelect(item.entry.title)} + onItemHover={(i) => search.setSelectedIndex(i)} + className="max-h-[160px] overflow-y-auto" + /> +
+ ) +} + +function InlineAddNote({ entries, onAdd }: { + entries: VaultEntry[] + onAdd: (noteTitle: string) => void +}) { + const [active, setActive] = useState(false) + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const search = useNoteSearch(entries, query, 8) + + const selectAndClose = useCallback((title: string) => { + onAdd(title) + setQuery('') + setActive(false) + }, [onAdd]) + + const handleConfirm = useCallback(() => { + const title = search.selectedEntry?.title ?? query.trim() + if (title) selectAndClose(title) + }, [search.selectedEntry, query, selectAndClose]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + search.handleKeyDown(e) + if (e.key === 'Enter') { e.preventDefault(); handleConfirm() } + else if (e.key === 'Escape') { setQuery(''); setActive(false) } + }, [search, handleConfirm]) + + if (!active) { + return ( + + ) + } + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + data-testid="add-relation-ref-input" + /> + +
+ {query.trim() && search.results.length > 0 && ( + + )} +
+ ) +} + +function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: { + label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record + onNavigate: (target: string) => void + onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void +}) { + if (refs.length === 0) return null + return ( +
+ {label} +
+ {refs.map((ref, idx) => { + const props = resolveRefProps(ref, entries, typeEntryMap) + return ( + onNavigate(props.target)} + onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined} + /> + ) + })} +
+ {onAddRef && } +
+ ) +} + +function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] { + return Object.entries(frontmatter) + .filter(([key, value]) => key !== 'Type' && (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) }) + return { key, refs } + }) + .filter(({ refs }) => refs.length > 0) +} + +function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: { + entries: VaultEntry[] + value: string + onChange: (v: string) => void + onSubmit?: () => void + onCancel?: () => void +}) { + const [focused, setFocused] = useState(false) + const search = useNoteSearch(entries, value, 8) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + search.handleKeyDown(e) + if (e.key === 'Enter') { + e.preventDefault() + if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) } + else onSubmit?.() + } else if (e.key === 'Escape') { onCancel?.() } + }, [search, onChange, onSubmit, onCancel]) + + const showDropdown = focused && value.trim() && search.results.length > 0 + + return ( +
+ onChange(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + onKeyDown={handleKeyDown} + /> + {showDropdown && ( + { onChange(title); setFocused(false) }} /> + )} +
+ ) +} + +function AddRelationshipForm({ entries, onAddProperty }: { + entries: VaultEntry[] + onAddProperty: (key: string, value: FrontmatterValue) => void +}) { + const [relKey, setRelKey] = useState('') + const [relTarget, setRelTarget] = useState('') + const [showForm, setShowForm] = useState(false) + const keyInputRef = useRef(null) + + const submitForm = useCallback((targetOverride?: string) => { + const key = relKey.trim() + const target = (targetOverride ?? relTarget).trim() + if (!key || !target) return + onAddProperty(key, `[[${target}]]`) + setRelKey(''); setRelTarget(''); setShowForm(false) + }, [relKey, relTarget, onAddProperty]) + + const resetForm = useCallback(() => { + setShowForm(false); setRelKey(''); setRelTarget('') + }, []) + + if (!showForm) { + return ( + + ) + } + + return ( +
+ setRelKey(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }} + /> + +
+ + +
+
+ ) +} + +function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null { + const remaining = refs.filter(r => r !== refToRemove) + if (remaining.length === 0) return null + return remaining.length === 1 ? remaining[0] : remaining +} + +function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false { + const newRef = `[[${noteTitle}]]` + if (refs.includes(newRef)) return false + const updated = [...refs, newRef] + return updated.length === 1 ? updated[0] : updated +} + +export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: { + frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record + onNavigate: (target: string) => void + onAddProperty?: (key: string, value: FrontmatterValue) => void + onUpdateProperty?: (key: string, value: FrontmatterValue) => void + onDeleteProperty?: (key: string) => void +}) { + const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter]) + + const handleRemoveRef = useCallback((key: string, refToRemove: string) => { + if (!onUpdateProperty || !onDeleteProperty) return + const group = relationshipEntries.find(g => g.key === key) + if (!group) return + const result = updateRefsForRemoval(group.refs, refToRemove) + if (result === null) onDeleteProperty(key) + else onUpdateProperty(key, result) + }, [relationshipEntries, onUpdateProperty, onDeleteProperty]) + + const handleAddRef = useCallback((key: string, noteTitle: string) => { + if (!onUpdateProperty) return + const existing = relationshipEntries.find(g => g.key === key)?.refs ?? [] + const result = updateRefsForAddition(existing, noteTitle) + if (result !== false) onUpdateProperty(key, result) + }, [relationshipEntries, onUpdateProperty]) + + const canEdit = !!onUpdateProperty && !!onDeleteProperty + + return ( +
+ {relationshipEntries.map(({ key, refs }) => ( + handleRemoveRef(key, ref) : undefined} + onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined} + /> + ))} + {onAddProperty + ? + : + } +
+ ) +} diff --git a/src/components/inspector/shared.ts b/src/components/inspector/shared.ts new file mode 100644 index 00000000..a83f07e6 --- /dev/null +++ b/src/components/inspector/shared.ts @@ -0,0 +1,43 @@ +import { wikilinkTarget, wikilinkDisplay } from '../../utils/wikilink' +import type { VaultEntry } from '../../types' +import { getTypeColor, getTypeLightColor } from '../../utils/typeColors' +import { getTypeIcon } from '../NoteItem' +import { findEntryByTarget } from '../../utils/wikilinkColors' + +export function isWikilink(value: string): boolean { + return /^\[\[.*\]\]$/.test(value) +} + +export function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined { + const target = wikilinkTarget(ref) + const byTitle = findEntryByTarget(entries, target) + if (byTitle) return byTitle + const lastSegment = target.split('/').pop() + return entries.find((e) => { + const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + if (stem === target) return true + return e.filename.replace(/\.md$/, '') === lastSegment + }) +} + +export function entryStatusTitle(entry: VaultEntry | undefined): string | undefined { + if (entry?.trashed) return 'Trashed' + if (entry?.archived) return 'Archived' + return undefined +} + +export function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Record) { + const resolved = resolveRef(ref, entries) + const refType = resolved?.isA ?? null + const te = typeEntryMap[refType ?? ''] + return { + label: wikilinkDisplay(ref), + typeColor: getTypeColor(refType, te?.color), + bgColor: getTypeLightColor(refType, te?.color), + isArchived: resolved?.archived ?? false, + isTrashed: resolved?.trashed ?? false, + target: wikilinkTarget(ref), + title: entryStatusTitle(resolved), + TypeIcon: getTypeIcon(refType, te?.icon), + } +}