476 lines
21 KiB
TypeScript
476 lines
21 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState, Fragment } from 'react'
|
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import type { Note } from '@/lib/types'
|
|
import type {
|
|
ColumnFilter,
|
|
ColumnSort,
|
|
NotebookSchemaPayload,
|
|
NotePropertyValues,
|
|
} from '@/lib/structured-views/types'
|
|
import {
|
|
filterNotesWithProperties,
|
|
sortNotesWithProperties,
|
|
} from '@/lib/structured-views/property-utils'
|
|
import { PropertyValueEditor } from './property-value-editor'
|
|
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
|
import { enUS, fr, faIR } from 'date-fns/locale'
|
|
import { ChevronDown, ChevronUp, Filter, Trash2, Sparkles, Brain, Loader2, ArrowUpRight, Link2 } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { openNotePeek } from '@/lib/note-peek-sync'
|
|
|
|
const localeMap: Record<string, any> = {
|
|
en: enUS,
|
|
fr,
|
|
fa: faIR
|
|
}
|
|
|
|
function getDateLocale(lang: string) {
|
|
return localeMap[lang] || enUS
|
|
}
|
|
|
|
const getBestFallbackKeyword = (title: string): string => {
|
|
const STOP_WORDS = new Set([
|
|
'dans', 'avec', 'pour', 'plus', 'tout', 'tous', 'cette', 'ceux', 'mais', 'sans',
|
|
'faire', 'fait', 'comme', 'sont', 'ont', 'etaient', 'etait', 'sujet', 'fiche', 'page',
|
|
'note', 'notes', 'guide', 'guides', 'projet', 'projets', 'test', 'tests', 'demo', 'base',
|
|
'bases', 'donnee', 'donnees', 'tableau', 'tableaux', 'quel', 'quels', 'quelle', 'quelles',
|
|
'about', 'with', 'from', 'that', 'this', 'your', 'have', 'were', 'was', 'project',
|
|
'projects', 'test', 'tests', 'page', 'pages', 'file', 'files', 'some', 'more', 'their',
|
|
'them', 'they', 'what', 'which', 'where', 'when', 'how', 'who', 'why'
|
|
])
|
|
|
|
const words = title
|
|
.toLowerCase()
|
|
.split(/[^\p{L}\d]+/u)
|
|
.filter(w => w.length > 3 && !STOP_WORDS.has(w))
|
|
|
|
if (words.length === 0) return ''
|
|
words.sort((a, b) => b.length - a.length)
|
|
return words[0]
|
|
}
|
|
|
|
type NotesStructuredTableProps = {
|
|
notes: Note[]
|
|
schema: NotebookSchemaPayload
|
|
noteValues: Record<string, NotePropertyValues>
|
|
onOpen: (note: Note) => void
|
|
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
|
|
onDeleteProperty?: (propertyId: string) => Promise<void>
|
|
}
|
|
|
|
export function NotesStructuredTable({
|
|
notes,
|
|
schema,
|
|
noteValues,
|
|
onOpen,
|
|
onPropertyChange,
|
|
onDeleteProperty,
|
|
}: NotesStructuredTableProps) {
|
|
const { t, language } = useLanguage()
|
|
const untitled = t('notes.untitled')
|
|
const [sort, setSort] = useState<ColumnSort>({ propertyId: 'updatedAt', direction: 'desc' })
|
|
const [filterPropId, setFilterPropId] = useState<string | null>(null)
|
|
const [filterOp, setFilterOp] = useState<ColumnFilter['operator']>('contains')
|
|
const [filterValue, setFilterValue] = useState('')
|
|
const [propertyToDelete, setPropertyToDelete] = useState<{ id: string; name: string } | null>(null)
|
|
const [deletingProperty, setDeletingProperty] = useState(false)
|
|
|
|
// Memory Echo states
|
|
const { requestAiConsent } = useAiConsent()
|
|
const [activeEchoNoteId, setActiveEchoNoteId] = useState<string | null>(null)
|
|
const [echoLoading, setEchoLoading] = useState(false)
|
|
const [echoError, setEchoError] = useState<string | null>(null)
|
|
const [echoConnections, setEchoConnections] = useState<Array<{ noteId: string; title: string; similarity: number; isTextMatch?: boolean }>>([])
|
|
|
|
const handleFetchRealEcho = async (noteId: string) => {
|
|
if (activeEchoNoteId === noteId) {
|
|
setActiveEchoNoteId(null)
|
|
setEchoConnections([])
|
|
setEchoError(null)
|
|
return
|
|
}
|
|
setActiveEchoNoteId(noteId)
|
|
setEchoLoading(true)
|
|
setEchoConnections([])
|
|
setEchoError(null)
|
|
|
|
try {
|
|
// 1. GDPR AI Consent check
|
|
const consented = await requestAiConsent()
|
|
if (!consented) {
|
|
setEchoError("Le consentement pour le traitement par IA est requis pour utiliser la résonance sémantique.")
|
|
setEchoLoading(false)
|
|
return
|
|
}
|
|
|
|
// 2. Fetch connections
|
|
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=5`)
|
|
|
|
if (res.status === 403) {
|
|
setEchoError("Le consentement pour le traitement par IA est requis pour utiliser la résonance sémantique.")
|
|
setEchoLoading(false)
|
|
return
|
|
}
|
|
|
|
const json = await res.json()
|
|
|
|
if (json.connections && Array.isArray(json.connections) && json.connections.length > 0) {
|
|
setEchoConnections(json.connections)
|
|
} else {
|
|
// 3. Fallback: Si aucune connexion pré-calculée en DB, on fait une recherche textuelle en direct
|
|
const targetNote = notes.find(n => n.id === noteId)
|
|
const q = (targetNote?.title || '').trim()
|
|
|
|
if (!q) {
|
|
setEchoError("Cette note n'a pas encore de titre ou de contenu suffisant pour trouver des résonances sémantiques.")
|
|
setEchoLoading(false)
|
|
return
|
|
}
|
|
|
|
let fallbackRes = await fetch(`/api/notes?search=${encodeURIComponent(q)}&limit=5`)
|
|
let fallbackJson = await fallbackRes.json()
|
|
|
|
let filteredNotes = (fallbackJson.success && Array.isArray(fallbackJson.data))
|
|
? fallbackJson.data.filter((n: any) => n.id !== noteId)
|
|
: []
|
|
|
|
if (filteredNotes.length === 0) {
|
|
// Fallback to searching significant words if full title matches nothing else
|
|
const fallbackWord = getBestFallbackKeyword(q)
|
|
if (fallbackWord) {
|
|
const resFallback = await fetch(`/api/notes?search=${encodeURIComponent(fallbackWord)}&limit=5`)
|
|
const jsonFallback = await resFallback.json()
|
|
if (jsonFallback.success && Array.isArray(jsonFallback.data)) {
|
|
filteredNotes = jsonFallback.data.filter((n: any) => n.id !== noteId)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (filteredNotes.length === 0) {
|
|
setEchoError(`Aucune note similaire à "${q}" n'a été détectée dans votre espace de travail.`)
|
|
} else {
|
|
setEchoConnections(filteredNotes.map((n: any, idx: number) => ({
|
|
noteId: n.id,
|
|
title: n.title || 'Sans titre',
|
|
similarity: 0.82 - idx * 0.04,
|
|
isTextMatch: true
|
|
})))
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
setEchoError("Une erreur est survenue lors de la recherche.")
|
|
} finally {
|
|
setEchoLoading(false)
|
|
}
|
|
}
|
|
|
|
const filters: ColumnFilter[] = useMemo(() => {
|
|
if (!filterPropId) return []
|
|
return [{ propertyId: filterPropId, operator: filterOp, value: filterValue }]
|
|
}, [filterPropId, filterOp, filterValue])
|
|
|
|
const displayed = useMemo(() => {
|
|
const filtered = filterNotesWithProperties(notes, noteValues, filters, schema.properties)
|
|
return sortNotesWithProperties(filtered, noteValues, sort, schema.properties)
|
|
}, [notes, noteValues, filters, sort, schema.properties])
|
|
|
|
const toggleSort = (propertyId: ColumnSort['propertyId']) => {
|
|
setSort((prev) =>
|
|
prev.propertyId === propertyId
|
|
? { propertyId, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
|
: { propertyId, direction: 'asc' },
|
|
)
|
|
}
|
|
|
|
const SortIcon = ({ field }: { field: ColumnSort['propertyId'] }) =>
|
|
sort.propertyId !== field ? null : sort.direction === 'asc' ? (
|
|
<ChevronUp size={12} />
|
|
) : (
|
|
<ChevronDown size={12} />
|
|
)
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
|
<Filter size={14} className="text-muted-foreground" />
|
|
<select
|
|
value={filterPropId ?? ''}
|
|
onChange={(e) => setFilterPropId(e.target.value || null)}
|
|
className="rounded-lg border border-border bg-background px-2 py-1"
|
|
>
|
|
<option value="">{t('structuredViews.noFilter')}</option>
|
|
<option value="title">{t('notes.tableTitle')}</option>
|
|
{schema.properties.map((p) => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
{filterPropId && (
|
|
<>
|
|
<select
|
|
value={filterOp}
|
|
onChange={(e) => setFilterOp(e.target.value as ColumnFilter['operator'])}
|
|
className="rounded-lg border border-border bg-background px-2 py-1"
|
|
>
|
|
<option value="contains">{t('structuredViews.filterContains')}</option>
|
|
<option value="equals">{t('structuredViews.filterEquals')}</option>
|
|
<option value="empty">{t('structuredViews.filterEmpty')}</option>
|
|
</select>
|
|
{filterOp !== 'empty' && (
|
|
<input
|
|
value={filterValue}
|
|
onChange={(e) => setFilterValue(e.target.value)}
|
|
className="rounded-lg border border-border bg-background px-2 py-1 min-w-[120px]"
|
|
placeholder={t('structuredViews.filterValue')}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
|
<table className="w-full text-left border-collapse min-w-[800px]">
|
|
<thead>
|
|
<tr className="border-b border-border/30">
|
|
<th
|
|
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground w-[22%]"
|
|
onClick={() => toggleSort('title')}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t('notes.tableTitle')} <SortIcon field="title" />
|
|
</span>
|
|
</th>
|
|
{schema.properties.map((p) => (
|
|
<th
|
|
key={p.id}
|
|
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground group/col"
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleSort(p.id)}
|
|
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
|
|
>
|
|
{p.name} <SortIcon field={p.id} />
|
|
</button>
|
|
{onDeleteProperty && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setPropertyToDelete({ id: p.id, name: p.name })
|
|
}}
|
|
className="opacity-40 group-hover/col:opacity-100 p-0.5 rounded hover:text-red-500 hover:bg-red-500/10 transition-all shrink-0"
|
|
title={t('structuredViews.deleteProperty')}
|
|
aria-label={t('structuredViews.deleteProperty')}
|
|
>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
)}
|
|
</span>
|
|
</th>
|
|
))}
|
|
<th
|
|
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground w-[12%]"
|
|
onClick={() => toggleSort('updatedAt')}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t('notes.tableModified')} <SortIcon field="updatedAt" />
|
|
</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-foreground/[0.03]">
|
|
{displayed.map((note) => {
|
|
const vals = noteValues[note.id] ?? {}
|
|
return (
|
|
<Fragment key={note.id}>
|
|
<tr key={note.id} className="hover:bg-foreground/[0.015] transition-colors group">
|
|
<td className="px-4 py-2 align-middle">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(note)}
|
|
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[200px] group-hover:text-brand-accent transition-colors shrink-0"
|
|
>
|
|
{getNoteDisplayTitle(note, untitled)}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleFetchRealEcho(note.id)}
|
|
className={cn(
|
|
"opacity-0 group-hover:opacity-100 p-1 rounded transition-all shrink-0",
|
|
activeEchoNoteId === note.id
|
|
? "text-purple-400 bg-purple-500/10 opacity-100"
|
|
: "text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10"
|
|
)}
|
|
title="Résonances sémantiques"
|
|
>
|
|
<Sparkles size={11} className={activeEchoNoteId === note.id ? "animate-pulse" : ""} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
{schema.properties.map((p) => (
|
|
<td
|
|
key={p.id}
|
|
className="px-4 py-2 align-top"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="min-w-[100px] max-w-[180px]">
|
|
<PropertyValueEditor
|
|
property={p}
|
|
value={vals[p.id]}
|
|
compact
|
|
onChange={(v) => onPropertyChange(note.id, p.id, v)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
))}
|
|
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap align-middle">
|
|
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy', getDateLocale(language))}
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Memory Echo collapsible details */}
|
|
{activeEchoNoteId === note.id && (
|
|
<tr className="bg-purple-500/[0.015]">
|
|
<td colSpan={schema.properties.length + 3} className="px-5 py-3.5 border-t border-b border-purple-500/10">
|
|
<div className="space-y-3 animate-in slide-in-from-top-1 duration-200">
|
|
<div className="flex items-center justify-between text-[11px] font-bold text-foreground/80">
|
|
<span className="flex items-center gap-1.5 text-purple-400">
|
|
<Brain className="w-3.5 h-3.5 animate-pulse" />
|
|
{t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'}
|
|
</span>
|
|
<button
|
|
onClick={() => { setActiveEchoNoteId(null); setEchoConnections([]); }}
|
|
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
|
>
|
|
Fermer
|
|
</button>
|
|
</div>
|
|
|
|
{echoLoading ? (
|
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground py-2">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-purple-400" />
|
|
<span>{t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'}</span>
|
|
</div>
|
|
) : echoError ? (
|
|
<p className="text-[11px] text-muted-foreground/90 font-medium py-1">
|
|
{echoError}
|
|
</p>
|
|
) : echoConnections.length === 0 ? (
|
|
<p className="text-[11px] text-muted-foreground italic py-1">
|
|
{t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-w-2xl">
|
|
{echoConnections.map((conn) => (
|
|
<div
|
|
key={conn.noteId}
|
|
className="flex items-center justify-between p-2.5 rounded-xl bg-card border border-border/50 hover:border-purple-500/40 hover:bg-purple-500/[0.02] text-left text-[11px] transition-all group shadow-sm gap-2"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => openNotePeek({ noteId: conn.noteId })}
|
|
className="flex-1 text-left truncate font-semibold text-foreground/80 hover:text-purple-400 transition-colors"
|
|
>
|
|
{conn.title}
|
|
</button>
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
window.dispatchEvent(new CustomEvent('memento-insert-citation', {
|
|
detail: {
|
|
noteId: conn.noteId,
|
|
noteTitle: conn.title || 'Sans titre',
|
|
excerpt: '',
|
|
atEnd: false
|
|
}
|
|
}))
|
|
}}
|
|
className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors"
|
|
title="Insérer le lien dans l'éditeur"
|
|
>
|
|
<Link2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="text-[9px] text-purple-400 font-bold bg-purple-500/10 px-2 py-0.5 rounded-full flex items-center gap-0.5 border border-purple-500/20">
|
|
{conn.isTextMatch ? 'Mot-clé' : `${Math.round((conn.similarity || 0) * 100)}%`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
{displayed.length === 0 && (
|
|
<p className="text-center py-8 text-muted-foreground text-sm">{t('structuredViews.noMatchingNotes')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<AlertDialog
|
|
open={Boolean(propertyToDelete)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPropertyToDelete(null)
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('structuredViews.deletePropertyTitle')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('structuredViews.deletePropertyConfirm', { name: propertyToDelete?.name ?? '' })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deletingProperty}>{t('general.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={deletingProperty || !propertyToDelete}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
if (!propertyToDelete || !onDeleteProperty) return
|
|
setDeletingProperty(true)
|
|
try {
|
|
await onDeleteProperty(propertyToDelete.id)
|
|
if (filterPropId === propertyToDelete.id) setFilterPropId(null)
|
|
setPropertyToDelete(null)
|
|
} finally {
|
|
setDeletingProperty(false)
|
|
}
|
|
}}
|
|
>
|
|
{t('structuredViews.deleteProperty')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
} |