Files
Momento/memento-note/components/structured-views/property-value-editor.tsx
Antigravity c21cbf84a1
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m38s
CI / Deploy production (on server) (push) Successful in 1m5s
revert: champ Relation retiré — doublon avec wikilinks [[note]]
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.
2026-06-19 20:29:33 +00:00

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>
)
}