-
- {/* Header */}
-
-
-
-
Note publiée
- {note.publishedAt && (
- <>
-
·
-
-
{format(new Date(note.publishedAt), 'MMM d, yyyy')}
- >
- )}
+
+
+
+
+
+
+
+ {/* Top bar */}
+
+
+ {/* Article */}
+
+ {/* Meta */}
+
+
+ {note.publishedAt ? format(new Date(note.publishedAt), 'd MMM yyyy') : ''}
+ ·
+
+ {readingTime} min de lecture
+
+
+ {/* Title */}
+
{note.title || 'Sans titre'}
- {note.user?.name && (
- par {note.user.name}
- )}
-
- {/* Content */}
-
+ {/* Author */}
+ {note.user?.name && (
+
+ {note.user.image && (
+
+ )}
+
par {note.user.name}
+
+ )}
+
+ {/* Divider */}
+
+
+ {/* Content */}
+
+
+ {/* Custom styles injected */}
+
+
{/* Footer */}
-
-
- Publié sur Momento
-
-
-
-
+
+ Momento
+ {' — Votre mémoire augmentée par l\'IA'}
+
+
+
)
}
diff --git a/memento-note/app/(public)/p/[slug]/report/page.tsx b/memento-note/app/(public)/p/[slug]/report/page.tsx
new file mode 100644
index 0000000..e38cf1f
--- /dev/null
+++ b/memento-note/app/(public)/p/[slug]/report/page.tsx
@@ -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 (
+
+
+ {done ? (
+
+
+
+
+
Signalement envoyé
+
Merci. Notre équipe va examiner ce contenu dans les plus brefs délais.
+
+ ) : (
+ <>
+
+
+
+
+
Signaler ce contenu
+
+
+
+ Motif
+ setReason(e.target.value)} style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none', background: 'white' }}>
+ Sélectionner...
+ Contenu illégal
+ Discours de haine
+ Violence / Menaces
+ Contenu sexuel
+ Harcèlement
+ Violation de copyright
+ Spam / Arnaque
+ Autre
+
+
+
+
+ Détails (optionnel)
+
+
+
+ {submitting ? : 'Signaler'}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/memento-note/app/api/notes/report/route.ts b/memento-note/app/api/notes/report/route.ts
new file mode 100644
index 0000000..8174a30
--- /dev/null
+++ b/memento-note/app/api/notes/report/route.ts
@@ -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 })
+ }
+}
diff --git a/memento-note/components/note-editor/publish-dialog.tsx b/memento-note/components/note-editor/publish-dialog.tsx
index 3894339..353e9d9 100644
--- a/memento-note/components/note-editor/publish-dialog.tsx
+++ b/memento-note/components/note-editor/publish-dialog.tsx
@@ -5,6 +5,7 @@ import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
+import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
interface PublishDialogProps {
open: boolean
@@ -65,11 +66,9 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
finally { setLoading(false) }
}
- const copyLink = () => {
- navigator.clipboard.writeText(publicUrl)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- toast.success('Lien copié !')
+ const copyLink = async () => {
+ const ok = await copyTextToClipboard(publicUrl)
+ if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success('Lien copié !') }
}
return (
diff --git a/memento-note/lib/ai/services/content-moderation.service.ts b/memento-note/lib/ai/services/content-moderation.service.ts
new file mode 100644
index 0000000..0b5a62f
--- /dev/null
+++ b/memento-note/lib/ai/services/content-moderation.service.ts
@@ -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
{
+ 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()