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>
274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
'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>
|
|
)
|
|
}
|