Le champ Relation reproduit ce que les wikilinks font déjà. Momento est centré sur les notes, pas sur les bases de données. Ajoute de la complexité pour un bénéfice nul.
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import type { PropertyType, SchemaProperty } from '@/lib/structured-views/types'
|
|
import { openNotePeek } from '@/lib/note-peek-sync'
|
|
|
|
type PropertyValueEditorProps = {
|
|
property: SchemaProperty
|
|
value: unknown
|
|
onChange: (value: unknown) => void
|
|
compact?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export function PropertyValueEditor({
|
|
property,
|
|
value,
|
|
onChange,
|
|
compact,
|
|
className,
|
|
}: PropertyValueEditorProps) {
|
|
const base = cn(
|
|
'w-full rounded-md border border-border/60 bg-background text-sm',
|
|
compact ? 'px-2 py-1 text-[12px]' : 'px-3 py-2',
|
|
className,
|
|
)
|
|
|
|
switch (property.type) {
|
|
case 'checkbox':
|
|
return (
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(value)}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="h-4 w-4 accent-brand-accent"
|
|
/>
|
|
)
|
|
case 'number':
|
|
return (
|
|
<input
|
|
type="number"
|
|
value={value == null || value === '' ? '' : String(value)}
|
|
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
|
className={base}
|
|
/>
|
|
)
|
|
case 'date':
|
|
return (
|
|
<input
|
|
type="date"
|
|
value={typeof value === 'string' ? value.slice(0, 10) : ''}
|
|
onChange={(e) => onChange(e.target.value || null)}
|
|
className={base}
|
|
/>
|
|
)
|
|
case 'select':
|
|
return (
|
|
<select
|
|
value={typeof value === 'string' ? value : ''}
|
|
onChange={(e) => onChange(e.target.value || null)}
|
|
className={base}
|
|
>
|
|
<option value="">—</option>
|
|
{property.options.map((opt) => (
|
|
<option key={opt} value={opt}>{opt}</option>
|
|
))}
|
|
</select>
|
|
)
|
|
case 'multiselect':
|
|
return (
|
|
<MultiSelectEditor
|
|
options={property.options}
|
|
value={Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []}
|
|
onChange={onChange}
|
|
className={base}
|
|
compact={compact}
|
|
/>
|
|
)
|
|
case 'relation':
|
|
return <RelationEditor value={value as string | null} onChange={onChange} compact={compact} className={className} />
|
|
default:
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={value == null ? '' : String(value)}
|
|
onChange={(e) => onChange(e.target.value || null)}
|
|
className={base}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
function MultiSelectEditor({
|
|
options,
|
|
value,
|
|
onChange,
|
|
className,
|
|
compact,
|
|
}: {
|
|
options: string[]
|
|
value: string[]
|
|
onChange: (v: string[]) => void
|
|
className?: string
|
|
compact?: boolean
|
|
}) {
|
|
const { t } = useLanguage()
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const toggle = (opt: string) => {
|
|
if (value.includes(opt)) onChange(value.filter((v) => v !== opt))
|
|
else onChange([...value, opt])
|
|
}
|
|
|
|
if (compact) {
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'w-full min-h-[28px] rounded-md border border-transparent hover:border-border/60 px-1 py-0.5 text-left transition-colors',
|
|
className,
|
|
)}
|
|
>
|
|
{value.length === 0 ? (
|
|
<span className="text-[11px] text-muted-foreground">{t('structuredViews.cellEmpty')}</span>
|
|
) : (
|
|
<span className="flex flex-wrap gap-1">
|
|
{value.map((opt) => (
|
|
<span
|
|
key={opt}
|
|
className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-foreground text-background"
|
|
>
|
|
{opt}
|
|
</span>
|
|
))}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-56 p-2 space-y-1">
|
|
<p className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground px-1 pb-1">
|
|
{t('structuredViews.multiselectPick')}
|
|
</p>
|
|
{options.map((opt) => {
|
|
const active = value.includes(opt)
|
|
return (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
onClick={() => toggle(opt)}
|
|
className={cn(
|
|
'w-full text-left px-2 py-1.5 rounded-md text-[12px] transition-colors',
|
|
active
|
|
? 'bg-foreground text-background font-medium'
|
|
: 'hover:bg-foreground/5 text-foreground',
|
|
)}
|
|
>
|
|
{opt}
|
|
</button>
|
|
)
|
|
})}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn('flex flex-wrap gap-1', className, 'p-1')}>
|
|
{options.map((opt) => {
|
|
const active = value.includes(opt)
|
|
return (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
onClick={() => toggle(opt)}
|
|
className={cn(
|
|
'px-2 py-0.5 rounded-full text-[10px] font-bold border transition-colors',
|
|
active
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'border-border text-muted-foreground hover:border-foreground/30',
|
|
)}
|
|
>
|
|
{opt}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function useDebouncedPropertySave(
|
|
noteId: string,
|
|
onSaved?: (values: Record<string, unknown>) => void,
|
|
delayMs = 500,
|
|
) {
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const pendingRef = useRef<Record<string, unknown>>({})
|
|
|
|
useEffect(() => () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current)
|
|
}, [])
|
|
|
|
const queueSave = (propertyId: string, value: unknown) => {
|
|
pendingRef.current[propertyId] = value
|
|
if (timerRef.current) clearTimeout(timerRef.current)
|
|
timerRef.current = setTimeout(async () => {
|
|
const payload = { ...pendingRef.current }
|
|
pendingRef.current = {}
|
|
try {
|
|
const res = await fetch(`/api/notes/${noteId}/properties`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ properties: payload }),
|
|
})
|
|
const json = await res.json()
|
|
if (json.success && json.data?.values) onSaved?.(json.data.values)
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}, delayMs)
|
|
}
|
|
|
|
return queueSave
|
|
}
|
|
|
|
export function formatPropertyTypeLabel(type: PropertyType, t: (k: string) => string) {
|
|
return t(`structuredViews.propertyTypes.${type}`)
|
|
}
|
|
|
|
function RelationEditor({
|
|
value,
|
|
onChange,
|
|
compact,
|
|
className,
|
|
}: {
|
|
value: string | { id: string; title: string } | null
|
|
onChange: (v: unknown) => void
|
|
compact?: boolean
|
|
className?: string
|
|
}) {
|
|
const { t } = useLanguage()
|
|
const [open, setOpen] = useState(false)
|
|
const [search, setSearch] = useState('')
|
|
const [results, setResults] = useState<Array<{ id: string; title: string | null }>>([])
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
// Parse value into { id, title }
|
|
const parsed = useMemo(() => {
|
|
if (!value) return null
|
|
if (typeof value === 'object' && value.id) return value as { id: string; title: string }
|
|
if (typeof value === 'string') {
|
|
try {
|
|
const obj = JSON.parse(value)
|
|
if (obj.id && obj.title) return obj
|
|
} catch {}
|
|
return { id: value, title: value }
|
|
}
|
|
return null
|
|
}, [value])
|
|
|
|
// Fetch real title if we only have an ID (old data)
|
|
const [resolvedTitle, setResolvedTitle] = useState<string | null>(null)
|
|
useEffect(() => {
|
|
if (!parsed || parsed.title !== parsed.id) { setResolvedTitle(null); return }
|
|
fetch(`/api/notes?limit=100`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const note = data.data?.find((n: any) => n.id === parsed.id)
|
|
if (note) setResolvedTitle(note.title || t('notes.untitled'))
|
|
})
|
|
.catch(() => {})
|
|
}, [parsed, t])
|
|
|
|
const displayTitle = resolvedTitle || parsed?.title || null
|
|
|
|
useEffect(() => {
|
|
if (!open || !search.trim()) { setResults([]); return }
|
|
setLoading(true)
|
|
const timer = setTimeout(() => {
|
|
fetch(`/api/notes?search=${encodeURIComponent(search.trim())}&limit=10`)
|
|
.then(r => r.json())
|
|
.then(data => setResults(data.data ?? []))
|
|
.catch(() => setResults([]))
|
|
.finally(() => setLoading(false))
|
|
}, 250)
|
|
return () => clearTimeout(timer)
|
|
}, [open, search])
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'w-full min-h-[28px] rounded-md border border-transparent hover:border-border/60 px-2 text-left transition-colors flex items-center gap-1',
|
|
compact && 'text-[12px] py-0.5',
|
|
!compact && 'py-1.5',
|
|
className,
|
|
)}
|
|
>
|
|
{displayTitle ? (
|
|
<span
|
|
role="link"
|
|
onClick={(e) => { e.stopPropagation(); if (parsed) openNotePeek({ noteId: parsed.id }) }}
|
|
className="truncate text-brand-accent hover:underline cursor-pointer"
|
|
>
|
|
{displayTitle}
|
|
</span>
|
|
) : parsed ? (
|
|
<span className="truncate text-muted-foreground text-[11px]">...</span>
|
|
) : (
|
|
<span className="text-muted-foreground/50 text-[11px]">{t('structuredViews.relationEmpty') || 'Lier une note...'}</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('structuredViews.relationSearch') || 'Rechercher une note...'}
|
|
autoFocus
|
|
className="w-full border-b border-border/40 px-3 py-2 text-sm bg-transparent outline-none"
|
|
/>
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{loading && <div className="px-3 py-2 text-xs text-muted-foreground">...</div>}
|
|
{!loading && results.length === 0 && search.trim() && (
|
|
<div className="px-3 py-2 text-xs text-muted-foreground">{t('structuredViews.relationNoResults') || 'Aucune note trouvée'}</div>
|
|
)}
|
|
{results.map((note) => (
|
|
<button
|
|
key={note.id}
|
|
type="button"
|
|
onClick={() => {
|
|
onChange({ id: note.id, title: note.title || t('notes.untitled') })
|
|
setOpen(false)
|
|
setSearch('')
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors truncate"
|
|
>
|
|
{note.title || t('notes.untitled')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|