feat: publication pages — design moderne + modération IA + signalement
Page publique (/p/[slug]): - Design éditorial moderne (Source Serif 4 + Inter, couleurs Momento) - KaTeX rendu côté serveur (plus de 3184089 en brut) - Callouts colorés, toggles, colonnes, code blocks rendus correctement - Barre sticky avec logo + bouton Signaler - Temps de lecture estimé - Footer Momento - Responsive Modération: - content-moderation.service.ts — IA classe safe/flagged/blocked - Sera appelée automatiquement à la publication Signalement: - Page /p/[slug]/report — formulaire de signalement public - API /api/notes/report — stocke notification au propriétaire + admins - 8 motifs: illegal, haine, violence, sexuel, harcèlement, copyright, spam, autre i18n: FR/EN
This commit is contained in:
@@ -1,17 +1,67 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { getPublishedNote } from '@/app/actions/notes-publishing'
|
import { getPublishedNote } from '@/app/actions/notes-publishing'
|
||||||
import { FileText, Calendar } from 'lucide-react'
|
import { FileText, Calendar, Clock, Flag } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
import katex from 'katex'
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const note = await getPublishedNote(slug)
|
const note = await getPublishedNote(slug)
|
||||||
if (!note) return { title: 'Note not found' }
|
if (!note) return { title: 'Note not found — Momento' }
|
||||||
return { title: note.title || 'Published note', description: note.content?.replace(/<[^>]+>/g, '').slice(0, 160) }
|
return {
|
||||||
|
title: `${note.title || 'Published note'} — Momento`,
|
||||||
|
description: note.content?.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = 'force-static'
|
export const revalidate = 60
|
||||||
export const revalidate = 3600
|
|
||||||
|
function renderMathInHtml(html: string): string {
|
||||||
|
let result = html
|
||||||
|
result = result.replace(/<div[^>]*data-type="math-equation"[^>]*data-latex="([^"]*)"[^>]*><\/div>/g, (_, latex) => {
|
||||||
|
try {
|
||||||
|
return `<div class="math-display">${katex.renderToString(decodeHtml(latex), { displayMode: true, throwOnError: false })}</div>`
|
||||||
|
} catch { return `<div class="math-display">${latex}</div>` }
|
||||||
|
})
|
||||||
|
result = result.replace(/<span[^>]*data-type="inline-math"[^>]*data-latex="([^"]*)"[^>]*>.*?<\/span>/g, (_, latex) => {
|
||||||
|
try {
|
||||||
|
return katex.renderToString(decodeHtml(latex), { displayMode: false, throwOnError: false })
|
||||||
|
} catch { return latex }
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtml(text: string): string {
|
||||||
|
return text.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
function processContent(html: string): string {
|
||||||
|
let result = renderMathInHtml(html)
|
||||||
|
// Render callouts
|
||||||
|
result = result.replace(/<div[^>]*data-type="callout-block"[^>]*data-callout-type="([^"]*)"[^>]*>/g, (_, type) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
info: '#eff6ff|#3b82f6', warning: '#fffbeb|#f59e0b', tip: '#faf5ff|#8b5cf6',
|
||||||
|
success: '#f0fdf4|#22c55e', danger: '#fef2f2|#ef4444',
|
||||||
|
}
|
||||||
|
const [bg, border] = (colors[type] || colors.info).split('|')
|
||||||
|
return `<div style="background:${bg};border-left:4px solid ${border};border-radius:8px;padding:12px 16px;margin:16px 0">`
|
||||||
|
})
|
||||||
|
// Close callouts
|
||||||
|
result = result.replace(/<\/div>(\s*<\/div>)/g, '$1')
|
||||||
|
// Outline blocks — remove (they don't work without the editor)
|
||||||
|
result = result.replace(/<div[^>]*data-type="outline-block"[^>]*><\/div>/g, '')
|
||||||
|
// Link preview searchable text
|
||||||
|
result = result.replace(/<div[^>]*class="link-preview-searchable"[^>]*>[\s\S]*?<\/div>/g, '')
|
||||||
|
// Remove buttons (delete/unwrap)
|
||||||
|
result = result.replace(/<button[^>]*>[\s\S]*?<\/button>/g, '')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateReadingTime(html: string): number {
|
||||||
|
const text = html.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
const words = text.split(/\s+/).length
|
||||||
|
return Math.max(1, Math.ceil(words / 200))
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
|
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
@@ -19,49 +69,128 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
|
|||||||
|
|
||||||
if (!note) notFound()
|
if (!note) notFound()
|
||||||
|
|
||||||
|
const processedContent = processContent(note.content || '')
|
||||||
|
const readingTime = estimateReadingTime(note.content || '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F2F0E9] dark:bg-zinc-950">
|
<html lang="fr">
|
||||||
<div className="max-w-3xl mx-auto px-6 py-12">
|
<head>
|
||||||
{/* Header */}
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||||
<div className="mb-8 pb-6 border-b border-black/10 dark:border-white/10">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
|
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
<FileText size={14} />
|
</head>
|
||||||
<span>Note publiée</span>
|
<body style={{
|
||||||
{note.publishedAt && (
|
margin: 0, background: '#FAF9F5', color: '#1a1a1a',
|
||||||
<>
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||||
<span>·</span>
|
lineHeight: 1.7,
|
||||||
<Calendar size={12} />
|
}}>
|
||||||
<span>{format(new Date(note.publishedAt), 'MMM d, yyyy')}</span>
|
{/* Top bar */}
|
||||||
</>
|
<div style={{
|
||||||
)}
|
position: 'sticky', top: 0, zIndex: 10,
|
||||||
|
background: 'rgba(250, 249, 245, 0.85)', backdropFilter: 'blur(12px)',
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.06)', padding: '10px 24px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, fontSize: '15px' }}>Momento</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-serif font-medium tracking-tight text-foreground leading-tight mb-2">
|
<a href={`/p/${slug}/report`} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '4px',
|
||||||
|
fontSize: '12px', color: '#888', textDecoration: 'none',
|
||||||
|
padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>
|
||||||
|
<Flag size={12} /> Signaler
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article */}
|
||||||
|
<article style={{ maxWidth: '720px', margin: '0 auto', padding: '48px 24px 80px' }}>
|
||||||
|
{/* Meta */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '16px' }}>
|
||||||
|
<Calendar size={12} />
|
||||||
|
<span>{note.publishedAt ? format(new Date(note.publishedAt), 'd MMM yyyy') : ''}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>{readingTime} min de lecture</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: "'Source Serif 4', Georgia, serif",
|
||||||
|
fontSize: 'clamp(28px, 5vw, 40px)', fontWeight: 600,
|
||||||
|
lineHeight: 1.2, letterSpacing: '-0.02em', margin: '0 0 12px',
|
||||||
|
}}>
|
||||||
{note.title || 'Sans titre'}
|
{note.title || 'Sans titre'}
|
||||||
</h1>
|
</h1>
|
||||||
{note.user?.name && (
|
|
||||||
<p className="text-sm text-muted-foreground">par {note.user.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Author */}
|
||||||
<article
|
{note.user?.name && (
|
||||||
dir="auto"
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '40px' }}>
|
||||||
className="prose prose-stone dark:prose-invert max-w-none
|
{note.user.image && (
|
||||||
prose-headings:font-serif prose-headings:font-medium
|
<img src={note.user.image} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} />
|
||||||
prose-p:leading-relaxed prose-p:text-[15px]
|
)}
|
||||||
prose-pre:bg-zinc-100 prose-pre:dark:bg-zinc-900
|
<span style={{ fontSize: '14px', color: '#666' }}>par {note.user.name}</span>
|
||||||
prose-blockquote:border-brand-accent/40
|
</div>
|
||||||
prose-a:text-brand-accent"
|
)}
|
||||||
dangerouslySetInnerHTML={{ __html: note.content || '' }}
|
|
||||||
/>
|
{/* Divider */}
|
||||||
|
<div style={{ height: '1px', background: 'rgba(0,0,0,0.08)', marginBottom: '40px' }} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
dir="auto"
|
||||||
|
style={{ fontSize: '16px', lineHeight: 1.8 }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: processedContent }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom styles injected */}
|
||||||
|
<style>{`
|
||||||
|
article h1, article h2, article h3 {
|
||||||
|
font-family: 'Source Serif 4', Georgia, serif;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin-top: 1.8em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
article h2 { font-size: 1.5em; }
|
||||||
|
article h3 { font-size: 1.2em; }
|
||||||
|
article p { margin: 1em 0; }
|
||||||
|
article ul, article ol { padding-left: 1.5em; margin: 1em 0; }
|
||||||
|
article li { margin: 0.4em 0; }
|
||||||
|
article blockquote {
|
||||||
|
border-left: 3px solid #A47148;
|
||||||
|
padding-left: 1em; margin: 1.2em 0;
|
||||||
|
color: #555; font-style: italic;
|
||||||
|
}
|
||||||
|
article pre {
|
||||||
|
background: #f4f3ef; padding: 16px; border-radius: 8px;
|
||||||
|
overflow-x: auto; font-size: 14px; margin: 1.2em 0;
|
||||||
|
}
|
||||||
|
article code { font-family: 'SF Mono', Menlo, monospace; }
|
||||||
|
article table { border-collapse: collapse; width: 100%; margin: 1.2em 0; }
|
||||||
|
article th, article td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||||
|
article th { background: #f4f3ef; font-weight: 600; }
|
||||||
|
article img { max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
|
||||||
|
article a { color: #A47148; text-decoration: none; }
|
||||||
|
article a:hover { text-decoration: underline; }
|
||||||
|
.math-display { margin: 1.5em 0; text-align: center; overflow-x: auto; }
|
||||||
|
.toggle-block { border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; margin: 1em 0; overflow: hidden; }
|
||||||
|
.toggle-block > div:first-child { background: #f4f3ef; padding: 8px 12px; font-size: 13px; font-weight: 600; }
|
||||||
|
[data-type="columns"] { display: flex; gap: 16px; margin: 1em 0; }
|
||||||
|
[data-type="columns"] > div { flex: 1; }
|
||||||
|
`}</style>
|
||||||
|
</article>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-12 pt-6 border-t border-black/10 dark:border-white/10 text-center">
|
<footer style={{
|
||||||
<p className="text-xs text-muted-foreground">
|
borderTop: '1px solid rgba(0,0,0,0.08)', padding: '32px 24px',
|
||||||
Publié sur Momento
|
textAlign: 'center', fontSize: '13px', color: '#999',
|
||||||
</p>
|
}}>
|
||||||
</div>
|
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, color: '#A47148' }}>Momento</span>
|
||||||
</div>
|
{' — Votre mémoire augmentée par l\'IA'}
|
||||||
</div>
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
81
memento-note/app/(public)/p/[slug]/report/page.tsx
Normal file
81
memento-note/app/(public)/p/[slug]/report/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Flag, X, Loader2, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ReportPage({ params }: { params: { slug: string } }) {
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [details, setDetails] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/notes/report', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug: params.slug, reason, details }),
|
||||||
|
})
|
||||||
|
setDone(true)
|
||||||
|
} catch {}
|
||||||
|
finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#FAF9F5', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }}>
|
||||||
|
<div style={{ maxWidth: '440px', width: '100%', background: 'white', borderRadius: '16px', border: '1px solid rgba(0,0,0,0.08)', padding: '32px' }}>
|
||||||
|
{done ? (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ width: '48px', height: '48px', borderRadius: '50%', background: '#f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
|
||||||
|
<Check size={24} style={{ color: '#22c55e' }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontFamily: 'Georgia, serif', fontSize: '18px', fontWeight: 600, margin: '0 0 8px' }}>Signalement envoyé</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666', margin: 0 }}>Merci. Notre équipe va examiner ce contenu dans les plus brefs délais.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '20px' }}>
|
||||||
|
<div style={{ width: '32px', height: '32px', borderRadius: '8px', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Flag size={16} style={{ color: '#ef4444' }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontFamily: 'Georgia, serif', fontSize: '17px', fontWeight: 600, margin: 0 }}>Signaler ce contenu</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ fontSize: '12px', fontWeight: 600, color: '#666', textTransform: 'uppercase', letterSpacing: '0.05em', display: 'block', marginBottom: '8px' }}>Motif</label>
|
||||||
|
<select value={reason} onChange={e => setReason(e.target.value)} style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none', background: 'white' }}>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
<option value="illegal">Contenu illégal</option>
|
||||||
|
<option value="hate">Discours de haine</option>
|
||||||
|
<option value="violence">Violence / Menaces</option>
|
||||||
|
<option value="sexual">Contenu sexuel</option>
|
||||||
|
<option value="harassment">Harcèlement</option>
|
||||||
|
<option value="copyright">Violation de copyright</option>
|
||||||
|
<option value="spam">Spam / Arnaque</option>
|
||||||
|
<option value="other">Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ fontSize: '12px', fontWeight: 600, color: '#666', textTransform: 'uppercase', letterSpacing: '0.05em', display: 'block', marginBottom: '8px' }}>Détails (optionnel)</label>
|
||||||
|
<textarea value={details} onChange={e => setDetails(e.target.value)} rows={3} placeholder="Expliquez le problème..." style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none', resize: 'none', background: 'white' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!reason || submitting}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px', borderRadius: '10px', border: 'none',
|
||||||
|
background: !reason || submitting ? '#ccc' : '#ef4444',
|
||||||
|
color: 'white', fontSize: '14px', fontWeight: 600, cursor: !reason || submitting ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : 'Signaler'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
memento-note/app/api/notes/report/route.ts
Normal file
51
memento-note/app/api/notes/report/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { slug, reason, details } = await request.json()
|
||||||
|
if (!slug || !reason) {
|
||||||
|
return NextResponse.json({ error: 'slug and reason required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await prisma.note.findFirst({
|
||||||
|
where: { publicSlug: slug, isPublic: true },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
})
|
||||||
|
if (!note) return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||||
|
|
||||||
|
// Store as a notification to the note owner + admin
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: note.userId,
|
||||||
|
type: 'content_report',
|
||||||
|
title: `Signalement : ${reason}`,
|
||||||
|
message: details || `Un visiteur a signalé votre note publiée pour: ${reason}`,
|
||||||
|
actionUrl: `/p/${slug}`,
|
||||||
|
relatedId: note.id,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// Also notify admins
|
||||||
|
const admins = await prisma.user.findMany({
|
||||||
|
where: { role: 'ADMIN' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const admin of admins) {
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: admin.id,
|
||||||
|
type: 'content_report',
|
||||||
|
title: `Signalement de contenu : ${reason}`,
|
||||||
|
message: `Note /p/${slug} signalée pour: ${reason}${details ? ` — ${details}` : ''}`,
|
||||||
|
actionUrl: `/admin/published`,
|
||||||
|
relatedId: note.id,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
|
|||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||||
|
|
||||||
interface PublishDialogProps {
|
interface PublishDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -65,11 +66,9 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
|||||||
finally { setLoading(false) }
|
finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = async () => {
|
||||||
navigator.clipboard.writeText(publicUrl)
|
const ok = await copyTextToClipboard(publicUrl)
|
||||||
setCopied(true)
|
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success('Lien copié !') }
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
toast.success('Lien copié !')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
52
memento-note/lib/ai/services/content-moderation.service.ts
Normal file
52
memento-note/lib/ai/services/content-moderation.service.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { getChatProvider } from '../factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
|
export type ModerationVerdict = 'safe' | 'flagged' | 'blocked'
|
||||||
|
|
||||||
|
export interface ModerationResult {
|
||||||
|
verdict: ModerationVerdict
|
||||||
|
categories: string[]
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContentModerationService {
|
||||||
|
async moderate(title: string, content: string): Promise<ModerationResult> {
|
||||||
|
const plainText = content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 3000)
|
||||||
|
const fullText = `${title}\n\n${plainText}`
|
||||||
|
|
||||||
|
const prompt = `Tu es un modérateur de contenu. Analyse ce texte et classe-le.
|
||||||
|
|
||||||
|
RÈGLES STRICTES :
|
||||||
|
- "blocked" : contenu illégal (pédopornographie, apologie du terrorisme, incitation à la haine violente, menaces crédibles, fraude organisée)
|
||||||
|
- "flagged" : contenu sensible mais légal (violence graphique, drogues, contenu sexuel explicite, discours haineux non violent, désinformation dangereuse)
|
||||||
|
- "safe" : contenu normal (éducation, travail, personnel, technique)
|
||||||
|
|
||||||
|
CATÉGORIES possibles : violence, hate, sexual, harassment, self-harm, illegal, drugs, spam, safe
|
||||||
|
|
||||||
|
TEXTE À ANALYSER :
|
||||||
|
${fullText}
|
||||||
|
|
||||||
|
FORMAT JSON UNIQUEMENT :
|
||||||
|
{"verdict": "safe|flagged|blocked", "categories": ["category1"], "reason": "Explication courte en français"}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
|
||||||
|
const jsonMatch = raw.match(/\{[\s\S]+\}/)
|
||||||
|
if (!jsonMatch) return { verdict: 'safe', categories: ['safe'], reason: 'Analysis failed' }
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0])
|
||||||
|
return {
|
||||||
|
verdict: parsed.verdict === 'blocked' ? 'blocked' : parsed.verdict === 'flagged' ? 'flagged' : 'safe',
|
||||||
|
categories: Array.isArray(parsed.categories) ? parsed.categories : ['safe'],
|
||||||
|
reason: String(parsed.reason || ''),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { verdict: 'safe', categories: ['safe'], reason: 'Moderation unavailable' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contentModerationService = new ContentModerationService()
|
||||||
Reference in New Issue
Block a user