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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,253 @@
'use client'
import { useMemo, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import type { Note } from '@/lib/types'
import type {
ColumnFilter,
ColumnSort,
NotebookSchemaPayload,
NotePropertyValues,
} from '@/lib/structured-views/types'
import {
filterNotesWithProperties,
sortNotesWithProperties,
} from '@/lib/structured-views/property-utils'
import { PropertyValueEditor } from './property-value-editor'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { ChevronDown, ChevronUp, Filter, Trash2 } from 'lucide-react'
type NotesStructuredTableProps = {
notes: Note[]
schema: NotebookSchemaPayload
noteValues: Record<string, NotePropertyValues>
onOpen: (note: Note) => void
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
onDeleteProperty?: (propertyId: string) => Promise<void>
}
export function NotesStructuredTable({
notes,
schema,
noteValues,
onOpen,
onPropertyChange,
onDeleteProperty,
}: NotesStructuredTableProps) {
const { t, language } = useLanguage()
const untitled = t('notes.untitled')
const [sort, setSort] = useState<ColumnSort>({ propertyId: 'updatedAt', direction: 'desc' })
const [filterPropId, setFilterPropId] = useState<string | null>(null)
const [filterOp, setFilterOp] = useState<ColumnFilter['operator']>('contains')
const [filterValue, setFilterValue] = useState('')
const [propertyToDelete, setPropertyToDelete] = useState<{ id: string; name: string } | null>(null)
const [deletingProperty, setDeletingProperty] = useState(false)
const filters: ColumnFilter[] = useMemo(() => {
if (!filterPropId) return []
return [{ propertyId: filterPropId, operator: filterOp, value: filterValue }]
}, [filterPropId, filterOp, filterValue])
const displayed = useMemo(() => {
const filtered = filterNotesWithProperties(notes, noteValues, filters, schema.properties)
return sortNotesWithProperties(filtered, noteValues, sort, schema.properties)
}, [notes, noteValues, filters, sort, schema.properties])
const toggleSort = (propertyId: ColumnSort['propertyId']) => {
setSort((prev) =>
prev.propertyId === propertyId
? { propertyId, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { propertyId, direction: 'asc' },
)
}
const SortIcon = ({ field }: { field: ColumnSort['propertyId'] }) =>
sort.propertyId !== field ? null : sort.direction === 'asc' ? (
<ChevronUp size={12} />
) : (
<ChevronDown size={12} />
)
return (
<div className="max-w-6xl mx-auto space-y-3">
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<Filter size={14} className="text-muted-foreground" />
<select
value={filterPropId ?? ''}
onChange={(e) => setFilterPropId(e.target.value || null)}
className="rounded-lg border border-border bg-background px-2 py-1"
>
<option value="">{t('structuredViews.noFilter')}</option>
<option value="title">{t('notes.tableTitle')}</option>
{schema.properties.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{filterPropId && (
<>
<select
value={filterOp}
onChange={(e) => setFilterOp(e.target.value as ColumnFilter['operator'])}
className="rounded-lg border border-border bg-background px-2 py-1"
>
<option value="contains">{t('structuredViews.filterContains')}</option>
<option value="equals">{t('structuredViews.filterEquals')}</option>
<option value="empty">{t('structuredViews.filterEmpty')}</option>
</select>
{filterOp !== 'empty' && (
<input
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
className="rounded-lg border border-border bg-background px-2 py-1 min-w-[120px]"
placeholder={t('structuredViews.filterValue')}
/>
)}
</>
)}
</div>
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
<table className="w-full text-left border-collapse min-w-[800px]">
<thead>
<tr className="border-b border-border/30">
<th
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground w-[22%]"
onClick={() => toggleSort('title')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableTitle')} <SortIcon field="title" />
</span>
</th>
{schema.properties.map((p) => (
<th
key={p.id}
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground group/col"
>
<span className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => toggleSort(p.id)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
>
{p.name} <SortIcon field={p.id} />
</button>
{onDeleteProperty && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setPropertyToDelete({ id: p.id, name: p.name })
}}
className="opacity-40 group-hover/col:opacity-100 p-0.5 rounded hover:text-red-500 hover:bg-red-500/10 transition-all shrink-0"
title={t('structuredViews.deleteProperty')}
aria-label={t('structuredViews.deleteProperty')}
>
<Trash2 size={11} />
</button>
)}
</span>
</th>
))}
<th
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground w-[12%]"
onClick={() => toggleSort('updatedAt')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableModified')} <SortIcon field="updatedAt" />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-foreground/[0.03]">
{displayed.map((note) => {
const vals = noteValues[note.id] ?? {}
return (
<tr key={note.id} className="hover:bg-foreground/[0.02] transition-colors group">
<td className="px-4 py-2">
<button
type="button"
onClick={() => onOpen(note)}
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[220px] group-hover:text-brand-accent transition-colors"
>
{getNoteDisplayTitle(note, untitled)}
</button>
</td>
{schema.properties.map((p) => (
<td
key={p.id}
className="px-4 py-2 align-top"
onClick={(e) => e.stopPropagation()}
>
<div className="min-w-[100px] max-w-[180px]">
<PropertyValueEditor
property={p}
value={vals[p.id]}
compact
onChange={(v) => onPropertyChange(note.id, p.id, v)}
/>
</div>
</td>
))}
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap">
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy')}
</td>
</tr>
)
})}
</tbody>
</table>
{displayed.length === 0 && (
<p className="text-center py-8 text-muted-foreground text-sm">{t('structuredViews.noMatchingNotes')}</p>
)}
</div>
<AlertDialog
open={Boolean(propertyToDelete)}
onOpenChange={(open) => {
if (!open) setPropertyToDelete(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('structuredViews.deletePropertyTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('structuredViews.deletePropertyConfirm', { name: propertyToDelete?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletingProperty}>{t('general.cancel')}</AlertDialogCancel>
<AlertDialogAction
disabled={deletingProperty || !propertyToDelete}
className="bg-red-600 hover:bg-red-700"
onClick={async (e) => {
e.preventDefault()
if (!propertyToDelete || !onDeleteProperty) return
setDeletingProperty(true)
try {
await onDeleteProperty(propertyToDelete.id)
if (filterPropId === propertyToDelete.id) setFilterPropId(null)
setPropertyToDelete(null)
} finally {
setDeletingProperty(false)
}
}}
>
{t('structuredViews.deleteProperty')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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