feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped

Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note)
avec activation guidée, tableau éditable, kanban et suppression de colonnes.
Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN.
Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la
robustesse du serveur MCP (config, validation, rate-limit, métriques).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-24 23:03:16 +00:00
parent ecd7e57c2e
commit 0784c94242
63 changed files with 10133 additions and 619 deletions

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { GraduationCap, Loader2, Sparkles, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
@@ -37,6 +38,20 @@ export function FlashcardGenerateDialog({
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [cards, setCards] = useState<PreviewCard[] | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
const handleGenerate = useCallback(async () => {
if (!(await requestAiConsent())) return
@@ -75,7 +90,9 @@ export function FlashcardGenerateDialog({
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.saveFailed'))
toast.error(
(data.errorKey ? t(data.errorKey) : null) || data.error || t('flashcards.saveFailed'),
)
return
}
toast.success(t('flashcards.savedCount', { count: data.savedCount }))
@@ -98,18 +115,18 @@ export function FlashcardGenerateDialog({
})
}
if (!open) return null
if (!open || !mounted) return null
return (
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center overflow-y-auto bg-black/40 backdrop-blur-sm p-4 sm:p-6"
onClick={onClose}
>
<div
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[min(90dvh,calc(100dvh-2rem))] my-auto overflow-hidden flex flex-col shrink-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
<div className="flex shrink-0 items-center justify-between px-5 py-4 border-b border-border/60">
<div className="flex items-center gap-2">
<GraduationCap size={18} className="text-brand-accent" />
<div>
@@ -122,7 +139,7 @@ export function FlashcardGenerateDialog({
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-5">
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain p-5 space-y-5 custom-scrollbar">
{!cards ? (
<>
<div>
@@ -185,7 +202,7 @@ export function FlashcardGenerateDialog({
)}
</div>
<div className="px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
<div className="shrink-0 px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
@@ -216,6 +233,7 @@ export function FlashcardGenerateDialog({
)}
</div>
</div>
</div>
</div>,
document.body,
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
interface WeekData {
week: string
rate: number
total: number
}
interface RetentionCurveProps {
data: WeekData[]
className?: string
}
export function RetentionCurve({ data, className }: RetentionCurveProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
// Filtrer les semaines avec données
const weeks = data.filter((w) => w.total > 0)
if (weeks.length === 0) {
return (
<div className="flex items-center justify-center h-28 border border-dashed border-border/40 rounded-xl bg-muted/10">
<p className="text-xs text-concrete/50 italic">Aucune donnée disponible</p>
</div>
)
}
// Dimensions SVG
const width = 500
const height = 120
const padding = { top: 15, right: 30, left: 30, bottom: 25 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
// Calculer les coordonnées des points
const points = weeks.map((w, i) => {
// Si 1 seul point, on le centre
const x = weeks.length === 1
? padding.left + chartWidth / 2
: padding.left + (i * chartWidth) / (weeks.length - 1)
// Taux entre 0 et 100
const val = Math.max(0, Math.min(100, w.rate))
const y = padding.top + chartHeight * (1 - val / 100)
return { x, y, rate: w.rate, week: w.week, total: w.total }
})
// Créer le path SVG pour la ligne
let linePath = ''
let areaPath = ''
if (points.length === 1) {
// Une seule semaine : ligne pointillée horizontale + point central
const yVal = points[0].y
linePath = `M ${padding.left} ${yVal} L ${width - padding.right} ${yVal}`
} else {
// Plusieurs semaines : tracer la courbe ligne
linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
// Area fermée pour le dégradé sous la courbe
areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`
}
const activePoint = hoveredIndex !== null ? points[hoveredIndex] : null
return (
<div className={cn('relative w-full select-none', className)}>
{/* SVG Container */}
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto overflow-visible"
>
<defs>
{/* Dégradé sous la courbe */}
<linearGradient id="retentionGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.22" />
<stop offset="100%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.0" />
</linearGradient>
{/* Lueur d'accent */}
<filter id="accentGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Lignes de guide d'arrière-plan (Grid) */}
<g stroke="currentColor" strokeOpacity={0.05} strokeWidth={1}>
<line x1={padding.left} y1={padding.top} x2={width - padding.right} y2={padding.top} />
<line x1={padding.left} y1={padding.top + chartHeight / 2} x2={width - padding.right} y2={padding.top + chartHeight / 2} />
<line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
</g>
{/* Tracé Dégradé Rempli (uniquement si plusieurs points) */}
{points.length > 1 && areaPath && (
<path
d={areaPath}
fill="url(#retentionGrad)"
className="transition-all duration-300"
/>
)}
{/* Ligne principale de la courbe */}
<path
d={linePath}
fill="none"
stroke="var(--color-brand-accent, #6366f1)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={points.length === 1 ? '4 4' : undefined}
className="transition-all duration-300"
/>
{/* Guide vertical au survol */}
{activePoint && (
<line
x1={activePoint.x}
y1={padding.top}
x2={activePoint.x}
y2={height - padding.bottom}
stroke="var(--color-brand-accent, #6366f1)"
strokeOpacity={0.25}
strokeWidth={1.5}
strokeDasharray="2 2"
/>
)}
{/* Points et zones interactives */}
{points.map((p, idx) => {
const isHovered = hoveredIndex === idx
return (
<g key={idx} className="cursor-pointer">
{/* Cercle extérieur (ombre / lueur d'accent si survol) */}
<circle
cx={p.x}
cy={p.y}
r={isHovered ? 8 : 4}
fill="var(--color-brand-accent, #6366f1)"
fillOpacity={isHovered ? 0.3 : 0.15}
className="transition-all duration-200"
/>
{/* Cercle intérieur (le point lui-même) */}
<circle
cx={p.x}
cy={p.y}
r={isHovered ? 4.5 : 3.5}
fill="var(--color-brand-accent, #6366f1)"
stroke={isHovered ? '#fff' : 'none'}
strokeWidth={1}
className="transition-all duration-200"
filter={isHovered ? 'url(#accentGlow)' : undefined}
/>
{/* Zone invisible large pour faciliter le survol souris/toucher */}
<rect
x={p.x - 20}
y={padding.top}
width={40}
height={chartHeight}
fill="transparent"
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
/>
</g>
);
})}
{/* Labels des semaines sous le graphe (X-Axis) */}
{points.map((p, idx) => {
const label = p.week.slice(5) // MM-DD
const isHovered = hoveredIndex === idx
// Pour éviter la surcharge visuelle, on n'affiche pas tous les labels si trop nombreux, sauf si survolé
const shouldShowLabel = points.length <= 8 || idx === 0 || idx === points.length - 1 || isHovered
if (!shouldShowLabel) return null
return (
<text
key={idx}
x={p.x}
y={height - 8}
textAnchor="middle"
className={cn(
"text-[9px] font-mono fill-concrete/60 select-none transition-colors",
isHovered && "fill-brand-accent font-bold"
)}
>
{label}
</text>
)
})}
</svg>
{/* Floating Info Tooltip */}
<div className="absolute right-0 top-0 h-4 flex items-center">
{activePoint ? (
<div className="text-[10px] text-foreground bg-card border border-border px-2 py-0.5 rounded-lg shadow-sm font-mono flex items-center gap-2 animate-fadeIn">
<span className="font-bold text-brand-accent">{activePoint.rate}% de succès</span>
<span className="text-concrete/60">({activePoint.total} révs)</span>
<span className="text-concrete/40">· sem. {activePoint.week.slice(5)}</span>
</div>
) : (
<span className="text-[9px] text-concrete/40 italic">Survolez un point pour les détails</span>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -15,7 +15,7 @@ interface RevisionHeatmapProps {
}
function intensityClass(count: number, max: number): string {
if (count <= 0) return 'bg-black/[0.04] dark:bg-white/[0.06]'
if (count <= 0) return 'bg-black/[0.06] dark:bg-white/[0.08]'
const ratio = count / Math.max(max, 1)
if (ratio >= 0.75) return 'bg-brand-accent'
if (ratio >= 0.5) return 'bg-brand-accent/70'
@@ -23,47 +23,159 @@ function intensityClass(count: number, max: number): string {
return 'bg-brand-accent/20'
}
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t } = useLanguage()
function resolveDateLocale(langCode: string): string {
if (langCode === 'fa') return 'fa-IR-u-ca-persian-nu-arabext'
return langCode
}
const { cells, maxCount } = useMemo(() => {
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t, language } = useLanguage()
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
const dateLocale = resolveDateLocale(language ?? 'en')
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
const map = new Map(data.map((d) => [d.date, d.count]))
const today = new Date()
const now = new Date()
// todayUTC est le début de la journée courante à minuit UTC
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
const cells: { date: string; count: number; label: string }[] = []
const monthLabels: { index: number; label: string }[] = []
let lastMonth = -1
for (let i = 89; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const d = new Date(todayUTC)
d.setUTCDate(d.getUTCDate() - i)
const key = d.toISOString().slice(0, 10)
const count = map.get(key) || 0
const month = d.getUTCMonth()
if (month !== lastMonth) {
monthLabels.push({
index: 89 - i,
label: d.toLocaleDateString(dateLocale, { month: 'short', timeZone: 'UTC' }),
})
lastMonth = month
}
cells.push({
date: key,
count: map.get(key) || 0,
label: d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }),
count,
label: d.toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }),
})
}
const maxCount = Math.max(1, ...cells.map((c) => c.count))
return { cells, maxCount }
}, [data])
const totalReviews = cells.reduce((s, c) => s + c.count, 0)
return { cells, maxCount, totalReviews, monthLabels }
}, [data, dateLocale])
const pct = (index: number) => `${(index / 90) * 100}%`
const activeInfo = hovered || selected
return (
<div className={cn('space-y-3', className)}>
<div className={cn('space-y-2', className)}>
{/* En-tête */}
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t('flashcards.heatmapTitle')}
</p>
<span className="text-[10px] text-concrete/60">{t('flashcards.heatmapLast90')}</span>
<span className="text-[10px] text-concrete/60">
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
</span>
</div>
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => (
<div
key={cell.date}
title={`${cell.label}: ${cell.count}`}
className={cn(
'aspect-square rounded-[3px] transition-colors',
intensityClass(cell.count, maxCount),
)}
/>
{/* Labels de mois au-dessus de la grille */}
<div className="relative h-4">
{monthLabels.map((m) => (
<span
key={m.label + m.index}
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
style={{ left: pct(m.index) }}
>
{m.label}
</span>
))}
</div>
{/* Grille pleine largeur */}
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => {
const isHovered = hovered?.date === cell.date
const isSelected = selected?.date === cell.date
const reviewText = cell.count > 0
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
: 'Aucune révision'
return (
<button
key={cell.date}
type="button"
title={`${reviewText} - ${cell.label}`}
className={cn(
'aspect-square rounded-[3px] transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-1 focus:ring-offset-background',
intensityClass(cell.count, maxCount),
(isHovered || isSelected) && 'ring-2 ring-brand-accent ring-offset-1 ring-offset-background scale-105 z-10',
)}
onMouseEnter={() => setHovered({ label: cell.label, count: cell.count, date: cell.date })}
onMouseLeave={() => setHovered(null)}
onClick={() => {
if (selected?.date === cell.date) {
setSelected(null)
} else {
setSelected({ label: cell.label, count: cell.count, date: cell.date })
}
}}
/>
)
})}
</div>
{/* Info au survol / clic */}
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
{activeInfo ? (
<p className="flex items-center gap-1.5 animate-fadeIn">
<span className="font-semibold text-foreground">
{activeInfo.count > 0
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
: 'Aucune révision'}
</span>
<span className="text-concrete">· {activeInfo.label}</span>
{selected?.date === activeInfo.date && !hovered && (
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
sélectionné
</span>
)}
</p>
) : (
<p className="text-[10px] text-concrete/40 italic">
Survolez ou cliquez sur un carré pour voir le détail
</p>
)}
{selected && (
<button
type="button"
onClick={() => setSelected(null)}
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
>
Effacer la sélection
</button>
)}
</div>
{/* Légende */}
<div className="flex items-center gap-2 pt-0.5">
<span className="text-[9px] text-concrete/50">Moins</span>
<div className="flex gap-0.5">
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
))}
</div>
<span className="text-[9px] text-concrete/50">Plus</span>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
import { NotesListViews, type NotesLayoutMode, type NotesViewType } from '@/components/notes-list-views'
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
import {
NOTES_LAYOUT_STORAGE_KEY,
NOTES_VIEW_TYPE_STORAGE_KEY,
@@ -14,10 +14,16 @@ import {
setNotesLayoutPreference,
setNotesViewTypePreference,
} from '@/lib/notes-view-preference'
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
import {
bootstrapStructuredNotebook,
ensureKanbanStatusField,
type BootstrapStructuredTarget,
} from '@/lib/structured-views/bootstrap-structured-notebook'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3 } from 'lucide-react'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
@@ -53,6 +59,22 @@ const OrganizeNotebookDialog = dynamic(
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
{ ssr: false }
)
const StructuredViewsIntro = dynamic(
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
{ ssr: false }
)
const StructuredViewsHelpBanner = dynamic(
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
{ ssr: false }
)
const StructuredViewsContainer = dynamic(
() => import('@/components/structured-views/structured-views-container').then(m => ({ default: m.StructuredViewsContainer })),
{ ssr: false }
)
const AddPropertyDialog = dynamic(
() => import('@/components/structured-views/add-property-dialog').then(m => ({ default: m.AddPropertyDialog })),
{ ssr: false }
)
type InitialSettings = {
showRecentNotes: boolean
@@ -116,6 +138,29 @@ export function HomeClient({
const [tagSearchQuery, setTagSearchQuery] = useState('')
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const notebookFilter = searchParams.get('notebook')
const schemaHook = useNotebookSchema(notebookFilter)
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
const wantsStructuredView = Boolean(
notebookFilter && (layoutMode === 'table' || layoutMode === 'kanban'),
)
const structuredViewMode: BootstrapStructuredTarget =
layoutMode === 'kanban' ? 'kanban' : 'table'
useEffect(() => {
if (layoutMode === 'gallery') {
setLayoutMode('grid')
}
}, [])
useEffect(() => {
if (!notebookFilter && (layoutMode === 'kanban' || layoutMode === 'gallery')) {
setLayoutMode('list')
}
}, [notebookFilter, layoutMode])
useEffect(() => {
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
@@ -141,7 +186,7 @@ export function HomeClient({
useEffect(() => {
const onLayoutChange = (e: Event) => {
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
if (detail === 'grid' || detail === 'list' || detail === 'table') {
if (detail === 'grid' || detail === 'list' || detail === 'table' || detail === 'kanban') {
setLayoutMode(detail)
setViewType('notes')
}
@@ -161,8 +206,6 @@ export function HomeClient({
}
}, [searchParams, router])
const notebookFilter = searchParams.get('notebook')
const fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => {
const search = searchParams.get('search')?.trim() || null
@@ -305,6 +348,95 @@ export function HomeClient({
})
}
const handleAddNoteWithProperties = (prefill: Record<string, unknown>) => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
type: 'richtext',
title: undefined,
notebookId: notebookFilter || undefined,
skipRevalidation: true,
})
if (!newNote) return
if (Object.keys(prefill).length > 0) {
await fetch(`/api/notes/${newNote.id}/properties`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ properties: prefill }),
})
schemaHook.patchNoteValuesLocal(newNote.id, prefill)
}
handleNoteCreated(newNote)
setEditingNote({ note: newNote, readOnly: false })
} catch {
toast.error(t('notes.createFailed'))
}
})
}
const structuredFieldLabels = useMemo(
() => ({
statusName: t('structuredViews.wizard.fields.status.name'),
statusOptions: t('structuredViews.wizard.fields.status.options')
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
}),
[t],
)
const structuredBootstrapActions = useMemo(
() => ({
getSchema: () => schemaHook.schema,
enableStructuredMode: schemaHook.enableStructuredMode,
addProperty: schemaHook.addProperty,
setKanbanGroupProperty: schemaHook.setKanbanGroupProperty,
}),
[schemaHook],
)
const handleEnableStructured = useCallback(
async (target: BootstrapStructuredTarget) => {
if (!notebookFilter) return
setIsEnablingStructured(true)
try {
await bootstrapStructuredNotebook(target, structuredFieldLabels, structuredBootstrapActions)
await schemaHook.reload()
setLayoutMode(target)
toast.success(t('structuredViews.intro.enabledSuccess'))
} catch {
toast.error(t('structuredViews.enableFailed'))
} finally {
setIsEnablingStructured(false)
}
},
[notebookFilter, structuredFieldLabels, structuredBootstrapActions, schemaHook, t],
)
const handleQuickAddKanbanStatus = useCallback(async () => {
try {
await ensureKanbanStatusField(structuredFieldLabels, structuredBootstrapActions)
await schemaHook.reload()
} catch {
toast.error(t('structuredViews.enableFailed'))
}
}, [structuredFieldLabels, structuredBootstrapActions, schemaHook, t])
const selectLayoutMode = useCallback((mode: NotesLayoutMode) => {
if (mode === 'gallery') return
setLayoutMode(mode)
setViewType('notes')
}, [])
const showStructuredIntro =
wantsStructuredView && !structuredModeActive && !schemaHook.loading
const showStructuredDataView = wantsStructuredView && structuredModeActive && schemaHook.schema
const showStructuredLoading = wantsStructuredView && schemaHook.loading
const classicLayoutMode: NotesClassicLayoutMode =
isClassicLayoutMode(layoutMode) ? layoutMode : 'list'
const handleOpenHistory = useCallback((note: Note) => {
setHistoryNote(note)
setHistoryOpen(true)
@@ -840,7 +972,7 @@ export function HomeClient({
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
<button
type="button"
onClick={() => setLayoutMode('grid')}
onClick={() => selectLayoutMode('grid')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'grid'
@@ -853,7 +985,7 @@ export function HomeClient({
</button>
<button
type="button"
onClick={() => setLayoutMode('list')}
onClick={() => selectLayoutMode('list')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'list'
@@ -864,22 +996,66 @@ export function HomeClient({
>
<List size={13} />
</button>
<button
type="button"
onClick={() => setLayoutMode('table')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'table'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutTableTitle')}
>
<Table size={13} />
</button>
{!notebookFilter && (
<button
type="button"
onClick={() => selectLayoutMode('table')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'table'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutTableTitle')}
>
<Table size={13} />
</button>
)}
{notebookFilter && (
<>
<span className="w-px h-4 bg-border/50 mx-0.5" aria-hidden />
<button
type="button"
onClick={() => selectLayoutMode('table')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'table'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('structuredViews.viewTableHint')}
>
<Table size={13} />
</button>
<button
type="button"
onClick={() => selectLayoutMode('kanban')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'kanban'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('structuredViews.viewKanbanHint')}
>
<Columns3 size={13} />
</button>
</>
)}
</div>
)}
{viewType === 'notes' && currentNotebook && structuredModeActive && (
<button
type="button"
onClick={() => setAddPropertyOpen(true)}
className="p-1.5 rounded-full text-muted-foreground hover:text-brand-accent transition-colors"
title={t('structuredViews.addProperty')}
>
<Plus size={16} />
</button>
)}
{searchParams.get('notebook') && (
<button
onClick={() => setSummaryDialogOpen(true)}
@@ -986,6 +1162,34 @@ export function HomeClient({
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : showStructuredLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : showStructuredIntro ? (
<StructuredViewsIntro
target={structuredViewMode}
enabling={isEnablingStructured}
onEnable={() => void handleEnableStructured(structuredViewMode)}
/>
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
<>
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
<StructuredViewsContainer
mode={structuredViewMode}
notes={sortedNotes}
schema={schemaHook.schema}
noteValues={schemaHook.noteValues}
notebookColor={currentNotebook?.color}
onOpen={(note) => handleOpenNoteFresh(note.id, false)}
onNoteValuesPatch={schemaHook.patchNoteValuesLocal}
onCreateNote={handleAddNoteWithProperties}
onSetKanbanGroupProperty={(id) => void schemaHook.setKanbanGroupProperty(id)}
onQuickAddKanbanStatus={() => void handleQuickAddKanbanStatus()}
onDeleteProperty={async (propertyId) => {
await schemaHook.deleteProperty(propertyId)
toast.success(t('structuredViews.deletePropertySuccess'))
}}
/>
</>
) : notes.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-memento-serif text-xl italic text-muted-foreground">
@@ -1004,7 +1208,7 @@ export function HomeClient({
notes={sortedNotes}
pinnedNotes={sortedPinnedNotes}
viewType={viewType}
layoutMode={layoutMode}
layoutMode={classicLayoutMode}
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
onOpenHistory={handleOpenHistory}
notebookName={currentNotebook?.name}
@@ -1090,6 +1294,15 @@ export function HomeClient({
}}
/>
)}
{notebookFilter && schemaHook.schema && (
<AddPropertyDialog
open={addPropertyOpen}
onClose={() => setAddPropertyOpen(false)}
onSubmit={async (name, type, options) => {
await schemaHook.addProperty(name, type, options)
}}
/>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X, ShieldAlert, Check } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence } from 'motion/react'
@@ -15,12 +16,27 @@ interface AiConsentModalProps {
export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps) {
const { t } = useLanguage()
const [remember, setRemember] = useState(true)
const [mounted, setMounted] = useState(false)
return (
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
{/* Glassmorphism Backdrop */}
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 sm:p-6">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -29,51 +45,51 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.4 }}
className={cn(
"relative w-full max-w-lg overflow-hidden border border-[var(--border)]",
"bg-[var(--memento-paper)] text-[var(--ink)] shadow-2xl rounded-lg p-6",
"flex flex-col gap-5"
'relative w-full max-w-lg overflow-hidden rounded-2xl border border-border',
'bg-memento-paper dark:bg-background text-foreground shadow-2xl p-6',
'flex flex-col gap-5',
)}
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-md text-[var(--ink)]/60 hover:text-[var(--ink)] hover:bg-[var(--concrete)] transition-colors"
className="absolute top-4 right-4 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Header */}
<div className="flex items-start gap-4">
<div className="p-3 bg-[var(--accent-tint)] text-[var(--accent-color)] rounded-lg">
<BrainIcon className="w-6 h-6 animate-pulse" />
<div className="p-3 bg-brand-accent/10 text-brand-accent rounded-xl">
<BrainIcon className="w-6 h-6" />
</div>
<div className="flex flex-col gap-1 pr-6">
<div className="flex flex-col gap-1 pr-8">
<h3 className="text-lg font-semibold tracking-tight">
{t('consent.ai.modalTitle') || 'Consentement requis pour le traitement par IA'}
</h3>
<span className="text-xs uppercase tracking-wider text-[var(--ink)]/50 font-medium">
<span className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{t('consent.ai.complianceBadge') || 'Conformité RGPD'}
</span>
</div>
</div>
{/* Content */}
<div className="text-sm leading-relaxed text-[var(--ink)]/80 flex flex-col gap-3">
<p>
<div className="text-sm leading-relaxed text-muted-foreground flex flex-col gap-3">
<p className="text-foreground/90">
{t('consent.ai.modalDescription') ||
"Pour analyser vos notes, PDFs ou sessions de remue-méninges, Memento transmet de manière sécurisée ces données à des API d'IA tierces (OpenAI, Gemini, DeepSeek). Nous appliquons une politique de rétention de données nulle. En acceptant, vous autorisez ce traitement."}
</p>
<div className="p-3 bg-[var(--concrete)] border border-[var(--border)] rounded-md text-xs flex gap-3 items-start">
<ShieldAlert className="w-4 h-4 text-[var(--accent-color)] shrink-0 mt-0.5" />
<div className="p-3 bg-black/[0.03] dark:bg-white/[0.04] border border-border rounded-xl text-xs flex gap-3 items-start">
<ShieldAlert className="w-4 h-4 text-brand-accent shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}</span>
<span className="font-semibold text-foreground">
{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}
</span>
<span>
{t('consent.ai.zeroRetentionDesc') ||
'Toutes les requêtes sortantes incluent des indicateurs de non-apprentissage pour protéger votre propriété intellectuelle.'}
@@ -82,7 +98,6 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
</div>
</div>
{/* Checkbox */}
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
<div className="relative">
<input
@@ -93,35 +108,30 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
/>
<div
className={cn(
"w-5 h-5 rounded border border-[var(--border)] transition-colors flex items-center justify-center",
remember ? "bg-[var(--accent-color)] border-[var(--accent-color)]" : "bg-transparent"
'w-5 h-5 rounded border border-border transition-colors flex items-center justify-center',
remember ? 'bg-brand-accent border-brand-accent' : 'bg-transparent',
)}
>
{remember && <Check className="w-3.5 h-3.5 text-white stroke-[3px]" />}
</div>
</div>
<span className="text-xs font-medium text-[var(--ink)]/80">
<span className="text-xs font-medium text-foreground/80">
{t('consent.ai.rememberMe') || 'Se souvenir de mon choix (ne plus demander)'}
</span>
</label>
{/* Actions */}
<div className="flex items-center justify-end gap-3 mt-2">
<div className="flex items-center justify-end gap-3 mt-1">
<button
type="button"
onClick={onClose}
className={cn(
"px-4 py-2 text-xs font-semibold rounded-md border border-[var(--border)]",
"hover:bg-[var(--concrete)] transition-colors bg-transparent text-[var(--ink)]"
)}
className="px-4 py-2 text-xs font-semibold rounded-lg border border-border hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors bg-transparent text-foreground"
>
{t('consent.ai.rejectButton') || 'Refuser'}
</button>
<button
type="button"
onClick={() => onConfirm(remember)}
className={cn(
"px-4 py-2 text-xs font-semibold rounded-md text-white shadow-sm transition-opacity hover:opacity-90",
"bg-[var(--accent-color)] flex items-center gap-2"
)}
className="px-4 py-2 text-xs font-semibold rounded-lg text-white shadow-sm transition-opacity hover:opacity-90 bg-brand-accent flex items-center gap-2"
>
<Sparkles className="w-3.5 h-3.5" />
{t('consent.ai.acceptButton') || 'Autoriser et continuer'}
@@ -130,7 +140,8 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
</motion.div>
</div>
)}
</AnimatePresence>
</AnimatePresence>,
document.body,
)
}

View File

@@ -69,6 +69,17 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
const handleConfirm = async (remember: boolean) => {
setModalOpen(false)
const grantConsentLocally = async () => {
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
await updateAISettings({ aiProcessingConsent: true })
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
}
}
try {
const res = await fetch('/api/user/ai-consent', {
method: 'POST',
@@ -76,32 +87,48 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
body: JSON.stringify({ consent: true, remember }),
})
if (!res.ok) {
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
return
}
if (res.ok) {
const data = await res.json().catch(() => ({}))
if (data?.auditLogged === false) {
console.warn('[AiConsentProvider] Consent saved without audit trail')
}
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
try {
await updateAISettings({ aiProcessingConsent: true })
} catch (e) {
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
try {
await updateAISettings({ aiProcessingConsent: true })
} catch (e) {
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
}
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
}
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
try {
await grantConsentLocally()
} catch (fallbackError) {
console.error('[AiConsentProvider] Consent fallback failed:', fallbackError)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
return
}
}
pendingResolveRef.current?.(true)
pendingResolveRef.current = null
} catch (e) {
console.error('[AiConsentProvider] Failed to log consent audit:', e)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
try {
await grantConsentLocally()
pendingResolveRef.current?.(true)
} catch (fallbackError) {
console.error('[AiConsentProvider] Consent fallback failed:', fallbackError)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
}
pendingResolveRef.current = null
}
}

View File

@@ -14,6 +14,7 @@ import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal'
import { NoteNetworkTab } from './note-network-tab'
import { NoteEditorPropertiesPanel } from './structured-views/note-editor-properties-panel'
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
import { useEffect } from 'react'
@@ -308,6 +309,13 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
</div>
</div>
</div>
<div className="px-4 pb-4">
<NoteEditorPropertiesPanel
noteId={note.id}
notebookId={note.notebookId}
/>
</div>
</div>
)}

View File

@@ -273,7 +273,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
noteId={note.id}
noteTitle={state.title || note.title || 'Untitled'}
onSaved={(deckId) => {
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
toast.success(t('flashcards.savedCount', { count: '' }).replace('{count}', ''), {
description: t('flashcards.reviewNow') || 'Review now',
action: {
label: t('flashcards.reviewNow') || 'Review now →',
onClick: () => {
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
},
},
duration: 8000,
})
}}
/>

View File

@@ -54,7 +54,12 @@ import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
export type NotesLayoutMode = 'grid' | 'list' | 'table'
export type NotesLayoutMode = 'grid' | 'list' | 'table' | 'kanban' | 'gallery'
export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
return mode === 'grid' || mode === 'list' || mode === 'table'
}
export type NotesViewType = 'notes' | 'tasks'
type TaskItem = {
@@ -741,7 +746,7 @@ function NotesGridSection({
const ids = useMemo(() => notes.map((n) => n.id), [notes])
const grid = (
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6', className)}>
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-stretch', className)}>
{notes.map((note, index) =>
sortEnabled ? (
<SortableGridCard
@@ -804,7 +809,7 @@ function SortableGridCard(props: GridCardSharedProps) {
{...attributes}
{...listeners}
className={cn(
'touch-none cursor-grab active:cursor-grabbing',
'touch-none cursor-grab active:cursor-grabbing h-full',
isDragging && 'opacity-40',
)}
>
@@ -856,9 +861,9 @@ function GridCard({
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
onClick={() => onOpen(note)}
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative"
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full"
>
<div className="aspect-[16/10] bg-muted/30 border-b border-border/20 overflow-hidden relative">
<div className="aspect-[16/10] shrink-0 bg-muted/30 border-b border-border/20 overflow-hidden relative">
<NoteGridThumbnail
note={note}
aiIllustrationEnabled={aiIllustrationEnabled}
@@ -875,17 +880,19 @@ function GridCard({
</div>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2.5">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug line-clamp-2 group-hover/card:text-brand-accent transition-colors">
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
<div className="space-y-2.5 flex-1">
<div className="min-h-[1.125rem]">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
</div>
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug truncate group-hover/card:text-brand-accent transition-colors">
{title}
</h3>
{excerpt && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light">{excerpt}</p>
)}
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light min-h-[3.75rem]">
{excerpt || '\u00A0'}
</p>
</div>
<div className="flex items-center justify-between pt-3 border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
<div className="flex items-center justify-between pt-3 mt-auto border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
<span>{formattedDate}</span>
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
<button

View File

@@ -9,6 +9,7 @@ import {
ChevronRight,
Lock,
BookOpen,
BookMarked,
Bot,
Inbox,
FlaskConical,
@@ -29,7 +30,6 @@ import {
PinOff,
Sparkles,
Home,
Network,
Search,
GraduationCap,
FileText,
@@ -474,6 +474,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
const [trashCount, setTrashCount] = useState(0)
const [draggedId, setDraggedId] = useState<string | null>(null)
@@ -494,6 +495,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
return map
}, [orderedNotebooks])
const filteredNotebookIds = useMemo(() => {
const q = notebookSearchQuery.trim().toLowerCase()
if (!q) return null
return new Set(
notebooks
.filter(
(nb) =>
nb.name.toLowerCase().includes(q) ||
(notebookNotes[nb.id] || []).some((n) => n.title.toLowerCase().includes(q)),
)
.map((nb) => nb.id),
)
}, [notebooks, notebookNotes, notebookSearchQuery])
const currentNotebookId = searchParams.get('notebook')
const currentNoteId = searchParams.get('openNote')
@@ -793,9 +808,10 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
}, [deletingNotebook, trashNotebook, currentNotebookId, router])
const renderCarnetTree = useCallback((parentId: string | undefined, level: number): React.ReactNode => {
const items = parentId === undefined
const items = (parentId === undefined
? rootNotebooks
: (childNotebooks.get(parentId) || [])
).filter((notebook) => !filteredNotebookIds || filteredNotebookIds.has(notebook.id))
return items.map((notebook: Notebook) => {
const isActive = currentNotebookId === notebook.id
@@ -885,7 +901,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</motion.div>
)
})
}, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
return (
<>
@@ -957,7 +973,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="flex flex-col gap-1.5 w-full px-1.5">
{([
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
@@ -1086,95 +1101,135 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
{/* Section header with sort button */}
<div className="flex items-center justify-between px-4 mb-3">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
{t('nav.notebooks')}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
title={t('notebook.create')}
>
<Plus size={12} />
</button>
<div className="relative">
<button
onClick={() => setShowSortMenu(s => !s)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
title={t('sidebar.sortOrder')}
>
<ArrowUpDown size={12} />
</button>
<AnimatePresence>
{showSortMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map(order => (
<button
key={order}
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
className={cn(
'w-full text-start px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{sortLabels[order]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<div className="px-4 pt-4 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1.5">
<BookMarked size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('sidebar.documents')}
</h3>
</div>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => {
setCreateParentId(null)
setIsCreateDialogOpen(true)
}}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
title={t('notebook.create')}
>
<Plus size={15} />
</button>
<div className="relative">
<button
type="button"
onClick={() => setShowSortMenu((s) => !s)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
title={t('sidebar.sortOrder')}
>
<ArrowUpDown size={13} />
</button>
<AnimatePresence>
{showSortMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map((order) => (
<button
key={order}
type="button"
onClick={() => {
setSortOrder(order)
setShowSortMenu(false)
}}
className={cn(
'w-full text-start px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40',
)}
>
{sortLabels[order]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
<div className="relative mb-4">
<input
type="text"
value={notebookSearchQuery}
onChange={(e) => setNotebookSearchQuery(e.target.value)}
placeholder={t('sidebar.searchNotebooksPlaceholder')}
className="w-full text-[11px] ps-7 pe-8 py-1.5 rounded-lg border border-border/60 bg-white/70 dark:bg-zinc-800 placeholder-concrete/50 outline-none focus:border-brand-accent transition-colors text-ink dark:text-dark-ink"
/>
<Search
size={11}
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete opacity-60 pointer-events-none"
/>
{notebookSearchQuery && (
<button
type="button"
onClick={() => setNotebookSearchQuery('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-[9px] uppercase font-bold text-concrete hover:text-ink"
aria-label={t('sidebar.clearSearch')}
>
X
</button>
)}
</div>
</div>
{/* Inbox — Notes without notebook */}
<button
onClick={handleInboxClick}
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isInboxActive
? 'bg-brand-accent text-white border-brand-accent'
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20'
)}>
<Inbox size={14} />
</div>
<span className={cn(
'text-[13px] font-medium truncate',
isInboxActive ? 'text-ink' : 'text-muted-ink'
)}>
{t('sidebar.inbox')}
</span>
</button>
{/* Divider */}
<div className="mx-4 my-3 h-px bg-border/40" />
{/* Notebooks list — draggable */}
<div
className="space-y-0.5 min-h-[60px]"
onDrop={handleDropToRoot}
onDragOver={(e) => e.preventDefault()}
>
{renderCarnetTree(undefined, 0)}
{draggedId && (
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4">
<button
type="button"
onClick={handleInboxClick}
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
>
<div
className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50"
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isInboxActive
? 'bg-brand-accent text-white border-brand-accent'
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20',
)}
>
{t('sidebar.dropToRoot')}
<Inbox size={14} />
</div>
)}
<span
className={cn(
'text-[13px] font-medium truncate',
isInboxActive ? 'text-ink' : 'text-muted-ink',
)}
>
{t('sidebar.inbox')}
</span>
</button>
<div className="my-3 h-px bg-border/40" />
<div
className="space-y-0.5 min-h-[60px]"
onDrop={handleDropToRoot}
onDragOver={(e) => e.preventDefault()}
>
{renderCarnetTree(undefined, 0)}
{draggedId && (
<div className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50">
{t('sidebar.dropToRoot')}
</div>
)}
</div>
</div>
</motion.div>
) : activeView === 'reminders' ? (

View File

@@ -0,0 +1,128 @@
'use client'
import { useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import type { PropertyType } from '@/lib/structured-views/types'
import { PROPERTY_TYPES } from '@/lib/structured-views/types'
import { cn } from '@/lib/utils'
type AddPropertyDialogProps = {
open: boolean
onClose: () => void
onSubmit: (name: string, type: PropertyType, options: string[]) => Promise<void>
}
export function AddPropertyDialog({ open, onClose, onSubmit }: AddPropertyDialogProps) {
const { t } = useLanguage()
const [name, setName] = useState('')
const [type, setType] = useState<PropertyType>('text')
const [optionsText, setOptionsText] = useState('')
const [saving, setSaving] = useState(false)
if (!open) return null
const needsOptions = type === 'select' || type === 'multiselect'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const options = needsOptions
? optionsText.split('\n').map((l) => l.trim()).filter(Boolean)
: []
if (needsOptions && options.length === 0) return
setSaving(true)
try {
await onSubmit(trimmed, type, options)
setName('')
setType('text')
setOptionsText('')
onClose()
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div
role="dialog"
aria-modal
className="w-full max-w-md rounded-2xl border border-border bg-memento-paper shadow-xl p-6 space-y-5"
>
<div className="flex items-center justify-between">
<h2 className="font-memento-serif text-lg">{t('structuredViews.addPropertyTitle')}</h2>
<button type="button" onClick={onClose} className="p-1 rounded-lg hover:bg-foreground/5">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-[12px] leading-relaxed text-muted-foreground rounded-lg bg-foreground/[0.03] px-3 py-2">
{t('structuredViews.addPropertyHint')}
</p>
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.propertyName')}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
autoFocus
/>
</div>
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.propertyType')}
</label>
<div className="mt-2 flex flex-wrap gap-2">
{PROPERTY_TYPES.map((pt) => (
<button
key={pt}
type="button"
onClick={() => setType(pt)}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border transition-colors',
type === pt
? 'bg-foreground text-background border-foreground'
: 'border-border text-muted-foreground hover:border-foreground/30',
)}
>
{t(`structuredViews.propertyTypes.${pt}`)}
</button>
))}
</div>
</div>
{needsOptions && (
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.selectOptions')}
</label>
<textarea
value={optionsText}
onChange={(e) => setOptionsText(e.target.value)}
placeholder={t('structuredViews.selectOptionsPlaceholder')}
rows={4}
className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button type="submit" disabled={saving || !name.trim()}>
{t('structuredViews.addProperty')}
</Button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { useEffect, useState } from 'react'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
import { NotePropertiesSection } from './note-properties-section'
import { AddPropertyDialog } from './add-property-dialog'
type NoteEditorPropertiesPanelProps = {
noteId: string
notebookId: string | null | undefined
readOnly?: boolean
}
export function NoteEditorPropertiesPanel({
noteId,
notebookId,
readOnly,
}: NoteEditorPropertiesPanelProps) {
const [schema, setSchema] = useState<NotebookSchemaPayload | null>(null)
const [values, setValues] = useState<NotePropertyValues>({})
const [addOpen, setAddOpen] = useState(false)
useEffect(() => {
if (!notebookId) {
setSchema(null)
setValues({})
return
}
let cancelled = false
;(async () => {
try {
const [schemaRes, valuesRes] = await Promise.all([
fetch(`/api/notebooks/${notebookId}/schema`),
fetch(`/api/notes/${noteId}/properties`),
])
const schemaJson = await schemaRes.json()
const valuesJson = await valuesRes.json()
if (cancelled) return
setSchema(schemaJson.success ? schemaJson.data.schema : null)
setValues(valuesJson.success ? valuesJson.data.values ?? {} : {})
} catch {
if (!cancelled) {
setSchema(null)
setValues({})
}
}
})()
return () => {
cancelled = true
}
}, [notebookId, noteId])
if (!notebookId || !schema) return null
const handleAddProperty = async (name: string, type: string, options?: string[]) => {
const res = await fetch(`/api/notebooks/${notebookId}/schema`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'addProperty', name, type, options }),
})
const json = await res.json()
if (json.success) setSchema(json.data.schema)
}
return (
<>
<NotePropertiesSection
noteId={noteId}
schema={schema}
initialValues={values}
readOnly={readOnly}
onAddProperty={() => setAddOpen(true)}
onValuesChange={setValues}
/>
<AddPropertyDialog
open={addOpen}
onClose={() => setAddOpen(false)}
onSubmit={handleAddProperty}
/>
</>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useEffect, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
import { PropertyValueEditor, useDebouncedPropertySave } from './property-value-editor'
import { useLanguage } from '@/lib/i18n'
import { MAX_PROPERTIES_PER_NOTEBOOK } from '@/lib/structured-views/types'
type NotePropertiesSectionProps = {
noteId: string
schema: NotebookSchemaPayload
initialValues?: NotePropertyValues
onAddProperty?: () => void
onValuesChange?: (values: NotePropertyValues) => void
readOnly?: boolean
}
export function NotePropertiesSection({
noteId,
schema,
initialValues = {},
onAddProperty,
onValuesChange,
readOnly,
}: NotePropertiesSectionProps) {
const { t } = useLanguage()
const [values, setValues] = useState<NotePropertyValues>(initialValues)
useEffect(() => {
setValues(initialValues)
}, [noteId, initialValues])
const queueSave = useDebouncedPropertySave(noteId, (saved) => {
setValues((v) => ({ ...v, ...saved }))
onValuesChange?.({ ...values, ...saved })
})
const handleChange = (propertyId: string, value: unknown) => {
setValues((prev) => {
const next = { ...prev, [propertyId]: value }
onValuesChange?.(next)
return next
})
if (!readOnly) queueSave(propertyId, value)
}
if (schema.properties.length === 0) {
return (
<div className="space-y-2 pt-4 border-t border-border/30">
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.propertiesSection')}
</p>
<p className="text-[12px] text-muted-foreground">{t('structuredViews.noPropertiesYet')}</p>
{onAddProperty && !readOnly && (
<button
type="button"
onClick={onAddProperty}
className="inline-flex items-center gap-1 text-[11px] text-brand-accent hover:underline"
>
<Plus size={12} />
{t('structuredViews.addProperty')}
</button>
)}
</div>
)
}
return (
<div className="space-y-3 pt-4 border-t border-border/30">
<div className="flex items-center justify-between">
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.propertiesSection')}
</p>
{onAddProperty && !readOnly && schema.properties.length < MAX_PROPERTIES_PER_NOTEBOOK && (
<button
type="button"
onClick={onAddProperty}
className="inline-flex items-center gap-1 text-[10px] text-brand-accent hover:underline"
>
<Plus size={10} />
{t('structuredViews.addProperty')}
</button>
)}
</div>
<div className="space-y-3">
{schema.properties.map((prop) => (
<div key={prop.id} className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{prop.name}</label>
{readOnly ? (
<p className="text-[13px]">{String(values[prop.id] ?? '—')}</p>
) : (
<PropertyValueEditor
property={prop}
value={values[prop.id]}
onChange={(v) => handleChange(prop.id, v)}
/>
)}
</div>
))}
</div>
</div>
)
}
export function SchemaPropertyList({
schema,
onDeleteProperty,
}: {
schema: NotebookSchemaPayload
onDeleteProperty?: (id: string) => void
}) {
const { t } = useLanguage()
return (
<ul className="space-y-1">
{schema.properties.map((p) => (
<li key={p.id} className="flex items-center justify-between text-[12px] py-1">
<span>{p.name}</span>
<span className="text-muted-foreground text-[10px] uppercase">{t(`structuredViews.propertyTypes.${p.type}`)}</span>
{onDeleteProperty && (
<button type="button" onClick={() => onDeleteProperty(p.id)} className="p-1 text-muted-foreground hover:text-red-500">
<Trash2 size={12} />
</button>
)}
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import type { Note } from '@/lib/types'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
import { formatPropertyDisplay } from '@/lib/structured-views/property-utils'
import { getNoteDisplayTitle, getNoteFeedImage } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
type NotesGalleryViewProps = {
notes: Note[]
schema: NotebookSchemaPayload
noteValues: Record<string, NotePropertyValues>
notebookColor?: string | null
onOpen: (note: Note) => void
}
export function NotesGalleryView({
notes,
schema,
noteValues,
notebookColor,
onOpen,
}: NotesGalleryViewProps) {
const { t } = useLanguage()
const untitled = t('notes.untitled')
const previewProps = schema.properties.slice(0, 2)
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 max-w-7xl mx-auto">
{notes.map((note) => (
<GalleryCard
key={note.id}
note={note}
title={getNoteDisplayTitle(note, untitled)}
image={getNoteFeedImage(note)}
notebookColor={notebookColor}
previewProps={previewProps}
allProps={schema.properties}
values={noteValues[note.id] ?? {}}
onOpen={() => onOpen(note)}
/>
))}
</div>
)
}
function GalleryCard({
note,
title,
image,
notebookColor,
previewProps,
allProps,
values,
onOpen,
}: {
note: Note
title: string
image: string | null
notebookColor?: string | null
previewProps: NotebookSchemaPayload['properties']
allProps: NotebookSchemaPayload['properties']
values: NotePropertyValues
onOpen: () => void
}) {
const [hover, setHover] = useState(false)
const accent = notebookColor || '#A47148'
return (
<button
type="button"
onClick={onOpen}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className="text-left rounded-2xl border border-border/40 bg-card/40 overflow-hidden shadow-sm hover:shadow-md hover:border-border transition-all group"
>
<div
className="aspect-[4/3] relative overflow-hidden"
style={{ backgroundColor: `${accent}18` }}
>
{image ? (
<img src={image} alt="" className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" />
) : note.illustrationSvg ? (
<div
className="w-full h-full p-4 [&_svg]:w-full [&_svg]:h-full opacity-80"
dangerouslySetInnerHTML={{ __html: note.illustrationSvg }}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span
className="font-memento-serif text-4xl opacity-20"
style={{ color: accent }}
>
{title.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
<div className="p-4 space-y-2">
<h3 className="font-memento-serif text-[15px] font-medium line-clamp-2 group-hover:text-brand-accent transition-colors">
{title}
</h3>
{!hover && previewProps.length > 0 && (
<div className="space-y-1">
{previewProps.map((p) => (
<div key={p.id} className="flex gap-2 text-[11px]">
<span className="text-muted-foreground shrink-0">{p.name}:</span>
<span className="truncate">{formatPropertyDisplay(p.type, values[p.id])}</span>
</div>
))}
</div>
)}
{hover && allProps.length > 0 ? (
<div className="space-y-1.5 pt-1 border-t border-border/30">
{allProps.map((p) => (
<div key={p.id} className="flex justify-between gap-2 text-[11px]">
<span className="text-muted-foreground">{p.name}</span>
<span className="font-medium text-right truncate max-w-[55%]">
{formatPropertyDisplay(p.type, values[p.id])}
</span>
</div>
))}
</div>
) : null}
</div>
</button>
)
}

View File

@@ -0,0 +1,223 @@
'use client'
import { useMemo, useState } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
useDraggable,
useDroppable,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import type { Note } from '@/lib/types'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
type NotesKanbanViewProps = {
notes: Note[]
schema: NotebookSchemaPayload
noteValues: Record<string, NotePropertyValues>
onOpen: (note: Note) => void
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
onCreateNote: (prefill: Record<string, unknown>) => void
onSetGroupProperty: (propertyId: string) => void
onQuickAddKanbanStatus?: () => void
}
function DraggableCard({ note, onOpen, dragDisabled }: { note: Note; onOpen: (note: Note) => void; dragDisabled?: boolean }) {
const { t } = useLanguage()
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: note.id, disabled: dragDisabled })
const style = transform
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
: undefined
return (
<div
ref={setNodeRef}
style={style}
{...(dragDisabled ? {} : listeners)}
{...(dragDisabled ? {} : attributes)}
className={cn(
'rounded-xl border border-border/50 bg-card p-3 shadow-sm',
!dragDisabled && 'cursor-grab active:cursor-grabbing touch-none',
isDragging && 'opacity-40',
)}
>
<button
type="button"
onClick={() => onOpen(note)}
className="font-memento-serif text-[13px] font-medium text-left w-full hover:text-brand-accent transition-colors pointer-events-auto"
>
{getNoteDisplayTitle(note, t('notes.untitled'))}
</button>
</div>
)
}
function DroppableColumn({
id,
children,
className,
}: {
id: string
children: React.ReactNode
className?: string
}) {
const { setNodeRef, isOver } = useDroppable({ id })
return (
<div
ref={setNodeRef}
className={cn(className, isOver && 'ring-2 ring-brand-accent/30 ring-inset')}
>
{children}
</div>
)
}
export function NotesKanbanView({
notes,
schema,
noteValues,
onOpen,
onPropertyChange,
onCreateNote,
onSetGroupProperty,
onQuickAddKanbanStatus,
}: NotesKanbanViewProps) {
const { t } = useLanguage()
const [activeId, setActiveId] = useState<string | null>(null)
const selectProps = schema.properties.filter((p) => p.type === 'select')
const groupPropId =
schema.viewSettings.kanbanGroupPropertyId &&
selectProps.some((p) => p.id === schema.viewSettings.kanbanGroupPropertyId)
? schema.viewSettings.kanbanGroupPropertyId
: selectProps[0]?.id ?? null
const groupProp = selectProps.find((p) => p.id === groupPropId) ?? null
const columns = useMemo(() => {
if (!groupProp) {
return [{ id: 'col:__none', label: t('structuredViews.kanbanAllNotes'), value: null as string | null }]
}
const cols = groupProp.options.map((opt) => ({ id: `col:${opt}`, label: opt, value: opt }))
return [...cols, { id: 'col:__none', label: t('structuredViews.kanbanUnassigned'), value: null }]
}, [groupProp, t])
const notesByColumn = useMemo(() => {
const map = new Map<string, Note[]>()
for (const col of columns) map.set(col.id, [])
for (const note of notes) {
if (!groupProp) {
map.get('col:__none')?.push(note)
continue
}
const raw = noteValues[note.id]?.[groupProp.id]
const val = typeof raw === 'string' ? raw : null
const colId = val && groupProp.options.includes(val) ? `col:${val}` : 'col:__none'
map.get(colId)?.push(note)
}
return map
}, [notes, noteValues, groupProp, columns])
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const handleDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id))
const handleDragEnd = (e: DragEndEvent) => {
setActiveId(null)
const noteId = String(e.active.id)
const overId = e.over?.id ? String(e.over.id) : null
if (!overId || !groupProp || !overId.startsWith('col:')) return
const value = overId === 'col:__none' ? null : overId.slice(4)
onPropertyChange(noteId, groupProp.id, value)
}
const activeNote = activeId ? notes.find((n) => n.id === activeId) : null
return (
<div className="space-y-4">
{!groupProp && (
<div className="rounded-xl border border-border/40 bg-foreground/[0.02] px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-[13px] text-muted-foreground flex-1">{t('structuredViews.kanbanSingleColumnHint')}</p>
{onQuickAddKanbanStatus && (
<button
type="button"
onClick={onQuickAddKanbanStatus}
className="shrink-0 px-4 py-2 rounded-full bg-foreground text-background text-[11px] font-bold uppercase tracking-wider"
>
{t('structuredViews.kanbanAddStatusColumns')}
</button>
)}
</div>
)}
{groupProp && selectProps.length > 1 && (
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-muted-foreground uppercase tracking-widest font-bold">
{t('structuredViews.kanbanGroupBy')}
</span>
<select
value={groupProp.id}
onChange={(e) => onSetGroupProperty(e.target.value)}
className="rounded-lg border border-border bg-background px-2 py-1"
>
{selectProps.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[420px]">
{columns.map((col) => {
const colNotes = notesByColumn.get(col.id) ?? []
return (
<div
key={col.id}
className="flex-shrink-0 w-[260px] rounded-2xl border border-border/40 bg-foreground/[0.02] flex flex-col"
>
<div className="px-3 py-3 border-b border-border/30 flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{col.label}
</span>
<span className="text-[10px] text-muted-foreground">{colNotes.length}</span>
</div>
<DroppableColumn id={col.id} className="flex-1 p-2 space-y-2 min-h-[120px]">
{colNotes.map((note) => (
<DraggableCard key={note.id} note={note} onOpen={onOpen} dragDisabled={!groupProp} />
))}
</DroppableColumn>
{groupProp && (
<button
type="button"
onClick={() => onCreateNote({ [groupProp.id]: col.value })}
className="m-2 flex items-center justify-center gap-1 py-2 rounded-lg border border-dashed border-border text-[11px] text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-colors"
>
<Plus size={14} />
{t('structuredViews.newNoteInColumn')}
</button>
)}
</div>
)
})}
</div>
<DragOverlay>
{activeNote ? (
<div className="rounded-xl border border-brand-accent/40 bg-card p-3 shadow-lg w-[240px]">
<span className="font-memento-serif text-[13px]">
{getNoteDisplayTitle(activeNote, t('notes.untitled'))}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
import { useMemo, useState } from 'react'
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 { ChevronDown, ChevronUp, Filter, Trash2 } from 'lucide-react'
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)
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 (
<tr key={note.id} className="hover:bg-foreground/[0.02] transition-colors group">
<td className="px-4 py-2">
<button
type="button"
onClick={() => onOpen(note)}
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[220px] group-hover:text-brand-accent transition-colors"
>
{getNoteDisplayTitle(note, untitled)}
</button>
</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">
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy')}
</td>
</tr>
)
})}
</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>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useLanguage } from '@/lib/i18n'
import type { PropertyType, SchemaProperty } from '@/lib/structured-views/types'
type PropertyValueEditorProps = {
property: SchemaProperty
value: unknown
onChange: (value: unknown) => void
compact?: boolean
className?: string
}
export function PropertyValueEditor({
property,
value,
onChange,
compact,
className,
}: PropertyValueEditorProps) {
const base = cn(
'w-full rounded-md border border-border/60 bg-background text-sm',
compact ? 'px-2 py-1 text-[12px]' : 'px-3 py-2',
className,
)
switch (property.type) {
case 'checkbox':
return (
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-brand-accent"
/>
)
case 'number':
return (
<input
type="number"
value={value == null || value === '' ? '' : String(value)}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
className={base}
/>
)
case 'date':
return (
<input
type="date"
value={typeof value === 'string' ? value.slice(0, 10) : ''}
onChange={(e) => onChange(e.target.value || null)}
className={base}
/>
)
case 'select':
return (
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value || null)}
className={base}
>
<option value=""></option>
{property.options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)
case 'multiselect':
return (
<MultiSelectEditor
options={property.options}
value={Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []}
onChange={onChange}
className={base}
compact={compact}
/>
)
default:
return (
<input
type="text"
value={value == null ? '' : String(value)}
onChange={(e) => onChange(e.target.value || null)}
className={base}
/>
)
}
}
function MultiSelectEditor({
options,
value,
onChange,
className,
compact,
}: {
options: string[]
value: string[]
onChange: (v: string[]) => void
className?: string
compact?: boolean
}) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const toggle = (opt: string) => {
if (value.includes(opt)) onChange(value.filter((v) => v !== opt))
else onChange([...value, opt])
}
if (compact) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'w-full min-h-[28px] rounded-md border border-transparent hover:border-border/60 px-1 py-0.5 text-left transition-colors',
className,
)}
>
{value.length === 0 ? (
<span className="text-[11px] text-muted-foreground">{t('structuredViews.cellEmpty')}</span>
) : (
<span className="flex flex-wrap gap-1">
{value.map((opt) => (
<span
key={opt}
className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-foreground text-background"
>
{opt}
</span>
))}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-2 space-y-1">
<p className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground px-1 pb-1">
{t('structuredViews.multiselectPick')}
</p>
{options.map((opt) => {
const active = value.includes(opt)
return (
<button
key={opt}
type="button"
onClick={() => toggle(opt)}
className={cn(
'w-full text-left px-2 py-1.5 rounded-md text-[12px] transition-colors',
active
? 'bg-foreground text-background font-medium'
: 'hover:bg-foreground/5 text-foreground',
)}
>
{opt}
</button>
)
})}
</PopoverContent>
</Popover>
)
}
return (
<div className={cn('flex flex-wrap gap-1', className, 'p-1')}>
{options.map((opt) => {
const active = value.includes(opt)
return (
<button
key={opt}
type="button"
onClick={() => toggle(opt)}
className={cn(
'px-2 py-0.5 rounded-full text-[10px] font-bold border transition-colors',
active
? 'bg-foreground text-background border-foreground'
: 'border-border text-muted-foreground hover:border-foreground/30',
)}
>
{opt}
</button>
)
})}
</div>
)
}
export function useDebouncedPropertySave(
noteId: string,
onSaved?: (values: Record<string, unknown>) => void,
delayMs = 500,
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingRef = useRef<Record<string, unknown>>({})
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
}, [])
const queueSave = (propertyId: string, value: unknown) => {
pendingRef.current[propertyId] = value
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(async () => {
const payload = { ...pendingRef.current }
pendingRef.current = {}
try {
const res = await fetch(`/api/notes/${noteId}/properties`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ properties: payload }),
})
const json = await res.json()
if (json.success && json.data?.values) onSaved?.(json.data.values)
} catch (e) {
console.error(e)
}
}, delayMs)
}
return queueSave
}
export function formatPropertyTypeLabel(type: PropertyType, t: (k: string) => string) {
return t(`structuredViews.propertyTypes.${type}`)
}

View File

@@ -0,0 +1,105 @@
'use client'
import { useCallback, useRef } from 'react'
import type { Note } from '@/lib/types'
import type { NotebookSchemaPayload, NotePropertyValues, StructuredViewMode } from '@/lib/structured-views/types'
import { NotesStructuredTable } from './notes-structured-table'
import { NotesKanbanView } from './notes-kanban-view'
import { NotesGalleryView } from './notes-gallery-view'
type StructuredViewsContainerProps = {
mode: StructuredViewMode
notes: Note[]
schema: NotebookSchemaPayload
noteValues: Record<string, NotePropertyValues>
notebookColor?: string | null
onOpen: (note: Note) => void
onNoteValuesPatch: (noteId: string, patch: NotePropertyValues) => void
onCreateNote: (prefill: Record<string, unknown>) => void
onSetKanbanGroupProperty: (propertyId: string) => void
onQuickAddKanbanStatus?: () => void
onDeleteProperty?: (propertyId: string) => Promise<void>
}
export function StructuredViewsContainer({
mode,
notes,
schema,
noteValues,
notebookColor,
onOpen,
onNoteValuesPatch,
onCreateNote,
onSetKanbanGroupProperty,
onQuickAddKanbanStatus,
onDeleteProperty,
}: StructuredViewsContainerProps) {
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const saveProperty = useCallback(
(noteId: string, propertyId: string, value: unknown) => {
onNoteValuesPatch(noteId, { [propertyId]: value })
const key = `${noteId}:${propertyId}`
const existing = timersRef.current.get(key)
if (existing) clearTimeout(existing)
timersRef.current.set(
key,
setTimeout(async () => {
try {
await fetch(`/api/notes/${noteId}/properties`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ properties: { [propertyId]: value } }),
})
} catch (e) {
console.error(e)
}
timersRef.current.delete(key)
}, 500),
)
},
[onNoteValuesPatch],
)
if (mode === 'table') {
return (
<NotesStructuredTable
notes={notes}
schema={schema}
noteValues={noteValues}
onOpen={onOpen}
onPropertyChange={saveProperty}
onDeleteProperty={onDeleteProperty}
/>
)
}
if (mode === 'kanban') {
return (
<NotesKanbanView
notes={notes}
schema={schema}
noteValues={noteValues}
onOpen={onOpen}
onPropertyChange={saveProperty}
onCreateNote={onCreateNote}
onSetGroupProperty={onSetKanbanGroupProperty}
onQuickAddKanbanStatus={onQuickAddKanbanStatus}
/>
)
}
if (mode === 'gallery') {
return (
<NotesGalleryView
notes={notes}
schema={schema}
noteValues={noteValues}
notebookColor={notebookColor}
onOpen={onOpen}
/>
)
}
return null
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useEffect, useState } from 'react'
import { Info, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import type { StructuredViewMode } from '@/lib/structured-views/types'
const DISMISS_PREFIX = 'memento-structured-help-dismissed-'
type StructuredViewsHelpBannerProps = {
notebookId: string
mode: StructuredViewMode
}
export function StructuredViewsHelpBanner({ notebookId, mode }: StructuredViewsHelpBannerProps) {
const { t } = useLanguage()
const storageKey = `${DISMISS_PREFIX}${notebookId}-${mode}`
const [visible, setVisible] = useState(false)
useEffect(() => {
try {
setVisible(localStorage.getItem(storageKey) !== '1')
} catch {
setVisible(true)
}
}, [storageKey])
if (!visible || (mode !== 'table' && mode !== 'kanban')) return null
const message =
mode === 'kanban'
? t('structuredViews.helpBanner.kanban')
: t('structuredViews.helpBanner.table')
return (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] px-4 py-3">
<Info size={16} className="text-brand-accent shrink-0 mt-0.5" />
<p className="text-[13px] leading-relaxed text-foreground/90 flex-1">{message}</p>
<button
type="button"
onClick={() => {
try {
localStorage.setItem(storageKey, '1')
} catch {
/* ignore */
}
setVisible(false)
}}
className="shrink-0 p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/5 transition-colors"
aria-label={t('structuredViews.helpBanner.dismiss')}
>
<X size={14} />
</button>
</div>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { Columns3, Database, Table2, type LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import type { BootstrapStructuredTarget } from '@/lib/structured-views/bootstrap-structured-notebook'
type StructuredViewsIntroProps = {
target: BootstrapStructuredTarget
enabling?: boolean
onEnable: () => void
}
export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredViewsIntroProps) {
const { t } = useLanguage()
return (
<div className="max-w-2xl mx-auto space-y-8 py-6">
<div className="space-y-3">
<div className="flex items-center gap-2 text-brand-accent">
<Database size={18} />
<h2 className="font-memento-serif text-2xl text-foreground">{t('structuredViews.intro.databaseTitle')}</h2>
</div>
<p className="text-[14px] leading-relaxed text-muted-foreground">
{t('structuredViews.intro.databaseBody')}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<IntroCard
icon={Table2}
title={t('structuredViews.intro.tableTitle')}
body={t('structuredViews.intro.tableBody')}
active={target === 'table'}
/>
<IntroCard
icon={Columns3}
title={t('structuredViews.intro.kanbanTitle')}
body={t('structuredViews.intro.kanbanBody')}
active={target === 'kanban'}
/>
</div>
<div className="rounded-2xl border border-border/50 bg-foreground/[0.02] px-5 py-4 space-y-4">
<p className="text-[13px] text-muted-foreground leading-relaxed">
{target === 'kanban'
? t('structuredViews.intro.activateKanbanHint')
: t('structuredViews.intro.activateTableHint')}
</p>
<button
type="button"
disabled={enabling}
onClick={onEnable}
className={cn(
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
)}
>
{enabling
? t('structuredViews.intro.enabling')
: target === 'kanban'
? t('structuredViews.intro.enableKanban')
: t('structuredViews.intro.enableTable')}
</button>
</div>
</div>
)
}
function IntroCard({
icon: Icon,
title,
body,
active,
}: {
icon: LucideIcon
title: string
body: string
active?: boolean
}) {
return (
<div
className={cn(
'rounded-2xl border p-4 space-y-2 transition-colors',
active ? 'border-brand-accent/40 bg-brand-accent/[0.04]' : 'border-border/40 bg-card/40',
)}
>
<div className="flex items-center gap-2">
<Icon size={16} className={active ? 'text-brand-accent' : 'text-muted-foreground'} />
<h3 className="text-[13px] font-semibold text-foreground">{title}</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">{body}</p>
</div>
)
}

View File

@@ -0,0 +1,273 @@
'use client'
import { useEffect, useState } from 'react'
import { X, CheckSquare, BookOpen, GraduationCap, LayoutList } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import type { NotesLayoutMode } from '@/components/notes-list-views'
import {
WIZARD_DEFAULT_VIEW,
WIZARD_FIELDS_BY_GOAL,
WIZARD_GOALS,
type WizardFieldDef,
type WizardFieldId,
type WizardGoal,
} from '@/lib/structured-views/wizard-templates'
import { cn } from '@/lib/utils'
type StructuredViewsWizardProps = {
open: boolean
onClose: () => void
onComplete: (view: NotesLayoutMode) => void
structuredModeActive: boolean
enableStructuredMode: () => Promise<unknown>
addProperty: (name: string, type: string, options?: string[]) => Promise<{ properties: { id: string; name: string; type: string }[] } | null | undefined>
setKanbanGroupProperty: (propertyId: string | null) => Promise<void>
initialGoal?: WizardGoal
}
const GOAL_ICONS: Record<WizardGoal, React.ElementType> = {
tasks: CheckSquare,
learning: GraduationCap,
reading: BookOpen,
simple: LayoutList,
}
const VIEW_LABEL_KEYS: Record<'list' | 'gallery' | 'table' | 'kanban', string> = {
list: 'structuredViews.viewList',
gallery: 'structuredViews.viewGallery',
table: 'structuredViews.viewTable',
kanban: 'structuredViews.viewKanban',
}
export function StructuredViewsWizard({
open,
onClose,
onComplete,
structuredModeActive,
enableStructuredMode,
addProperty,
setKanbanGroupProperty,
initialGoal,
}: StructuredViewsWizardProps) {
const { t } = useLanguage()
const [step, setStep] = useState(0)
const [goal, setGoal] = useState<WizardGoal>('tasks')
const [selectedFields, setSelectedFields] = useState<Set<WizardFieldId>>(new Set())
const [view, setView] = useState<NotesLayoutMode>('list')
const [saving, setSaving] = useState(false)
const fieldDefs = WIZARD_FIELDS_BY_GOAL[goal]
useEffect(() => {
if (!open) return
const g = initialGoal ?? 'tasks'
setStep(0)
setGoal(g)
setSelectedFields(new Set(WIZARD_FIELDS_BY_GOAL[g].map((f) => f.id)))
setView(WIZARD_DEFAULT_VIEW[g])
}, [open, initialGoal])
useEffect(() => {
setSelectedFields(new Set(fieldDefs.map((f) => f.id)))
setView(WIZARD_DEFAULT_VIEW[goal])
}, [goal, fieldDefs])
const kanbanAllowed = fieldDefs.some((f) => f.type === 'select' && selectedFields.has(f.id))
if (!open) return null
const toggleField = (id: WizardFieldId) => {
setSelectedFields((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const fieldLabel = (def: WizardFieldDef) => t(`structuredViews.wizard.fields.${def.id}.name`)
const parseOptions = (fieldId: WizardFieldId) =>
t(`structuredViews.wizard.fields.${fieldId}.options`)
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const handleFinish = async () => {
setSaving(true)
try {
if (!structuredModeActive) await enableStructuredMode()
let kanbanPropertyId: string | null = null
for (const def of fieldDefs) {
if (!selectedFields.has(def.id)) continue
const name = fieldLabel(def)
const options = def.hasOptions ? parseOptions(def.id) : []
const schema = await addProperty(name, def.type, options)
const created = schema?.properties.find((p) => p.name === name)
if (created && def.type === 'select' && !kanbanPropertyId) {
kanbanPropertyId = created.id
}
}
const finalView = view === 'kanban' && !kanbanAllowed ? 'list' : view
if (finalView === 'kanban' && kanbanPropertyId) {
await setKanbanGroupProperty(kanbanPropertyId)
}
onComplete(finalView)
onClose()
} catch {
toast.error(t('structuredViews.enableFailed'))
} finally {
setSaving(false)
}
}
const goNext = () => {
if (step === 0 && goal === 'simple') {
setStep(2)
return
}
setStep((s) => Math.min(s + 1, 2))
}
return (
<div className="fixed inset-0 z-[210] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div
role="dialog"
aria-modal
className="w-full max-w-lg rounded-2xl border border-border bg-memento-paper shadow-xl p-6 space-y-5"
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-memento-serif text-xl">{t('structuredViews.wizard.title')}</h2>
<p className="text-[13px] text-muted-foreground mt-1">{t('structuredViews.wizard.subtitle')}</p>
</div>
<button type="button" onClick={onClose} className="p-1 rounded-lg hover:bg-foreground/5 shrink-0">
<X size={18} />
</button>
</div>
{step === 0 && (
<div className="space-y-3">
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.wizard.stepGoal')}
</p>
<div className="grid gap-2">
{WIZARD_GOALS.map((g) => {
const Icon = GOAL_ICONS[g]
return (
<button
key={g}
type="button"
onClick={() => setGoal(g)}
className={cn(
'flex items-start gap-3 rounded-xl border p-3 text-left transition-colors',
goal === g
? 'border-foreground bg-foreground/[0.04]'
: 'border-border hover:border-foreground/25',
)}
>
<Icon size={18} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<div className="text-[13px] font-medium">{t(`structuredViews.wizard.goals.${g}.title`)}</div>
<div className="text-[12px] text-muted-foreground mt-0.5">
{t(`structuredViews.wizard.goals.${g}.desc`)}
</div>
</div>
</button>
)
})}
</div>
</div>
)}
{step === 1 && fieldDefs.length > 0 && (
<div className="space-y-3">
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.wizard.stepFields')}
</p>
<p className="text-[12px] text-muted-foreground">{t('structuredViews.wizard.fieldsHint')}</p>
<div className="space-y-2">
{fieldDefs.map((def) => (
<label
key={def.id}
className="flex items-center gap-3 rounded-xl border border-border px-3 py-2.5 cursor-pointer hover:bg-foreground/[0.02]"
>
<input
type="checkbox"
checked={selectedFields.has(def.id)}
onChange={() => toggleField(def.id)}
className="rounded border-border"
/>
<span className="text-[13px] font-medium">{fieldLabel(def)}</span>
<span className="text-[10px] text-muted-foreground ms-auto uppercase">
{t(`structuredViews.propertyTypes.${def.type}`)}
</span>
</label>
))}
</div>
</div>
)}
{step === 2 && (
<div className="space-y-3">
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{t('structuredViews.wizard.stepView')}
</p>
<div className="grid grid-cols-2 gap-2">
{(['list', 'gallery', 'table', 'kanban'] as const).map((v) => {
const disabled = v === 'kanban' && !kanbanAllowed
return (
<button
key={v}
type="button"
disabled={disabled}
onClick={() => setView(v)}
className={cn(
'rounded-xl border px-3 py-3 text-[12px] font-bold uppercase tracking-wider transition-colors',
view === v
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground hover:border-foreground/30',
disabled && 'opacity-40 cursor-not-allowed',
)}
>
{t(VIEW_LABEL_KEYS[v])}
</button>
)
})}
</div>
{view === 'kanban' && !kanbanAllowed && (
<p className="text-[12px] text-muted-foreground">{t('structuredViews.wizard.kanbanNeedsStatus')}</p>
)}
<p className="text-[12px] text-muted-foreground">{t('structuredViews.wizard.doneHint')}</p>
</div>
)}
<div className="flex justify-between gap-2 pt-2">
<Button
type="button"
variant="ghost"
onClick={() => (step === 0 ? onClose() : setStep((s) => s - 1))}
disabled={saving}
>
{step === 0 ? t('general.cancel') : t('structuredViews.wizard.back')}
</Button>
{step < 2 ? (
<Button type="button" onClick={goNext}>
{t('structuredViews.wizard.next')}
</Button>
) : (
<Button type="button" onClick={() => void handleFinish()} disabled={saving}>
{saving ? t('general.loading') : t('structuredViews.wizard.finish')}
</Button>
)}
</div>
</div>
</div>
)
}