Files
tolaria/src/components/note-item/PropertyChips.tsx
2026-04-10 18:31:51 +02:00

308 lines
9.8 KiB
TypeScript

import { createElement, useMemo, useState, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { detectPropertyType } from '../../utils/propertyTypes'
import { getMappedStatusStyle } from '../../utils/statusStyles'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
import { getTypeIcon } from './typeIcon'
interface PropertyChipValue {
label: string
noteIcon: string | null
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
style?: CSSProperties
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
tone: 'neutral' | 'relationship' | 'status' | 'url'
}
const URL_CHIP_STYLE: CSSProperties = {
backgroundColor: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
}
type ChipScalarValue = string | number | boolean | null
function toChipTestId(propName: string, index: number): string {
const slug = propName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return `property-chip-${slug || 'value'}-${index}`
}
function normalizeOpenableUrl(value: string): string | null {
if (!isUrlValue(value)) return null
const normalized = normalizeUrl(value)
try {
const url = new URL(normalized)
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null
} catch {
return null
}
}
function formatChipLabel(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null
const raw = String(value)
const openableUrl = normalizeOpenableUrl(raw)
if (openableUrl) return new URL(openableUrl).hostname
return raw.length > 40 ? `${raw.slice(0, 37)}` : raw
}
function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
}
function findMatchingKey(values: Record<string, unknown>, propName: string): string | undefined {
return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
}
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
return { color, backgroundColor }
}
function resolveRelationshipChip(
ref: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue | null {
const label = wikilinkDisplay(ref)
if (!label) return null
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
if (!targetEntry) {
return {
label,
noteIcon: null,
typeIcon: null,
tone: 'neutral',
}
}
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
return {
label,
noteIcon: targetEntry.icon ?? null,
typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null,
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
action: { kind: 'note', entry: targetEntry },
tone: 'relationship',
}
}
function resolveScalarChip(value: unknown): PropertyChipValue | null {
const label = formatChipLabel(value)
if (!label) return null
const openableUrl = typeof value === 'string' ? normalizeOpenableUrl(value) : null
if (openableUrl) {
return {
label,
noteIcon: null,
typeIcon: null,
style: URL_CHIP_STYLE,
action: { kind: 'url', url: openableUrl },
tone: 'url',
}
}
return {
label,
noteIcon: null,
typeIcon: null,
tone: 'neutral',
}
}
function resolveStatusChip(value: ChipScalarValue): PropertyChipValue | null {
const label = formatChipLabel(value)
if (!label) return null
const status = String(value)
const style = getMappedStatusStyle(status)
return {
label: `${label}`,
noteIcon: null,
typeIcon: null,
style: style ? { backgroundColor: style.bg, color: style.color } : undefined,
tone: 'status',
}
}
function resolvePropertyValueChip(propName: string, value: ChipScalarValue | undefined): PropertyChipValue | null {
if (value === undefined) return null
if (detectPropertyType(propName, value) !== 'status') {
return resolveScalarChip(value)
}
return resolveStatusChip(value)
}
function resolveRelationshipChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] | null {
const relationshipKey = findMatchingKey(entry.relationships, propName)
if (!relationshipKey) return null
return entry.relationships[relationshipKey]
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] {
const propertyKey = findMatchingKey(entry.properties, propName)
if (!propertyKey) return []
const rawValue = entry.properties[propertyKey]
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
return values
.map((value) => resolvePropertyValueChip(propertyKey, value))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
function resolvePropertyChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const statusChip = resolvePropertyValueChip(propName, entry.status)
return statusChip ? [statusChip] : []
}
return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName)
}
function RelationshipTypeIcon({
typeIcon,
}: {
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
}) {
if (!typeIcon) return null
return createElement(typeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
}
function renderResolvedNoteIcon(
noteIcon: string | null | undefined,
imageFailed: boolean,
onImageError: () => void,
): ReactNode {
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center leading-none" style={{ fontSize: 11, lineHeight: 1 }}>
{resolvedNoteIcon.value}
</span>
)
}
if (resolvedNoteIcon.kind === 'phosphor') {
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
}
if (resolvedNoteIcon.kind !== 'image' || imageFailed) return null
return (
<img
src={resolvedNoteIcon.src}
alt=""
aria-hidden="true"
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
onError={onImageError}
/>
)
}
function PropertyChipIcon({
noteIcon,
typeIcon,
tone,
}: {
noteIcon?: string | null
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
tone: PropertyChipValue['tone']
}) {
const [imageFailed, setImageFailed] = useState(false)
if (tone === 'url') {
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
}
const noteIconElement = renderResolvedNoteIcon(noteIcon, imageFailed, () => setImageFailed(true))
if (noteIconElement) return noteIconElement
return <RelationshipTypeIcon typeIcon={typeIcon} />
}
async function handleChipClick(
event: MouseEvent<HTMLSpanElement>,
chip: PropertyChipValue,
onOpenNote: (entry: VaultEntry, event: MouseEvent) => void,
) {
event.preventDefault()
event.stopPropagation()
if (!event.metaKey || !chip.action) return
if (chip.action.kind === 'note') {
onOpenNote(chip.action.entry, event)
return
}
await openExternalUrl(chip.action.url).catch(() => {})
}
export function PropertyChips({
entry,
displayProps,
allEntries,
typeEntryMap,
onOpenNote,
}: {
entry: VaultEntry
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onOpenNote: (entry: VaultEntry, event: MouseEvent) => void
}) {
const chips = useMemo(() => {
const result: { key: string; values: PropertyChipValue[] }[] = []
for (const prop of displayProps) {
const values = resolvePropertyChipValues(entry, prop, allEntries, typeEntryMap)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [allEntries, displayProps, entry, typeEntryMap])
if (chips.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((chip, index) => (
<span
key={`${key}-${index}`}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground',
chip.action && 'cursor-pointer',
)}
style={chip.style}
onClick={(event) => { void handleChipClick(event, chip, onOpenNote) }}
data-testid={toChipTestId(key, index)}
>
<PropertyChipIcon noteIcon={chip.noteIcon} typeIcon={chip.typeIcon} tone={chip.tone} />
<span className="truncate whitespace-nowrap">{chip.label}</span>
</span>
))
)}
</div>
)
}