Files
Momento/memento-note/components/structured-view-block-embed.tsx

1245 lines
52 KiB
TypeScript

'use client'
import { useEffect, useState, useMemo, useCallback, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
import { useNotebooks } from '@/context/notebooks-context'
import { useLanguage } from '@/lib/i18n'
import type { Editor } from '@tiptap/core'
import type { Note } from '@/lib/types'
import { StructuredViewsContainer } from '@/components/structured-views/structured-views-container'
import {
LayoutGrid, Table, ExternalLink, Loader2, AlertCircle, Plus, Trash2,
FolderPlus, Settings, Sparkles, Check, CheckSquare, Type, List, HelpCircle,
BarChart3, Brain, ArrowUpRight, Link2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { openNotePeek } from '@/lib/note-peek-sync'
interface LocalColumn {
id: string
name: string
type: 'text' | 'checkbox' | 'select'
options?: string[]
}
interface LocalRow {
id: string
values: Record<string, any>
}
interface StructuredViewBlockEmbedProps {
notebookId: string | null
displayMode: 'table' | 'gallery'
filterJson: string
isLocal: boolean
localColumnsJson: string
localRowsJson: string
updateAttributes: (attrs: Record<string, any>) => void
editor: Editor
}
export function StructuredViewBlockEmbed({
notebookId,
displayMode,
filterJson,
isLocal,
localColumnsJson,
localRowsJson,
updateAttributes,
editor,
}: StructuredViewBlockEmbedProps) {
const { t, language } = useLanguage()
const router = useRouter()
const { notebooks, refreshNotebooks } = useNotebooks()
// New features states
const [showAnalytics, setShowAnalytics] = useState(false)
const [activeEchoRowId, setActiveEchoRowId] = 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; updatedAt?: string; isTextMatch?: boolean }>>([])
// Parse local database data
const localColumns: LocalColumn[] = useMemo(() => {
try {
return JSON.parse(localColumnsJson || '[]')
} catch {
return []
}
}, [localColumnsJson])
const localRows: LocalRow[] = useMemo(() => {
try {
return JSON.parse(localRowsJson || '[]')
} catch {
return []
}
}, [localRowsJson])
// State for notebook creation from local DB
const [converting, setConverting] = useState(false)
const [newNotebookName, setNewNotebookName] = useState('')
const [showConvertModal, setShowConvertModal] = useState(false)
const effectiveNotebookId = useMemo(() => {
return notebookId || ((editor.storage as any).structuredViewBlock?.notebookId as string | null) || null
}, [notebookId, (editor.storage as any).structuredViewBlock?.notebookId])
// Get current notebook info for visual style and naming
const currentNotebook = useMemo(() => {
return notebooks.find((n) => n.id === effectiveNotebookId)
}, [notebooks, effectiveNotebookId])
// Schema state hook
const schemaHook = useNotebookSchema(effectiveNotebookId)
// Fetch notebook notes
const [notes, setNotes] = useState<Note[]>([])
const [notesLoading, setNotesLoading] = useState(false)
const [notesError, setNotesError] = useState<string | null>(null)
const fetchNotes = useCallback(async () => {
if (isLocal || !effectiveNotebookId) {
setNotes([])
return
}
setNotesLoading(true)
setNotesError(null)
try {
const res = await fetch(`/api/notes?notebookId=${effectiveNotebookId}&limit=50`)
const json = await res.json()
if (json.success && Array.isArray(json.data)) {
setNotes(json.data)
} else {
setNotesError(t('structuredViewBlock.loadError') || 'Failed to load notes')
}
} catch (e) {
setNotesError(e instanceof Error ? e.message : 'Error loading notes')
} finally {
setNotesLoading(false)
}
}, [effectiveNotebookId, isLocal, t])
useEffect(() => {
void fetchNotes()
}, [fetchNotes])
// Handle mode switch between Table and Gallery
const setMode = (mode: 'table' | 'gallery') => {
updateAttributes({ displayMode: mode })
}
// Handle open note from the embedded view (using split peek)
const handleOpenNote = (note: Note) => {
openNotePeek({ noteId: note.id })
}
// Reload action for retry
const handleRetry = () => {
void schemaHook.reload()
void fetchNotes()
}
// ----------------------------------------------------
// LOCAL DATABASE ACTIONS (Notion-like inline table)
// ----------------------------------------------------
const updateLocalData = (cols: LocalColumn[], rows: LocalRow[]) => {
updateAttributes({
localColumnsJson: JSON.stringify(cols),
localRowsJson: JSON.stringify(rows),
})
}
const addLocalColumn = () => {
const newId = `col-${Date.now()}`
const newCol: LocalColumn = {
id: newId,
name: `Propriété ${localColumns.length + 1}`,
type: 'text',
}
const updatedCols = [...localColumns, newCol]
const updatedRows = localRows.map(row => ({
...row,
values: { ...row.values, [newId]: '' }
}))
updateLocalData(updatedCols, updatedRows)
toast.success('Colonne ajoutée !')
}
const deleteLocalColumn = (colId: string) => {
const updatedCols = localColumns.filter(c => c.id !== colId)
const updatedRows = localRows.map(row => {
const nextValues = { ...row.values }
delete nextValues[colId]
return { ...row, values: nextValues }
})
updateLocalData(updatedCols, updatedRows)
toast.success('Colonne supprimée')
}
const updateLocalColumnName = (colId: string, name: string) => {
const updatedCols = localColumns.map(c => c.id === colId ? { ...c, name } : c)
updateLocalData(updatedCols, localRows)
}
const updateLocalColumnType = (colId: string, type: 'text' | 'checkbox' | 'select') => {
const updatedCols = localColumns.map(c => {
if (c.id === colId) {
const next: LocalColumn = { ...c, type }
if (type === 'select') {
next.options = ['Option 1', 'Option 2']
} else {
delete next.options
}
return next
}
return c
})
// Reset values for rows to match type
const updatedRows = localRows.map(row => {
const nextValues = { ...row.values }
if (type === 'checkbox') {
nextValues[colId] = false
} else {
nextValues[colId] = ''
}
return { ...row, values: nextValues }
})
updateLocalData(updatedCols, updatedRows)
}
const updateLocalColumnOptions = (colId: string, optionsText: string) => {
const options = optionsText.split(',').map(o => o.trim()).filter(Boolean)
const updatedCols = localColumns.map(c => c.id === colId ? { ...c, options } : c)
updateLocalData(updatedCols, localRows)
}
const addLocalRow = () => {
const newId = `row-${Date.now()}`
const newValues: Record<string, any> = {}
localColumns.forEach(c => {
newValues[c.id] = c.type === 'checkbox' ? false : ''
})
const newRow: LocalRow = {
id: newId,
values: newValues
}
updateLocalData(localColumns, [...localRows, newRow])
}
const deleteLocalRow = (rowId: string) => {
updateLocalData(localColumns, localRows.filter(r => r.id !== rowId))
}
const updateLocalCellValue = (rowId: string, colId: string, value: any) => {
const updatedRows = localRows.map(row => {
if (row.id === rowId) {
return {
...row,
values: { ...row.values, [colId]: value }
}
}
return row
})
updateLocalData(localColumns, updatedRows)
}
// ----------------------------------------------------
// CONVERT LOCAL DATABASE TO DYNAMIC NOTEBOOK VIEW
// ----------------------------------------------------
const handleConvertToNotebook = async () => {
const name = newNotebookName.trim()
if (!name) {
toast.error('Veuillez entrer un nom pour le carnet.')
return
}
setConverting(true)
try {
// 1. Create Notebook
const createNbRes = await fetch('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: '#A47148', order: 0 })
})
const createdNbJson = await createNbRes.json()
if (!createNbRes.ok || !createdNbJson.success) {
throw new Error(createdNbJson.error || 'Erreur lors de la création du carnet.')
}
const newNotebook = createdNbJson.data
// 2. Enable Structured Mode (Creates schema)
const enableSchemaRes = await fetch(`/api/notebooks/${newNotebook.id}/schema`, {
method: 'POST'
})
const schemaJson = await enableSchemaRes.json()
if (!enableSchemaRes.ok || !schemaJson.success) {
throw new Error(schemaJson.error || 'Erreur lors de l\'activation de la structure.')
}
const schema = schemaJson.data.schema
// 3. Add properties matching columns
const columnMapping: Record<string, string> = {} // maps local col ID to prisma prop ID
for (const col of localColumns) {
// Skip title/name column since Note already has a title field
if (col.id === 'col-title') continue
const propRes = await fetch(`/api/notebooks/${newNotebook.id}/schema`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'addProperty',
name: col.name,
type: col.type,
options: col.options || []
})
})
const propJson = await propRes.json()
if (!propRes.ok || !propJson.success) {
throw new Error(propJson.error || 'Erreur d\'ajout de propriété.')
}
// Find added property ID in the updated schema
const addedProp = propJson.data.schema.properties.find((p: any) => p.name === col.name)
if (addedProp) {
columnMapping[col.id] = addedProp.id
}
}
// 4. Create Notes for each row
for (const row of localRows) {
const titleVal = row.values['col-title'] || 'Sans titre'
const noteRes = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleVal,
notebookId: newNotebook.id,
content: ''
})
})
const noteJson = await noteRes.json()
if (!noteRes.ok || !noteJson.success) {
throw new Error('Erreur de création de note.')
}
const createdNote = noteJson.data
// Associate cell values to properties
const propertiesPayload: Record<string, any> = {}
for (const [colId, propId] of Object.entries(columnMapping)) {
propertiesPayload[propId] = row.values[colId]
}
if (Object.keys(propertiesPayload).length > 0) {
await fetch(`/api/notes/${createdNote.id}/properties`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ properties: propertiesPayload })
})
}
}
// 5. Update block attributes to point to the new notebook
updateAttributes({
notebookId: newNotebook.id,
isLocal: false,
localColumnsJson: '[]',
localRowsJson: '[]'
})
await refreshNotebooks()
toast.success('Conversion réussie ! Base liée créée.')
setShowConvertModal(false)
} catch (e) {
console.error(e)
toast.error(e instanceof Error ? e.message : 'Une erreur est survenue.')
} finally {
setConverting(false)
}
}
// Dynamic colors for select pill badges
const getOptionColor = useCallback((text: string) => {
if (!text) return { style: { backgroundColor: 'rgba(128, 128, 128, 0.1)', color: '#888' } }
let hash = 0
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash)
}
const h = Math.abs(hash) % 360
return {
style: {
backgroundColor: `hsla(${h}, 45%, 50%, 0.12)`,
color: `hsla(${h}, 75%, 60%, 1)`,
border: `1px solid hsla(${h}, 50%, 50%, 0.2)`
}
}
}, [])
// Fetch echo connections for a local row by search (simulated similarity)
const handleToggleLocalEcho = async (rowId: string, titleVal: string) => {
if (activeEchoRowId === rowId) {
setActiveEchoRowId(null)
setEchoConnections([])
setEchoError(null)
return
}
setActiveEchoRowId(rowId)
setEchoLoading(true)
setEchoConnections([])
setEchoError(null)
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]
}
try {
const q = (titleVal || '').trim()
if (!q) {
setEchoConnections([])
setEchoError("Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.")
setEchoLoading(false)
return
}
let res = await fetch(`/api/notes?search=${encodeURIComponent(q)}&limit=5`)
let json = await res.json()
let data = (json.success && Array.isArray(json.data)) ? json.data : []
if (data.length === 0) {
// Fallback to searching significant words if full title matches nothing
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)) {
data = jsonFallback.data
}
}
}
if (data.length === 0) {
setEchoError(`Aucune note correspondante contenant "${q}" n'a été trouvée dans votre espace de travail.`)
} else {
setEchoConnections(data.map((n: any, idx: number) => ({
noteId: n.id,
title: n.title || 'Sans titre',
similarity: Math.round((0.88 - idx * 0.04) * 100),
updatedAt: n.updatedAt,
isTextMatch: true
})))
}
} catch (e) {
console.error(e)
setEchoError("Une erreur est survenue lors de la recherche.")
} finally {
setEchoLoading(false)
}
}
// Real-time analytics calculation for local/ notebook modes
const analyticsData = useMemo(() => {
if (isLocal) {
if (localRows.length === 0) return null
const total = localRows.length
const checkboxStats: Array<{ colName: string; percentage: number; count: number }> = []
localColumns.forEach(col => {
if (col.type === 'checkbox') {
const checked = localRows.filter(r => !!r.values[col.id]).length
checkboxStats.push({
colName: col.name,
percentage: Math.round((checked / total) * 100),
count: checked
})
}
})
const selectStats: Array<{ colName: string; distribution: Record<string, number> }> = []
localColumns.forEach(col => {
if (col.type === 'select') {
const dist: Record<string, number> = {}
localRows.forEach(r => {
const val = r.values[col.id] || ''
if (val) {
dist[val] = (dist[val] || 0) + 1
}
})
selectStats.push({
colName: col.name,
distribution: dist
})
}
})
return { total, checkboxStats, selectStats }
} else {
if (notes.length === 0 || !schemaHook.schema) return null
const total = notes.length
const props = schemaHook.schema.properties || []
const checkboxStats: Array<{ colName: string; percentage: number; count: number }> = []
const selectStats: Array<{ colName: string; distribution: Record<string, number> }> = []
props.forEach(p => {
if (p.type === 'checkbox') {
let checked = 0
notes.forEach(n => {
const noteVals = schemaHook.noteValues[n.id] || {}
if (noteVals[p.id] === true || noteVals[p.id] === 'true') {
checked++
}
})
checkboxStats.push({
colName: p.name,
percentage: Math.round((checked / total) * 100),
count: checked
})
} else if (p.type === 'select') {
const dist: Record<string, number> = {}
notes.forEach(n => {
const noteVals = schemaHook.noteValues[n.id] || {}
const val = (noteVals[p.id] as string) || ''
if (val) {
dist[val] = (dist[val] || 0) + 1
}
})
selectStats.push({
colName: p.name,
distribution: dist
})
}
})
return { total, checkboxStats, selectStats }
}
}, [isLocal, localRows, localColumns, notes, schemaHook.schema, schemaHook.noteValues])
const renderAnalyticsPanel = () => {
if (!analyticsData || !showAnalytics) return null
const { total, checkboxStats, selectStats } = analyticsData
const hasCheckboxStats = checkboxStats.length > 0
const hasSelectStats = selectStats.some(s => Object.keys(s.distribution).length > 0)
if (total === 0 || (!hasCheckboxStats && !hasSelectStats)) {
return (
<div className="p-4 border-b border-border/40 bg-muted/10 text-center text-xs text-muted-foreground italic flex items-center justify-center gap-1.5">
<BarChart3 className="w-4 h-4 text-muted-foreground/60 animate-pulse" />
<span>{t('structuredViewBlock.analyticsNoData') || 'Aucune donnée d\'analyse disponible.'}</span>
</div>
)
}
return (
<div className="p-4 border-b border-border/40 bg-muted/10 space-y-4 animate-in slide-in-from-top duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-foreground/85">
<BarChart3 className="w-4 h-4 text-primary" />
<span>{t('structuredViewBlock.analyticsTitle') || 'Analyses & Insights'}</span>
</div>
<div className="text-[10px] text-muted-foreground bg-muted border border-border/60 rounded-md px-2 py-0.5 font-medium">
{t('structuredViewBlock.analyticsTotalRows') || 'Total des lignes'} : <span className="font-bold text-foreground">{total}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Checkboxes completion rates */}
{hasCheckboxStats && (
<div className="bg-card border border-border/40 rounded-xl p-3 space-y-3 shadow-sm">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground block">
{t('structuredViewBlock.analyticsCompletion') || 'Taux de complétion'}
</span>
<div className="space-y-2.5">
{checkboxStats.map(stat => (
<div key={stat.colName} className="space-y-1">
<div className="flex justify-between text-[11px] font-medium">
<span className="text-foreground/80">{stat.colName}</span>
<span className="text-primary font-bold">{stat.percentage}% ({stat.count}/{total})</span>
</div>
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary/80 to-primary transition-all duration-500 rounded-full"
style={{ width: `${stat.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Select dropdowns distribution */}
{hasSelectStats && (
<div className="bg-card border border-border/40 rounded-xl p-3 space-y-3 shadow-sm">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground block">
{t('structuredViewBlock.analyticsDistribution') || 'Répartition'}
</span>
<div className="space-y-3 max-h-[140px] overflow-y-auto custom-scrollbar pr-1">
{selectStats.map(stat => {
const distKeys = Object.keys(stat.distribution)
if (distKeys.length === 0) return null
return (
<div key={stat.colName} className="space-y-1.5">
<div className="text-[11px] font-bold text-foreground/80">{stat.colName}</div>
<div className="flex flex-wrap gap-1.5">
{distKeys.map(val => {
const count = distKeys ? stat.distribution[val] : 0
const pct = Math.round((count / total) * 100)
const badgeStyle = getOptionColor(val).style
return (
<div
key={val}
style={badgeStyle}
className="text-[10px] px-2 py-0.5 rounded-full font-medium flex items-center gap-1.5 shadow-sm border border-border/30"
>
<span className="truncate max-w-[80px]">{val}</span>
<span className="opacity-80 font-bold">({count} - {pct}%)</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}
const isRTL = language === 'fa' || language === 'ar'
// ====================================================
// LOCAL DATABASE RENDERING (Mode A)
// ====================================================
if (isLocal) {
return (
<div
className="structured-view-embed-container border border-border/85 rounded-2xl bg-card text-card-foreground my-4 overflow-hidden shadow-lg border-l-4 border-l-primary/60 transition-all duration-300"
dir="auto"
>
{/* Header toolbar */}
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 px-4 py-3 border-b border-border/50 bg-muted/30 text-xs",
isRTL ? "flex-row-reverse" : "flex-row"
)}
>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary animate-pulse" />
<span className="font-bold text-foreground/80 tracking-wide">
{t('structuredViewBlock.localDbTitle') || 'Base de Données Autonome'}
</span>
</div>
<div className="flex items-center gap-2">
{/* Analytics toggle */}
<Button
size="sm"
variant={showAnalytics ? "secondary" : "ghost"}
onClick={() => setShowAnalytics(!showAnalytics)}
className="text-[11px] h-7 px-2.5 rounded-lg transition-all flex items-center gap-1.5"
>
<BarChart3 className="w-3.5 h-3.5 text-muted-foreground" />
<span>Analyses</span>
</Button>
<span className="h-4 w-px bg-border/50" />
{/* Convert button */}
<Button
size="sm"
variant="outline"
onClick={() => setShowConvertModal(true)}
className="text-[11px] h-7 px-2.5 rounded-lg border-primary/30 hover:border-primary hover:bg-primary/5 text-primary transition-all flex items-center gap-1.5"
>
<FolderPlus className="w-3.5 h-3.5" />
<span>Convertir en carnet</span>
</Button>
<span className="h-4 w-px bg-border/50" />
{/* Switch to notebook view */}
<Button
size="sm"
variant="ghost"
onClick={() => updateAttributes({ isLocal: false })}
className="text-[11px] h-7 px-2 text-muted-foreground hover:text-foreground"
>
Lier à un carnet
</Button>
</div>
</div>
{/* Analytics Insights Panel */}
{renderAnalyticsPanel()}
{/* Modal for conversion */}
{showConvertModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<FolderPlus className="w-5 h-5 text-primary" />
Convertir en carnet structuré
</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note de votre carnet, et vos colonnes seront configurées comme propriétés réutilisables.
</p>
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Nom du nouveau carnet</label>
<input
type="text"
placeholder="ex. Mes lectures, Suivi de projets"
value={newNotebookName}
onChange={(e) => setNewNotebookName(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex items-center justify-end gap-2 text-xs pt-2">
<Button size="sm" variant="ghost" onClick={() => setShowConvertModal(false)} disabled={converting}>
Annuler
</Button>
<Button size="sm" onClick={handleConvertToNotebook} disabled={converting || !newNotebookName}>
{converting ? (
<>
<Loader2 className="w-3 h-3 animate-spin mr-1.5" />
Conversion...
</>
) : 'Créer le carnet'}
</Button>
</div>
</div>
</div>
)}
{/* Interactive Local Table */}
<div className="p-4 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs min-w-[700px]">
<thead>
<tr className="border-b border-border/40">
<th className="py-2.5 px-3 w-10"></th>
{localColumns.map((col) => (
<th key={col.id} className="py-2.5 px-3 align-middle min-w-[150px] group/header">
<div className="flex items-center gap-2">
{/* Editable column header name */}
<input
value={col.name}
onChange={(e) => updateLocalColumnName(col.id, e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
className="bg-transparent border-0 outline-none font-bold text-[10px] uppercase tracking-widest text-muted-foreground focus:text-foreground focus:bg-background/50 rounded px-1 w-24 transition-colors"
/>
{/* Column type badge/selector */}
{col.id !== 'col-title' && (
<select
value={col.type}
onChange={(e) => updateLocalColumnType(col.id, e.target.value as any)}
className="text-[9px] bg-transparent border border-border/30 rounded px-1 py-0.5 text-muted-foreground hover:border-border transition-colors outline-none cursor-pointer"
>
<option value="text">Texte</option>
<option value="checkbox">Case</option>
<option value="select">Liste</option>
</select>
)}
{/* Display select options configurations inline */}
{col.type === 'select' && (
<input
placeholder="opts separées par virgule"
value={col.options?.join(', ') || ''}
onChange={(e) => updateLocalColumnOptions(col.id, e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
className="text-[9px] text-muted-foreground bg-transparent border-b border-dashed border-border/60 outline-none focus:border-foreground max-w-[120px] px-1"
/>
)}
{/* Delete column button */}
{col.id !== 'col-title' && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
deleteLocalColumn(col.id)
}}
className="opacity-40 hover:opacity-100 hover:text-red-500 p-0.5 rounded transition-all ml-auto shrink-0"
title="Supprimer la colonne"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</th>
))}
<th className="py-2 px-3 w-12 text-right">
<button
type="button"
onClick={addLocalColumn}
className="p-1 rounded bg-primary/10 hover:bg-primary/20 text-primary transition-all"
title="Ajouter une colonne"
>
<Plus className="w-3.5 h-3.5" />
</button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-foreground/[0.03]">
{localRows.map((row) => (
<Fragment key={row.id}>
<tr key={row.id} className="hover:bg-foreground/[0.015] transition-all group/row">
{/* Delete and Echo row handlers */}
<td className="py-2 px-2 text-center align-middle">
<div className="flex items-center gap-1.5 justify-center">
<button
type="button"
onClick={() => deleteLocalRow(row.id)}
className="opacity-0 group-hover/row:opacity-100 hover:text-red-500 p-1 rounded hover:bg-red-500/10 transition-all"
title="Supprimer la ligne"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleToggleLocalEcho(row.id, row.values['col-title'])}
className={cn(
"opacity-35 group-hover/row:opacity-100 p-1 rounded transition-all",
activeEchoRowId === row.id
? "text-purple-400 bg-purple-500/10 opacity-100"
: "hover:text-purple-400 hover:bg-purple-500/10"
)}
title="Résonances sémantiques"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
</div>
</td>
{/* Render editable values for each column */}
{localColumns.map((col) => {
const cellVal = row.values[col.id];
const badgeStyle = col.type === 'select' && cellVal ? getOptionColor(cellVal).style : undefined;
return (
<td key={col.id} className="py-2 px-3 align-middle min-w-[150px]">
<div className="w-full">
{col.type === 'checkbox' ? (
<div className="flex items-center">
<input
type="checkbox"
checked={!!cellVal}
onChange={(e) => updateLocalCellValue(row.id, col.id, e.target.checked)}
onKeyDown={(e) => e.stopPropagation()}
className="rounded border-border text-primary focus:ring-primary cursor-pointer w-4 h-4"
/>
</div>
) : col.type === 'select' ? (
<div className="relative flex items-center">
<select
value={cellVal || ''}
onChange={(e) => updateLocalCellValue(row.id, col.id, e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
style={badgeStyle}
className={cn(
"w-full bg-transparent border-0 outline-none text-xs px-2 py-1 rounded-md cursor-pointer transition-all border-b border-border/20 font-medium",
!cellVal && "text-muted-foreground/60"
)}
>
<option value="" className="bg-card text-foreground">--</option>
{col.options?.map((opt) => (
<option key={opt} value={opt} className="bg-card text-foreground">{opt}</option>
))}
</select>
</div>
) : (
<input
type="text"
value={cellVal || ''}
placeholder={col.id === 'col-title' ? 'Saisir un nom…' : ''}
onChange={(e) => updateLocalCellValue(row.id, col.id, e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
addLocalRow()
}
}}
className="w-full bg-transparent border-0 border-b border-border/10 focus:border-primary/40 focus:bg-background/20 outline-none px-2 py-1 text-xs text-foreground/90 transition-all rounded font-medium"
/>
)}
</div>
</td>
);
})}
<td className="py-2 px-3 w-12 text-right"></td>
</tr>
{/* Memory Echo Collapsible detail row */}
{activeEchoRowId === row.id && (
<tr className="bg-purple-500/[0.015]">
<td colSpan={localColumns.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={() => { setActiveEchoRowId(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 ? (
<div className="space-y-2 py-1">
<p className="text-[11px] text-muted-foreground/90 font-medium">
{echoError}
</p>
<div className="text-[10px] text-muted-foreground/75 bg-muted/30 p-2.5 rounded-lg border border-border/40 max-w-lg">
{t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
</div>
</div>
) : echoConnections.length === 0 ? (
<div className="space-y-2 py-1">
<p className="text-[11px] text-muted-foreground/80 italic">
{t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
</p>
<div className="text-[10px] text-muted-foreground/75 bg-muted/30 p-2.5 rounded-lg border border-border/40 max-w-lg">
{t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
</div>
</div>
) : (
<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
}
}))
toast.success("Citation insérée dans l'éditeur !")
}}
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é' : `${conn.similarity}%`}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
{localRows.length === 0 && (
<p className="text-center py-6 text-muted-foreground text-xs italic">Aucune ligne dans le tableau.</p>
)}
{/* Add row button */}
<div className="mt-3 flex items-center">
<Button
size="sm"
variant="ghost"
onClick={addLocalRow}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 h-8 px-2.5 rounded-lg"
>
<Plus className="w-3.5 h-3.5" />
<span>Ajouter une ligne</span>
</Button>
</div>
</div>
</div>
);
}
// ====================================================
// CARNET LINKED VIEW RENDERING (Mode B)
// ====================================================
// Scenario 3: No notebook associated - show interactive notebook selector
if (!effectiveNotebookId) {
return (
<div
className="flex flex-col items-start gap-3 p-5 rounded-lg border border-border bg-card text-card-foreground text-sm my-2 shadow-sm border-l-4 border-l-warning"
dir={isRTL ? 'rtl' : 'ltr'}
>
<div className="flex items-center gap-2 text-muted-foreground font-medium">
<Table className="w-4 h-4 text-primary" />
<span>{t('structuredViewBlock.selectNotebook') || 'Lier à un carnet'}</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{t('structuredViewBlock.noNotebookDesc') || 'Ce bloc affiche la vue structurée d\'un carnet. Choisissez le carnet à lier :'}
</p>
<div className="flex items-center gap-3 w-full max-w-md">
<select
onChange={(e) => {
if (e.target.value) {
updateAttributes({ notebookId: e.target.value })
}
}}
className="rounded-lg border border-border bg-background px-3 py-1.5 text-xs text-foreground outline-none focus:border-primary max-w-xs w-full shadow-sm"
defaultValue=""
>
<option value="" disabled>-- {t('structuredViewBlock.chooseNotebook') || 'Choisir un carnet'} --</option>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>
{nb.icon ? `${nb.icon} ` : ''}{nb.name}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">ou</span>
<Button
size="sm"
variant="ghost"
onClick={() => updateAttributes({ isLocal: true })}
className="text-xs text-primary hover:bg-primary/5"
>
Créer une base locale autonome
</Button>
</div>
</div>
)
}
// Loading state
const isLoading = schemaHook.loading || notesLoading
if (isLoading) {
return (
<div className="flex items-center justify-center p-8 rounded-lg border border-border bg-muted/10 my-2">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)
}
// Error state
const hasError = schemaHook.error || notesError
if (hasError) {
return (
<div
className="flex flex-col items-center justify-center gap-3 p-6 rounded-lg border border-destructive/20 bg-destructive/5 text-destructive-foreground text-sm my-2"
dir={isRTL ? 'rtl' : 'ltr'}
>
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{schemaHook.error || notesError}</span>
</div>
<Button size="sm" variant="outline" onClick={handleRetry}>
{t('structuredViewBlock.retry') || 'Réessayer'}
</Button>
</div>
)
}
// Scenario 2: Notebook has no schema configured
if (!schemaHook.schema) {
return (
<div
className="flex flex-col items-start gap-3 p-5 rounded-lg border border-border bg-card text-card-foreground text-sm my-2 shadow-sm"
dir={isRTL ? 'rtl' : 'ltr'}
>
<div className="flex items-center gap-2 text-muted-foreground font-medium">
<Table className="w-4 h-4" />
<span>{currentNotebook?.name || t('structuredViewBlock.insertLabel') || 'Vue structurée'}</span>
<button
onClick={() => updateAttributes({ notebookId: null })}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
>
({t('structuredViewBlock.change') || 'Changer'})
</button>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{t('structuredViewBlock.noSchema') || 'Ce carnet n\'a pas encore de vue structurée. Configurez-en une depuis l\'en-tête du carnet.'}
</p>
<Button
size="sm"
variant="outline"
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
className="text-xs flex items-center gap-1.5"
>
<span>{t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}</span>
<ExternalLink className="w-3.5 h-3.5" />
</Button>
</div>
)
}
// Normal view rendering
return (
<div
className="structured-view-embed-container border border-border/80 rounded-xl bg-card text-card-foreground my-4 overflow-hidden shadow-md"
dir="auto"
>
{/* Mini toolbar */}
<div
className={cn(
"flex flex-wrap items-center justify-between gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/20 text-xs",
isRTL ? "flex-row-reverse" : "flex-row"
)}
>
<div className="flex items-center gap-2 font-medium">
{currentNotebook?.icon ? (
<span className="text-sm">{currentNotebook.icon}</span>
) : (
<Table className="w-3.5 h-3.5 text-primary" />
)}
<span className="text-foreground/90">
{currentNotebook?.name}
</span>
<button
onClick={() => updateAttributes({ notebookId: null })}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
title={t('structuredViewBlock.changeNotebook') || 'Changer de carnet'}
>
({t('structuredViewBlock.change') || 'Changer'})
</button>
</div>
<div className="flex items-center gap-2.5">
{/* View selector buttons */}
<div className="flex items-center rounded-lg border border-border bg-background p-0.5 shadow-sm">
<button
onClick={() => setMode('table')}
className={cn(
"p-1 rounded-md transition-all",
displayMode === 'table'
? "bg-muted text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
title={t('structuredViewBlock.displayModeTable') || 'Tableau'}
>
<Table className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setMode('gallery')}
className={cn(
"p-1 rounded-md transition-all",
displayMode === 'gallery'
? "bg-muted text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
title={t('structuredViewBlock.displayModeGallery') || 'Galerie'}
>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
</div>
<span className="h-4 w-px bg-border/80" />
{/* Analytics toggle */}
<button
onClick={() => setShowAnalytics(!showAnalytics)}
className={cn(
"flex items-center gap-1 text-[11px] font-medium transition-colors px-1.5 py-0.5 rounded",
showAnalytics ? "text-primary bg-primary/10" : "text-muted-foreground hover:text-foreground"
)}
>
<BarChart3 className="w-3 h-3" />
<span>Analyses</span>
</button>
<span className="h-4 w-px bg-border/80" />
{/* Open Notebook link */}
<button
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}</span>
<ExternalLink className="w-3 h-3" />
</button>
{/* Switch to local database option */}
<button
onClick={() => updateAttributes({ isLocal: true, notebookId: null })}
className="text-[11px] text-primary hover:underline ml-1"
>
Passer en base locale
</button>
</div>
</div>
{/* Analytics Insights Panel */}
{renderAnalyticsPanel()}
{/* Structured views content wrapper */}
<div className="p-4 overflow-x-auto min-h-[120px] max-h-[400px] overflow-y-auto custom-scrollbar">
<StructuredViewsContainer
mode={displayMode}
notes={notes}
schema={schemaHook.schema}
noteValues={schemaHook.noteValues}
notebookColor={currentNotebook?.color}
onOpen={handleOpenNote}
onNoteValuesPatch={schemaHook.patchNoteValuesLocal}
onCreateNote={() => {}}
onSetKanbanGroupProperty={() => {}}
onDeleteProperty={async (propertyId) => {
await schemaHook.deleteProperty(propertyId)
}}
/>
</div>
</div>
)
}