1245 lines
52 KiB
TypeScript
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>
|
|
)
|
|
}
|