feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
213
memento-note/components/flashcards/retention-curve.tsx
Normal file
213
memento-note/components/flashcards/retention-curve.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 d’enregistrer 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 d’enregistrer 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 d’enregistrer 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 d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
}
|
||||
pendingResolveRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
128
memento-note/components/structured-views/add-property-dialog.tsx
Normal file
128
memento-note/components/structured-views/add-property-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
129
memento-note/components/structured-views/notes-gallery-view.tsx
Normal file
129
memento-note/components/structured-views/notes-gallery-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
223
memento-note/components/structured-views/notes-kanban-view.tsx
Normal file
223
memento-note/components/structured-views/notes-kanban-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user