From 1d614dd9c0bdf5058c3d315737cf13ee484428c2 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Fri, 19 Jun 2026 22:03:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Publication=20de=20pages=20=E2=80=94=20?= =?UTF-8?q?notes=20publiques=20sur=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: champs isPublic + publicSlug + publishedAt sur Note - Route publique /p/[slug] — rendu SSR sans auth, prose styled - Server actions: publishNote / unpublishNote / getPublishedNote - API /api/notes/publish — toggle publication + génération slug - PublishDialog — modal avec lien copiable + bouton dépublier - Bouton Globe dans le toolbar (vert si publiée) - i18n FR/EN - Pattern inspiré de BrainstormSession.isPublic --- memento-note/app/(public)/p/[slug]/page.tsx | 67 +++++++++ memento-note/app/actions/notes-publishing.ts | 81 +++++++++++ memento-note/app/api/notes/publish/route.ts | 52 +++++++ .../note-editor/note-editor-toolbar.tsx | 30 +++- .../components/note-editor/publish-dialog.tsx | 128 ++++++++++++++++++ memento-note/locales/en.json | 7 + memento-note/locales/fr.json | 7 + .../migration.sql | 7 + memento-note/prisma/schema.prisma | 3 + 9 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 memento-note/app/(public)/p/[slug]/page.tsx create mode 100644 memento-note/app/actions/notes-publishing.ts create mode 100644 memento-note/app/api/notes/publish/route.ts create mode 100644 memento-note/components/note-editor/publish-dialog.tsx create mode 100644 memento-note/prisma/migrations/20260619200000_add_note_publishing/migration.sql diff --git a/memento-note/app/(public)/p/[slug]/page.tsx b/memento-note/app/(public)/p/[slug]/page.tsx new file mode 100644 index 0000000..6eb997e --- /dev/null +++ b/memento-note/app/(public)/p/[slug]/page.tsx @@ -0,0 +1,67 @@ +import { notFound } from 'next/navigation' +import { getPublishedNote } from '@/app/actions/notes-publishing' +import { FileText, Calendar } from 'lucide-react' +import { format } from 'date-fns' + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const note = await getPublishedNote(slug) + if (!note) return { title: 'Note not found' } + return { title: note.title || 'Published note', description: note.content?.replace(/<[^>]+>/g, '').slice(0, 160) } +} + +export const dynamic = 'force-static' +export const revalidate = 3600 + +export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const note = await getPublishedNote(slug) + + if (!note) notFound() + + return ( +
+
+ {/* Header */} +
+
+ + Note publiée + {note.publishedAt && ( + <> + · + + {format(new Date(note.publishedAt), 'MMM d, yyyy')} + + )} +
+

+ {note.title || 'Sans titre'} +

+ {note.user?.name && ( +

par {note.user.name}

+ )} +
+ + {/* Content */} +
+ + {/* Footer */} +
+

+ Publié sur Momento +

+
+
+
+ ) +} diff --git a/memento-note/app/actions/notes-publishing.ts b/memento-note/app/actions/notes-publishing.ts new file mode 100644 index 0000000..ec413af --- /dev/null +++ b/memento-note/app/actions/notes-publishing.ts @@ -0,0 +1,81 @@ +'use server' + +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { revalidatePath } from 'next/cache' + +function generateSlug(title: string): string { + const base = title + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'note' + + return `${base}-${Math.random().toString(36).slice(2, 8)}` +} + +export async function publishNote(noteId: string): Promise<{ success: boolean; slug?: string; url?: string; error?: string }> { + const session = await auth() + if (!session?.user?.id) return { success: false, error: 'Unauthorized' } + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true, title: true, isPublic: true, publicSlug: true }, + }) + if (!note) return { success: false, error: 'Note not found' } + + let slug = note.publicSlug + if (!slug) { + slug = generateSlug(note.title || 'note') + // Ensure uniqueness + const existing = await prisma.note.findUnique({ where: { publicSlug: slug } }) + if (existing && existing.id !== noteId) { + slug = `${slug}-${Date.now().toString(36)}` + } + } + + await prisma.note.update({ + where: { id: noteId }, + data: { isPublic: true, publicSlug: slug, publishedAt: new Date() }, + }) + + revalidatePath(`/p/${slug}`) + return { success: true, slug, url: `/p/${slug}` } +} + +export async function unpublishNote(noteId: string): Promise<{ success: boolean; error?: string }> { + const session = await auth() + if (!session?.user?.id) return { success: false, error: 'Unauthorized' } + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true, publicSlug: true }, + }) + if (!note) return { success: false, error: 'Note not found' } + + await prisma.note.update({ + where: { id: noteId }, + data: { isPublic: false, publicSlug: null, publishedAt: null }, + }) + + if (note.publicSlug) revalidatePath(`/p/${note.publicSlug}`) + return { success: true } +} + +export async function getPublishedNote(slug: string) { + const note = await prisma.note.findFirst({ + where: { publicSlug: slug, isPublic: true, trashedAt: null }, + select: { + id: true, + title: true, + content: true, + publishedAt: true, + createdAt: true, + updatedAt: true, + user: { select: { name: true, image: true } }, + }, + }) + return note +} diff --git a/memento-note/app/api/notes/publish/route.ts b/memento-note/app/api/notes/publish/route.ts new file mode 100644 index 0000000..8ff0bc8 --- /dev/null +++ b/memento-note/app/api/notes/publish/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' + +function generateSlug(title: string): string { + const base = title + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'note' + return `${base}-${Math.random().toString(36).slice(2, 8)}` +} + +export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { noteId, action } = await request.json() + if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 }) + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true, title: true, publicSlug: true }, + }) + if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + if (action === 'publish') { + let slug = note.publicSlug + if (!slug) { + slug = generateSlug(note.title || 'note') + const existing = await prisma.note.findUnique({ where: { publicSlug: slug } }) + if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}` + } + await prisma.note.update({ + where: { id: noteId }, + data: { isPublic: true, publicSlug: slug, publishedAt: new Date() }, + }) + return NextResponse.json({ success: true, slug }) + } + + if (action === 'unpublish') { + await prisma.note.update({ + where: { id: noteId }, + data: { isPublic: false, publicSlug: null, publishedAt: null }, + }) + return NextResponse.json({ success: true }) + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) +} diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx index cf48aa9..334a7b3 100644 --- a/memento-note/components/note-editor/note-editor-toolbar.tsx +++ b/memento-note/components/note-editor/note-editor-toolbar.tsx @@ -19,10 +19,11 @@ import { Badge } from '@/components/ui/badge' import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal, - Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon + Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe } from 'lucide-react' import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog' import { NoteShareDialog } from './note-share-dialog' +import { PublishDialog } from './publish-dialog' import { deleteNote, leaveSharedNote } from '@/app/actions/notes' import { emitNoteChange } from '@/lib/note-change-sync' import { useLanguage } from '@/lib/i18n' @@ -46,6 +47,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme const [isConverting, setIsConverting] = useState(false) const [shareOpen, setShareOpen] = useState(false) const [flashcardsOpen, setFlashcardsOpen] = useState(false) + const [publishOpen, setPublishOpen] = useState(false) const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null) @@ -496,6 +498,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme )} + {!readOnly && ( + + )} + {!readOnly && ( + + +

+ {t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'} +

+ + {isPublic && slug ? ( +
+
+ + {t('richTextEditor.publishLive') || 'En ligne'} +
+
+ {publicUrl} + + + + +
+ +
+ ) : ( + + )} + + + ) +} diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 1d6ab6b..14d4e54 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -2563,6 +2563,13 @@ "slashAiWriter": "Write with AI", "slashAiWriterDesc": "Generate content at cursor", "aiWriterPlaceholder": "Describe what you want to write...", + "publishTitle": "Public publishing", + "publishDesc": "Publish this note on a public URL. Anyone with the link can read it.", + "publish": "Publish", + "publishSuccess": "Note published!", + "publishLive": "Live", + "unpublish": "Unpublish", + "unpublishSuccess": "Note unpublished", "slashSubPage": "Sub-page", "slashSubPageDesc": "Create a linked note inside this note", "exercisesLoading": "Generating exercises...", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index fb97b1c..ebfae14 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -2567,6 +2567,13 @@ "slashAiWriter": "Écrire avec l'IA", "slashAiWriterDesc": "Générer du contenu au curseur", "aiWriterPlaceholder": "Décris ce que tu veux écrire...", + "publishTitle": "Publication publique", + "publishDesc": "Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.", + "publish": "Publier", + "publishSuccess": "Note publiée !", + "publishLive": "En ligne", + "unpublish": "Dépublier", + "unpublishSuccess": "Note dépubliée", "slashSubPage": "Sous-page", "slashSubPageDesc": "Créer une note liée dans cette note", "exercisesLoading": "Génération des exercices...", diff --git a/memento-note/prisma/migrations/20260619200000_add_note_publishing/migration.sql b/memento-note/prisma/migrations/20260619200000_add_note_publishing/migration.sql new file mode 100644 index 0000000..0a95a13 --- /dev/null +++ b/memento-note/prisma/migrations/20260619200000_add_note_publishing/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Note" ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Note" ADD COLUMN "publicSlug" TEXT; +ALTER TABLE "Note" ADD COLUMN "publishedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX "Note_publicSlug_key" ON "Note"("publicSlug"); diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 8e7d376..6f9f0e6 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -176,6 +176,9 @@ model Note { lastAiAnalysis DateTime? trashedAt DateTime? historyEnabled Boolean @default(false) + isPublic Boolean @default(false) + publicSlug String? @unique + publishedAt DateTime? /// URL d'origine pour les clips web (Web Clipper) sourceUrl String? /// Note de démonstration insérée lors de l'onboarding