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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user