revert: champ Relation retiré — doublon avec wikilinks [[note]]
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m38s
CI / Deploy production (on server) (push) Successful in 1m5s

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.
This commit is contained in:
Antigravity
2026-06-19 20:29:33 +00:00
parent 5b9930b02e
commit c21cbf84a1
2 changed files with 133 additions and 1 deletions

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import type { PropertyType, SchemaProperty } from '@/lib/structured-views/types' import type { PropertyType, SchemaProperty } from '@/lib/structured-views/types'
import { openNotePeek } from '@/lib/note-peek-sync'
type PropertyValueEditorProps = { type PropertyValueEditorProps = {
property: SchemaProperty property: SchemaProperty
@@ -78,6 +79,8 @@ export function PropertyValueEditor({
compact={compact} compact={compact}
/> />
) )
case 'relation':
return <RelationEditor value={value as string | null} onChange={onChange} compact={compact} className={className} />
default: default:
return ( return (
<input <input
@@ -227,3 +230,123 @@ export function useDebouncedPropertySave(
export function formatPropertyTypeLabel(type: PropertyType, t: (k: string) => string) { export function formatPropertyTypeLabel(type: PropertyType, t: (k: string) => string) {
return t(`structuredViews.propertyTypes.${type}`) 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>
)
}

View File

@@ -5,6 +5,7 @@ import type {
PropertyType, PropertyType,
SchemaProperty, SchemaProperty,
} from './types' } from './types'
import { PROPERTY_TYPES } from './types'
export function parsePropertyOptions(raw: string | null | undefined): string[] { export function parsePropertyOptions(raw: string | null | undefined): string[] {
if (!raw) return [] if (!raw) return []
@@ -31,6 +32,12 @@ export function serializePropertyValue(type: PropertyType, value: unknown): stri
const arr = Array.isArray(value) ? value : [] const arr = Array.isArray(value) ? value : []
return JSON.stringify(arr.filter((v) => typeof v === 'string')) return JSON.stringify(arr.filter((v) => typeof v === 'string'))
} }
if (type === 'relation') {
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value)
}
return JSON.stringify(String(value))
}
return JSON.stringify(String(value)) return JSON.stringify(String(value))
} }
@@ -38,6 +45,7 @@ export function parseStoredPropertyValue(type: PropertyType, raw: string | null
if (raw == null || raw === '') { if (raw == null || raw === '') {
if (type === 'checkbox') return false if (type === 'checkbox') return false
if (type === 'multiselect') return [] if (type === 'multiselect') return []
if (type === 'relation') return null
return null return null
} }
try { try {
@@ -45,6 +53,7 @@ export function parseStoredPropertyValue(type: PropertyType, raw: string | null
if (type === 'checkbox') return Boolean(parsed) if (type === 'checkbox') return Boolean(parsed)
if (type === 'number') return typeof parsed === 'number' ? parsed : Number(parsed) if (type === 'number') return typeof parsed === 'number' ? parsed : Number(parsed)
if (type === 'multiselect') return Array.isArray(parsed) ? parsed : [] if (type === 'multiselect') return Array.isArray(parsed) ? parsed : []
if (type === 'relation') return typeof parsed === 'object' ? parsed : { id: String(parsed), title: String(parsed) }
return parsed return parsed
} catch { } catch {
return raw return raw