feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning
- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert) all buttons now icon-only with title tooltip for accessibility - Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu - Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory now checks note.historyEnabled directly — unblocks manual per-note history - Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel calls commitNoteHistory with visual feedback (spinner → success state) - note-document-info-panel: import commitNoteHistory, add isSavingVersion state - notes.ts: fix double guard that silently blocked all history operations
This commit is contained in:
@@ -23,7 +23,7 @@ export type UserAISettingsData = {
|
||||
autoLabeling?: boolean
|
||||
noteHistory?: boolean
|
||||
noteHistoryMode?: 'manual' | 'auto'
|
||||
fontFamily?: 'inter' | 'system'
|
||||
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
|
||||
}
|
||||
|
||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||
@@ -191,7 +191,7 @@ const getCachedAISettings = unstable_cache(
|
||||
autoLabeling: settings.autoLabeling ?? true,
|
||||
noteHistory: settings.noteHistory ?? false,
|
||||
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system',
|
||||
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting AI settings:', error)
|
||||
|
||||
100
memento-note/app/actions/note-illustration.ts
Normal file
100
memento-note/app/actions/note-illustration.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
'use server'
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
function extractSvgSnippet(raw: string): string | null {
|
||||
const trimmed = raw.trim()
|
||||
const fenced = trimmed.match(/```(?:svg)?\s*([\s\S]*?)```/i)
|
||||
const candidate = (fenced ? fenced[1] : trimmed).trim()
|
||||
const start = candidate.indexOf('<svg')
|
||||
const end = candidate.lastIndexOf('</svg>')
|
||||
if (start === -1 || end === -1 || end <= start) return null
|
||||
return candidate.slice(start, end + 6)
|
||||
}
|
||||
|
||||
function sanitizeSvgMarkup(svg: string): string {
|
||||
return DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['use'],
|
||||
ADD_ATTR: ['viewBox', 'xmlns', 'preserveAspectRatio'],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
|
||||
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
|
||||
*/
|
||||
export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
|
||||
|
||||
try {
|
||||
const settings = await getAISettings(session.user.id)
|
||||
if (settings.paragraphRefactor === false) {
|
||||
return { ok: false, error: 'Assistant IA désactivé dans vos paramètres.' }
|
||||
}
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
if (!note) return { ok: false, error: 'Note introuvable' }
|
||||
|
||||
const plainTitle = (note.title || '').slice(0, 200)
|
||||
const plainBody = note.content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 1200)
|
||||
|
||||
if (!plainBody && !plainTitle) {
|
||||
return { ok: false, error: 'Ajoutez du contenu avant de générer une illustration.' }
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
const prompt = `Tu es un designer minimaliste. Produis UN SEUL document SVG valide pour une vignette de carte note.
|
||||
Contraintes strictes:
|
||||
- viewBox="0 0 224 168" (rapport 4:3), pas de width/height fixes en px sur la racine ou width="100%" height="100%"
|
||||
- Style architectural / papier, 2–4 formes géométriques ou lignes, palette sobre (noir/gris/une couleur douce), pas de texte lisible
|
||||
- AUCUN script, AUCUNE balise foreignObject, AUCUN lien externe, AUCUN attribut on*
|
||||
- Réponds UNIQUEMENT avec le fragment SVG (commence par <svg ...> et finit par </svg>), sans markdown ni commentaire.
|
||||
|
||||
Thème à suggérer visuellement (abstrait, pas littéral):
|
||||
Titre: ${plainTitle || '(sans titre)'}
|
||||
Extrait: ${plainBody.slice(0, 400)}`
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const extracted = extractSvgSnippet(raw)
|
||||
if (!extracted) {
|
||||
return { ok: false, error: 'Le modèle n’a pas renvoyé un SVG valide. Réessayez.' }
|
||||
}
|
||||
|
||||
const safe = sanitizeSvgMarkup(extracted)
|
||||
if (!safe.includes('<svg')) {
|
||||
return { ok: false, error: 'SVG rejeté après sécurisation.' }
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
data: {
|
||||
illustrationSvg: safe,
|
||||
lastAiAnalysis: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
console.error('[note-illustration]', e)
|
||||
const msg = e instanceof Error ? e.message : 'Erreur inconnue'
|
||||
return { ok: false, error: msg.includes('required') ? 'Configurez un fournisseur IA (admin ou paramètres système).' : msg }
|
||||
}
|
||||
}
|
||||
@@ -372,16 +372,14 @@ export async function getNoteHistory(noteId: string, limit = 30) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||
if (!enabled) return []
|
||||
|
||||
const clampedLimit = Math.min(Math.max(limit, 1), 100)
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: { id: true },
|
||||
select: { id: true, historyEnabled: true },
|
||||
})
|
||||
if (!note) return []
|
||||
// History not found or not enabled on this note
|
||||
if (!note || !note.historyEnabled) return []
|
||||
|
||||
const entries = await prisma.noteHistory.findMany({
|
||||
where: { noteId: note.id, userId: session.user.id },
|
||||
@@ -396,13 +394,10 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
||||
if (!enabled) throw new Error('History is disabled')
|
||||
|
||||
const [note, historyEntry] = await Promise.all([
|
||||
prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: { id: true, notebookId: true },
|
||||
select: { id: true, notebookId: true, historyEnabled: true },
|
||||
}),
|
||||
prisma.noteHistory.findFirst({
|
||||
where: {
|
||||
@@ -413,9 +408,8 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
||||
}),
|
||||
])
|
||||
|
||||
if (!note || !historyEntry) {
|
||||
throw new Error('History entry not found')
|
||||
}
|
||||
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
|
||||
if (!historyEntry) throw new Error('History entry not found')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
@@ -861,10 +855,18 @@ export async function updateNote(id: string, data: {
|
||||
updateData.contentUpdatedAt = new Date()
|
||||
}
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: updateData
|
||||
})
|
||||
console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
|
||||
let note
|
||||
try {
|
||||
note = await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: updateData
|
||||
})
|
||||
console.log('[updateNote] Succeeded, note id:', note?.id)
|
||||
} catch (dbError: any) {
|
||||
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
|
||||
throw dbError
|
||||
}
|
||||
|
||||
// Sync labels (JSON + labelRelations + Label rows)
|
||||
const notebookMoved =
|
||||
@@ -908,9 +910,15 @@ export async function updateNote(id: string, data: {
|
||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||
const isStructuralChange = structuralFields.some(field => field in data)
|
||||
|
||||
if (isStructuralChange && !options?.skipRevalidation) {
|
||||
revalidatePath('/')
|
||||
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
|
||||
|
||||
if (!options?.skipRevalidation) {
|
||||
// Always revalidate note individual page on content changes so UI reflects saved data
|
||||
revalidatePath(`/note/${id}`)
|
||||
revalidatePath('/')
|
||||
}
|
||||
|
||||
if (isStructuralChange) {
|
||||
if (data.isArchived !== undefined) {
|
||||
revalidatePath('/archive')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user