Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, 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'
|
|
|
|
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}
|
|
/>
|
|
)
|
|
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}`)
|
|
}
|