feat: Publication de pages — notes publiques sur URL
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m0s
CI / Deploy production (on server) (push) Failing after 40s

- 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
This commit is contained in:
Antigravity
2026-06-19 22:03:53 +00:00
parent 299836bd74
commit 1d614dd9c0
9 changed files with 381 additions and 1 deletions

View File

@@ -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 (
<div className="min-h-screen bg-[#F2F0E9] dark:bg-zinc-950">
<div className="max-w-3xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8 pb-6 border-b border-black/10 dark:border-white/10">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
<FileText size={14} />
<span>Note publiée</span>
{note.publishedAt && (
<>
<span>·</span>
<Calendar size={12} />
<span>{format(new Date(note.publishedAt), 'MMM d, yyyy')}</span>
</>
)}
</div>
<h1 className="text-3xl font-serif font-medium tracking-tight text-foreground leading-tight mb-2">
{note.title || 'Sans titre'}
</h1>
{note.user?.name && (
<p className="text-sm text-muted-foreground">par {note.user.name}</p>
)}
</div>
{/* Content */}
<article
dir="auto"
className="prose prose-stone dark:prose-invert max-w-none
prose-headings:font-serif prose-headings:font-medium
prose-p:leading-relaxed prose-p:text-[15px]
prose-pre:bg-zinc-100 prose-pre:dark:bg-zinc-900
prose-blockquote:border-brand-accent/40
prose-a:text-brand-accent"
dangerouslySetInnerHTML={{ __html: note.content || '' }}
/>
{/* Footer */}
<div className="mt-12 pt-6 border-t border-black/10 dark:border-white/10 text-center">
<p className="text-xs text-muted-foreground">
Publié sur Momento
</p>
</div>
</div>
</div>
)
}

View File

@@ -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
}

View File

@@ -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 })
}

View File

@@ -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
</div>
)}
{!readOnly && (
<button
title={t('richTextEditor.publishTitle') || 'Publication publique'}
onClick={() => setPublishOpen(true)}
className={cn(
"p-1.5 rounded-full border transition-all",
note.isPublic
? "border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20"
: "border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5"
)}
>
<Globe size={16} />
</button>
)}
{!readOnly && (
<button
title={t('notes.shareNoteTitle')}
@@ -588,6 +605,17 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
}}
/>
{publishOpen && (
<PublishDialog
open={publishOpen}
onClose={() => setPublishOpen(false)}
noteId={note.id}
noteTitle={state.title || note.title || 'Untitled'}
isPublic={note.isPublic}
publicSlug={note.publicSlug ?? null}
/>
)}
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}

View File

@@ -0,0 +1,128 @@
'use client'
import { useState, useEffect } from 'react'
import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
interface PublishDialogProps {
open: boolean
onClose: () => void
noteId: string
noteTitle: string
isPublic: boolean
publicSlug: string | null
}
export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: initialPublic, publicSlug }: PublishDialogProps) {
const { t } = useLanguage()
const [isPublic, setIsPublic] = useState(initialPublic)
const [slug, setSlug] = useState(publicSlug)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => { setIsPublic(initialPublic); setSlug(publicSlug) }, [initialPublic, publicSlug])
if (!open) return null
const publicUrl = slug ? `${window.location.origin}/p/${slug}` : ''
const handlePublish = async () => {
setLoading(true)
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, action: 'publish' }),
})
const data = await res.json()
if (res.ok && data.slug) {
setIsPublic(true)
setSlug(data.slug)
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
} else {
toast.error(data.error || 'Erreur')
}
} catch { toast.error('Erreur') }
finally { setLoading(false) }
}
const handleUnpublish = async () => {
setLoading(true)
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, action: 'unpublish' }),
})
if (res.ok) {
setIsPublic(false)
setSlug(null)
toast.success(t('richTextEditor.unpublishSuccess') || 'Note dépubliée')
} else { toast.error('Erreur') }
} catch { toast.error('Erreur') }
finally { setLoading(false) }
}
const copyLink = () => {
navigator.clipboard.writeText(publicUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast.success('Lien copié !')
}
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" dir="auto" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl border border-border bg-card shadow-2xl p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 flex items-center justify-center">
<Globe size={15} className="text-brand-accent" />
</div>
<h3 className="text-sm font-semibold">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground"><X size={16} /></button>
</div>
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
</p>
{isPublic && slug ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
<Check size={14} className="text-green-500 shrink-0" />
<span className="text-xs font-medium text-green-700 dark:text-green-400">{t('richTextEditor.publishLive') || 'En ligne'}</span>
</div>
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-border bg-background">
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicUrl}</span>
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
<ExternalLink size={14} />
</a>
</div>
<button
onClick={handleUnpublish}
disabled={loading}
className="w-full py-2 rounded-xl border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors disabled:opacity-40"
>
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
</button>
</div>
) : (
<button
onClick={handlePublish}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
{t('richTextEditor.publish') || 'Publier'}
</button>
)}
</div>
</div>
)
}

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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");

View File

@@ -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