feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
This commit is contained in:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View File

@@ -419,7 +419,7 @@ export function ByokSettingsPanel() {
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Momento</span></>
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Memento</span></>
)}
</div>
)}

View File

@@ -69,6 +69,7 @@ export function AutoLabelSuggestionDialog({
setSuggestions(data.data)
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
setSelectedLabels(allLabelNames)
window.dispatchEvent(new Event('ai-usage-changed'))
} else {
if (data.message) {
toast.info(data.message)

View File

@@ -275,6 +275,13 @@ export function ContextualAIChat({
const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg)
const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent
// Dispatch quota refresh quand le streaming se termine
useEffect(() => {
if (status === 'ready' && messages.length > 0) {
window.dispatchEvent(new Event('ai-usage-changed'))
}
}, [status])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, resourcePreview])
@@ -1283,7 +1290,7 @@ export function ContextualAIChat({
<div className="flex flex-col items-center gap-4 py-8 opacity-20 mt-auto shrink-0 border-t border-border/10">
<PenTool size={20} />
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">
{t('nav.workspace') || 'Momento Workspace'}
{t('nav.workspace') || 'Memento Workspace'}
</span>
</div>
</div>

View File

@@ -20,7 +20,7 @@ import {
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
@@ -58,10 +58,18 @@ const OrganizeNotebookDialog = dynamic(
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
{ ssr: false }
)
const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
)
const StructuredViewsIntro = dynamic(
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
{ ssr: false }
)
const StructuredViewsWizard = dynamic(
() => import('@/components/structured-views/structured-views-wizard').then(m => ({ default: m.StructuredViewsWizard })),
{ ssr: false }
)
const StructuredViewsHelpBanner = dynamic(
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
{ ssr: false }
@@ -95,7 +103,7 @@ export function HomeClient({
}: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const { t, language } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes)
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
@@ -136,6 +144,10 @@ export function HomeClient({
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
const [showNotebookSite, setShowNotebookSite] = useState(false)
const [aiMenuOpen, setAiMenuOpen] = useState(false)
const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false)
@@ -237,7 +249,7 @@ export function HomeClient({
}
let allNotes = search
? await searchNotes(search, true, notebook || undefined)
? await searchNotes(search, true, notebook || undefined).then(r => { window.dispatchEvent(new Event('ai-usage-changed')); return r })
: await getAllNotes(false, notebook || undefined)
if (sharedOnly) {
@@ -1045,31 +1057,65 @@ export function HomeClient({
)}
{searchParams.get('notebook') && initialSettings.aiAssistantEnabled && (
<div className="flex items-center gap-3">
<div className="relative" ref={aiMenuRef}>
<button
onClick={() => setSummaryDialogOpen(true)}
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setAiMenuOpen(o => !o)}
className={cn(
'flex items-center gap-1.5 text-[12px] font-medium transition-colors px-2.5 py-1.5 rounded-lg border',
aiMenuOpen
? 'bg-brand-accent/10 border-brand-accent/30 text-brand-accent'
: 'border-border/50 text-muted-foreground hover:text-foreground hover:border-border',
)}
>
<FileText size={14} />
<span>{t('notebook.summary')}</span>
</button>
<span className="w-px h-3.5 bg-border/40" />
<button
onClick={() => setShowStudyPlanner(true)}
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
>
<CalendarDays size={14} />
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
</button>
<span className="w-px h-3.5 bg-border/40" />
<button
onClick={() => setOrganizeNotebookOpen(true)}
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
title={t('notebook.organizeNotebookWithAITooltip')}
>
<Sparkles size={14} />
<span>{t('batch.organize')}</span>
<Sparkles size={13} />
<span>IA</span>
<ChevronDown size={11} className={cn('transition-transform', aiMenuOpen && 'rotate-180')} />
</button>
{aiMenuOpen && (
<>
{/* overlay invisible pour fermer au clic extérieur */}
<div className="fixed inset-0 z-10" onClick={() => setAiMenuOpen(false)} />
<div className="absolute right-0 top-full mt-1.5 z-20 w-52 bg-popover border border-border rounded-xl shadow-lg overflow-hidden">
{[
{
icon: <FileText size={14} />,
label: t('notebook.summary') || 'Résumé du carnet',
action: () => { setSummaryDialogOpen(true); setAiMenuOpen(false) },
},
{
icon: <CalendarDays size={14} />,
label: t('wizard.studyPlanner') || 'Planning de révision',
action: () => { setShowStudyPlanner(true); setAiMenuOpen(false) },
},
{
icon: <Sparkles size={14} />,
label: t('batch.organize') || 'Organiser avec l\'IA',
action: () => { setOrganizeNotebookOpen(true); setAiMenuOpen(false) },
},
{
icon: <TagIcon size={14} />,
label: t('wizard.autoTags') || 'Tags automatiques',
action: () => { setShowOrganizer(true); setAiMenuOpen(false) },
},
{
icon: <Globe size={14} />,
label: t('notebookSite.shortTitle') || 'Site web',
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
},
].map((item, i) => (
<button
key={i}
onClick={item.action}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-[12px] text-foreground hover:bg-muted/60 transition-colors text-left"
>
<span className="text-muted-foreground">{item.icon}</span>
{item.label}
</button>
))}
</div>
</>
)}
</div>
)}
{searchParams.get('notebook') && (
@@ -1185,11 +1231,29 @@ export function HomeClient({
) : showStructuredLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : showStructuredIntro ? (
<StructuredViewsIntro
target={structuredViewMode}
enabling={isEnablingStructured}
onEnable={() => void handleEnableStructured(structuredViewMode)}
/>
showStructuredWizard ? (
<StructuredViewsWizard
open={showStructuredWizard}
onClose={() => setShowStructuredWizard(false)}
onComplete={(view) => {
setShowStructuredWizard(false)
if (view !== 'gallery') setLayoutMode(view)
void schemaHook.reload()
}}
structuredModeActive={structuredModeActive}
enableStructuredMode={schemaHook.enableStructuredMode}
addProperty={schemaHook.addProperty}
setKanbanGroupProperty={schemaHook.setKanbanGroupProperty}
initialGoal={structuredViewMode === 'kanban' ? 'tasks' : undefined}
/>
) : (
<StructuredViewsIntro
target={structuredViewMode}
enabling={isEnablingStructured}
onEnable={() => void handleEnableStructured(structuredViewMode)}
onOpenWizard={() => setShowStructuredWizard(true)}
/>
)
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
<>
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
@@ -1347,6 +1411,14 @@ export function HomeClient({
onClose={() => setShowOrganizer(false)}
/>
)}
{showNotebookSite && currentNotebook && (
<NotebookSiteDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowNotebookSite(false)}
/>
)}
</div>
)
}

View File

@@ -49,7 +49,7 @@ export function LandingPage() {
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
<span className="text-paper font-serif text-2xl font-bold">M</span>
</div>
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
</div>
<div className="hidden md:flex items-center gap-10">
@@ -104,7 +104,7 @@ export function LandingPage() {
{/* App Preview Mockup */}
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
<img src="/images/workspace-hero.jpg" alt="Momento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
<img src="/images/workspace-hero.jpg" alt="Memento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
@@ -381,7 +381,7 @@ export function LandingPage() {
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
<span className="text-paper font-serif text-lg font-bold">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</div>
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
</div>

View File

@@ -839,11 +839,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
void handleSaveInPlaceRef.current()
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}
}
document.addEventListener('keydown', handler)

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -19,11 +19,10 @@ 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, Globe
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink
} 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'
@@ -33,6 +32,10 @@ import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
import { toast } from 'sonner'
import { format } from 'date-fns'
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
import { getCalloutColors } from '@/lib/editor/callout-colors'
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { PUBLISH_TEMPLATES, type PublishTemplateId } from '@/lib/publish/types'
interface NoteEditorToolbarProps {
mode: 'fullPage' | 'dialog'
@@ -44,12 +47,64 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const [publishOpen, setPublishOpen] = useState(false)
const [publishLoading, setPublishLoading] = useState(false)
const [publishMeta, setPublishMeta] = useState({
isPublic: Boolean(note.isPublic),
slug: note.publicSlug ?? null,
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
})
const [publishLinkCopied, setPublishLinkCopied] = useState(false)
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
const [publishRewrite, setPublishRewrite] = useState(false)
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null)
const [publishEnhanceLocked, setPublishEnhanceLocked] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
useEffect(() => {
setPublishMeta({
isPublic: Boolean(note.isPublic),
slug: note.publicSlug ?? null,
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
})
}, [note.id, note.isPublic, note.publicSlug, note.publishedTemplate])
useEffect(() => {
if (!publishOpen) return
const loadPublishQuota = () => {
void fetch('/api/usage/current')
.then((r) => r.ok ? r.json() : null)
.then((data) => {
const q = data?.quotas?.publish_enhance
if (q === undefined) {
setPublishEnhanceLocked(false)
setPublishEnhanceRemaining(null)
return
}
if (q.limit === 0) {
setPublishEnhanceLocked(true)
setPublishEnhanceRemaining(0)
return
}
setPublishEnhanceLocked(false)
setPublishEnhanceRemaining(q.remaining ?? 0)
})
.catch(() => {
setPublishEnhanceLocked(false)
setPublishEnhanceRemaining(null)
})
}
loadPublishQuota()
window.addEventListener('ai-usage-changed', loadPublishQuota)
return () => window.removeEventListener('ai-usage-changed', loadPublishQuota)
}, [publishOpen])
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
// ── Voice transcription ──────────────────────────────────────────────────
@@ -137,18 +192,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
// Apply callout colors as inline styles (Tailwind classes won't work in print window)
clone.querySelectorAll('[data-callout-type]').forEach(el => {
const type = el.getAttribute('data-callout-type')
const colors: Record<string, { bg: string; border: string }> = {
info: { bg: '#eff6ff', border: '#93c5fd' },
warning: { bg: '#fffbeb', border: '#fcd34d' },
tip: { bg: '#faf5ff', border: '#c4b5fd' },
success: { bg: '#f0fdf4', border: '#86efac' },
danger: { bg: '#fef2f2', border: '#fca5a5' },
}
const c = colors[type || 'info'] || colors.info
const inner = el.querySelector('div')
const { bg, border } = getCalloutColors(type)
const inner = el.querySelector('div') as HTMLElement | null
if (inner) {
(inner as HTMLElement).style.background = c.bg
(inner as HTMLElement).style.borderColor = c.border
inner.style.background = bg
inner.style.borderColor = border
}
})
@@ -239,8 +287,176 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const [generatingExercises, setGeneratingExercises] = useState(false)
const [showEduMenu, setShowEduMenu] = useState(false)
const publicPageUrl = publishMeta.slug
? `${typeof window !== 'undefined' ? window.location.origin : ''}/p/${publishMeta.slug}`
: ''
const handlePublishNote = async () => {
if (publishLoading) return
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
}
setPublishLoading(true)
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId: note.id, action: 'publish', mode: 'simple' }),
})
const data = await res.json()
if (res.ok && data.slug) {
setPublishMeta({ isPublic: true, slug: data.slug, template: null })
emitNoteChange({
type: 'updated',
note: { ...note, isPublic: true, publicSlug: data.slug, publishedTemplate: null },
})
const url = `${window.location.origin}/p/${data.slug}`
toast.success(t('richTextEditor.publishSuccess'), {
description: url,
action: {
label: t('richTextEditor.publishLive') || 'Voir',
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
},
duration: 8000,
})
setPublishOpen(false)
} else if (data.error === 'blocked') {
toast.error(t('richTextEditor.publishBlocked'), {
description: data.reason || undefined,
duration: 6000,
})
} else {
toast.error(data.error || t('general.error'))
}
} catch {
toast.error(t('general.error'))
} finally {
setPublishLoading(false)
}
}
const publishTemplateLabels: Record<PublishTemplateId, string> = {
magazine: t('richTextEditor.publishTemplateMagazine'),
brief: t('richTextEditor.publishTemplateBrief'),
essay: t('richTextEditor.publishTemplateEssay'),
}
const handlePublishWithAi = async () => {
if (publishLoading || publishEnhanceLocked || (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)) return
const consented = await requestAiConsent()
if (!consented) return
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
}
setPublishLoading(true)
const toastId = toast.loading(t('richTextEditor.publishWithAiGenerating'))
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId: note.id,
action: 'publish',
mode: 'ai',
template: publishTemplate,
rewrite: publishRewrite,
language,
}),
})
const data = await res.json()
if (res.status === 402) {
toast.dismiss(toastId)
toast.error(
data.errorKey === 'ai.featureLocked'
? t('richTextEditor.publishWithAiLocked')
: t('ai.quotaExceeded'),
)
return
}
if (res.ok && data.slug) {
setPublishMeta({ isPublic: true, slug: data.slug, template: publishTemplate })
emitNoteChange({
type: 'updated',
note: {
...note,
isPublic: true,
publicSlug: data.slug,
publishedTemplate: publishTemplate,
},
})
window.dispatchEvent(new Event('ai-usage-changed'))
const url = `${window.location.origin}/p/${data.slug}`
toast.success(t('richTextEditor.publishAiSuccess'), {
id: toastId,
description: url,
action: {
label: t('richTextEditor.publishLive'),
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
},
duration: 8000,
})
setPublishOpen(false)
} else if (data.error === 'blocked') {
toast.dismiss(toastId)
toast.error(t('richTextEditor.publishBlocked'), {
description: data.reason || undefined,
duration: 6000,
})
} else {
toast.dismiss(toastId)
toast.error(data.error || t('general.error'))
}
} catch {
toast.dismiss(toastId)
toast.error(t('general.error'))
} finally {
setPublishLoading(false)
}
}
const handleUnpublishNote = async () => {
if (publishLoading) return
setPublishLoading(true)
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId: note.id, action: 'unpublish' }),
})
if (res.ok) {
setPublishMeta({ isPublic: false, slug: null, template: null })
emitNoteChange({
type: 'updated',
note: { ...note, isPublic: false, publicSlug: null, publishedTemplate: null },
})
toast.success(t('richTextEditor.unpublishSuccess'))
setPublishOpen(false)
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || t('general.error'))
}
} catch {
toast.error(t('general.error'))
} finally {
setPublishLoading(false)
}
}
const handleCopyPublicLink = async () => {
if (!publicPageUrl) return
const ok = await copyTextToClipboard(publicPageUrl)
if (ok) {
setPublishLinkCopied(true)
setTimeout(() => setPublishLinkCopied(false), 2000)
toast.success(t('richTextEditor.copyPublicLink') || 'Lien copié')
}
}
const handleGenerateExercises = async () => {
if (generatingExercises) return
const consented = await requestAiConsent()
if (!consented) return
setGeneratingExercises(true)
try {
const res = await fetch('/api/ai/generate-exercises', {
@@ -252,13 +468,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
if (!res.ok) {
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
} else {
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés dans ce carnet !'}`, {
action: {
label: t('richTextEditor.seeExercises') || 'Voir',
onClick: () => window.location.reload(),
},
})
// Emit events so the note list refreshes
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés !'}`)
for (const ex of data.exercises || []) {
emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '<p></p>' } as any })
}
@@ -499,18 +709,191 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
)}
{!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>
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
title={t('richTextEditor.publishTitle')}
aria-label={t('richTextEditor.publishTitle')}
className={cn(
'p-1.5 rounded-full border transition-all',
publishMeta.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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 rounded-xl p-4 shadow-xl">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
<Globe size={13} className="text-brand-accent" />
</div>
<span className="text-sm font-semibold">{t('richTextEditor.publishTitle')}</span>
</div>
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t('richTextEditor.publishDesc')}
</p>
{publishMeta.isPublic && publishMeta.slug ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2 rounded-lg 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')}
{publishMeta.template && (
<span className="text-green-600/80 dark:text-green-400/80">
{' · '}{publishTemplateLabels[publishMeta.template]}
</span>
)}
</span>
</div>
<div className="flex items-center gap-1.5 p-2 rounded-lg border border-border bg-muted/40">
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicPageUrl}</span>
<button
type="button"
onClick={handleCopyPublicLink}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={t('richTextEditor.copyPublicLink')}
>
{publishLinkCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<a
href={publicPageUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={t('richTextEditor.openPublicPage')}
>
<ExternalLink size={14} />
</a>
</div>
<button
type="button"
onClick={handleUnpublishNote}
disabled={publishLoading}
className="w-full py-2 rounded-lg 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 disabled:opacity-40 transition-colors"
>
{publishLoading ? <Loader2 size={14} className="animate-spin mx-auto" /> : t('richTextEditor.unpublish')}
</button>
</div>
) : (
<div className="space-y-3">
<button
type="button"
onClick={handlePublishNote}
disabled={publishLoading}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-border bg-background text-sm font-medium hover:bg-muted disabled:opacity-40 transition-colors"
>
{publishLoading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
{t('richTextEditor.publishSimple')}
</button>
<p className="text-[10px] text-muted-foreground text-center -mt-1">
{t('richTextEditor.publishSimpleHint')}
</p>
<div className="border-t border-border/50 pt-3 space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles size={13} className="text-brand-accent shrink-0" />
<span className="text-xs font-semibold">{t('richTextEditor.publishWithAi')}</span>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{publishEnhanceLocked
? t('richTextEditor.publishWithAiLocked')
: t('richTextEditor.publishWithAiHint').replace(
'{count}',
String(publishEnhanceRemaining ?? '…'),
)}
</p>
{/* Sélection template */}
<div className="space-y-1">
{PUBLISH_TEMPLATES.map((tpl) => (
<label
key={tpl}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer text-xs transition-colors',
publishTemplate === tpl
? 'bg-brand-accent/10 text-brand-accent font-medium'
: 'hover:bg-muted text-foreground',
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
)}
>
<input
type="radio"
name="publish-template"
value={tpl}
checked={publishTemplate === tpl}
onChange={() => setPublishTemplate(tpl)}
className="accent-[var(--color-brand-accent)]"
/>
{publishTemplateLabels[tpl]}
</label>
))}
</div>
{/* Toggle reformulation */}
<div
className={cn(
'rounded-xl border p-3 transition-colors',
publishRewrite
? 'border-brand-accent/40 bg-brand-accent/5'
: 'border-border bg-muted/30',
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
)}
>
<label className="flex items-start gap-2.5 cursor-pointer">
<div className="relative mt-0.5 shrink-0">
<input
type="checkbox"
checked={publishRewrite}
onChange={e => setPublishRewrite(e.target.checked)}
className="sr-only"
/>
<div className={cn(
'w-8 h-4 rounded-full transition-colors',
publishRewrite ? 'bg-brand-accent' : 'bg-muted-foreground/30',
)}>
<div className={cn(
'w-3 h-3 rounded-full bg-white shadow transition-transform mt-0.5',
publishRewrite ? 'translate-x-4' : 'translate-x-0.5',
)} />
</div>
</div>
<div>
<p className="text-xs font-semibold leading-tight">
{t('richTextEditor.publishRewriteLabel')}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
{publishRewrite
? t('richTextEditor.publishRewriteOnHint')
: t('richTextEditor.publishRewriteOffHint')}
</p>
</div>
</label>
</div>
<button
type="button"
onClick={handlePublishWithAi}
disabled={
publishLoading
|| publishEnhanceLocked
|| (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)
}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 disabled:opacity-40 transition-colors"
>
{publishLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Sparkles size={14} />
)}
{t('richTextEditor.publishWithAi')}
</button>
</div>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{!readOnly && (
@@ -752,16 +1135,6 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
)}
</div>
</div>
{publishOpen && (
<PublishDialog
open={publishOpen}
onClose={() => setPublishOpen(false)}
noteId={note.id}
noteTitle={state.title || note.title || 'Untitled'}
isPublic={note.isPublic}
publicSlug={note.publicSlug ?? null}
/>
)}
</>
)
}

