feat: Publication de pages — notes publiques sur URL
- 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:
67
memento-note/app/(public)/p/[slug]/page.tsx
Normal file
67
memento-note/app/(public)/p/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
memento-note/app/actions/notes-publishing.ts
Normal file
81
memento-note/app/actions/notes-publishing.ts
Normal 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
|
||||
}
|
||||
52
memento-note/app/api/notes/publish/route.ts
Normal file
52
memento-note/app/api/notes/publish/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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) }}
|
||||
|
||||
128
memento-note/components/note-editor/publish-dialog.tsx
Normal file
128
memento-note/components/note-editor/publish-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user