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

@@ -12,6 +12,7 @@ import {
parseNotesLayoutMode,
setNotesLayoutPreference,
} from '@/lib/notes-view-preference'
import { isClassicLayoutMode, type NotesClassicLayoutMode } from '@/components/notes-list-views'
import { motion } from 'motion/react'
const PRESET_COLORS = [
@@ -43,10 +44,11 @@ export function AppearanceSettingsClient({
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [fontFamily, setFontFamily] = useState(initialFontFamily)
const [accentColor, setAccentColor] = useState(initialAccentColor)
const [notesLayout, setNotesLayout] = useState<'grid' | 'list' | 'table'>('list')
const [notesLayout, setNotesLayout] = useState<NotesClassicLayoutMode>('list')
useEffect(() => {
setNotesLayout(parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY)))
const stored = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
setNotesLayout(isClassicLayoutMode(stored) ? stored : 'list')
}, [])
useEffect(() => {

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
/**
* PATCH /api/flashcards/[id]
* Update front and/or back of a flashcard.
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: cardId } = await params
const body = await request.json()
const front = typeof body.front === 'string' ? body.front.trim() : undefined
const back = typeof body.back === 'string' ? body.back.trim() : undefined
if (!front && !back) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
const card = await prisma.flashcard.findFirst({
where: { id: cardId, deck: { userId: session.user.id } },
})
if (!card) {
return NextResponse.json({ error: 'Card not found' }, { status: 404 })
}
const updated = await prisma.flashcard.update({
where: { id: cardId },
data: {
...(front !== undefined && { front }),
...(back !== undefined && { back }),
},
})
return NextResponse.json({ card: { id: updated.id, front: updated.front, back: updated.back } })
} catch (error) {
console.error('[flashcards/[id] PATCH]', error)
return NextResponse.json({ error: 'Failed to update card' }, { status: 500 })
}
}
/**
* DELETE /api/flashcards/[id]
* Delete a single flashcard.
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: cardId } = await params
const card = await prisma.flashcard.findFirst({
where: { id: cardId, deck: { userId: session.user.id } },
})
if (!card) {
return NextResponse.json({ error: 'Card not found' }, { status: 404 })
}
await prisma.flashcard.delete({ where: { id: cardId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('[flashcards/[id] DELETE]', error)
return NextResponse.json({ error: 'Failed to delete card' }, { status: 500 })
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getDeckDetail } from '@/lib/flashcards/deck-queries'
export async function GET(
@@ -35,3 +36,35 @@ export async function GET(
return NextResponse.json({ error: 'Failed to load deck' }, { status: 500 })
}
}
/**
* DELETE /api/flashcards/decks/[id]
* Permanently deletes the deck and all its cards (cascade via Prisma).
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const deck = await prisma.flashcardDeck.findFirst({
where: { id, userId: session.user.id },
})
if (!deck) {
return NextResponse.json({ error: 'Deck not found' }, { status: 404 })
}
await prisma.flashcardDeck.delete({ where: { id } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('[flashcards/decks/[id] DELETE]', error)
return NextResponse.json({ error: 'Failed to delete deck' }, { status: 500 })
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { prisma } from '@/lib/prisma'
import { getOrCreateDeckForNotebook } from '@/lib/flashcards/deck-utils'
import type { FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
@@ -12,6 +13,13 @@ interface CardInput {
export async function POST(request: NextRequest) {
try {
if (!prisma.flashcard || !prisma.flashcardDeck) {
return NextResponse.json(
{ error: 'Flashcards client outdated. Restart the app after prisma generate.', errorKey: 'flashcards.schemaMissing' },
{ status: 503 },
)
}
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -103,6 +111,15 @@ export async function POST(request: NextRequest) {
})
} catch (error) {
console.error('[flashcards/save]', error)
return NextResponse.json({ error: 'Failed to save flashcards' }, { status: 500 })
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2021' || error.code === 'P2022') {
return NextResponse.json(
{ error: 'Flashcards schema not migrated. Run prisma migrate deploy.', errorKey: 'flashcards.schemaMissing' },
{ status: 503 },
)
}
}
const message = error instanceof Error ? error.message : 'Failed to save flashcards'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -13,19 +13,26 @@ export async function GET() {
const userId = session.user.id
const now = new Date()
const oneYearAgo = new Date()
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1)
// Récupérer les reviews de la dernière année + toutes les cartes
const [reviews, allCards] = await Promise.all([
prisma.flashcardReview.findMany({
where: { card: { deck: { userId } } },
where: {
card: { deck: { userId } },
reviewedAt: { gte: oneYearAgo },
},
select: { reviewedAt: true, grade: true },
orderBy: { reviewedAt: 'desc' },
take: 500,
orderBy: { reviewedAt: 'asc' },
}),
prisma.flashcard.findMany({
where: { deck: { userId } },
select: { id: true, interval: true, easinessFactor: true, front: true, deck: { select: { name: true } } },
select: { id: true, interval: true, easinessFactor: true, front: true, deck: { select: { id: true, name: true } } },
}),
])
// --- Heatmap (90 derniers jours) ---
const heatmapMap = new Map<string, number>()
for (const r of reviews) {
const day = r.reviewedAt.toISOString().slice(0, 10)
@@ -36,33 +43,62 @@ export async function GET() {
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-90)
// --- Taux de rétention global (cartes maîtrisées / total) ---
const totalCards = allCards.length
const masteredCount = allCards.filter((c) => isCardMastered(c.interval)).length
// Maîtrisée = interval >= 7 jours (cohérent avec deck-queries)
const masteredCount = allCards.filter((c) => c.interval >= 7).length
const retentionRate = totalCards > 0 ? Math.round((masteredCount / totalCards) * 100) : 0
// --- Rétention par semaine : vrai taux de succès (grade >= 3) sur les reviews réelles ---
const weekMs = 7 * 24 * 60 * 60 * 1000
const retentionByWeek: { week: string; rate: number }[] = []
const retentionByWeek: { week: string; rate: number; total: number }[] = []
for (let i = 7; i >= 0; i--) {
const weekEnd = new Date(now.getTime() - i * weekMs)
const weekStart = new Date(weekEnd.getTime() - weekMs)
const cardsAtWeek = allCards.filter((c) => c.interval >= 7 * (8 - i))
const masteredAtWeek = cardsAtWeek.filter((c) => isCardMastered(c.interval)).length
const rate = cardsAtWeek.length > 0
? Math.round((masteredAtWeek / cardsAtWeek.length) * 100)
: retentionRate
const weekReviews = reviews.filter((r) => {
const t = r.reviewedAt.getTime()
return t >= weekStart.getTime() && t < weekEnd.getTime()
})
const total = weekReviews.length
const successful = weekReviews.filter((r) => r.grade >= 3).length
// Si aucune review cette semaine, on laisse null pour distinguer "pas de données" vs "0%"
const rate = total > 0 ? Math.round((successful / total) * 100) : -1
retentionByWeek.push({
week: weekStart.toISOString().slice(0, 10),
rate,
total,
})
}
// --- Streak (jours consécutifs avec au moins 1 review) ---
const reviewDays = new Set(reviews.map((r) => r.reviewedAt.toISOString().slice(0, 10)))
let streak = 0
for (let d = 0; d < 365; d++) {
const day = new Date(now)
day.setDate(day.getDate() - d)
const key = day.toISOString().slice(0, 10)
if (reviewDays.has(key)) {
streak++
} else {
// On tolère le jour actuel s'il n'a pas encore eu de review (on commence à hier)
if (d === 0) continue
break
}
}
// --- Cartes difficiles (facteur d'aisance le plus bas) ---
const difficultCards = [...allCards]
.sort((a, b) => a.easinessFactor - b.easinessFactor)
.slice(0, 5)
.slice(0, 10)
.map((c) => ({
id: c.id,
front: c.front.slice(0, 120),
front: c.front.slice(0, 200),
easinessFactor: c.easinessFactor,
deckId: c.deck.id,
deckName: c.deck.name,
}))
@@ -72,6 +108,9 @@ export async function GET() {
retentionByWeek,
difficultCards,
totalReviews: reviews.length,
streak,
totalCards,
masteredCount,
})
} catch (error) {
console.error('[flashcards/stats]', error)

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { MAX_PROPERTIES_PER_NOTEBOOK } from '@/lib/structured-views/types'
import { isValidPropertyType } from '@/lib/structured-views/property-utils'
import { buildNoteValuesMap, parseViewSettings, serializeSchema } from '@/lib/structured-views/schema-serialize'
async function assertNotebookAccess(notebookId: string, userId: string) {
return prisma.notebook.findFirst({
where: { id: notebookId, userId, trashedAt: null },
select: { id: true },
})
}
const schemaInclude = {
properties: { orderBy: { position: 'asc' as const } },
} as const
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id: notebookId } = await params
const notebook = await assertNotebookAccess(notebookId, session.user.id)
if (!notebook) {
return NextResponse.json({ success: false, error: 'Notebook not found' }, { status: 404 })
}
const raw = await prisma.notebookSchema.findUnique({
where: { notebookId },
include: schemaInclude,
})
const schema = serializeSchema(raw)
if (!schema) {
return NextResponse.json({ success: true, data: { schema: null, noteValues: {} } })
}
const notes = await prisma.note.findMany({
where: { notebookId, userId: session.user.id, trashedAt: null },
select: { id: true },
})
const noteIds = notes.map((n) => n.id)
const rows = noteIds.length
? await prisma.noteProperty.findMany({
where: { noteId: { in: noteIds } },
include: { property: { select: { type: true } } },
})
: []
const noteValues = buildNoteValuesMap(noteIds, rows)
return NextResponse.json({ success: true, data: { schema, noteValues } })
} catch (error) {
console.error('GET notebook schema:', error)
return NextResponse.json({ success: false, error: 'Failed to load schema' }, { status: 500 })
}
}
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id: notebookId } = await params
const notebook = await assertNotebookAccess(notebookId, session.user.id)
if (!notebook) {
return NextResponse.json({ success: false, error: 'Notebook not found' }, { status: 404 })
}
const existing = await prisma.notebookSchema.findUnique({ where: { notebookId } })
if (existing) {
const raw = await prisma.notebookSchema.findUnique({
where: { notebookId },
include: schemaInclude,
})
return NextResponse.json({ success: true, data: { schema: serializeSchema(raw) } })
}
const created = await prisma.notebookSchema.create({
data: { notebookId },
include: schemaInclude,
})
return NextResponse.json({ success: true, data: { schema: serializeSchema(created) } })
} catch (error) {
console.error('POST notebook schema:', error)
return NextResponse.json({ success: false, error: 'Failed to create schema' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id: notebookId } = await params
const notebook = await assertNotebookAccess(notebookId, session.user.id)
if (!notebook) {
return NextResponse.json({ success: false, error: 'Notebook not found' }, { status: 404 })
}
const body = await request.json()
const action = typeof body.action === 'string' ? body.action : ''
let schema = await prisma.notebookSchema.findUnique({ where: { notebookId } })
if (!schema) {
schema = await prisma.notebookSchema.create({ data: { notebookId } })
}
if (action === 'addProperty') {
const name = typeof body.name === 'string' ? body.name.trim() : ''
const type = typeof body.type === 'string' ? body.type : ''
if (!name || !isValidPropertyType(type)) {
return NextResponse.json({ success: false, error: 'Invalid property' }, { status: 400 })
}
const count = await prisma.notebookProperty.count({ where: { schemaId: schema.id } })
if (count >= MAX_PROPERTIES_PER_NOTEBOOK) {
return NextResponse.json({ success: false, error: 'Max properties reached' }, { status: 400 })
}
let options: string[] = []
if (type === 'select' || type === 'multiselect') {
options = Array.isArray(body.options)
? body.options
.filter((o: unknown): o is string => typeof o === 'string' && o.trim().length > 0)
.map((o: string) => o.trim())
: []
if (options.length === 0) {
return NextResponse.json({ success: false, error: 'Options required' }, { status: 400 })
}
}
await prisma.notebookProperty.create({
data: {
schemaId: schema.id,
name,
type,
options: options.length ? JSON.stringify(options) : null,
position: count,
},
})
} else if (action === 'deleteProperty') {
const propertyId = typeof body.propertyId === 'string' ? body.propertyId : ''
if (!propertyId) {
return NextResponse.json({ success: false, error: 'propertyId required' }, { status: 400 })
}
const currentSettings = parseViewSettings(schema.viewSettings)
await prisma.notebookProperty.deleteMany({
where: { id: propertyId, schemaId: schema.id },
})
if (currentSettings.kanbanGroupPropertyId === propertyId) {
await prisma.notebookSchema.update({
where: { id: schema.id },
data: { viewSettings: JSON.stringify({ ...currentSettings, kanbanGroupPropertyId: null }) },
})
}
} else if (action === 'updateViewSettings') {
const current = parseViewSettings(schema.viewSettings)
const next: Record<string, unknown> = { ...current }
if ('kanbanGroupPropertyId' in body) {
next.kanbanGroupPropertyId =
typeof body.kanbanGroupPropertyId === 'string' ? body.kanbanGroupPropertyId : null
}
await prisma.notebookSchema.update({
where: { id: schema.id },
data: { viewSettings: JSON.stringify(next) },
})
} else if (action === 'disable') {
await prisma.notebookSchema.delete({ where: { id: schema.id } })
return NextResponse.json({ success: true, data: { schema: null } })
} else {
return NextResponse.json({ success: false, error: 'Unknown action' }, { status: 400 })
}
const raw = await prisma.notebookSchema.findUnique({
where: { notebookId },
include: schemaInclude,
})
return NextResponse.json({ success: true, data: { schema: serializeSchema(raw) } })
} catch (error) {
console.error('PATCH notebook schema:', error)
return NextResponse.json({ success: false, error: 'Failed to update schema' }, { status: 500 })
}
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { isValidPropertyType, serializePropertyValue } from '@/lib/structured-views/property-utils'
import { parseStoredPropertyValue } from '@/lib/structured-views/property-utils'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id: noteId } = await params
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id, trashedAt: null },
select: { id: true, notebookId: true },
})
if (!note?.notebookId) {
return NextResponse.json({ success: false, error: 'Note not found' }, { status: 404 })
}
const schema = await prisma.notebookSchema.findUnique({
where: { notebookId: note.notebookId },
include: { properties: true },
})
if (!schema) {
return NextResponse.json({ success: false, error: 'Schema not found' }, { status: 404 })
}
const body = await request.json()
const updates = body.properties && typeof body.properties === 'object'
? body.properties as Record<string, unknown>
: null
if (!updates) {
return NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 })
}
const propertyById = new Map(schema.properties.map((p) => [p.id, p]))
const result: Record<string, unknown> = {}
for (const [propertyId, value] of Object.entries(updates)) {
const prop = propertyById.get(propertyId)
if (!prop || !isValidPropertyType(prop.type)) continue
const serialized = serializePropertyValue(prop.type, value)
if (serialized == null) {
await prisma.noteProperty.deleteMany({ where: { noteId, propertyId } })
result[propertyId] = parseStoredPropertyValue(prop.type, null)
} else {
await prisma.noteProperty.upsert({
where: { noteId_propertyId: { noteId, propertyId } },
create: { noteId, propertyId, value: serialized },
update: { value: serialized },
})
result[propertyId] = parseStoredPropertyValue(prop.type, serialized)
}
}
return NextResponse.json({ success: true, data: { values: result } })
} catch (error) {
console.error('PATCH note properties:', error)
return NextResponse.json({ success: false, error: 'Failed to update properties' }, { status: 500 })
}
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id: noteId } = await params
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
})
if (!note) {
return NextResponse.json({ success: false, error: 'Note not found' }, { status: 404 })
}
const rows = await prisma.noteProperty.findMany({
where: { noteId },
include: { property: { select: { type: true } } },
})
const values: Record<string, unknown> = {}
for (const row of rows) {
if (!isValidPropertyType(row.property.type)) continue
values[row.propertyId] = parseStoredPropertyValue(row.property.type, row.value)
}
return NextResponse.json({ success: true, data: { values } })
} catch (error) {
console.error('GET note properties:', error)
return NextResponse.json({ success: false, error: 'Failed to load properties' }, { status: 500 })
}
}

View File

@@ -36,31 +36,48 @@ export async function POST(req: NextRequest) {
// Hash/Anonymize IP address for strict GDPR compliance
const anonymizedIp = ip ? createHash('sha256').update(ip).digest('hex') : null
// Create persistent audit log of this consent decision
await prisma.aiConsentLog.create({
data: {
userId,
consent,
ipAddress: anonymizedIp,
userAgent,
},
})
// If remember is requested, persist state in UserAISettings
if (remember) {
await prisma.userAISettings.upsert({
where: { userId },
create: {
let auditOk = true
try {
await prisma.aiConsentLog.create({
data: {
userId,
aiProcessingConsent: consent,
},
update: {
aiProcessingConsent: consent,
consent,
ipAddress: anonymizedIp,
userAgent,
},
})
} catch (auditError) {
auditOk = false
console.error('[POST /api/user/ai-consent] audit log failed:', auditError)
}
if (remember) {
try {
await prisma.userAISettings.upsert({
where: { userId },
create: {
userId,
aiProcessingConsent: consent,
},
update: {
aiProcessingConsent: consent,
},
})
} catch (settingsError) {
console.error('[POST /api/user/ai-consent] settings upsert failed:', settingsError)
return NextResponse.json({ error: 'settings_update_failed' }, { status: 500 })
}
}
if (!auditOk) {
return NextResponse.json({
success: true,
auditLogged: false,
warning: 'audit_log_failed',
})
}
return NextResponse.json({ success: true })
return NextResponse.json({ success: true, auditLogged: true })
} catch (error) {
console.error('[POST /api/user/ai-consent] failed:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
type SchemaState = {
schema: NotebookSchemaPayload | null
noteValues: Record<string, NotePropertyValues>
loading: boolean
error: string | null
}
export function useNotebookSchema(notebookId: string | null | undefined) {
const [state, setState] = useState<SchemaState>({
schema: null,
noteValues: {},
loading: false,
error: null,
})
const reload = useCallback(async () => {
if (!notebookId) {
setState({ schema: null, noteValues: {}, loading: false, error: null })
return
}
setState((s) => ({ ...s, loading: true, error: null }))
try {
const res = await fetch(`/api/notebooks/${notebookId}/schema`)
const json = await res.json()
if (!res.ok || !json.success) {
throw new Error(json.error || 'Failed to load schema')
}
setState({
schema: json.data.schema,
noteValues: json.data.noteValues ?? {},
loading: false,
error: null,
})
} catch (e) {
setState((s) => ({
...s,
loading: false,
error: e instanceof Error ? e.message : 'Error',
}))
}
}, [notebookId])
useEffect(() => {
void reload()
}, [reload])
const enableStructuredMode = useCallback(async () => {
if (!notebookId) return null
const res = await fetch(`/api/notebooks/${notebookId}/schema`, { method: 'POST' })
const json = await res.json()
if (!res.ok || !json.success) throw new Error(json.error || 'Failed')
await reload()
return json.data.schema as NotebookSchemaPayload
}, [notebookId, reload])
const disableStructuredMode = useCallback(async () => {
if (!notebookId) return
const res = await fetch(`/api/notebooks/${notebookId}/schema`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'disable' }),
})
const json = await res.json()
if (!res.ok || !json.success) throw new Error(json.error || 'Failed')
await reload()
}, [notebookId, reload])
const addProperty = useCallback(
async (name: string, type: string, options?: string[]) => {
if (!notebookId) return null
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 (!res.ok || !json.success) throw new Error(json.error || 'Failed')
await reload()
return json.data.schema as NotebookSchemaPayload
},
[notebookId, reload],
)
const deleteProperty = useCallback(
async (propertyId: string) => {
if (!notebookId) return
const res = await fetch(`/api/notebooks/${notebookId}/schema`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'deleteProperty', propertyId }),
})
const json = await res.json()
if (!res.ok || !json.success) throw new Error(json.error || 'Failed')
await reload()
},
[notebookId, reload],
)
const setKanbanGroupProperty = useCallback(
async (propertyId: string | null) => {
if (!notebookId) return
const res = await fetch(`/api/notebooks/${notebookId}/schema`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'updateViewSettings', kanbanGroupPropertyId: propertyId }),
})
const json = await res.json()
if (!res.ok || !json.success) throw new Error(json.error || 'Failed')
await reload()
},
[notebookId, reload],
)
const patchNoteValuesLocal = useCallback((noteId: string, patch: NotePropertyValues) => {
setState((s) => ({
...s,
noteValues: {
...s.noteValues,
[noteId]: { ...(s.noteValues[noteId] ?? {}), ...patch },
},
}))
}, [])
return {
...state,
reload,
enableStructuredMode,
disableStructuredMode,
addProperty,
deleteProperty,
setKanbanGroupProperty,
patchNoteValuesLocal,
}
}

View File

@@ -5,10 +5,12 @@ export interface DeckSummary {
id: string
name: string
notebookId: string | null
notebookName: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: string | null
nextReviewAt: string | null
createdAt: string
}
@@ -17,6 +19,7 @@ export async function listDeckSummaries(userId: string): Promise<DeckSummary[]>
const decks = await prisma.flashcardDeck.findMany({
where: { userId },
include: {
notebook: { select: { name: true } },
flashcards: {
select: {
id: true,
@@ -36,19 +39,27 @@ export async function listDeckSummaries(userId: string): Promise<DeckSummary[]>
return decks.map((deck) => {
const totalCards = deck.flashcards.length
const dueCount = deck.flashcards.filter((c) => c.nextReviewAt <= now).length
const masteredCount = deck.flashcards.filter((c) => isCardMastered(c.interval)).length
// Maîtrisée = interval >= 7 jours (une semaine de bonne mémorisation)
const masteredCount = deck.flashcards.filter((c) => c.interval >= 7).length
const lastReview = deck.flashcards
.flatMap((c) => c.reviews.map((r) => r.reviewedAt))
.sort((a, b) => b.getTime() - a.getTime())[0]
// Date de la prochaine carte à réviser (la plus proche dans le futur)
const nextReviewAt = deck.flashcards
.map((c) => c.nextReviewAt)
.sort((a, b) => a.getTime() - b.getTime())[0] ?? null
return {
id: deck.id,
name: deck.name,
notebookId: deck.notebookId,
notebookName: deck.notebook?.name ?? null,
totalCards,
dueCount,
masteredCount,
lastReviewedAt: lastReview ? lastReview.toISOString() : null,
nextReviewAt: nextReviewAt ? nextReviewAt.toISOString() : null,
createdAt: deck.createdAt.toISOString(),
}
})

View File

@@ -1,4 +1,5 @@
import prisma from '@/lib/prisma'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
export async function getOrCreateDeckForNotebook(params: {
userId: string
@@ -28,6 +29,14 @@ export async function getOrCreateDeckForNotebook(params: {
notebookId,
name: notebook.name,
},
}).catch(async (error) => {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const raced = await prisma.flashcardDeck.findFirst({
where: { userId, notebookId },
})
if (raced) return raced
}
throw error
})
}

View File

@@ -5,11 +5,15 @@ export const NOTES_VIEW_TYPE_COOKIE = 'memento-notes-view-type'
export const NOTES_LAYOUT_STORAGE_KEY = 'memento-notes-layout'
export const NOTES_VIEW_TYPE_STORAGE_KEY = 'memento-notes-view-type'
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table']
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table', 'kanban', 'gallery']
const VIEW_TYPE_VALUES: NotesViewType[] = ['notes', 'tasks']
export function parseNotesLayoutMode(value: string | undefined | null): NotesLayoutMode {
if (value && (LAYOUT_VALUES as string[]).includes(value)) return value as NotesLayoutMode
if (value && (LAYOUT_VALUES as string[]).includes(value)) {
const mode = value as NotesLayoutMode
if (mode === 'gallery') return 'grid'
return mode
}
return 'list'
}

View File

@@ -10,19 +10,27 @@ const prismaClientSingleton = () => {
})
}
/** Dev hot-reload can keep an old PrismaClient missing newly generated models. */
function needsFreshPrismaClient(client: PrismaClient | undefined): boolean {
if (!client) return true
return typeof (client as PrismaClient & { flashcard?: unknown }).flashcard === 'undefined'
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
let prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
// Log current model keys to verify availability
if (process.env.NODE_ENV !== 'production') {
if (needsFreshPrismaClient(globalThis.prismaGlobal)) {
prisma = prismaClientSingleton()
}
globalThis.prismaGlobal = prisma
const models = Object.keys(prisma).filter(k => !k.startsWith('_') && !k.startsWith('$'))
console.log('[Prisma] Models loaded:', models.join(', '))
}
export { prisma }
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

View File

@@ -0,0 +1,71 @@
import type { NotebookSchemaPayload } from '@/lib/structured-views/types'
export type BootstrapStructuredTarget = 'table' | 'kanban'
export type BootstrapStructuredLabels = {
statusName: string
statusOptions: string[]
}
export type BootstrapStructuredActions = {
getSchema: () => NotebookSchemaPayload | null
enableStructuredMode: () => Promise<NotebookSchemaPayload | null>
addProperty: (name: string, type: string, options?: string[]) => Promise<NotebookSchemaPayload | null>
setKanbanGroupProperty: (propertyId: string | null) => Promise<void>
}
function pickGroupProperty(schema: NotebookSchemaPayload, statusName: string) {
return (
schema.properties.find((p) => p.type === 'select' && p.name === statusName) ??
schema.properties.find((p) => p.type === 'select') ??
null
)
}
/** Active une base organisable avec champs par défaut (Statut) si nécessaire. */
export async function bootstrapStructuredNotebook(
target: BootstrapStructuredTarget,
labels: BootstrapStructuredLabels,
actions: BootstrapStructuredActions,
): Promise<NotebookSchemaPayload> {
let schema = actions.getSchema()
if (!schema) {
schema = await actions.enableStructuredMode()
}
if (!schema) {
throw new Error('enable_failed')
}
const selectProps = schema.properties.filter((p) => p.type === 'select')
const needsDefaultStatus =
target === 'kanban' ? selectProps.length === 0 : schema.properties.length === 0
if (needsDefaultStatus) {
schema =
(await actions.addProperty(labels.statusName, 'select', labels.statusOptions)) ?? schema
}
if (target === 'kanban') {
const groupProp = pickGroupProperty(schema, labels.statusName)
if (!groupProp) {
throw new Error('kanban_needs_select')
}
if (schema.viewSettings.kanbanGroupPropertyId !== groupProp.id) {
await actions.setKanbanGroupProperty(groupProp.id)
schema = {
...schema,
viewSettings: { ...schema.viewSettings, kanbanGroupPropertyId: groupProp.id },
}
}
}
return schema
}
/** Ajoute un champ Statut + colonnes kanban sur une base déjà active. */
export async function ensureKanbanStatusField(
labels: BootstrapStructuredLabels,
actions: BootstrapStructuredActions,
): Promise<NotebookSchemaPayload> {
return bootstrapStructuredNotebook('kanban', labels, actions)
}

View File

@@ -0,0 +1,22 @@
import type { StructuredViewMode } from './types'
const MODES: StructuredViewMode[] = ['list', 'table', 'kanban', 'gallery']
export function structuredViewStorageKey(notebookId: string) {
return `memento-structured-view-${notebookId}`
}
export function parseStructuredViewMode(value: string | null | undefined): StructuredViewMode {
if (value && (MODES as string[]).includes(value)) return value as StructuredViewMode
return 'list'
}
export function getStructuredViewPreference(notebookId: string): StructuredViewMode {
if (typeof window === 'undefined') return 'list'
return parseStructuredViewMode(localStorage.getItem(structuredViewStorageKey(notebookId)))
}
export function setStructuredViewPreference(notebookId: string, mode: StructuredViewMode) {
if (typeof window === 'undefined') return
localStorage.setItem(structuredViewStorageKey(notebookId), mode)
}

View File

@@ -0,0 +1,167 @@
import type {
ColumnFilter,
ColumnSort,
NotePropertyValues,
PropertyType,
SchemaProperty,
} from './types'
export function parsePropertyOptions(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
export function serializePropertyValue(type: PropertyType, value: unknown): string | null {
if (value === null || value === undefined || value === '') {
return type === 'checkbox' ? JSON.stringify(false) : null
}
if (type === 'checkbox') {
return JSON.stringify(Boolean(value))
}
if (type === 'number') {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? JSON.stringify(n) : null
}
if (type === 'multiselect') {
const arr = Array.isArray(value) ? value : []
return JSON.stringify(arr.filter((v) => typeof v === 'string'))
}
return JSON.stringify(String(value))
}
export function parseStoredPropertyValue(type: PropertyType, raw: string | null | undefined): unknown {
if (raw == null || raw === '') {
if (type === 'checkbox') return false
if (type === 'multiselect') return []
return null
}
try {
const parsed = JSON.parse(raw)
if (type === 'checkbox') return Boolean(parsed)
if (type === 'number') return typeof parsed === 'number' ? parsed : Number(parsed)
if (type === 'multiselect') return Array.isArray(parsed) ? parsed : []
return parsed
} catch {
return raw
}
}
export function formatPropertyDisplay(type: PropertyType, value: unknown): string {
if (value == null || value === '') return '—'
if (type === 'checkbox') return value ? '✓' : '—'
if (type === 'multiselect') {
return Array.isArray(value) ? value.join(', ') : String(value)
}
if (type === 'date' && typeof value === 'string') {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString()
}
return String(value)
}
export function comparePropertyValues(
type: PropertyType,
a: unknown,
b: unknown,
direction: 'asc' | 'desc',
): number {
const emptyA = a == null || a === '' || (Array.isArray(a) && a.length === 0)
const emptyB = b == null || b === '' || (Array.isArray(b) && b.length === 0)
if (emptyA && emptyB) return 0
if (emptyA) return direction === 'asc' ? 1 : -1
if (emptyB) return direction === 'asc' ? -1 : 1
let cmp = 0
if (type === 'number') {
cmp = Number(a) - Number(b)
} else if (type === 'date') {
cmp = new Date(String(a)).getTime() - new Date(String(b)).getTime()
} else if (type === 'checkbox') {
cmp = Number(Boolean(a)) - Number(Boolean(b))
} else if (type === 'multiselect') {
cmp = formatPropertyDisplay(type, a).localeCompare(formatPropertyDisplay(type, b))
} else {
cmp = String(a).localeCompare(String(b), undefined, { sensitivity: 'base' })
}
return direction === 'asc' ? cmp : -cmp
}
export function matchesFilter(
type: PropertyType,
value: unknown,
filter: ColumnFilter,
): boolean {
const { operator, value: filterValue } = filter
const empty = value == null || value === '' || (Array.isArray(value) && value.length === 0)
if (operator === 'empty') return empty
if (empty) return false
const haystack = formatPropertyDisplay(type, value).toLowerCase()
const needle = (filterValue ?? '').toLowerCase()
if (operator === 'equals') {
if (type === 'multiselect' && Array.isArray(value)) {
return value.some((v) => String(v).toLowerCase() === needle)
}
return haystack === needle
}
return haystack.includes(needle)
}
export function sortNotesWithProperties<T extends { id: string; title?: string | null; updatedAt: string | Date }>(
notes: T[],
valuesByNote: Record<string, NotePropertyValues>,
sort: ColumnSort,
properties: SchemaProperty[],
): T[] {
const prop = properties.find((p) => p.id === sort.propertyId)
const copy = [...notes]
copy.sort((a, b) => {
if (sort.propertyId === 'title') {
const ta = (a.title ?? '').toLowerCase()
const tb = (b.title ?? '').toLowerCase()
const cmp = ta.localeCompare(tb)
return sort.direction === 'asc' ? cmp : -cmp
}
if (sort.propertyId === 'updatedAt') {
const cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return sort.direction === 'asc' ? cmp : -cmp
}
if (!prop) return 0
const va = valuesByNote[a.id]?.[prop.id]
const vb = valuesByNote[b.id]?.[prop.id]
return comparePropertyValues(prop.type, va, vb, sort.direction)
})
return copy
}
export function filterNotesWithProperties<T extends { id: string }>(
notes: T[],
valuesByNote: Record<string, NotePropertyValues>,
filters: ColumnFilter[],
properties: SchemaProperty[],
): T[] {
if (filters.length === 0) return notes
return notes.filter((note) => {
const vals = valuesByNote[note.id] ?? {}
return filters.every((filter) => {
if (filter.propertyId === 'title') {
const title = (note as { title?: string | null }).title ?? ''
return matchesFilter('text', title, filter)
}
const prop = properties.find((p) => p.id === filter.propertyId)
if (!prop) return true
return matchesFilter(prop.type, vals[prop.id], filter)
})
})
}
export function isValidPropertyType(value: string): value is PropertyType {
return ['text', 'number', 'date', 'select', 'multiselect', 'checkbox'].includes(value)
}

View File

@@ -0,0 +1,68 @@
import type { NotebookViewSettings, NotebookSchemaPayload, SchemaProperty } from './types'
import { parsePropertyOptions, parseStoredPropertyValue } from './property-utils'
import { isValidPropertyType } from './property-utils'
type RawSchema = {
id: string
notebookId: string
viewSettings: string | null
properties: Array<{
id: string
name: string
type: string
options: string | null
position: number
}>
}
export function parseViewSettings(raw: string | null | undefined): NotebookViewSettings {
if (!raw) return {}
try {
const parsed = JSON.parse(raw) as NotebookViewSettings
return {
kanbanGroupPropertyId: parsed.kanbanGroupPropertyId ?? null,
}
} catch {
return {}
}
}
export function serializeSchema(raw: RawSchema | null): NotebookSchemaPayload | null {
if (!raw) return null
const properties: SchemaProperty[] = raw.properties
.filter((p) => isValidPropertyType(p.type))
.sort((a, b) => a.position - b.position)
.map((p) => ({
id: p.id,
name: p.name,
type: p.type as SchemaProperty['type'],
options: parsePropertyOptions(p.options),
position: p.position,
}))
return {
id: raw.id,
notebookId: raw.notebookId,
viewSettings: parseViewSettings(raw.viewSettings),
properties,
}
}
export function buildNoteValuesMap(
noteIds: string[],
rows: Array<{ noteId: string; propertyId: string; value: string | null; property: { type: string } }>,
): Record<string, Record<string, unknown>> {
const map: Record<string, Record<string, unknown>> = {}
for (const id of noteIds) {
map[id] = {}
}
for (const row of rows) {
if (!map[row.noteId]) map[row.noteId] = {}
if (!isValidPropertyType(row.property.type)) continue
map[row.noteId][row.propertyId] = parseStoredPropertyValue(
row.property.type,
row.value,
)
}
return map
}

View File

@@ -0,0 +1,55 @@
export const PROPERTY_TYPES = [
'text',
'number',
'date',
'select',
'multiselect',
'checkbox',
] as const
export type PropertyType = (typeof PROPERTY_TYPES)[number]
export const MAX_PROPERTIES_PER_NOTEBOOK = 15
export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery'
export type NotebookViewSettings = {
kanbanGroupPropertyId?: string | null
}
export type SchemaProperty = {
id: string
name: string
type: PropertyType
options: string[]
position: number
}
export type NotebookSchemaPayload = {
id: string
notebookId: string
viewSettings: NotebookViewSettings
properties: SchemaProperty[]
}
export type NotePropertyValues = Record<string, unknown>
export type StructuredNotebookData = {
schema: NotebookSchemaPayload | null
noteValues: Record<string, NotePropertyValues>
}
export type ColumnFilterOperator = 'contains' | 'equals' | 'empty'
export type ColumnFilter = {
propertyId: string
operator: ColumnFilterOperator
value?: string
}
export type SortDirection = 'asc' | 'desc'
export type ColumnSort = {
propertyId: 'title' | 'updatedAt' | string
direction: SortDirection
}

View File

@@ -0,0 +1,39 @@
import type { NotesLayoutMode } from '@/components/notes-list-views'
import type { PropertyType } from '@/lib/structured-views/types'
export type WizardGoal = 'tasks' | 'learning' | 'reading' | 'simple'
export type WizardFieldId = 'status' | 'dueDate' | 'level' | 'lastReview' | 'read' | 'source'
export type WizardFieldDef = {
id: WizardFieldId
type: PropertyType
hasOptions?: boolean
}
export const WIZARD_GOALS: WizardGoal[] = ['tasks', 'learning', 'reading', 'simple']
export const WIZARD_FIELDS_BY_GOAL: Record<WizardGoal, WizardFieldDef[]> = {
tasks: [
{ id: 'status', type: 'select', hasOptions: true },
{ id: 'dueDate', type: 'date' },
],
learning: [
{ id: 'level', type: 'select', hasOptions: true },
{ id: 'lastReview', type: 'date' },
],
reading: [
{ id: 'read', type: 'checkbox' },
{ id: 'source', type: 'text' },
],
simple: [],
}
export const WIZARD_DEFAULT_VIEW: Record<WizardGoal, NotesLayoutMode> = {
tasks: 'kanban',
learning: 'gallery',
reading: 'gallery',
simple: 'list',
}
export const KANBAN_GROUP_FIELD_ID: WizardFieldId = 'status'

View File

@@ -71,7 +71,10 @@
"sortManual": "Custom order",
"moveFailed": "Failed to move notebook",
"dropToRoot": "Drop here to move to root",
"noReminders": "No active reminders."
"noReminders": "No active reminders.",
"documents": "Documents",
"searchNotebooksPlaceholder": "Search notebooks…",
"clearSearch": "Clear search"
},
"notes": {
"title": "Notes",
@@ -2410,6 +2413,7 @@
"confirmSave": "Save to deck",
"generateFailed": "Could not generate flashcards",
"saveFailed": "Could not save flashcards",
"schemaMissing": "Flashcards are not available yet on this server (database migration pending).",
"savedCount": "{count} flashcards saved",
"cardCount": "Number of cards",
"styleLabel": "Card style",
@@ -2434,6 +2438,10 @@
"cardCountLabel": "{count} cards",
"masteredShort": "mastered",
"viewDeck": "Details",
"hideDeck": "Hide",
"deckCardsEmpty": "This deck has no cards yet.",
"dueBadge": "Due",
"masteredBadge": "Mastered",
"review": "Review",
"startReview": "Start review",
"activeDeck": "Active deck",
@@ -2446,6 +2454,8 @@
"front": "Front",
"back": "Back",
"tapToFlip": "Space or tap to flip",
"gradeSelected": "Saved — {label}",
"ratePrompt": "How well did you remember this card?",
"grade": {
"hard": "Hard (1)",
"difficult": "Difficult (2)",
@@ -2459,8 +2469,161 @@
"heatmapTitle": "Review activity",
"heatmapLast90": "Last 90 days",
"retentionRate": "Retention rate",
"retentionCurve": "Weekly retention",
"difficultCards": "Hardest cards"
"masteredLabel": "{count}/{total} mastered",
"retentionCurve": "Weekly success rate",
"retentionCurveHint": "Based on reviews with grade \u2265 Good (3 or 4)",
"retentionNoDataYet": "Review more cards across multiple weeks to see your retention curve.",
"streak": "Current streak",
"streakDays": "days",
"totalReviewsLabel": "Total reviews",
"totalCardsLabel": "Total cards",
"nextReviewLabel": "Next",
"dueToday": "Due today",
"nextReviewIn": "In {days}d",
"difficultCards": "Hardest cards",
"notebookBadge": "Notebook",
"reviewMode": "Mode",
"reviewModeAll": "All cards",
"reviewModeDue": "Due only",
"sessionStats": "Session summary",
"sessionReviewed": "Cards reviewed",
"sessionNewMastered": "Newly mastered",
"sessionDuration": "Duration",
"editCard": "Edit",
"deleteCard": "Delete card",
"deleteCardConfirm": "Delete this card?",
"deleteDeck": "Delete deck",
"deleteDeckConfirm": "Delete this deck and all its cards permanently?",
"deckDeleted": "Deck deleted",
"cardDeleted": "Card deleted",
"cardSaved": "Card saved",
"cardTypeBadge": "{type}",
"reviewNow": "Review now",
"deleteFailed": "Could not delete"
},
"structuredViews": {
"enableTitle": "Organize this notebook (table, kanban, gallery…)",
"enableLabel": "Organize",
"enabledHint": "Organized mode enabled",
"enabledHintDetail": "Fields show up in each note's Info panel.",
"enableFailed": "Could not enable organized view",
"viewList": "List",
"viewTable": "Table",
"viewTableHint": "Structured table — one row per note, one column per field (status, date…)",
"viewKanban": "Kanban",
"viewGallery": "Gallery",
"viewKanbanHint": "Columns — like Trello to track your notes",
"viewGalleryHint": "Visual cards — browse your notes at a glance",
"intro": {
"databaseTitle": "Organized notebook",
"databaseBody": "Add shared fields (Status, Due date, Priority…) to every note in this notebook. Your note content stays the same — these are shared metadata, like a lightweight Notion database.",
"tableTitle": "Table view",
"tableBody": "All notes as rows, your fields as columns. Edit status or dates inline in the grid.",
"kanbanTitle": "Kanban view",
"kanbanBody": "The same notes as cards grouped in columns (e.g. To do → In progress → Done). Drag and drop to update status.",
"activateTableHint": "We create a default “Status” field. Add more fields anytime with the + button.",
"activateKanbanHint": "We create a “Status” field with three columns. You can rename options or add fields later.",
"enableTable": "Enable table",
"enableKanban": "Enable Kanban",
"enabling": "Enabling…",
"enabledSuccess": "Organized notebook enabled for this notebook"
},
"helpBanner": {
"table": "Table view: one row per note, one column per field. Click a cell to edit. Hover a column header to delete it (trash icon).",
"kanban": "Kanban view: drag a card to change its status. Use + in a column to create a note already classified.",
"dismiss": "Got it"
},
"addPropertyTitle": "Add field",
"addProperty": "Add field",
"addPropertyHint": "The column appears on every note in this notebook, but each note keeps its own value (empty until you fill it in).",
"deleteProperty": "Delete field",
"deletePropertyTitle": "Delete this field?",
"deletePropertyConfirm": "The field \"{name}\" and all its values on notes in this notebook will be removed. This cannot be undone.",
"deletePropertySuccess": "Field deleted",
"cellEmpty": "—",
"multiselectPick": "Choose…",
"propertyName": "Field name",
"propertyType": "Data type",
"selectOptions": "Options (one per line)",
"selectOptionsPlaceholder": "To do\nIn progress\nDone",
"propertiesSection": "Notebook fields",
"noPropertiesYet": "No fields yet.",
"noFilter": "No filter",
"filterContains": "Contains",
"filterEquals": "Equals",
"filterEmpty": "Is empty",
"filterValue": "Filter value…",
"noMatchingNotes": "No notes match this filter.",
"kanbanGroupBy": "Group by",
"kanbanUnassigned": "Unassigned",
"kanbanAllNotes": "All notes",
"kanbanSingleColumnHint": "Your notes are here. For multiple columns (To do, In progress, Done), one click.",
"kanbanAddStatusColumns": "Add Status columns",
"chooseGroupProperty": "Choose grouping field",
"newNoteInColumn": "New note",
"propertyTypes": {
"text": "Text",
"number": "Number",
"date": "Date",
"select": "Single choice",
"multiselect": "Multi-choice",
"checkbox": "Yes/no"
},
"wizard": {
"title": "Organize this notebook",
"subtitle": "Like a light spreadsheet: extra info on each note, without changing the text.",
"stepGoal": "What is this notebook for?",
"stepFields": "Which fields to add?",
"stepView": "How should notes appear?",
"fieldsHint": "A field = a shared column (status, date…). Fill it per note when you want.",
"kanbanNeedsStatus": "Check a single-choice field (e.g. Status) to use Kanban.",
"doneHint": "You can fill fields later — or leave them empty.",
"back": "Back",
"next": "Next",
"finish": "Finish",
"readyToast": "Ready — fill in fields whenever you like.",
"openFromKanban": "Set up with assistant",
"goals": {
"tasks": {
"title": "Track tasks",
"desc": "Status, due date — great for Kanban."
},
"learning": {
"title": "Learn / review",
"desc": "Level, last review — works well as gallery."
},
"reading": {
"title": "Read and collect",
"desc": "Read flag, source — for articles and references."
},
"simple": {
"title": "Just organize",
"desc": "Change views without required fields."
}
},
"fields": {
"status": {
"name": "Status",
"options": "To do\nIn progress\nDone"
},
"dueDate": {
"name": "Due date"
},
"level": {
"name": "Level",
"options": "Beginner\nIntermediate\nAdvanced"
},
"lastReview": {
"name": "Last review"
},
"read": {
"name": "Read"
},
"source": {
"name": "Source"
}
}
}
},
"brainstorm": {
"title": "Waves of Thought",
@@ -3148,4 +3311,4 @@
"uploadFailed": "Upload failed",
"uploading": "Uploading..."
}
}
}

View File

@@ -71,7 +71,10 @@
"sortManual": "Ordre libre",
"moveFailed": "Échec du déplacement du carnet",
"dropToRoot": "Déposez ici pour déplacer à la racine",
"noReminders": "Aucun rappel actif."
"noReminders": "Aucun rappel actif.",
"documents": "Documents",
"searchNotebooksPlaceholder": "Rechercher un carnet…",
"clearSearch": "Effacer la recherche"
},
"notes": {
"title": "Notes",
@@ -2414,6 +2417,7 @@
"confirmSave": "Enregistrer dans le deck",
"generateFailed": "Impossible de générer les flashcards",
"saveFailed": "Impossible d'enregistrer les flashcards",
"schemaMissing": "Les flashcards ne sont pas encore disponibles sur ce serveur (migration base de données en attente).",
"savedCount": "{count} flashcards enregistrées",
"cardCount": "Nombre de cartes",
"styleLabel": "Style de cartes",
@@ -2438,6 +2442,10 @@
"cardCountLabel": "{count} cartes",
"masteredShort": "maîtrisées",
"viewDeck": "Détails",
"hideDeck": "Masquer",
"deckCardsEmpty": "Ce deck ne contient aucune carte pour l'instant.",
"dueBadge": "À réviser",
"masteredBadge": "Maîtrisée",
"review": "Réviser",
"startReview": "Lancer la révision",
"activeDeck": "Deck actif",
@@ -2450,6 +2458,8 @@
"front": "Recto",
"back": "Verso",
"tapToFlip": "Espace ou clic pour retourner",
"gradeSelected": "Enregistré — {label}",
"ratePrompt": "À quel point aviez-vous mémorisé cette carte ?",
"grade": {
"hard": "Difficile (1)",
"difficult": "Dur (2)",
@@ -2463,8 +2473,161 @@
"heatmapTitle": "Activité de révision",
"heatmapLast90": "90 derniers jours",
"retentionRate": "Taux de rétention",
"retentionCurve": "Rétention hebdomadaire",
"difficultCards": "Cartes difficiles"
"masteredLabel": "{count}/{total} maîtrisées",
"retentionCurve": "Taux de succès hebdomadaire",
"retentionCurveHint": "Basé sur les révisions avec note ≥ Bien (3 ou 4)",
"retentionNoDataYet": "Révisez des cartes sur plusieurs semaines pour voir votre courbe de rétention.",
"streak": "Série en cours",
"streakDays": "j",
"totalReviewsLabel": "Révisions totales",
"totalCardsLabel": "Cartes au total",
"nextReviewLabel": "Prochain",
"dueToday": "Dû aujourd'hui",
"nextReviewIn": "Dans {days}j",
"difficultCards": "Cartes difficiles",
"notebookBadge": "Carnet",
"reviewMode": "Mode",
"reviewModeAll": "Toutes les cartes",
"reviewModeDue": "Cartes dues uniquement",
"sessionStats": "Bilan de session",
"sessionReviewed": "Cartes révisées",
"sessionNewMastered": "Nouvellement maîtrisées",
"sessionDuration": "Durée",
"editCard": "Modifier",
"deleteCard": "Supprimer la carte",
"deleteCardConfirm": "Supprimer cette carte définitivement ?",
"deleteDeck": "Supprimer le deck",
"deleteDeckConfirm": "Supprimer ce deck et toutes ses cartes définitivement ?",
"deckDeleted": "Deck supprimé",
"cardDeleted": "Carte supprimée",
"cardSaved": "Carte enregistrée",
"cardTypeBadge": "{type}",
"reviewNow": "Réviser maintenant",
"deleteFailed": "Impossible de supprimer"
},
"structuredViews": {
"enableTitle": "Organiser ce carnet (tableau, kanban, galerie…)",
"enableLabel": "Organiser",
"enabledHint": "Mode organisé activé",
"enabledHintDetail": "Vos champs apparaissent dans le panneau Info de chaque note.",
"enableFailed": "Impossible d'activer l'organisation",
"viewList": "Liste",
"viewTable": "Tableau",
"viewTableHint": "Tableau structuré — une ligne par note, une colonne par champ (statut, date…)",
"viewKanban": "Kanban",
"viewGallery": "Galerie",
"viewKanbanHint": "Colonnes — comme un tableau Trello pour faire avancer vos notes",
"viewGalleryHint": "Cartes visuelles — parcourir vos notes en un coup d'œil",
"intro": {
"databaseTitle": "Base organisable",
"databaseBody": "Ajoutez des champs partagés (Statut, Échéance, Priorité…) à chaque note de ce carnet. Le texte de vos notes ne change pas : ce sont des métadonnées communes, comme une mini base de données Notion.",
"tableTitle": "Vue tableau",
"tableBody": "Toutes vos notes en lignes, vos champs en colonnes. Modifiez statut ou date directement dans la grille.",
"kanbanTitle": "Vue Kanban",
"kanbanBody": "Les mêmes notes, en cartes réparties en colonnes (ex. À faire → En cours → Terminé). Glissez-déposez pour changer le statut.",
"activateTableHint": "Nous créons un champ « Statut » par défaut. Vous pourrez en ajouter d'autres avec le bouton +.",
"activateKanbanHint": "Nous créons un champ « Statut » avec trois colonnes. Vous pourrez renommer les options ou ajouter des champs ensuite.",
"enableTable": "Activer le tableau",
"enableKanban": "Activer le Kanban",
"enabling": "Activation…",
"enabledSuccess": "Base organisable activée pour ce carnet"
},
"helpBanner": {
"table": "Vue tableau : une ligne par note, une colonne par champ. Cliquez une cellule pour modifier. Survolez l'en-tête d'une colonne pour la supprimer (icône poubelle).",
"kanban": "Vue Kanban : faites glisser une carte pour changer son statut. Utilisez + dans une colonne pour créer une note déjà classée.",
"dismiss": "Compris"
},
"addPropertyTitle": "Ajouter un champ",
"addProperty": "Ajouter un champ",
"addPropertyHint": "La colonne apparaît sur toutes les notes du carnet, mais chaque note garde sa propre valeur (vide tant que vous ne l'avez pas remplie).",
"deleteProperty": "Supprimer le champ",
"deletePropertyTitle": "Supprimer ce champ ?",
"deletePropertyConfirm": "Le champ « {name} » et toutes ses valeurs sur les notes de ce carnet seront supprimés. Cette action est irréversible.",
"deletePropertySuccess": "Champ supprimé",
"cellEmpty": "—",
"multiselectPick": "Choisir…",
"propertyName": "Nom du champ",
"propertyType": "Type de donnée",
"selectOptions": "Options (une par ligne)",
"selectOptionsPlaceholder": "À faire\nEn cours\nTerminé",
"propertiesSection": "Champs du carnet",
"noPropertiesYet": "Aucun champ pour l'instant.",
"noFilter": "Sans filtre",
"filterContains": "Contient",
"filterEquals": "Est égal à",
"filterEmpty": "Est vide",
"filterValue": "Valeur du filtre…",
"noMatchingNotes": "Aucune note ne correspond à ce filtre.",
"kanbanGroupBy": "Regrouper par",
"kanbanUnassigned": "Non classé",
"kanbanAllNotes": "Toutes les notes",
"kanbanSingleColumnHint": "Vos notes sont ici. Pour avoir plusieurs colonnes (À faire, En cours, Terminé), un clic suffit.",
"kanbanAddStatusColumns": "Créer colonnes Statut",
"chooseGroupProperty": "Choisir le champ de regroupement",
"newNoteInColumn": "Nouvelle note",
"propertyTypes": {
"text": "Texte",
"number": "Nombre",
"date": "Date",
"select": "Liste de choix",
"multiselect": "Choix multiples",
"checkbox": "Case oui/non"
},
"wizard": {
"title": "Organiser ce carnet",
"subtitle": "Comme un petit tableur : des infos en plus sur chaque note, sans toucher au texte.",
"stepGoal": "À quoi sert ce carnet ?",
"stepFields": "Quels champs ajouter ?",
"stepView": "Comment afficher vos notes ?",
"fieldsHint": "Un champ = une colonne partagée (statut, date…). Vous pourrez le remplir note par note.",
"kanbanNeedsStatus": "Cochez un champ « liste de choix » (ex. Statut) pour utiliser le Kanban.",
"doneHint": "Vous pourrez remplir les champs plus tard — ou laisser vide.",
"back": "Retour",
"next": "Suivant",
"finish": "Terminer",
"readyToast": "C'est prêt — remplissez les champs quand vous voulez.",
"openFromKanban": "Configurer avec l'assistant",
"goals": {
"tasks": {
"title": "Suivre des tâches",
"desc": "Statut, échéance — idéal pour un Kanban."
},
"learning": {
"title": "Apprendre / réviser",
"desc": "Niveau, date de révision — idéal en galerie."
},
"reading": {
"title": "Lire et classer",
"desc": "Lu ou pas, source — pour articles et références."
},
"simple": {
"title": "Juste organiser",
"desc": "Changer de vue sans champs obligatoires."
}
},
"fields": {
"status": {
"name": "Statut",
"options": "À faire\nEn cours\nTerminé"
},
"dueDate": {
"name": "Échéance"
},
"level": {
"name": "Niveau",
"options": "Débutant\nIntermédiaire\nAvancé"
},
"lastReview": {
"name": "Dernière révision"
},
"read": {
"name": "Lu"
},
"source": {
"name": "Source"
}
}
}
},
"brainstorm": {
"title": "Vagues de pensée",
@@ -3152,4 +3315,4 @@
"uploadFailed": "Échec du téléchargement",
"uploading": "Téléchargement..."
}
}
}

View File

@@ -0,0 +1,26 @@
-- AiConsentLog (audit RGPD) + consentement persistant sur UserAISettings
CREATE TABLE IF NOT EXISTS "AiConsentLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"consent" BOOLEAN NOT NULL DEFAULT true,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiConsentLog_pkey" PRIMARY KEY ("id")
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AiConsentLog_userId_fkey'
) THEN
ALTER TABLE "AiConsentLog"
ADD CONSTRAINT "AiConsentLog_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
ALTER TABLE "UserAISettings"
ADD COLUMN IF NOT EXISTS "aiProcessingConsent" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "NotebookSchema" (
"id" TEXT NOT NULL,
"notebookId" TEXT NOT NULL,
"viewSettings" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotebookSchema_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotebookProperty" (
"id" TEXT NOT NULL,
"schemaId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"options" TEXT,
"position" INTEGER NOT NULL,
CONSTRAINT "NotebookProperty_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NoteProperty" (
"id" TEXT NOT NULL,
"noteId" TEXT NOT NULL,
"propertyId" TEXT NOT NULL,
"value" TEXT,
CONSTRAINT "NoteProperty_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NotebookSchema_notebookId_key" ON "NotebookSchema"("notebookId");
-- CreateIndex
CREATE INDEX "NotebookProperty_schemaId_position_idx" ON "NotebookProperty"("schemaId", "position");
-- CreateIndex
CREATE INDEX "NoteProperty_noteId_idx" ON "NoteProperty"("noteId");
-- CreateIndex
CREATE INDEX "NoteProperty_propertyId_idx" ON "NoteProperty"("propertyId");
-- CreateIndex
CREATE UNIQUE INDEX "NoteProperty_noteId_propertyId_key" ON "NoteProperty"("noteId", "propertyId");
-- AddForeignKey
ALTER TABLE "NotebookSchema" ADD CONSTRAINT "NotebookSchema_notebookId_fkey" FOREIGN KEY ("notebookId") REFERENCES "Notebook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotebookProperty" ADD CONSTRAINT "NotebookProperty_schemaId_fkey" FOREIGN KEY ("schemaId") REFERENCES "NotebookSchema"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteProperty" ADD CONSTRAINT "NoteProperty_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteProperty" ADD CONSTRAINT "NoteProperty_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "NotebookProperty"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -113,6 +113,7 @@ model Notebook {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workflows Workflow[]
flashcardDeck FlashcardDeck?
schema NotebookSchema?
@@index([userId, order])
@@index([userId])
@@ -198,6 +199,7 @@ model Note {
sourceLiveBlocks LiveBlockRef[] @relation("SourceLiveBlocks")
targetLiveBlocks LiveBlockRef[] @relation("TargetLiveBlocks")
flashcards Flashcard[]
properties NoteProperty[]
@@index([isPinned])
@@index([isArchived])
@@ -859,6 +861,42 @@ model BridgeSuggestion {
@@index([clusterAId, clusterBId])
}
model NotebookSchema {
id String @id @default(cuid())
notebookId String @unique
viewSettings String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
properties NotebookProperty[]
}
model NotebookProperty {
id String @id @default(cuid())
schemaId String
name String
type String
options String?
position Int
schema NotebookSchema @relation(fields: [schemaId], references: [id], onDelete: Cascade)
noteValues NoteProperty[]
@@index([schemaId, position])
}
model NoteProperty {
id String @id @default(cuid())
noteId String
propertyId String
value String?
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
property NotebookProperty @relation(fields: [propertyId], references: [id], onDelete: Cascade)
@@unique([noteId, propertyId])
@@index([noteId])
@@index([propertyId])
}
model FlashcardDeck {
id String @id @default(cuid())
userId String