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 && (
+ {publishOpen && (
+ setPublishOpen(false)}
+ noteId={note.id}
+ noteTitle={state.title || note.title || 'Untitled'}
+ isPublic={note.isPublic}
+ publicSlug={note.publicSlug ?? null}
+ />
+ )}
+