Files
Momento/memento-note/components/structured-views/notes-structured-table.tsx
Antigravity e3369e03b5
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 42s
CI / Deploy production (on server) (push) Has been skipped
fix(eslint): resolve TypeScript imports require lint rules and prefer-const warnings
2026-05-28 21:18:51 +00:00

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
}
const fallbackRes = await fetch(`/api/notes?search=${encodeURIComponent(q)}&limit=5`)
const 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>
)
}