feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
80
memento-note/app/api/flashcards/[id]/route.ts
Normal file
80
memento-note/app/api/flashcards/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
204
memento-note/app/api/notebooks/[id]/schema/route.ts
Normal file
204
memento-note/app/api/notebooks/[id]/schema/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
105
memento-note/app/api/notes/[id]/properties/route.ts
Normal file
105
memento-note/app/api/notes/[id]/properties/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { GraduationCap, Loader2, Sparkles, X } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
@@ -37,6 +38,20 @@ export function FlashcardGenerateDialog({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [cards, setCards] = useState<PreviewCard[] | null>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!(await requestAiConsent())) return
|
||||
@@ -75,7 +90,9 @@ export function FlashcardGenerateDialog({
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || t('flashcards.saveFailed'))
|
||||
toast.error(
|
||||
(data.errorKey ? t(data.errorKey) : null) || data.error || t('flashcards.saveFailed'),
|
||||
)
|
||||
return
|
||||
}
|
||||
toast.success(t('flashcards.savedCount', { count: data.savedCount }))
|
||||
@@ -98,18 +115,18 @@ export function FlashcardGenerateDialog({
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
if (!open || !mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center overflow-y-auto bg-black/40 backdrop-blur-sm p-4 sm:p-6"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"
|
||||
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[min(90dvh,calc(100dvh-2rem))] my-auto overflow-hidden flex flex-col shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
|
||||
<div className="flex shrink-0 items-center justify-between px-5 py-4 border-b border-border/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap size={18} className="text-brand-accent" />
|
||||
<div>
|
||||
@@ -122,7 +139,7 @@ export function FlashcardGenerateDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain p-5 space-y-5 custom-scrollbar">
|
||||
{!cards ? (
|
||||
<>
|
||||
<div>
|
||||
@@ -185,7 +202,7 @@ export function FlashcardGenerateDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
|
||||
<div className="shrink-0 px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -216,6 +233,7 @@ export function FlashcardGenerateDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
213
memento-note/components/flashcards/retention-curve.tsx
Normal file
213
memento-note/components/flashcards/retention-curve.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WeekData {
|
||||
week: string
|
||||
rate: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface RetentionCurveProps {
|
||||
data: WeekData[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RetentionCurve({ data, className }: RetentionCurveProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
|
||||
// Filtrer les semaines avec données
|
||||
const weeks = data.filter((w) => w.total > 0)
|
||||
|
||||
if (weeks.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-28 border border-dashed border-border/40 rounded-xl bg-muted/10">
|
||||
<p className="text-xs text-concrete/50 italic">Aucune donnée disponible</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Dimensions SVG
|
||||
const width = 500
|
||||
const height = 120
|
||||
const padding = { top: 15, right: 30, left: 30, bottom: 25 }
|
||||
const chartWidth = width - padding.left - padding.right
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
|
||||
// Calculer les coordonnées des points
|
||||
const points = weeks.map((w, i) => {
|
||||
// Si 1 seul point, on le centre
|
||||
const x = weeks.length === 1
|
||||
? padding.left + chartWidth / 2
|
||||
: padding.left + (i * chartWidth) / (weeks.length - 1)
|
||||
|
||||
// Taux entre 0 et 100
|
||||
const val = Math.max(0, Math.min(100, w.rate))
|
||||
const y = padding.top + chartHeight * (1 - val / 100)
|
||||
|
||||
return { x, y, rate: w.rate, week: w.week, total: w.total }
|
||||
})
|
||||
|
||||
// Créer le path SVG pour la ligne
|
||||
let linePath = ''
|
||||
let areaPath = ''
|
||||
|
||||
if (points.length === 1) {
|
||||
// Une seule semaine : ligne pointillée horizontale + point central
|
||||
const yVal = points[0].y
|
||||
linePath = `M ${padding.left} ${yVal} L ${width - padding.right} ${yVal}`
|
||||
} else {
|
||||
// Plusieurs semaines : tracer la courbe ligne
|
||||
linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||
// Area fermée pour le dégradé sous la courbe
|
||||
areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`
|
||||
}
|
||||
|
||||
const activePoint = hoveredIndex !== null ? points[hoveredIndex] : null
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-full select-none', className)}>
|
||||
{/* SVG Container */}
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
{/* Dégradé sous la courbe */}
|
||||
<linearGradient id="retentionGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Lueur d'accent */}
|
||||
<filter id="accentGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Lignes de guide d'arrière-plan (Grid) */}
|
||||
<g stroke="currentColor" strokeOpacity={0.05} strokeWidth={1}>
|
||||
<line x1={padding.left} y1={padding.top} x2={width - padding.right} y2={padding.top} />
|
||||
<line x1={padding.left} y1={padding.top + chartHeight / 2} x2={width - padding.right} y2={padding.top + chartHeight / 2} />
|
||||
<line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
|
||||
</g>
|
||||
|
||||
{/* Tracé Dégradé Rempli (uniquement si plusieurs points) */}
|
||||
{points.length > 1 && areaPath && (
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="url(#retentionGrad)"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ligne principale de la courbe */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="var(--color-brand-accent, #6366f1)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={points.length === 1 ? '4 4' : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Guide vertical au survol */}
|
||||
{activePoint && (
|
||||
<line
|
||||
x1={activePoint.x}
|
||||
y1={padding.top}
|
||||
x2={activePoint.x}
|
||||
y2={height - padding.bottom}
|
||||
stroke="var(--color-brand-accent, #6366f1)"
|
||||
strokeOpacity={0.25}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points et zones interactives */}
|
||||
{points.map((p, idx) => {
|
||||
const isHovered = hoveredIndex === idx
|
||||
return (
|
||||
<g key={idx} className="cursor-pointer">
|
||||
{/* Cercle extérieur (ombre / lueur d'accent si survol) */}
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHovered ? 8 : 4}
|
||||
fill="var(--color-brand-accent, #6366f1)"
|
||||
fillOpacity={isHovered ? 0.3 : 0.15}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
{/* Cercle intérieur (le point lui-même) */}
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHovered ? 4.5 : 3.5}
|
||||
fill="var(--color-brand-accent, #6366f1)"
|
||||
stroke={isHovered ? '#fff' : 'none'}
|
||||
strokeWidth={1}
|
||||
className="transition-all duration-200"
|
||||
filter={isHovered ? 'url(#accentGlow)' : undefined}
|
||||
/>
|
||||
|
||||
{/* Zone invisible large pour faciliter le survol souris/toucher */}
|
||||
<rect
|
||||
x={p.x - 20}
|
||||
y={padding.top}
|
||||
width={40}
|
||||
height={chartHeight}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHoveredIndex(idx)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Labels des semaines sous le graphe (X-Axis) */}
|
||||
{points.map((p, idx) => {
|
||||
const label = p.week.slice(5) // MM-DD
|
||||
const isHovered = hoveredIndex === idx
|
||||
// Pour éviter la surcharge visuelle, on n'affiche pas tous les labels si trop nombreux, sauf si survolé
|
||||
const shouldShowLabel = points.length <= 8 || idx === 0 || idx === points.length - 1 || isHovered
|
||||
|
||||
if (!shouldShowLabel) return null
|
||||
|
||||
return (
|
||||
<text
|
||||
key={idx}
|
||||
x={p.x}
|
||||
y={height - 8}
|
||||
textAnchor="middle"
|
||||
className={cn(
|
||||
"text-[9px] font-mono fill-concrete/60 select-none transition-colors",
|
||||
isHovered && "fill-brand-accent font-bold"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Floating Info Tooltip */}
|
||||
<div className="absolute right-0 top-0 h-4 flex items-center">
|
||||
{activePoint ? (
|
||||
<div className="text-[10px] text-foreground bg-card border border-border px-2 py-0.5 rounded-lg shadow-sm font-mono flex items-center gap-2 animate-fadeIn">
|
||||
<span className="font-bold text-brand-accent">{activePoint.rate}% de succès</span>
|
||||
<span className="text-concrete/60">({activePoint.total} révs)</span>
|
||||
<span className="text-concrete/40">· sem. {activePoint.week.slice(5)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[9px] text-concrete/40 italic">Survolez un point pour les détails</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
@@ -15,7 +15,7 @@ interface RevisionHeatmapProps {
|
||||
}
|
||||
|
||||
function intensityClass(count: number, max: number): string {
|
||||
if (count <= 0) return 'bg-black/[0.04] dark:bg-white/[0.06]'
|
||||
if (count <= 0) return 'bg-black/[0.06] dark:bg-white/[0.08]'
|
||||
const ratio = count / Math.max(max, 1)
|
||||
if (ratio >= 0.75) return 'bg-brand-accent'
|
||||
if (ratio >= 0.5) return 'bg-brand-accent/70'
|
||||
@@ -23,47 +23,159 @@ function intensityClass(count: number, max: number): string {
|
||||
return 'bg-brand-accent/20'
|
||||
}
|
||||
|
||||
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
const { t } = useLanguage()
|
||||
function resolveDateLocale(langCode: string): string {
|
||||
if (langCode === 'fa') return 'fa-IR-u-ca-persian-nu-arabext'
|
||||
return langCode
|
||||
}
|
||||
|
||||
const { cells, maxCount } = useMemo(() => {
|
||||
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
|
||||
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
|
||||
|
||||
const dateLocale = resolveDateLocale(language ?? 'en')
|
||||
|
||||
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
|
||||
const map = new Map(data.map((d) => [d.date, d.count]))
|
||||
const today = new Date()
|
||||
const now = new Date()
|
||||
// todayUTC est le début de la journée courante à minuit UTC
|
||||
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||
|
||||
const cells: { date: string; count: number; label: string }[] = []
|
||||
const monthLabels: { index: number; label: string }[] = []
|
||||
let lastMonth = -1
|
||||
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() - i)
|
||||
const d = new Date(todayUTC)
|
||||
d.setUTCDate(d.getUTCDate() - i)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
const count = map.get(key) || 0
|
||||
const month = d.getUTCMonth()
|
||||
|
||||
if (month !== lastMonth) {
|
||||
monthLabels.push({
|
||||
index: 89 - i,
|
||||
label: d.toLocaleDateString(dateLocale, { month: 'short', timeZone: 'UTC' }),
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
|
||||
cells.push({
|
||||
date: key,
|
||||
count: map.get(key) || 0,
|
||||
label: d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }),
|
||||
count,
|
||||
label: d.toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }),
|
||||
})
|
||||
}
|
||||
|
||||
const maxCount = Math.max(1, ...cells.map((c) => c.count))
|
||||
return { cells, maxCount }
|
||||
}, [data])
|
||||
const totalReviews = cells.reduce((s, c) => s + c.count, 0)
|
||||
return { cells, maxCount, totalReviews, monthLabels }
|
||||
}, [data, dateLocale])
|
||||
|
||||
const pct = (index: number) => `${(index / 90) * 100}%`
|
||||
|
||||
const activeInfo = hovered || selected
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
{t('flashcards.heatmapTitle')}
|
||||
</p>
|
||||
<span className="text-[10px] text-concrete/60">{t('flashcards.heatmapLast90')}</span>
|
||||
<span className="text-[10px] text-concrete/60">
|
||||
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.date}
|
||||
title={`${cell.label}: ${cell.count}`}
|
||||
className={cn(
|
||||
'aspect-square rounded-[3px] transition-colors',
|
||||
intensityClass(cell.count, maxCount),
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Labels de mois au-dessus de la grille */}
|
||||
<div className="relative h-4">
|
||||
{monthLabels.map((m) => (
|
||||
<span
|
||||
key={m.label + m.index}
|
||||
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
|
||||
style={{ left: pct(m.index) }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille pleine largeur */}
|
||||
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
|
||||
{cells.map((cell) => {
|
||||
const isHovered = hovered?.date === cell.date
|
||||
const isSelected = selected?.date === cell.date
|
||||
const reviewText = cell.count > 0
|
||||
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
|
||||
: 'Aucune révision'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cell.date}
|
||||
type="button"
|
||||
title={`${reviewText} - ${cell.label}`}
|
||||
className={cn(
|
||||
'aspect-square rounded-[3px] transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-1 focus:ring-offset-background',
|
||||
intensityClass(cell.count, maxCount),
|
||||
(isHovered || isSelected) && 'ring-2 ring-brand-accent ring-offset-1 ring-offset-background scale-105 z-10',
|
||||
)}
|
||||
onMouseEnter={() => setHovered({ label: cell.label, count: cell.count, date: cell.date })}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => {
|
||||
if (selected?.date === cell.date) {
|
||||
setSelected(null)
|
||||
} else {
|
||||
setSelected({ label: cell.label, count: cell.count, date: cell.date })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info au survol / clic */}
|
||||
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
|
||||
{activeInfo ? (
|
||||
<p className="flex items-center gap-1.5 animate-fadeIn">
|
||||
<span className="font-semibold text-foreground">
|
||||
{activeInfo.count > 0
|
||||
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
|
||||
: 'Aucune révision'}
|
||||
</span>
|
||||
<span className="text-concrete">· {activeInfo.label}</span>
|
||||
{selected?.date === activeInfo.date && !hovered && (
|
||||
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
|
||||
sélectionné
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-concrete/40 italic">
|
||||
Survolez ou cliquez sur un carré pour voir le détail
|
||||
</p>
|
||||
)}
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
|
||||
>
|
||||
Effacer la sélection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<span className="text-[9px] text-concrete/50">Moins</span>
|
||||
<div className="flex gap-0.5">
|
||||
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
|
||||
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-concrete/50">Plus</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesViewType } from '@/components/notes-list-views'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
NOTES_VIEW_TYPE_STORAGE_KEY,
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
setNotesLayoutPreference,
|
||||
setNotesViewTypePreference,
|
||||
} from '@/lib/notes-view-preference'
|
||||
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
|
||||
import {
|
||||
bootstrapStructuredNotebook,
|
||||
ensureKanbanStatusField,
|
||||
type BootstrapStructuredTarget,
|
||||
} from '@/lib/structured-views/bootstrap-structured-notebook'
|
||||
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3 } from 'lucide-react'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
@@ -53,6 +59,22 @@ const OrganizeNotebookDialog = dynamic(
|
||||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsIntro = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsHelpBanner = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsContainer = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-container').then(m => ({ default: m.StructuredViewsContainer })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const AddPropertyDialog = dynamic(
|
||||
() => import('@/components/structured-views/add-property-dialog').then(m => ({ default: m.AddPropertyDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type InitialSettings = {
|
||||
showRecentNotes: boolean
|
||||
@@ -116,6 +138,29 @@ export function HomeClient({
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const schemaHook = useNotebookSchema(notebookFilter)
|
||||
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
|
||||
const wantsStructuredView = Boolean(
|
||||
notebookFilter && (layoutMode === 'table' || layoutMode === 'kanban'),
|
||||
)
|
||||
const structuredViewMode: BootstrapStructuredTarget =
|
||||
layoutMode === 'kanban' ? 'kanban' : 'table'
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutMode === 'gallery') {
|
||||
setLayoutMode('grid')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notebookFilter && (layoutMode === 'kanban' || layoutMode === 'gallery')) {
|
||||
setLayoutMode('list')
|
||||
}
|
||||
}, [notebookFilter, layoutMode])
|
||||
|
||||
useEffect(() => {
|
||||
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
||||
@@ -141,7 +186,7 @@ export function HomeClient({
|
||||
useEffect(() => {
|
||||
const onLayoutChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
|
||||
if (detail === 'grid' || detail === 'list' || detail === 'table') {
|
||||
if (detail === 'grid' || detail === 'list' || detail === 'table' || detail === 'kanban') {
|
||||
setLayoutMode(detail)
|
||||
setViewType('notes')
|
||||
}
|
||||
@@ -161,8 +206,6 @@ export function HomeClient({
|
||||
}
|
||||
}, [searchParams, router])
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
|
||||
const fetchNotesForCurrentView = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
@@ -305,6 +348,95 @@ export function HomeClient({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNoteWithProperties = (prefill: Record<string, unknown>) => {
|
||||
startCreating(async () => {
|
||||
try {
|
||||
const newNote = await createNote({
|
||||
content: '',
|
||||
type: 'richtext',
|
||||
title: undefined,
|
||||
notebookId: notebookFilter || undefined,
|
||||
skipRevalidation: true,
|
||||
})
|
||||
if (!newNote) return
|
||||
if (Object.keys(prefill).length > 0) {
|
||||
await fetch(`/api/notes/${newNote.id}/properties`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ properties: prefill }),
|
||||
})
|
||||
schemaHook.patchNoteValuesLocal(newNote.id, prefill)
|
||||
}
|
||||
handleNoteCreated(newNote)
|
||||
setEditingNote({ note: newNote, readOnly: false })
|
||||
} catch {
|
||||
toast.error(t('notes.createFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const structuredFieldLabels = useMemo(
|
||||
() => ({
|
||||
statusName: t('structuredViews.wizard.fields.status.name'),
|
||||
statusOptions: t('structuredViews.wizard.fields.status.options')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
[t],
|
||||
)
|
||||
|
||||
const structuredBootstrapActions = useMemo(
|
||||
() => ({
|
||||
getSchema: () => schemaHook.schema,
|
||||
enableStructuredMode: schemaHook.enableStructuredMode,
|
||||
addProperty: schemaHook.addProperty,
|
||||
setKanbanGroupProperty: schemaHook.setKanbanGroupProperty,
|
||||
}),
|
||||
[schemaHook],
|
||||
)
|
||||
|
||||
const handleEnableStructured = useCallback(
|
||||
async (target: BootstrapStructuredTarget) => {
|
||||
if (!notebookFilter) return
|
||||
setIsEnablingStructured(true)
|
||||
try {
|
||||
await bootstrapStructuredNotebook(target, structuredFieldLabels, structuredBootstrapActions)
|
||||
await schemaHook.reload()
|
||||
setLayoutMode(target)
|
||||
toast.success(t('structuredViews.intro.enabledSuccess'))
|
||||
} catch {
|
||||
toast.error(t('structuredViews.enableFailed'))
|
||||
} finally {
|
||||
setIsEnablingStructured(false)
|
||||
}
|
||||
},
|
||||
[notebookFilter, structuredFieldLabels, structuredBootstrapActions, schemaHook, t],
|
||||
)
|
||||
|
||||
const handleQuickAddKanbanStatus = useCallback(async () => {
|
||||
try {
|
||||
await ensureKanbanStatusField(structuredFieldLabels, structuredBootstrapActions)
|
||||
await schemaHook.reload()
|
||||
} catch {
|
||||
toast.error(t('structuredViews.enableFailed'))
|
||||
}
|
||||
}, [structuredFieldLabels, structuredBootstrapActions, schemaHook, t])
|
||||
|
||||
const selectLayoutMode = useCallback((mode: NotesLayoutMode) => {
|
||||
if (mode === 'gallery') return
|
||||
setLayoutMode(mode)
|
||||
setViewType('notes')
|
||||
}, [])
|
||||
|
||||
const showStructuredIntro =
|
||||
wantsStructuredView && !structuredModeActive && !schemaHook.loading
|
||||
const showStructuredDataView = wantsStructuredView && structuredModeActive && schemaHook.schema
|
||||
const showStructuredLoading = wantsStructuredView && schemaHook.loading
|
||||
|
||||
const classicLayoutMode: NotesClassicLayoutMode =
|
||||
isClassicLayoutMode(layoutMode) ? layoutMode : 'list'
|
||||
|
||||
const handleOpenHistory = useCallback((note: Note) => {
|
||||
setHistoryNote(note)
|
||||
setHistoryOpen(true)
|
||||
@@ -840,7 +972,7 @@ export function HomeClient({
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('grid')}
|
||||
onClick={() => selectLayoutMode('grid')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'grid'
|
||||
@@ -853,7 +985,7 @@ export function HomeClient({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('list')}
|
||||
onClick={() => selectLayoutMode('list')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'list'
|
||||
@@ -864,22 +996,66 @@ export function HomeClient({
|
||||
>
|
||||
<List size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutTableTitle')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
{!notebookFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutTableTitle')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
)}
|
||||
{notebookFilter && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-border/50 mx-0.5" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('structuredViews.viewTableHint')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('kanban')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'kanban'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('structuredViews.viewKanbanHint')}
|
||||
>
|
||||
<Columns3 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewType === 'notes' && currentNotebook && structuredModeActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddPropertyOpen(true)}
|
||||
className="p-1.5 rounded-full text-muted-foreground hover:text-brand-accent transition-colors"
|
||||
title={t('structuredViews.addProperty')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{searchParams.get('notebook') && (
|
||||
<button
|
||||
onClick={() => setSummaryDialogOpen(true)}
|
||||
@@ -986,6 +1162,34 @@ export function HomeClient({
|
||||
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : showStructuredLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : showStructuredIntro ? (
|
||||
<StructuredViewsIntro
|
||||
target={structuredViewMode}
|
||||
enabling={isEnablingStructured}
|
||||
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
||||
/>
|
||||
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
|
||||
<>
|
||||
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
|
||||
<StructuredViewsContainer
|
||||
mode={structuredViewMode}
|
||||
notes={sortedNotes}
|
||||
schema={schemaHook.schema}
|
||||
noteValues={schemaHook.noteValues}
|
||||
notebookColor={currentNotebook?.color}
|
||||
onOpen={(note) => handleOpenNoteFresh(note.id, false)}
|
||||
onNoteValuesPatch={schemaHook.patchNoteValuesLocal}
|
||||
onCreateNote={handleAddNoteWithProperties}
|
||||
onSetKanbanGroupProperty={(id) => void schemaHook.setKanbanGroupProperty(id)}
|
||||
onQuickAddKanbanStatus={() => void handleQuickAddKanbanStatus()}
|
||||
onDeleteProperty={async (propertyId) => {
|
||||
await schemaHook.deleteProperty(propertyId)
|
||||
toast.success(t('structuredViews.deletePropertySuccess'))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<p className="font-memento-serif text-xl italic text-muted-foreground">
|
||||
@@ -1004,7 +1208,7 @@ export function HomeClient({
|
||||
notes={sortedNotes}
|
||||
pinnedNotes={sortedPinnedNotes}
|
||||
viewType={viewType}
|
||||
layoutMode={layoutMode}
|
||||
layoutMode={classicLayoutMode}
|
||||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
notebookName={currentNotebook?.name}
|
||||
@@ -1090,6 +1294,15 @@ export function HomeClient({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{notebookFilter && schemaHook.schema && (
|
||||
<AddPropertyDialog
|
||||
open={addPropertyOpen}
|
||||
onClose={() => setAddPropertyOpen(false)}
|
||||
onSubmit={async (name, type, options) => {
|
||||
await schemaHook.addProperty(name, type, options)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X, ShieldAlert, Check } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
@@ -15,12 +16,27 @@ interface AiConsentModalProps {
|
||||
export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
return (
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
|
||||
{/* Glassmorphism Backdrop */}
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 sm:p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -29,51 +45,51 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.4 }}
|
||||
className={cn(
|
||||
"relative w-full max-w-lg overflow-hidden border border-[var(--border)]",
|
||||
"bg-[var(--memento-paper)] text-[var(--ink)] shadow-2xl rounded-lg p-6",
|
||||
"flex flex-col gap-5"
|
||||
'relative w-full max-w-lg overflow-hidden rounded-2xl border border-border',
|
||||
'bg-memento-paper dark:bg-background text-foreground shadow-2xl p-6',
|
||||
'flex flex-col gap-5',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1 rounded-md text-[var(--ink)]/60 hover:text-[var(--ink)] hover:bg-[var(--concrete)] transition-colors"
|
||||
className="absolute top-4 right-4 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-[var(--accent-tint)] text-[var(--accent-color)] rounded-lg">
|
||||
<BrainIcon className="w-6 h-6 animate-pulse" />
|
||||
<div className="p-3 bg-brand-accent/10 text-brand-accent rounded-xl">
|
||||
<BrainIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pr-6">
|
||||
<div className="flex flex-col gap-1 pr-8">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{t('consent.ai.modalTitle') || 'Consentement requis pour le traitement par IA'}
|
||||
</h3>
|
||||
<span className="text-xs uppercase tracking-wider text-[var(--ink)]/50 font-medium">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{t('consent.ai.complianceBadge') || 'Conformité RGPD'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-sm leading-relaxed text-[var(--ink)]/80 flex flex-col gap-3">
|
||||
<p>
|
||||
<div className="text-sm leading-relaxed text-muted-foreground flex flex-col gap-3">
|
||||
<p className="text-foreground/90">
|
||||
{t('consent.ai.modalDescription') ||
|
||||
"Pour analyser vos notes, PDFs ou sessions de remue-méninges, Memento transmet de manière sécurisée ces données à des API d'IA tierces (OpenAI, Gemini, DeepSeek). Nous appliquons une politique de rétention de données nulle. En acceptant, vous autorisez ce traitement."}
|
||||
</p>
|
||||
<div className="p-3 bg-[var(--concrete)] border border-[var(--border)] rounded-md text-xs flex gap-3 items-start">
|
||||
<ShieldAlert className="w-4 h-4 text-[var(--accent-color)] shrink-0 mt-0.5" />
|
||||
<div className="p-3 bg-black/[0.03] dark:bg-white/[0.04] border border-border rounded-xl text-xs flex gap-3 items-start">
|
||||
<ShieldAlert className="w-4 h-4 text-brand-accent shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}
|
||||
</span>
|
||||
<span>
|
||||
{t('consent.ai.zeroRetentionDesc') ||
|
||||
'Toutes les requêtes sortantes incluent des indicateurs de non-apprentissage pour protéger votre propriété intellectuelle.'}
|
||||
@@ -82,7 +98,6 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -93,35 +108,30 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border border-[var(--border)] transition-colors flex items-center justify-center",
|
||||
remember ? "bg-[var(--accent-color)] border-[var(--accent-color)]" : "bg-transparent"
|
||||
'w-5 h-5 rounded border border-border transition-colors flex items-center justify-center',
|
||||
remember ? 'bg-brand-accent border-brand-accent' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{remember && <Check className="w-3.5 h-3.5 text-white stroke-[3px]" />}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-[var(--ink)]/80">
|
||||
<span className="text-xs font-medium text-foreground/80">
|
||||
{t('consent.ai.rememberMe') || 'Se souvenir de mon choix (ne plus demander)'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 mt-2">
|
||||
<div className="flex items-center justify-end gap-3 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-semibold rounded-md border border-[var(--border)]",
|
||||
"hover:bg-[var(--concrete)] transition-colors bg-transparent text-[var(--ink)]"
|
||||
)}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-lg border border-border hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors bg-transparent text-foreground"
|
||||
>
|
||||
{t('consent.ai.rejectButton') || 'Refuser'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(remember)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-semibold rounded-md text-white shadow-sm transition-opacity hover:opacity-90",
|
||||
"bg-[var(--accent-color)] flex items-center gap-2"
|
||||
)}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-lg text-white shadow-sm transition-opacity hover:opacity-90 bg-brand-accent flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{t('consent.ai.acceptButton') || 'Autoriser et continuer'}
|
||||
@@ -130,7 +140,8 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,17 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
|
||||
const handleConfirm = async (remember: boolean) => {
|
||||
setModalOpen(false)
|
||||
|
||||
const grantConsentLocally = async () => {
|
||||
if (remember) {
|
||||
setLocalStorageAiConsent(true)
|
||||
setPersistentConsent(true)
|
||||
await updateAISettings({ aiProcessingConsent: true })
|
||||
} else {
|
||||
await updateSession({ aiSessionConsent: true })
|
||||
setSessionConsent(true)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/ai-consent', {
|
||||
method: 'POST',
|
||||
@@ -76,32 +87,48 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
|
||||
body: JSON.stringify({ consent: true, remember }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(t('consent.ai.auditFailed') || 'Impossible d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
pendingResolveRef.current = null
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (data?.auditLogged === false) {
|
||||
console.warn('[AiConsentProvider] Consent saved without audit trail')
|
||||
}
|
||||
|
||||
if (remember) {
|
||||
setLocalStorageAiConsent(true)
|
||||
setPersistentConsent(true)
|
||||
try {
|
||||
await updateAISettings({ aiProcessingConsent: true })
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
|
||||
if (remember) {
|
||||
setLocalStorageAiConsent(true)
|
||||
setPersistentConsent(true)
|
||||
try {
|
||||
await updateAISettings({ aiProcessingConsent: true })
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
|
||||
}
|
||||
} else {
|
||||
await updateSession({ aiSessionConsent: true })
|
||||
setSessionConsent(true)
|
||||
}
|
||||
} else {
|
||||
await updateSession({ aiSessionConsent: true })
|
||||
setSessionConsent(true)
|
||||
try {
|
||||
await grantConsentLocally()
|
||||
} catch (fallbackError) {
|
||||
console.error('[AiConsentProvider] Consent fallback failed:', fallbackError)
|
||||
toast.error(t('consent.ai.auditFailed') || 'Impossible d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
pendingResolveRef.current = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pendingResolveRef.current?.(true)
|
||||
pendingResolveRef.current = null
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to log consent audit:', e)
|
||||
toast.error(t('consent.ai.auditFailed') || 'Impossible d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
try {
|
||||
await grantConsentLocally()
|
||||
pendingResolveRef.current?.(true)
|
||||
} catch (fallbackError) {
|
||||
console.error('[AiConsentProvider] Consent fallback failed:', fallbackError)
|
||||
toast.error(t('consent.ai.auditFailed') || 'Impossible d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
}
|
||||
pendingResolveRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteHistoryModal } from './note-history-modal'
|
||||
import { NoteNetworkTab } from './note-network-tab'
|
||||
import { NoteEditorPropertiesPanel } from './structured-views/note-editor-properties-panel'
|
||||
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
@@ -308,6 +309,13 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<NoteEditorPropertiesPanel
|
||||
noteId={note.id}
|
||||
notebookId={note.notebookId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -273,7 +273,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title || 'Untitled'}
|
||||
onSaved={(deckId) => {
|
||||
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
|
||||
toast.success(t('flashcards.savedCount', { count: '' }).replace('{count}', ''), {
|
||||
description: t('flashcards.reviewNow') || 'Review now',
|
||||
action: {
|
||||
label: t('flashcards.reviewNow') || 'Review now →',
|
||||
onClick: () => {
|
||||
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
|
||||
},
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -54,7 +54,12 @@ import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
|
||||
export type NotesLayoutMode = 'grid' | 'list' | 'table'
|
||||
export type NotesLayoutMode = 'grid' | 'list' | 'table' | 'kanban' | 'gallery'
|
||||
export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
|
||||
|
||||
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
|
||||
return mode === 'grid' || mode === 'list' || mode === 'table'
|
||||
}
|
||||
export type NotesViewType = 'notes' | 'tasks'
|
||||
|
||||
type TaskItem = {
|
||||
@@ -741,7 +746,7 @@ function NotesGridSection({
|
||||
const ids = useMemo(() => notes.map((n) => n.id), [notes])
|
||||
|
||||
const grid = (
|
||||
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6', className)}>
|
||||
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-stretch', className)}>
|
||||
{notes.map((note, index) =>
|
||||
sortEnabled ? (
|
||||
<SortableGridCard
|
||||
@@ -804,7 +809,7 @@ function SortableGridCard(props: GridCardSharedProps) {
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
'touch-none cursor-grab active:cursor-grabbing',
|
||||
'touch-none cursor-grab active:cursor-grabbing h-full',
|
||||
isDragging && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
@@ -856,9 +861,9 @@ function GridCard({
|
||||
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
onClick={() => onOpen(note)}
|
||||
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative"
|
||||
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full"
|
||||
>
|
||||
<div className="aspect-[16/10] bg-muted/30 border-b border-border/20 overflow-hidden relative">
|
||||
<div className="aspect-[16/10] shrink-0 bg-muted/30 border-b border-border/20 overflow-hidden relative">
|
||||
<NoteGridThumbnail
|
||||
note={note}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
@@ -875,17 +880,19 @@ function GridCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2.5">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
|
||||
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug line-clamp-2 group-hover/card:text-brand-accent transition-colors">
|
||||
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
|
||||
<div className="space-y-2.5 flex-1">
|
||||
<div className="min-h-[1.125rem]">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
|
||||
</div>
|
||||
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug truncate group-hover/card:text-brand-accent transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{excerpt && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light">{excerpt}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light min-h-[3.75rem]">
|
||||
{excerpt || '\u00A0'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
<div className="flex items-center justify-between pt-3 mt-auto border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
<span>{formattedDate}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronRight,
|
||||
Lock,
|
||||
BookOpen,
|
||||
BookMarked,
|
||||
Bot,
|
||||
Inbox,
|
||||
FlaskConical,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
PinOff,
|
||||
Sparkles,
|
||||
Home,
|
||||
Network,
|
||||
Search,
|
||||
GraduationCap,
|
||||
FileText,
|
||||
@@ -474,6 +474,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
|
||||
const [trashCount, setTrashCount] = useState(0)
|
||||
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
@@ -494,6 +495,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
return map
|
||||
}, [orderedNotebooks])
|
||||
|
||||
const filteredNotebookIds = useMemo(() => {
|
||||
const q = notebookSearchQuery.trim().toLowerCase()
|
||||
if (!q) return null
|
||||
return new Set(
|
||||
notebooks
|
||||
.filter(
|
||||
(nb) =>
|
||||
nb.name.toLowerCase().includes(q) ||
|
||||
(notebookNotes[nb.id] || []).some((n) => n.title.toLowerCase().includes(q)),
|
||||
)
|
||||
.map((nb) => nb.id),
|
||||
)
|
||||
}, [notebooks, notebookNotes, notebookSearchQuery])
|
||||
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
const currentNoteId = searchParams.get('openNote')
|
||||
|
||||
@@ -793,9 +808,10 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
}, [deletingNotebook, trashNotebook, currentNotebookId, router])
|
||||
|
||||
const renderCarnetTree = useCallback((parentId: string | undefined, level: number): React.ReactNode => {
|
||||
const items = parentId === undefined
|
||||
const items = (parentId === undefined
|
||||
? rootNotebooks
|
||||
: (childNotebooks.get(parentId) || [])
|
||||
).filter((notebook) => !filteredNotebookIds || filteredNotebookIds.has(notebook.id))
|
||||
|
||||
return items.map((notebook: Notebook) => {
|
||||
const isActive = currentNotebookId === notebook.id
|
||||
@@ -885,7 +901,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
}, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -957,7 +973,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{([
|
||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
||||
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
||||
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
|
||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||
@@ -1086,95 +1101,135 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
{/* Section header with sort button */}
|
||||
<div className="flex items-center justify-between px-4 mb-3">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
|
||||
{t('nav.notebooks')}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
|
||||
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
|
||||
title={t('notebook.create')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSortMenu(s => !s)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||
title={t('sidebar.sortOrder')}
|
||||
>
|
||||
<ArrowUpDown size={12} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showSortMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
>
|
||||
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map(order => (
|
||||
<button
|
||||
key={order}
|
||||
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
||||
className={cn(
|
||||
'w-full text-start px-4 py-2 text-[12px] transition-colors',
|
||||
sortOrder === order
|
||||
? 'font-bold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{sortLabels[order]}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('sidebar.documents')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateParentId(null)
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
||||
title={t('notebook.create')}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSortMenu((s) => !s)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
||||
title={t('sidebar.sortOrder')}
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showSortMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
>
|
||||
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map((order) => (
|
||||
<button
|
||||
key={order}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSortOrder(order)
|
||||
setShowSortMenu(false)
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-start px-4 py-2 text-[12px] transition-colors',
|
||||
sortOrder === order
|
||||
? 'font-bold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
{sortLabels[order]}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={notebookSearchQuery}
|
||||
onChange={(e) => setNotebookSearchQuery(e.target.value)}
|
||||
placeholder={t('sidebar.searchNotebooksPlaceholder')}
|
||||
className="w-full text-[11px] ps-7 pe-8 py-1.5 rounded-lg border border-border/60 bg-white/70 dark:bg-zinc-800 placeholder-concrete/50 outline-none focus:border-brand-accent transition-colors text-ink dark:text-dark-ink"
|
||||
/>
|
||||
<Search
|
||||
size={11}
|
||||
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete opacity-60 pointer-events-none"
|
||||
/>
|
||||
{notebookSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotebookSearchQuery('')}
|
||||
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-[9px] uppercase font-bold text-concrete hover:text-ink"
|
||||
aria-label={t('sidebar.clearSearch')}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inbox — Notes without notebook */}
|
||||
<button
|
||||
onClick={handleInboxClick}
|
||||
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isInboxActive
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20'
|
||||
)}>
|
||||
<Inbox size={14} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[13px] font-medium truncate',
|
||||
isInboxActive ? 'text-ink' : 'text-muted-ink'
|
||||
)}>
|
||||
{t('sidebar.inbox')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-4 my-3 h-px bg-border/40" />
|
||||
|
||||
{/* Notebooks list — draggable */}
|
||||
<div
|
||||
className="space-y-0.5 min-h-[60px]"
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderCarnetTree(undefined, 0)}
|
||||
{draggedId && (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInboxClick}
|
||||
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
|
||||
>
|
||||
<div
|
||||
className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50"
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isInboxActive
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20',
|
||||
)}
|
||||
>
|
||||
{t('sidebar.dropToRoot')}
|
||||
<Inbox size={14} />
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-medium truncate',
|
||||
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
||||
)}
|
||||
>
|
||||
{t('sidebar.inbox')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-3 h-px bg-border/40" />
|
||||
|
||||
<div
|
||||
className="space-y-0.5 min-h-[60px]"
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderCarnetTree(undefined, 0)}
|
||||
{draggedId && (
|
||||
<div className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50">
|
||||
{t('sidebar.dropToRoot')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'reminders' ? (
|
||||
|
||||
128
memento-note/components/structured-views/add-property-dialog.tsx
Normal file
128
memento-note/components/structured-views/add-property-dialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { PropertyType } from '@/lib/structured-views/types'
|
||||
import { PROPERTY_TYPES } from '@/lib/structured-views/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AddPropertyDialogProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (name: string, type: PropertyType, options: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function AddPropertyDialog({ open, onClose, onSubmit }: AddPropertyDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<PropertyType>('text')
|
||||
const [optionsText, setOptionsText] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const needsOptions = type === 'select' || type === 'multiselect'
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const options = needsOptions
|
||||
? optionsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
: []
|
||||
if (needsOptions && options.length === 0) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSubmit(trimmed, type, options)
|
||||
setName('')
|
||||
setType('text')
|
||||
setOptionsText('')
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal
|
||||
className="w-full max-w-md rounded-2xl border border-border bg-memento-paper shadow-xl p-6 space-y-5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-memento-serif text-lg">{t('structuredViews.addPropertyTitle')}</h2>
|
||||
<button type="button" onClick={onClose} className="p-1 rounded-lg hover:bg-foreground/5">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground rounded-lg bg-foreground/[0.03] px-3 py-2">
|
||||
{t('structuredViews.addPropertyHint')}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.propertyName')}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.propertyType')}
|
||||
</label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PROPERTY_TYPES.map((pt) => (
|
||||
<button
|
||||
key={pt}
|
||||
type="button"
|
||||
onClick={() => setType(pt)}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border transition-colors',
|
||||
type === pt
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-border text-muted-foreground hover:border-foreground/30',
|
||||
)}
|
||||
>
|
||||
{t(`structuredViews.propertyTypes.${pt}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsOptions && (
|
||||
<div>
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.selectOptions')}
|
||||
</label>
|
||||
<textarea
|
||||
value={optionsText}
|
||||
onChange={(e) => setOptionsText(e.target.value)}
|
||||
placeholder={t('structuredViews.selectOptionsPlaceholder')}
|
||||
rows={4}
|
||||
className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || !name.trim()}>
|
||||
{t('structuredViews.addProperty')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
|
||||
import { NotePropertiesSection } from './note-properties-section'
|
||||
import { AddPropertyDialog } from './add-property-dialog'
|
||||
|
||||
type NoteEditorPropertiesPanelProps = {
|
||||
noteId: string
|
||||
notebookId: string | null | undefined
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function NoteEditorPropertiesPanel({
|
||||
noteId,
|
||||
notebookId,
|
||||
readOnly,
|
||||
}: NoteEditorPropertiesPanelProps) {
|
||||
const [schema, setSchema] = useState<NotebookSchemaPayload | null>(null)
|
||||
const [values, setValues] = useState<NotePropertyValues>({})
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!notebookId) {
|
||||
setSchema(null)
|
||||
setValues({})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const [schemaRes, valuesRes] = await Promise.all([
|
||||
fetch(`/api/notebooks/${notebookId}/schema`),
|
||||
fetch(`/api/notes/${noteId}/properties`),
|
||||
])
|
||||
const schemaJson = await schemaRes.json()
|
||||
const valuesJson = await valuesRes.json()
|
||||
if (cancelled) return
|
||||
setSchema(schemaJson.success ? schemaJson.data.schema : null)
|
||||
setValues(valuesJson.success ? valuesJson.data.values ?? {} : {})
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSchema(null)
|
||||
setValues({})
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [notebookId, noteId])
|
||||
|
||||
if (!notebookId || !schema) return null
|
||||
|
||||
const handleAddProperty = async (name: string, type: string, options?: string[]) => {
|
||||
const res = await fetch(`/api/notebooks/${notebookId}/schema`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'addProperty', name, type, options }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.success) setSchema(json.data.schema)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotePropertiesSection
|
||||
noteId={noteId}
|
||||
schema={schema}
|
||||
initialValues={values}
|
||||
readOnly={readOnly}
|
||||
onAddProperty={() => setAddOpen(true)}
|
||||
onValuesChange={setValues}
|
||||
/>
|
||||
<AddPropertyDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSubmit={handleAddProperty}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
|
||||
import { PropertyValueEditor, useDebouncedPropertySave } from './property-value-editor'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { MAX_PROPERTIES_PER_NOTEBOOK } from '@/lib/structured-views/types'
|
||||
|
||||
type NotePropertiesSectionProps = {
|
||||
noteId: string
|
||||
schema: NotebookSchemaPayload
|
||||
initialValues?: NotePropertyValues
|
||||
onAddProperty?: () => void
|
||||
onValuesChange?: (values: NotePropertyValues) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function NotePropertiesSection({
|
||||
noteId,
|
||||
schema,
|
||||
initialValues = {},
|
||||
onAddProperty,
|
||||
onValuesChange,
|
||||
readOnly,
|
||||
}: NotePropertiesSectionProps) {
|
||||
const { t } = useLanguage()
|
||||
const [values, setValues] = useState<NotePropertyValues>(initialValues)
|
||||
|
||||
useEffect(() => {
|
||||
setValues(initialValues)
|
||||
}, [noteId, initialValues])
|
||||
|
||||
const queueSave = useDebouncedPropertySave(noteId, (saved) => {
|
||||
setValues((v) => ({ ...v, ...saved }))
|
||||
onValuesChange?.({ ...values, ...saved })
|
||||
})
|
||||
|
||||
const handleChange = (propertyId: string, value: unknown) => {
|
||||
setValues((prev) => {
|
||||
const next = { ...prev, [propertyId]: value }
|
||||
onValuesChange?.(next)
|
||||
return next
|
||||
})
|
||||
if (!readOnly) queueSave(propertyId, value)
|
||||
}
|
||||
|
||||
if (schema.properties.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2 pt-4 border-t border-border/30">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.propertiesSection')}
|
||||
</p>
|
||||
<p className="text-[12px] text-muted-foreground">{t('structuredViews.noPropertiesYet')}</p>
|
||||
{onAddProperty && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddProperty}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-accent hover:underline"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('structuredViews.addProperty')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-4 border-t border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.propertiesSection')}
|
||||
</p>
|
||||
{onAddProperty && !readOnly && schema.properties.length < MAX_PROPERTIES_PER_NOTEBOOK && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddProperty}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-brand-accent hover:underline"
|
||||
>
|
||||
<Plus size={10} />
|
||||
{t('structuredViews.addProperty')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{schema.properties.map((prop) => (
|
||||
<div key={prop.id} className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">{prop.name}</label>
|
||||
{readOnly ? (
|
||||
<p className="text-[13px]">{String(values[prop.id] ?? '—')}</p>
|
||||
) : (
|
||||
<PropertyValueEditor
|
||||
property={prop}
|
||||
value={values[prop.id]}
|
||||
onChange={(v) => handleChange(prop.id, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchemaPropertyList({
|
||||
schema,
|
||||
onDeleteProperty,
|
||||
}: {
|
||||
schema: NotebookSchemaPayload
|
||||
onDeleteProperty?: (id: string) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{schema.properties.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between text-[12px] py-1">
|
||||
<span>{p.name}</span>
|
||||
<span className="text-muted-foreground text-[10px] uppercase">{t(`structuredViews.propertyTypes.${p.type}`)}</span>
|
||||
{onDeleteProperty && (
|
||||
<button type="button" onClick={() => onDeleteProperty(p.id)} className="p-1 text-muted-foreground hover:text-red-500">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
129
memento-note/components/structured-views/notes-gallery-view.tsx
Normal file
129
memento-note/components/structured-views/notes-gallery-view.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Note } from '@/lib/types'
|
||||
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
|
||||
import { formatPropertyDisplay } from '@/lib/structured-views/property-utils'
|
||||
import { getNoteDisplayTitle, getNoteFeedImage } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type NotesGalleryViewProps = {
|
||||
notes: Note[]
|
||||
schema: NotebookSchemaPayload
|
||||
noteValues: Record<string, NotePropertyValues>
|
||||
notebookColor?: string | null
|
||||
onOpen: (note: Note) => void
|
||||
}
|
||||
|
||||
export function NotesGalleryView({
|
||||
notes,
|
||||
schema,
|
||||
noteValues,
|
||||
notebookColor,
|
||||
onOpen,
|
||||
}: NotesGalleryViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const untitled = t('notes.untitled')
|
||||
const previewProps = schema.properties.slice(0, 2)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 max-w-7xl mx-auto">
|
||||
{notes.map((note) => (
|
||||
<GalleryCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
title={getNoteDisplayTitle(note, untitled)}
|
||||
image={getNoteFeedImage(note)}
|
||||
notebookColor={notebookColor}
|
||||
previewProps={previewProps}
|
||||
allProps={schema.properties}
|
||||
values={noteValues[note.id] ?? {}}
|
||||
onOpen={() => onOpen(note)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GalleryCard({
|
||||
note,
|
||||
title,
|
||||
image,
|
||||
notebookColor,
|
||||
previewProps,
|
||||
allProps,
|
||||
values,
|
||||
onOpen,
|
||||
}: {
|
||||
note: Note
|
||||
title: string
|
||||
image: string | null
|
||||
notebookColor?: string | null
|
||||
previewProps: NotebookSchemaPayload['properties']
|
||||
allProps: NotebookSchemaPayload['properties']
|
||||
values: NotePropertyValues
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const accent = notebookColor || '#A47148'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className="text-left rounded-2xl border border-border/40 bg-card/40 overflow-hidden shadow-sm hover:shadow-md hover:border-border transition-all group"
|
||||
>
|
||||
<div
|
||||
className="aspect-[4/3] relative overflow-hidden"
|
||||
style={{ backgroundColor: `${accent}18` }}
|
||||
>
|
||||
{image ? (
|
||||
<img src={image} alt="" className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" />
|
||||
) : note.illustrationSvg ? (
|
||||
<div
|
||||
className="w-full h-full p-4 [&_svg]:w-full [&_svg]:h-full opacity-80"
|
||||
dangerouslySetInnerHTML={{ __html: note.illustrationSvg }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className="font-memento-serif text-4xl opacity-20"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<h3 className="font-memento-serif text-[15px] font-medium line-clamp-2 group-hover:text-brand-accent transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{!hover && previewProps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{previewProps.map((p) => (
|
||||
<div key={p.id} className="flex gap-2 text-[11px]">
|
||||
<span className="text-muted-foreground shrink-0">{p.name}:</span>
|
||||
<span className="truncate">{formatPropertyDisplay(p.type, values[p.id])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hover && allProps.length > 0 ? (
|
||||
<div className="space-y-1.5 pt-1 border-t border-border/30">
|
||||
{allProps.map((p) => (
|
||||
<div key={p.id} className="flex justify-between gap-2 text-[11px]">
|
||||
<span className="text-muted-foreground">{p.name}</span>
|
||||
<span className="font-medium text-right truncate max-w-[55%]">
|
||||
{formatPropertyDisplay(p.type, values[p.id])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
223
memento-note/components/structured-views/notes-kanban-view.tsx
Normal file
223
memento-note/components/structured-views/notes-kanban-view.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import type { Note } from '@/lib/types'
|
||||
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NotesKanbanViewProps = {
|
||||
notes: Note[]
|
||||
schema: NotebookSchemaPayload
|
||||
noteValues: Record<string, NotePropertyValues>
|
||||
onOpen: (note: Note) => void
|
||||
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
|
||||
onCreateNote: (prefill: Record<string, unknown>) => void
|
||||
onSetGroupProperty: (propertyId: string) => void
|
||||
onQuickAddKanbanStatus?: () => void
|
||||
}
|
||||
|
||||
function DraggableCard({ note, onOpen, dragDisabled }: { note: Note; onOpen: (note: Note) => void; dragDisabled?: boolean }) {
|
||||
const { t } = useLanguage()
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: note.id, disabled: dragDisabled })
|
||||
const style = transform
|
||||
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...(dragDisabled ? {} : listeners)}
|
||||
{...(dragDisabled ? {} : attributes)}
|
||||
className={cn(
|
||||
'rounded-xl border border-border/50 bg-card p-3 shadow-sm',
|
||||
!dragDisabled && 'cursor-grab active:cursor-grabbing touch-none',
|
||||
isDragging && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(note)}
|
||||
className="font-memento-serif text-[13px] font-medium text-left w-full hover:text-brand-accent transition-colors pointer-events-auto"
|
||||
>
|
||||
{getNoteDisplayTitle(note, t('notes.untitled'))}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DroppableColumn({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id })
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isOver && 'ring-2 ring-brand-accent/30 ring-inset')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotesKanbanView({
|
||||
notes,
|
||||
schema,
|
||||
noteValues,
|
||||
onOpen,
|
||||
onPropertyChange,
|
||||
onCreateNote,
|
||||
onSetGroupProperty,
|
||||
onQuickAddKanbanStatus,
|
||||
}: NotesKanbanViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const selectProps = schema.properties.filter((p) => p.type === 'select')
|
||||
const groupPropId =
|
||||
schema.viewSettings.kanbanGroupPropertyId &&
|
||||
selectProps.some((p) => p.id === schema.viewSettings.kanbanGroupPropertyId)
|
||||
? schema.viewSettings.kanbanGroupPropertyId
|
||||
: selectProps[0]?.id ?? null
|
||||
|
||||
const groupProp = selectProps.find((p) => p.id === groupPropId) ?? null
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!groupProp) {
|
||||
return [{ id: 'col:__none', label: t('structuredViews.kanbanAllNotes'), value: null as string | null }]
|
||||
}
|
||||
const cols = groupProp.options.map((opt) => ({ id: `col:${opt}`, label: opt, value: opt }))
|
||||
return [...cols, { id: 'col:__none', label: t('structuredViews.kanbanUnassigned'), value: null }]
|
||||
}, [groupProp, t])
|
||||
|
||||
const notesByColumn = useMemo(() => {
|
||||
const map = new Map<string, Note[]>()
|
||||
for (const col of columns) map.set(col.id, [])
|
||||
for (const note of notes) {
|
||||
if (!groupProp) {
|
||||
map.get('col:__none')?.push(note)
|
||||
continue
|
||||
}
|
||||
const raw = noteValues[note.id]?.[groupProp.id]
|
||||
const val = typeof raw === 'string' ? raw : null
|
||||
const colId = val && groupProp.options.includes(val) ? `col:${val}` : 'col:__none'
|
||||
map.get(colId)?.push(note)
|
||||
}
|
||||
return map
|
||||
}, [notes, noteValues, groupProp, columns])
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
|
||||
const handleDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id))
|
||||
const handleDragEnd = (e: DragEndEvent) => {
|
||||
setActiveId(null)
|
||||
const noteId = String(e.active.id)
|
||||
const overId = e.over?.id ? String(e.over.id) : null
|
||||
if (!overId || !groupProp || !overId.startsWith('col:')) return
|
||||
const value = overId === 'col:__none' ? null : overId.slice(4)
|
||||
onPropertyChange(noteId, groupProp.id, value)
|
||||
}
|
||||
|
||||
const activeNote = activeId ? notes.find((n) => n.id === activeId) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!groupProp && (
|
||||
<div className="rounded-xl border border-border/40 bg-foreground/[0.02] px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<p className="text-[13px] text-muted-foreground flex-1">{t('structuredViews.kanbanSingleColumnHint')}</p>
|
||||
{onQuickAddKanbanStatus && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQuickAddKanbanStatus}
|
||||
className="shrink-0 px-4 py-2 rounded-full bg-foreground text-background text-[11px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
{t('structuredViews.kanbanAddStatusColumns')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupProp && selectProps.length > 1 && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{t('structuredViews.kanbanGroupBy')}
|
||||
</span>
|
||||
<select
|
||||
value={groupProp.id}
|
||||
onChange={(e) => onSetGroupProperty(e.target.value)}
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
>
|
||||
{selectProps.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[420px]">
|
||||
{columns.map((col) => {
|
||||
const colNotes = notesByColumn.get(col.id) ?? []
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex-shrink-0 w-[260px] rounded-2xl border border-border/40 bg-foreground/[0.02] flex flex-col"
|
||||
>
|
||||
<div className="px-3 py-3 border-b border-border/30 flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||
{col.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{colNotes.length}</span>
|
||||
</div>
|
||||
<DroppableColumn id={col.id} className="flex-1 p-2 space-y-2 min-h-[120px]">
|
||||
{colNotes.map((note) => (
|
||||
<DraggableCard key={note.id} note={note} onOpen={onOpen} dragDisabled={!groupProp} />
|
||||
))}
|
||||
</DroppableColumn>
|
||||
{groupProp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateNote({ [groupProp.id]: col.value })}
|
||||
className="m-2 flex items-center justify-center gap-1 py-2 rounded-lg border border-dashed border-border text-[11px] text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('structuredViews.newNoteInColumn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeNote ? (
|
||||
<div className="rounded-xl border border-brand-accent/40 bg-card p-3 shadow-lg w-[240px]">
|
||||
<span className="font-memento-serif text-[13px]">
|
||||
{getNoteDisplayTitle(activeNote, t('notes.untitled'))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import type { Note } from '@/lib/types'
|
||||
import type {
|
||||
ColumnFilter,
|
||||
ColumnSort,
|
||||
NotebookSchemaPayload,
|
||||
NotePropertyValues,
|
||||
} from '@/lib/structured-views/types'
|
||||
import {
|
||||
filterNotesWithProperties,
|
||||
sortNotesWithProperties,
|
||||
} from '@/lib/structured-views/property-utils'
|
||||
import { PropertyValueEditor } from './property-value-editor'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { ChevronDown, ChevronUp, Filter, Trash2 } from 'lucide-react'
|
||||
|
||||
type NotesStructuredTableProps = {
|
||||
notes: Note[]
|
||||
schema: NotebookSchemaPayload
|
||||
noteValues: Record<string, NotePropertyValues>
|
||||
onOpen: (note: Note) => void
|
||||
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
|
||||
onDeleteProperty?: (propertyId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function NotesStructuredTable({
|
||||
notes,
|
||||
schema,
|
||||
noteValues,
|
||||
onOpen,
|
||||
onPropertyChange,
|
||||
onDeleteProperty,
|
||||
}: NotesStructuredTableProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const untitled = t('notes.untitled')
|
||||
const [sort, setSort] = useState<ColumnSort>({ propertyId: 'updatedAt', direction: 'desc' })
|
||||
const [filterPropId, setFilterPropId] = useState<string | null>(null)
|
||||
const [filterOp, setFilterOp] = useState<ColumnFilter['operator']>('contains')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
const [propertyToDelete, setPropertyToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||
const [deletingProperty, setDeletingProperty] = useState(false)
|
||||
|
||||
const filters: ColumnFilter[] = useMemo(() => {
|
||||
if (!filterPropId) return []
|
||||
return [{ propertyId: filterPropId, operator: filterOp, value: filterValue }]
|
||||
}, [filterPropId, filterOp, filterValue])
|
||||
|
||||
const displayed = useMemo(() => {
|
||||
const filtered = filterNotesWithProperties(notes, noteValues, filters, schema.properties)
|
||||
return sortNotesWithProperties(filtered, noteValues, sort, schema.properties)
|
||||
}, [notes, noteValues, filters, sort, schema.properties])
|
||||
|
||||
const toggleSort = (propertyId: ColumnSort['propertyId']) => {
|
||||
setSort((prev) =>
|
||||
prev.propertyId === propertyId
|
||||
? { propertyId, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { propertyId, direction: 'asc' },
|
||||
)
|
||||
}
|
||||
|
||||
const SortIcon = ({ field }: { field: ColumnSort['propertyId'] }) =>
|
||||
sort.propertyId !== field ? null : sort.direction === 'asc' ? (
|
||||
<ChevronUp size={12} />
|
||||
) : (
|
||||
<ChevronDown size={12} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<Filter size={14} className="text-muted-foreground" />
|
||||
<select
|
||||
value={filterPropId ?? ''}
|
||||
onChange={(e) => setFilterPropId(e.target.value || null)}
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
>
|
||||
<option value="">{t('structuredViews.noFilter')}</option>
|
||||
<option value="title">{t('notes.tableTitle')}</option>
|
||||
{schema.properties.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{filterPropId && (
|
||||
<>
|
||||
<select
|
||||
value={filterOp}
|
||||
onChange={(e) => setFilterOp(e.target.value as ColumnFilter['operator'])}
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
>
|
||||
<option value="contains">{t('structuredViews.filterContains')}</option>
|
||||
<option value="equals">{t('structuredViews.filterEquals')}</option>
|
||||
<option value="empty">{t('structuredViews.filterEmpty')}</option>
|
||||
</select>
|
||||
{filterOp !== 'empty' && (
|
||||
<input
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
className="rounded-lg border border-border bg-background px-2 py-1 min-w-[120px]"
|
||||
placeholder={t('structuredViews.filterValue')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
||||
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th
|
||||
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground w-[22%]"
|
||||
onClick={() => toggleSort('title')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableTitle')} <SortIcon field="title" />
|
||||
</span>
|
||||
</th>
|
||||
{schema.properties.map((p) => (
|
||||
<th
|
||||
key={p.id}
|
||||
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground group/col"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(p.id)}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
{p.name} <SortIcon field={p.id} />
|
||||
</button>
|
||||
{onDeleteProperty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPropertyToDelete({ id: p.id, name: p.name })
|
||||
}}
|
||||
className="opacity-40 group-hover/col:opacity-100 p-0.5 rounded hover:text-red-500 hover:bg-red-500/10 transition-all shrink-0"
|
||||
title={t('structuredViews.deleteProperty')}
|
||||
aria-label={t('structuredViews.deleteProperty')}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className="px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground w-[12%]"
|
||||
onClick={() => toggleSort('updatedAt')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableModified')} <SortIcon field="updatedAt" />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-foreground/[0.03]">
|
||||
{displayed.map((note) => {
|
||||
const vals = noteValues[note.id] ?? {}
|
||||
return (
|
||||
<tr key={note.id} className="hover:bg-foreground/[0.02] transition-colors group">
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(note)}
|
||||
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[220px] group-hover:text-brand-accent transition-colors"
|
||||
>
|
||||
{getNoteDisplayTitle(note, untitled)}
|
||||
</button>
|
||||
</td>
|
||||
{schema.properties.map((p) => (
|
||||
<td
|
||||
key={p.id}
|
||||
className="px-4 py-2 align-top"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="min-w-[100px] max-w-[180px]">
|
||||
<PropertyValueEditor
|
||||
property={p}
|
||||
value={vals[p.id]}
|
||||
compact
|
||||
onChange={(v) => onPropertyChange(note.id, p.id, v)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy')}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{displayed.length === 0 && (
|
||||
<p className="text-center py-8 text-muted-foreground text-sm">{t('structuredViews.noMatchingNotes')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(propertyToDelete)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPropertyToDelete(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('structuredViews.deletePropertyTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('structuredViews.deletePropertyConfirm', { name: propertyToDelete?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deletingProperty}>{t('general.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deletingProperty || !propertyToDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!propertyToDelete || !onDeleteProperty) return
|
||||
setDeletingProperty(true)
|
||||
try {
|
||||
await onDeleteProperty(propertyToDelete.id)
|
||||
if (filterPropId === propertyToDelete.id) setFilterPropId(null)
|
||||
setPropertyToDelete(null)
|
||||
} finally {
|
||||
setDeletingProperty(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('structuredViews.deleteProperty')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { PropertyType, SchemaProperty } from '@/lib/structured-views/types'
|
||||
|
||||
type PropertyValueEditorProps = {
|
||||
property: SchemaProperty
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PropertyValueEditor({
|
||||
property,
|
||||
value,
|
||||
onChange,
|
||||
compact,
|
||||
className,
|
||||
}: PropertyValueEditorProps) {
|
||||
const base = cn(
|
||||
'w-full rounded-md border border-border/60 bg-background text-sm',
|
||||
compact ? 'px-2 py-1 text-[12px]' : 'px-3 py-2',
|
||||
className,
|
||||
)
|
||||
|
||||
switch (property.type) {
|
||||
case 'checkbox':
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-brand-accent"
|
||||
/>
|
||||
)
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value == null || value === '' ? '' : String(value)}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
className={base}
|
||||
/>
|
||||
)
|
||||
case 'date':
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={typeof value === 'string' ? value.slice(0, 10) : ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={base}
|
||||
/>
|
||||
)
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={base}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{property.options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
case 'multiselect':
|
||||
return (
|
||||
<MultiSelectEditor
|
||||
options={property.options}
|
||||
value={Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []}
|
||||
onChange={onChange}
|
||||
className={base}
|
||||
compact={compact}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value == null ? '' : String(value)}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={base}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function MultiSelectEditor({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
compact,
|
||||
}: {
|
||||
options: string[]
|
||||
value: string[]
|
||||
onChange: (v: string[]) => void
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const toggle = (opt: string) => {
|
||||
if (value.includes(opt)) onChange(value.filter((v) => v !== opt))
|
||||
else onChange([...value, opt])
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full min-h-[28px] rounded-md border border-transparent hover:border-border/60 px-1 py-0.5 text-left transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value.length === 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground">{t('structuredViews.cellEmpty')}</span>
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{value.map((opt) => (
|
||||
<span
|
||||
key={opt}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-foreground text-background"
|
||||
>
|
||||
{opt}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-2 space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground px-1 pb-1">
|
||||
{t('structuredViews.multiselectPick')}
|
||||
</p>
|
||||
{options.map((opt) => {
|
||||
const active = value.includes(opt)
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggle(opt)}
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-[12px] transition-colors',
|
||||
active
|
||||
? 'bg-foreground text-background font-medium'
|
||||
: 'hover:bg-foreground/5 text-foreground',
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-1', className, 'p-1')}>
|
||||
{options.map((opt) => {
|
||||
const active = value.includes(opt)
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggle(opt)}
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-[10px] font-bold border transition-colors',
|
||||
active
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-border text-muted-foreground hover:border-foreground/30',
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDebouncedPropertySave(
|
||||
noteId: string,
|
||||
onSaved?: (values: Record<string, unknown>) => void,
|
||||
delayMs = 500,
|
||||
) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}, [])
|
||||
|
||||
const queueSave = (propertyId: string, value: unknown) => {
|
||||
pendingRef.current[propertyId] = value
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(async () => {
|
||||
const payload = { ...pendingRef.current }
|
||||
pendingRef.current = {}
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/properties`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ properties: payload }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.success && json.data?.values) onSaved?.(json.data.values)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
return queueSave
|
||||
}
|
||||
|
||||
export function formatPropertyTypeLabel(type: PropertyType, t: (k: string) => string) {
|
||||
return t(`structuredViews.propertyTypes.${type}`)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { Note } from '@/lib/types'
|
||||
import type { NotebookSchemaPayload, NotePropertyValues, StructuredViewMode } from '@/lib/structured-views/types'
|
||||
import { NotesStructuredTable } from './notes-structured-table'
|
||||
import { NotesKanbanView } from './notes-kanban-view'
|
||||
import { NotesGalleryView } from './notes-gallery-view'
|
||||
|
||||
type StructuredViewsContainerProps = {
|
||||
mode: StructuredViewMode
|
||||
notes: Note[]
|
||||
schema: NotebookSchemaPayload
|
||||
noteValues: Record<string, NotePropertyValues>
|
||||
notebookColor?: string | null
|
||||
onOpen: (note: Note) => void
|
||||
onNoteValuesPatch: (noteId: string, patch: NotePropertyValues) => void
|
||||
onCreateNote: (prefill: Record<string, unknown>) => void
|
||||
onSetKanbanGroupProperty: (propertyId: string) => void
|
||||
onQuickAddKanbanStatus?: () => void
|
||||
onDeleteProperty?: (propertyId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function StructuredViewsContainer({
|
||||
mode,
|
||||
notes,
|
||||
schema,
|
||||
noteValues,
|
||||
notebookColor,
|
||||
onOpen,
|
||||
onNoteValuesPatch,
|
||||
onCreateNote,
|
||||
onSetKanbanGroupProperty,
|
||||
onQuickAddKanbanStatus,
|
||||
onDeleteProperty,
|
||||
}: StructuredViewsContainerProps) {
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
|
||||
const saveProperty = useCallback(
|
||||
(noteId: string, propertyId: string, value: unknown) => {
|
||||
onNoteValuesPatch(noteId, { [propertyId]: value })
|
||||
const key = `${noteId}:${propertyId}`
|
||||
const existing = timersRef.current.get(key)
|
||||
if (existing) clearTimeout(existing)
|
||||
timersRef.current.set(
|
||||
key,
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}/properties`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ properties: { [propertyId]: value } }),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
timersRef.current.delete(key)
|
||||
}, 500),
|
||||
)
|
||||
},
|
||||
[onNoteValuesPatch],
|
||||
)
|
||||
|
||||
if (mode === 'table') {
|
||||
return (
|
||||
<NotesStructuredTable
|
||||
notes={notes}
|
||||
schema={schema}
|
||||
noteValues={noteValues}
|
||||
onOpen={onOpen}
|
||||
onPropertyChange={saveProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'kanban') {
|
||||
return (
|
||||
<NotesKanbanView
|
||||
notes={notes}
|
||||
schema={schema}
|
||||
noteValues={noteValues}
|
||||
onOpen={onOpen}
|
||||
onPropertyChange={saveProperty}
|
||||
onCreateNote={onCreateNote}
|
||||
onSetGroupProperty={onSetKanbanGroupProperty}
|
||||
onQuickAddKanbanStatus={onQuickAddKanbanStatus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'gallery') {
|
||||
return (
|
||||
<NotesGalleryView
|
||||
notes={notes}
|
||||
schema={schema}
|
||||
noteValues={noteValues}
|
||||
notebookColor={notebookColor}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Info, X } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { StructuredViewMode } from '@/lib/structured-views/types'
|
||||
|
||||
const DISMISS_PREFIX = 'memento-structured-help-dismissed-'
|
||||
|
||||
type StructuredViewsHelpBannerProps = {
|
||||
notebookId: string
|
||||
mode: StructuredViewMode
|
||||
}
|
||||
|
||||
export function StructuredViewsHelpBanner({ notebookId, mode }: StructuredViewsHelpBannerProps) {
|
||||
const { t } = useLanguage()
|
||||
const storageKey = `${DISMISS_PREFIX}${notebookId}-${mode}`
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setVisible(localStorage.getItem(storageKey) !== '1')
|
||||
} catch {
|
||||
setVisible(true)
|
||||
}
|
||||
}, [storageKey])
|
||||
|
||||
if (!visible || (mode !== 'table' && mode !== 'kanban')) return null
|
||||
|
||||
const message =
|
||||
mode === 'kanban'
|
||||
? t('structuredViews.helpBanner.kanban')
|
||||
: t('structuredViews.helpBanner.table')
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] px-4 py-3">
|
||||
<Info size={16} className="text-brand-accent shrink-0 mt-0.5" />
|
||||
<p className="text-[13px] leading-relaxed text-foreground/90 flex-1">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setVisible(false)
|
||||
}}
|
||||
className="shrink-0 p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||
aria-label={t('structuredViews.helpBanner.dismiss')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { Columns3, Database, Table2, type LucideIcon } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BootstrapStructuredTarget } from '@/lib/structured-views/bootstrap-structured-notebook'
|
||||
|
||||
type StructuredViewsIntroProps = {
|
||||
target: BootstrapStructuredTarget
|
||||
enabling?: boolean
|
||||
onEnable: () => void
|
||||
}
|
||||
|
||||
export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredViewsIntroProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8 py-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-brand-accent">
|
||||
<Database size={18} />
|
||||
<h2 className="font-memento-serif text-2xl text-foreground">{t('structuredViews.intro.databaseTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-[14px] leading-relaxed text-muted-foreground">
|
||||
{t('structuredViews.intro.databaseBody')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<IntroCard
|
||||
icon={Table2}
|
||||
title={t('structuredViews.intro.tableTitle')}
|
||||
body={t('structuredViews.intro.tableBody')}
|
||||
active={target === 'table'}
|
||||
/>
|
||||
<IntroCard
|
||||
icon={Columns3}
|
||||
title={t('structuredViews.intro.kanbanTitle')}
|
||||
body={t('structuredViews.intro.kanbanBody')}
|
||||
active={target === 'kanban'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-foreground/[0.02] px-5 py-4 space-y-4">
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">
|
||||
{target === 'kanban'
|
||||
? t('structuredViews.intro.activateKanbanHint')
|
||||
: t('structuredViews.intro.activateTableHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={enabling}
|
||||
onClick={onEnable}
|
||||
className={cn(
|
||||
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
|
||||
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{enabling
|
||||
? t('structuredViews.intro.enabling')
|
||||
: target === 'kanban'
|
||||
? t('structuredViews.intro.enableKanban')
|
||||
: t('structuredViews.intro.enableTable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IntroCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
active,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
body: string
|
||||
active?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border p-4 space-y-2 transition-colors',
|
||||
active ? 'border-brand-accent/40 bg-brand-accent/[0.04]' : 'border-border/40 bg-card/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={active ? 'text-brand-accent' : 'text-muted-foreground'} />
|
||||
<h3 className="text-[13px] font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X, CheckSquare, BookOpen, GraduationCap, LayoutList } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import type { NotesLayoutMode } from '@/components/notes-list-views'
|
||||
import {
|
||||
WIZARD_DEFAULT_VIEW,
|
||||
WIZARD_FIELDS_BY_GOAL,
|
||||
WIZARD_GOALS,
|
||||
type WizardFieldDef,
|
||||
type WizardFieldId,
|
||||
type WizardGoal,
|
||||
} from '@/lib/structured-views/wizard-templates'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type StructuredViewsWizardProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onComplete: (view: NotesLayoutMode) => void
|
||||
structuredModeActive: boolean
|
||||
enableStructuredMode: () => Promise<unknown>
|
||||
addProperty: (name: string, type: string, options?: string[]) => Promise<{ properties: { id: string; name: string; type: string }[] } | null | undefined>
|
||||
setKanbanGroupProperty: (propertyId: string | null) => Promise<void>
|
||||
initialGoal?: WizardGoal
|
||||
}
|
||||
|
||||
const GOAL_ICONS: Record<WizardGoal, React.ElementType> = {
|
||||
tasks: CheckSquare,
|
||||
learning: GraduationCap,
|
||||
reading: BookOpen,
|
||||
simple: LayoutList,
|
||||
}
|
||||
|
||||
const VIEW_LABEL_KEYS: Record<'list' | 'gallery' | 'table' | 'kanban', string> = {
|
||||
list: 'structuredViews.viewList',
|
||||
gallery: 'structuredViews.viewGallery',
|
||||
table: 'structuredViews.viewTable',
|
||||
kanban: 'structuredViews.viewKanban',
|
||||
}
|
||||
|
||||
export function StructuredViewsWizard({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
structuredModeActive,
|
||||
enableStructuredMode,
|
||||
addProperty,
|
||||
setKanbanGroupProperty,
|
||||
initialGoal,
|
||||
}: StructuredViewsWizardProps) {
|
||||
const { t } = useLanguage()
|
||||
const [step, setStep] = useState(0)
|
||||
const [goal, setGoal] = useState<WizardGoal>('tasks')
|
||||
const [selectedFields, setSelectedFields] = useState<Set<WizardFieldId>>(new Set())
|
||||
const [view, setView] = useState<NotesLayoutMode>('list')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fieldDefs = WIZARD_FIELDS_BY_GOAL[goal]
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const g = initialGoal ?? 'tasks'
|
||||
setStep(0)
|
||||
setGoal(g)
|
||||
setSelectedFields(new Set(WIZARD_FIELDS_BY_GOAL[g].map((f) => f.id)))
|
||||
setView(WIZARD_DEFAULT_VIEW[g])
|
||||
}, [open, initialGoal])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFields(new Set(fieldDefs.map((f) => f.id)))
|
||||
setView(WIZARD_DEFAULT_VIEW[goal])
|
||||
}, [goal, fieldDefs])
|
||||
|
||||
const kanbanAllowed = fieldDefs.some((f) => f.type === 'select' && selectedFields.has(f.id))
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const toggleField = (id: WizardFieldId) => {
|
||||
setSelectedFields((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const fieldLabel = (def: WizardFieldDef) => t(`structuredViews.wizard.fields.${def.id}.name`)
|
||||
|
||||
const parseOptions = (fieldId: WizardFieldId) =>
|
||||
t(`structuredViews.wizard.fields.${fieldId}.options`)
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const handleFinish = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (!structuredModeActive) await enableStructuredMode()
|
||||
|
||||
let kanbanPropertyId: string | null = null
|
||||
|
||||
for (const def of fieldDefs) {
|
||||
if (!selectedFields.has(def.id)) continue
|
||||
const name = fieldLabel(def)
|
||||
const options = def.hasOptions ? parseOptions(def.id) : []
|
||||
const schema = await addProperty(name, def.type, options)
|
||||
const created = schema?.properties.find((p) => p.name === name)
|
||||
if (created && def.type === 'select' && !kanbanPropertyId) {
|
||||
kanbanPropertyId = created.id
|
||||
}
|
||||
}
|
||||
|
||||
const finalView = view === 'kanban' && !kanbanAllowed ? 'list' : view
|
||||
if (finalView === 'kanban' && kanbanPropertyId) {
|
||||
await setKanbanGroupProperty(kanbanPropertyId)
|
||||
}
|
||||
|
||||
onComplete(finalView)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('structuredViews.enableFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (step === 0 && goal === 'simple') {
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
setStep((s) => Math.min(s + 1, 2))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[210] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal
|
||||
className="w-full max-w-lg rounded-2xl border border-border bg-memento-paper shadow-xl p-6 space-y-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-memento-serif text-xl">{t('structuredViews.wizard.title')}</h2>
|
||||
<p className="text-[13px] text-muted-foreground mt-1">{t('structuredViews.wizard.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="p-1 rounded-lg hover:bg-foreground/5 shrink-0">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.wizard.stepGoal')}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{WIZARD_GOALS.map((g) => {
|
||||
const Icon = GOAL_ICONS[g]
|
||||
return (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => setGoal(g)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-xl border p-3 text-left transition-colors',
|
||||
goal === g
|
||||
? 'border-foreground bg-foreground/[0.04]'
|
||||
: 'border-border hover:border-foreground/25',
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-[13px] font-medium">{t(`structuredViews.wizard.goals.${g}.title`)}</div>
|
||||
<div className="text-[12px] text-muted-foreground mt-0.5">
|
||||
{t(`structuredViews.wizard.goals.${g}.desc`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && fieldDefs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.wizard.stepFields')}
|
||||
</p>
|
||||
<p className="text-[12px] text-muted-foreground">{t('structuredViews.wizard.fieldsHint')}</p>
|
||||
<div className="space-y-2">
|
||||
{fieldDefs.map((def) => (
|
||||
<label
|
||||
key={def.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-border px-3 py-2.5 cursor-pointer hover:bg-foreground/[0.02]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFields.has(def.id)}
|
||||
onChange={() => toggleField(def.id)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-[13px] font-medium">{fieldLabel(def)}</span>
|
||||
<span className="text-[10px] text-muted-foreground ms-auto uppercase">
|
||||
{t(`structuredViews.propertyTypes.${def.type}`)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{t('structuredViews.wizard.stepView')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['list', 'gallery', 'table', 'kanban'] as const).map((v) => {
|
||||
const disabled = v === 'kanban' && !kanbanAllowed
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setView(v)}
|
||||
className={cn(
|
||||
'rounded-xl border px-3 py-3 text-[12px] font-bold uppercase tracking-wider transition-colors',
|
||||
view === v
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:border-foreground/30',
|
||||
disabled && 'opacity-40 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{t(VIEW_LABEL_KEYS[v])}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{view === 'kanban' && !kanbanAllowed && (
|
||||
<p className="text-[12px] text-muted-foreground">{t('structuredViews.wizard.kanbanNeedsStatus')}</p>
|
||||
)}
|
||||
<p className="text-[12px] text-muted-foreground">{t('structuredViews.wizard.doneHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => (step === 0 ? onClose() : setStep((s) => s - 1))}
|
||||
disabled={saving}
|
||||
>
|
||||
{step === 0 ? t('general.cancel') : t('structuredViews.wizard.back')}
|
||||
</Button>
|
||||
{step < 2 ? (
|
||||
<Button type="button" onClick={goNext}>
|
||||
{t('structuredViews.wizard.next')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={() => void handleFinish()} disabled={saving}>
|
||||
{saving ? t('general.loading') : t('structuredViews.wizard.finish')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
memento-note/hooks/use-notebook-schema.ts
Normal file
139
memento-note/hooks/use-notebook-schema.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
22
memento-note/lib/structured-views/preferences.ts
Normal file
22
memento-note/lib/structured-views/preferences.ts
Normal 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)
|
||||
}
|
||||
167
memento-note/lib/structured-views/property-utils.ts
Normal file
167
memento-note/lib/structured-views/property-utils.ts
Normal 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)
|
||||
}
|
||||
68
memento-note/lib/structured-views/schema-serialize.ts
Normal file
68
memento-note/lib/structured-views/schema-serialize.ts
Normal 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
|
||||
}
|
||||
55
memento-note/lib/structured-views/types.ts
Normal file
55
memento-note/lib/structured-views/types.ts
Normal 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
|
||||
}
|
||||
39
memento-note/lib/structured-views/wizard-templates.ts
Normal file
39
memento-note/lib/structured-views/wizard-templates.ts
Normal 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'
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user