View File

@@ -1,10 +1,10 @@
'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
interface PublishDialogProps {
@@ -25,8 +25,6 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
useEffect(() => { setIsPublic(initialPublic); setSlug(publicSlug) }, [initialPublic, publicSlug])
if (!open) return null
const publicUrl = slug ? `${window.location.origin}/p/${slug}` : ''
const handlePublish = async () => {
@@ -41,16 +39,10 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
if (res.ok && data.slug) {
setIsPublic(true)
setSlug(data.slug)
if (data.moderation === 'flagged') {
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !', {
description: '⚠️ Un modérateur examinera le contenu sous peu.',
})
} else {
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
}
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
} else if (data.error === 'blocked') {
toast.error(t('richTextEditor.publishBlocked') || 'Publication refusée', {
description: data.reason || 'Le contenu ne respecte pas les règles de publication.',
description: data.reason || 'Le contenu ne respecte pas les règles.',
duration: 6000,
})
} else {
@@ -82,57 +74,70 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
if (ok) { 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 max-h-[90vh] overflow-y-auto 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">
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
<div className="flex items-center gap-2.5">
<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>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{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>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-400">
<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>
{/* Body */}
<div className="px-5 py-4">
<p className="text-xs text-gray-500 dark:text-gray-400 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} />}
{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-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800">
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate px-1">{publicUrl}</span>
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white 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-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white 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>
<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}
onClick={handlePublish}
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"
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 mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
{t('richTextEditor.publish') || '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>
</div>
</div>,
document.body
)
}

View File

@@ -41,7 +41,7 @@ export function OrganizeNotebookDialog({
const handleAnalyze = useCallback(async () => {
setStep('analyzing'); setError(null); setPlan(null)
const res = await analyzeNotebookForOrganization(notebookId)
const res = await analyzeNotebookForOrganization(notebookId, language)
if (!res.success || !res.plan) { setError(res.error ?? 'Erreur'); setStep('idle'); return }
setPlan(res.plan)
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))

View File

@@ -695,7 +695,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
</div>
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
<Command size={10} />
<span>Momento Search</span>
<span>Memento Search</span>
</div>
</div>
</motion.div>

View File

@@ -209,7 +209,7 @@ export function BillingPlans() {
name: t('billing.freePlan'),
price: t('billing.freePrice') || 'Gratuit',
period: '',
description: t('billing.freeDescription') || 'Pour découvrir la magie de Momento.',
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
features: [
t('billing.freeF1') || '100 Notes max',
t('billing.freeF2') || '3 Carnets',

View File

@@ -46,6 +46,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
};
const currentFeatureLabel = featureLabels[feature] || feature;

View File

@@ -20,6 +20,7 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
aiTranscribe: 'sidebar.aiTranscribe',
aiDiagram: 'sidebar.aiDiagram',
aiAgent: 'sidebar.aiAgent',
publish_enhance: 'usageMeter.featurePublishEnhance',
};
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {

View File

@@ -398,25 +398,27 @@ function SidebarCarnetItem({
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
)}
<div
{...dragHandleProps}
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<GripVertical size={12} />
</div>
<div className="flex-1 flex items-center gap-0.5">
{/* Drag handle — visible au hover, dans son propre slot avant le chevron */}
<div
{...dragHandleProps}
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
onClick={(e) => e.stopPropagation()}
>
<GripVertical size={11} />
</div>
<div className="flex-1 flex items-center gap-1">
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
</motion.div>
</button>
) : (
<div className="w-6" />
<div className="w-5" />
)}
<motion.div
@@ -1704,9 +1706,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{showAiWizard && (
<AiNotebookWizard
onClose={() => setShowAiWizard(false)}
onComplete={() => {
onComplete={(notebookId: string) => {
setShowAiWizard(false)
window.location.reload()
router.push(`/home?notebook=${notebookId}`)
}}
/>
)}

View File

@@ -1,6 +1,6 @@
'use client'
import { Columns3, Database, Table2, type LucideIcon } from 'lucide-react'
import { Columns3, Database, Table2, Wand2, type LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import type { BootstrapStructuredTarget } from '@/lib/structured-views/bootstrap-structured-notebook'
@@ -9,9 +9,10 @@ type StructuredViewsIntroProps = {
target: BootstrapStructuredTarget
enabling?: boolean
onEnable: () => void
onOpenWizard?: () => void
}
export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredViewsIntroProps) {
export function StructuredViewsIntro({ target, enabling, onEnable, onOpenWizard }: StructuredViewsIntroProps) {
const { t } = useLanguage()
return (
@@ -47,21 +48,33 @@ export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredV
? t('structuredViews.intro.activateKanbanHint')
: t('structuredViews.intro.activateTableHint')}
</p>
<button
type="button"
disabled={enabling}
onClick={onEnable}
className={cn(
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
disabled={enabling}
onClick={onEnable}
className={cn(
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
)}
>
{enabling
? t('structuredViews.intro.enabling')
: target === 'kanban'
? t('structuredViews.intro.enableKanban')
: t('structuredViews.intro.enableTable')}
</button>
{onOpenWizard && (
<button
type="button"
onClick={onOpenWizard}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-full text-[11px] font-semibold transition-all border border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
>
<Wand2 size={13} />
{t('structuredViews.wizard.openFromKanban') || 'Configurer avec l\'assistant'}
</button>
)}
>
{enabling
? t('structuredViews.intro.enabling')
: target === 'kanban'
? t('structuredViews.intro.enableKanban')
: t('structuredViews.intro.enableTable')}
</button>
</div>
</div>
</div>
)

View File

@@ -32,8 +32,8 @@ export function UsageMeter({ className }: UsageMeterProps) {
const json = await res.json();
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
},
staleTime: 5000,
refetchInterval: 10000,
staleTime: 0,
refetchInterval: 5000,
});
useEffect(() => {
@@ -71,6 +71,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
'chat',
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
'suggest_charts',
'publish_enhance',
]);
const featureLabels: Record<string, string> = {
@@ -81,6 +82,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
suggest_charts: t('usageMeter.featureCharts'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
};
const featureQuotas = Object.entries(data.quotas)

View File

@@ -43,6 +43,7 @@ export function NotebookOrganizerDialog({
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
} else {
setResult(data)
window.dispatchEvent(new Event('ai-usage-changed'))
}
} catch (e: any) {
toast.error(e.message || 'Erreur')
@@ -133,7 +134,7 @@ export function NotebookOrganizerDialog({
: 'bg-foreground/5 hover:bg-foreground/10 text-foreground'
)}
>
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> Appliqué</>) : (t('wizard.apply') || 'Appliquer')}
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> {t('wizard.tagApplied') || 'Appliqué'}</>) : (t('wizard.apply') || 'Appliquer')}
</button>
</div>
))}

View File

@@ -0,0 +1,398 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Sparkles, Globe, Check, Loader2, ExternalLink, Trash2, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { motion, AnimatePresence } from 'motion/react'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
type Step = 'loading' | 'published' | 'idle' | 'analyzing' | 'selection' | 'publishing' | 'done' | 'unpublishing'
interface NoteItem {
id: string
title: string
include: boolean
reason: string
}
interface NotebookSiteDialogProps {
notebookId: string
notebookName: string
onClose: () => void
}
const TEMPLATES = [
{ id: 'magazine', label: 'Magazine', desc: 'Sombre, Playfair Display' },
{ id: 'brief', label: 'Brief', desc: 'Professionnel, sobre' },
{ id: 'essay', label: 'Essai', desc: 'Élégant, ivoire' },
] as const
export function NotebookSiteDialog({ notebookId, notebookName, onClose }: NotebookSiteDialogProps) {
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [step, setStep] = useState<Step>('loading')
const [existingSlug, setExistingSlug] = useState<string | null>(null)
const [notes, setNotes] = useState<NoteItem[]>([])
const [description, setDescription] = useState('')
const [template, setTemplate] = useState<'magazine' | 'brief' | 'essay'>('magazine')
const [siteUrl, setSiteUrl] = useState('')
// Charger l'état existant au montage
useEffect(() => {
fetch(`/api/notebooks/${notebookId}/publish`)
.then(r => r.json())
.then(data => {
if (data.site?.slug) {
setExistingSlug(data.site.slug)
setSiteUrl(`/c/${data.site.slug}`)
setTemplate(data.site.template || 'magazine')
setStep('published')
} else {
setStep('idle')
}
})
.catch(() => setStep('idle'))
}, [notebookId])
const handleAnalyze = async () => {
const consented = await requestAiConsent()
if (!consented) return
setStep('analyzing')
try {
const res = await fetch('/api/ai/notebook-publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, language }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
setStep(existingSlug ? 'published' : 'idle')
return
}
const items: NoteItem[] = (data.notes || []).map((n: any) => ({
id: n.noteId,
title: data.noteTitles?.[n.noteId] || n.noteId,
include: n.include,
reason: n.reason,
}))
setNotes(items)
setDescription(data.description || '')
setStep('selection')
window.dispatchEvent(new Event('ai-usage-changed'))
} catch (e: any) {
toast.error(e.message || 'Erreur')
setStep(existingSlug ? 'published' : 'idle')
}
}
const handlePublish = async () => {
const selected = notes.filter(n => n.include).map(n => n.id)
if (selected.length === 0) { toast.error('Sélectionne au moins une note'); return }
setStep('publishing')
try {
const res = await fetch(`/api/notebooks/${notebookId}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
setExistingSlug(data.slug)
setSiteUrl(data.url)
setStep('done')
} catch (e: any) {
toast.error(e.message || 'Erreur')
setStep('selection')
}
}
const handleUnpublish = async () => {
setStep('unpublishing')
try {
const res = await fetch(`/api/notebooks/${notebookId}/publish`, { method: 'DELETE' })
if (!res.ok) { toast.error('Erreur'); setStep('published'); return }
setExistingSlug(null)
setSiteUrl('')
toast.success('Site dépublié')
setStep('idle')
} catch {
toast.error('Erreur')
setStep('published')
}
}
const selectedCount = notes.filter(n => n.include).length
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: .96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: .96 }}
className="relative bg-background border border-border rounded-2xl shadow-2xl w-full max-w-lg flex flex-col max-h-[90vh]"
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border">
<div className="flex items-center gap-2.5">
<Globe size={18} className="text-brand-accent" />
<div>
<p className="text-[13px] font-bold">{t('notebookSite.title') || 'Publier en site web'}</p>
<p className="text-[11px] text-muted-foreground truncate max-w-[280px]">{notebookName}</p>
</div>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors p-1">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5">
<AnimatePresence mode="wait">
{/* Chargement initial */}
{step === 'loading' && (
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex justify-center py-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</motion.div>
)}
{/* Déjà publié */}
{step === 'published' && (
<motion.div key="published" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-4">
<div className="rounded-xl border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-4 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center shrink-0">
<Check size={15} className="text-green-600 dark:text-green-400" />
</div>
<div className="min-w-0">
<p className="text-[13px] font-bold text-green-800 dark:text-green-300 mb-1">Site en ligne</p>
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] font-mono text-green-700 dark:text-green-400 hover:underline flex items-center gap-1"
>
memento-note.com{siteUrl} <ExternalLink size={10} />
</a>
</div>
</div>
<div className="space-y-2">
<button
onClick={() => setStep('idle')}
className="w-full flex items-center gap-2 px-4 py-3 rounded-xl border border-border hover:bg-muted/50 transition-colors text-[13px] font-semibold text-left"
>
<RefreshCw size={15} className="text-brand-accent" />
<div>
<p>{t('notebookSite.updateSite') || 'Mettre à jour le site'}</p>
<p className="text-[11px] text-muted-foreground font-normal">Relancer l'analyse IA et modifier la sélection</p>
</div>
</button>
<button
onClick={handleUnpublish}
className="w-full flex items-center gap-2 px-4 py-3 rounded-xl border border-red-200 dark:border-red-900 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors text-[13px] font-semibold text-left text-red-600 dark:text-red-400"
>
<Trash2 size={15} />
<div>
<p>{t('notebookSite.unpublish') || 'Dépublier le site'}</p>
<p className="text-[11px] text-red-400 font-normal">Le lien public ne sera plus accessible</p>
</div>
</button>
</div>
</motion.div>
)}
{/* Dépublication en cours */}
{step === 'unpublishing' && (
<motion.div key="unpublishing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
<Loader2 size={28} className="animate-spin text-red-500" />
<p className="text-[13px] text-muted-foreground">Dépublication en cours...</p>
</motion.div>
)}
{/* Étape 1 : explication */}
{step === 'idle' && (
<motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-4">
<div className="rounded-xl border border-border/50 bg-muted/30 p-4 space-y-3">
<p className="text-[13px] font-semibold">{t('notebookSite.howItWorks') || 'Comment ça marche'}</p>
<ol className="space-y-2 text-[12px] text-muted-foreground">
{[
t('notebookSite.step1') || "L'IA analyse vos notes et recommande lesquelles publier",
t('notebookSite.step2') || "Vous validez ou modifiez la sélection",
t('notebookSite.step3') || "Votre site est généré avec navigation et table des matières",
].map((s, i) => (
<li key={i} className="flex items-start gap-2.5">
<span className="shrink-0 w-4 h-4 rounded-full bg-brand-accent/20 text-brand-accent text-[10px] font-bold flex items-center justify-center mt-0.5">{i + 1}</span>
{s}
</li>
))}
</ol>
</div>
<p className="text-[11px] text-muted-foreground">{t('notebookSite.quotaWarning') || "L'analyse consomme 1 crédit IA."}</p>
</motion.div>
)}
{/* Analyse en cours */}
{step === 'analyzing' && (
<motion.div key="analyzing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
<Loader2 size={30} className="animate-spin text-brand-accent" />
<p className="text-[13px] text-muted-foreground">{t('notebookSite.analyzing') || "L'IA analyse vos notes..."}</p>
</motion.div>
)}
{/* Sélection */}
{step === 'selection' && (
<motion.div key="selection" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
{description && (
<div className="rounded-xl bg-brand-accent/5 border border-brand-accent/20 p-3">
<p className="text-[11px] font-bold text-brand-accent uppercase tracking-widest mb-1">{t('notebookSite.siteDescription') || 'Description générée'}</p>
<p className="text-[12px] text-foreground leading-relaxed">{description}</p>
</div>
)}
<p className="text-[12px] text-muted-foreground">
<strong>{selectedCount}</strong> / {notes.length} {t('notebookSite.notesSelected') || 'notes sélectionnées'}
</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{notes.map(note => (
<button
key={note.id}
onClick={() => setNotes(prev => prev.map(n => n.id === note.id ? { ...n, include: !n.include } : n))}
className={cn(
'w-full text-left rounded-lg border p-2.5 transition-colors flex items-start gap-3',
note.include
? 'border-brand-accent/40 bg-brand-accent/5'
: 'border-border/40 bg-muted/10 opacity-55',
)}
>
<div className={cn(
'shrink-0 w-4 h-4 rounded border flex items-center justify-center mt-0.5',
note.include ? 'bg-brand-accent border-brand-accent' : 'border-muted-foreground/30 bg-background',
)}>
{note.include && <Check size={9} className="text-white" strokeWidth={3} />}
</div>
<div className="min-w-0">
<p className="text-[12px] font-semibold text-foreground truncate">{note.title || 'Sans titre'}</p>
{note.reason && <p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">{note.reason}</p>}
</div>
</button>
))}
</div>
<div>
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">{t('notebookSite.template') || 'Style visuel'}</p>
<div className="grid grid-cols-3 gap-2">
{TEMPLATES.map(tpl => (
<button
key={tpl.id}
onClick={() => setTemplate(tpl.id)}
className={cn(
'rounded-lg border p-2.5 text-left transition-colors',
template === tpl.id
? 'border-brand-accent bg-brand-accent/5'
: 'border-border/50 hover:border-border',
)}
>
<p className="text-[11px] font-bold">{tpl.label}</p>
<p className="text-[10px] text-muted-foreground">{tpl.desc}</p>
</button>
))}
</div>
</div>
</motion.div>
)}
{/* Publication en cours */}
{step === 'publishing' && (
<motion.div key="publishing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
<Loader2 size={30} className="animate-spin text-brand-accent" />
<p className="text-[13px] text-muted-foreground">{t('notebookSite.publishing') || 'Génération du site...'}</p>
</motion.div>
)}
{/* Succès */}
{step === 'done' && (
<motion.div key="done" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-5 py-6 text-center">
<div className="w-14 h-14 rounded-full bg-green-100 dark:bg-green-950/40 flex items-center justify-center">
<Check size={26} className="text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-[16px] font-bold mb-1">{t('notebookSite.published') || 'Site publié !'}</p>
<p className="text-[12px] text-muted-foreground">{selectedCount} notes · accessible publiquement</p>
</div>
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-6 py-2.5 rounded-full bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 transition-opacity"
>
<ExternalLink size={13} />
{t('notebookSite.openSite') || 'Ouvrir le site'}
</a>
<p className="text-[10px] text-muted-foreground font-mono break-all">memento-note.com{siteUrl}</p>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer actions */}
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
<div>
{(step === 'selection' || step === 'idle') && existingSlug && (
<button
onClick={() => setStep('published')}
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
← Retour au site actuel
</button>
)}
</div>
<div className="flex gap-2">
{step === 'idle' && (
<button
onClick={handleAnalyze}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 transition-opacity"
>
<Sparkles size={13} />
{t('notebookSite.analyzeBtn') || "Analyser avec l'IA"}
</button>
)}
{step === 'selection' && (
<>
<button
onClick={() => setStep('idle')}
className="px-3 py-2 rounded-lg text-[12px] text-muted-foreground hover:text-foreground transition-colors"
>
← Retour
</button>
<button
onClick={handlePublish}
disabled={selectedCount === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 disabled:opacity-40 transition-opacity"
>
<Globe size={13} />
{t('notebookSite.publishBtn') || 'Publier'} ({selectedCount})
</button>
</>
)}
{(step === 'done' || step === 'published') && (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-foreground text-background text-[12px] font-bold hover:opacity-90 transition-opacity"
>
Fermer
</button>
)}
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -42,6 +42,7 @@ export function StudyPlannerDialog({
} else {
setPlan(data)
toast.success(t('wizard.studyPlanSuccess') || 'Planning créé ! Des rappels ont été ajoutés à vos notes.')
window.dispatchEvent(new Event('ai-usage-changed'))
}
} catch (e: any) {
toast.error(e.message || 'Erreur')