- 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
101 lines
3.7 KiB
TypeScript
101 lines
3.7 KiB
TypeScript
'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 }
|
||
}
|
||
}
|