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:
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