feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
@@ -664,10 +664,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
setIsDirty(false)
|
||||
toast.success('Note sauvegardée !')
|
||||
toast.success(t('notes.saved') || 'Saved')
|
||||
} catch (error) {
|
||||
console.error('[SAVE] updateNote failed:', error)
|
||||
toast.error('Erreur lors de la sauvegarde.')
|
||||
toast.error(t('notes.saveFailed') || 'Save failed')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
@@ -10,19 +10,30 @@ import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||
import { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Note } from '@/lib/types'
|
||||
import { GhostTags } from '@/components/ghost-tags'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { NoteAttachments } from '@/components/note-attachments'
|
||||
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
|
||||
const [docQAAttachment, setDocQAAttachment] = useState<{ id: string; fileName: string } | null>(null)
|
||||
const [attachmentsCount, setAttachmentsCount] = useState(0)
|
||||
const [uploadTrigger, setUploadTrigger] = useState(0)
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
@@ -40,7 +51,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
|
||||
|
||||
{/* TOOLBAR */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
|
||||
|
||||
{/* BODY — max-w-4xl, responsive px, py-16 */}
|
||||
<div className="max-w-4xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
|
||||
@@ -52,7 +63,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
{notebookName && <span style={{ color: 'var(--color-ink)' }}>{notebookName}</span>}
|
||||
{notebookName && <ChevronRight size={10} style={{ color: 'var(--color-concrete)' }} />}
|
||||
<span suppressHydrationWarning style={{ color: 'var(--color-concrete)' }}>
|
||||
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
|
||||
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -94,8 +105,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
)}
|
||||
|
||||
{/* Content area — max-w-3xl for wider reading column */}
|
||||
<div className="max-w-3xl mx-auto w-full pb-32">
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
|
||||
<NoteContentArea />
|
||||
|
||||
<NoteAttachments
|
||||
noteId={note.id}
|
||||
onOpenDocQA={(att) => setDocQAAttachment(att)}
|
||||
onCountChange={setAttachmentsCount}
|
||||
triggerUpload={uploadTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +142,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||
if (wordCount < 10) {
|
||||
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
actions.setIsProcessingAI(true)
|
||||
@@ -139,14 +157,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const s = data.suggestions?.[0]?.title ?? ''
|
||||
if (s) {
|
||||
actions.setTitle(s)
|
||||
toast.success('Titre généré !')
|
||||
toast.success(t('ai.titleApplied'))
|
||||
} else {
|
||||
toast.error('Impossible de générer un titre.')
|
||||
toast.error(t('ai.titleGenerationFailed'))
|
||||
}
|
||||
} else {
|
||||
toast.error('Erreur lors de la génération du titre.')
|
||||
toast.error(t('ai.titleGenerationError'))
|
||||
}
|
||||
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
|
||||
} catch { toast.error(t('ai.networkErrorShort')) } finally { actions.setIsProcessingAI(false) }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -166,6 +184,20 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
|
||||
|
||||
{docQAAttachment && (
|
||||
<DocumentQAOverlay
|
||||
attachment={docQAAttachment}
|
||||
noteId={note.id}
|
||||
noteContent={state.content}
|
||||
onClose={() => setDocQAAttachment(null)}
|
||||
onApplyToNote={(content) => {
|
||||
actions.setPreviousContentForCopilot(state.content)
|
||||
actions.setContent(state.content + '\n\n' + content)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReminderDialog
|
||||
open={state.showReminderDialog}
|
||||
onOpenChange={actions.setShowReminderDialog}
|
||||
|
||||
@@ -18,7 +18,7 @@ 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
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip
|
||||
} from 'lucide-react'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
@@ -32,9 +32,11 @@ import { format } from 'date-fns'
|
||||
interface NoteEditorToolbarProps {
|
||||
mode: 'fullPage' | 'dialog'
|
||||
onClose: () => void
|
||||
onToggleAttachments?: () => void
|
||||
attachmentsCount?: number
|
||||
}
|
||||
|
||||
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
@@ -95,22 +97,22 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
<span className="text-sm font-medium">Back to collection</span>
|
||||
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||
{state.isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
|
||||
: state.isDirty
|
||||
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
|
||||
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
||||
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus')}</span></>
|
||||
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus')}</span></>}
|
||||
</span>
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
|
||||
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
||||
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
@@ -140,8 +142,8 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
)}
|
||||
|
||||
<button
|
||||
title="AI Assistant"
|
||||
aria-label="Ouvrir l'assistant IA"
|
||||
title={t('ai.openAssistant')}
|
||||
aria-label={t('ai.openAssistant')}
|
||||
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
@@ -153,10 +155,41 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={t('notes.brainstormThisIdea')}
|
||||
aria-label={t('notes.brainstormThisIdeaAria')}
|
||||
onClick={() => {
|
||||
const title = note.title || ''
|
||||
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
||||
const seed = title ? `${title}. ${summary}` : summary
|
||||
if (!seed.trim()) return
|
||||
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
||||
}}
|
||||
className="p-1.5 rounded-full border border-orange-300 dark:border-orange-700 text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
aria-label={t('notes.attachments') || 'Attachments'}
|
||||
onClick={onToggleAttachments}
|
||||
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
{(attachmentsCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
||||
{attachmentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
|
||||
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
|
||||
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
||||
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
||||
onClick={actions.handleSaveInPlace}
|
||||
disabled={state.isSaving || !state.isDirty}
|
||||
className={cn(
|
||||
@@ -172,8 +205,8 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
title="Partager la note"
|
||||
aria-label="Partager la note"
|
||||
title={t('notes.shareNoteTitle')}
|
||||
aria-label={t('notes.shareNoteAria')}
|
||||
onClick={() => setShareOpen(true)}
|
||||
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
@@ -184,7 +217,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button aria-label="Menu des options" className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
|
||||
<button aria-label={t('notes.optionsMenuAria')} className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -194,14 +227,14 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
refreshNotes(note.notebookId)
|
||||
toast.success('Note supprimée.')
|
||||
toast.success(t('notes.noteDeletedToast'))
|
||||
onClose()
|
||||
} catch { toast.error('Impossible de supprimer.') }
|
||||
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
||||
}}
|
||||
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Supprimer la note
|
||||
<Trash2 className="h-4 w-4 me-2" />
|
||||
{t('notes.deleteNoteConfirmItem')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -216,7 +249,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
)}
|
||||
|
||||
<button
|
||||
aria-label="Informations du document"
|
||||
aria-label={t('notes.documentInfoAria')}
|
||||
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
@@ -259,11 +292,15 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
||||
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
||||
onClick={() => actions.setAiOpen(!state.aiOpen)}
|
||||
title={t('ai.aiNoteTitle')}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">IA Note</span>
|
||||
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -330,7 +367,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
onClick={async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
toast.success(t('notes.leftShare'))
|
||||
refreshNotes(note.notebookId)
|
||||
onClose()
|
||||
} catch {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createShareRequest, removeCollaborator, getNoteCollaborators } from '@/
|
||||
import { toast } from 'sonner'
|
||||
import { X, UserPlus, Users, Mail, Trash2, Loader2, Share2, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface Collaborator {
|
||||
id: string
|
||||
@@ -21,6 +22,7 @@ interface NoteShareDialogProps {
|
||||
}
|
||||
|
||||
export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [email, setEmail] = useState('')
|
||||
const [permission, setPermission] = useState<'view' | 'edit'>('view')
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
@@ -60,13 +62,13 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
await createShareRequest(noteId, trimmed, permission)
|
||||
setSent(true)
|
||||
setEmail('')
|
||||
toast.success(`Invitation envoyée à ${trimmed}`)
|
||||
toast.success(t('collaboration.toastInviteSentTo', { email: trimmed }))
|
||||
setTimeout(() => setSent(false), 2000)
|
||||
loadCollaborators()
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Erreur lors du partage'
|
||||
if (msg.includes('not found')) toast.error('Aucun compte trouvé avec cet email.')
|
||||
else if (msg.includes('already shared')) toast.error('Cette note est déjà partagée avec cet utilisateur.')
|
||||
const msg = err?.message || t('collaboration.toastSharingError')
|
||||
if (msg.includes('not found')) toast.error(t('collaboration.toastEmailNotFound'))
|
||||
else if (msg.includes('already shared')) toast.error(t('collaboration.toastAlreadySharedUser'))
|
||||
else toast.error(msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
@@ -78,9 +80,11 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
try {
|
||||
await removeCollaborator(noteId, collaboratorId)
|
||||
setCollaborators(prev => prev.filter(c => c.id !== collaboratorId))
|
||||
toast.success(`Accès retiré à ${collaboratorEmail || "l'utilisateur"}`)
|
||||
toast.success(t('collaboration.toastAccessRemoved', {
|
||||
target: collaboratorEmail || t('collaboration.toastUserFallback'),
|
||||
}))
|
||||
} catch {
|
||||
toast.error("Impossible de retirer l'accès.")
|
||||
toast.error(t('collaboration.toastRemoveAccessFailed'))
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
@@ -100,8 +104,8 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Share2 size={15} className="text-[#75B2D6]" />
|
||||
<h2 className="text-sm font-bold text-foreground tracking-tight">Partager</h2>
|
||||
<Share2 size={15} className="text-[#A47148]" />
|
||||
<h2 className="text-sm font-bold text-foreground tracking-tight">{t('collaboration.shareCompactTitle')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -114,7 +118,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
{/* Invite form */}
|
||||
<form onSubmit={handleInvite} className="px-6 py-5 space-y-3">
|
||||
<label className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/40">
|
||||
Inviter par email
|
||||
{t('collaboration.inviteByEmailLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -126,7 +130,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full pl-9 pr-3 py-2.5 text-[13px] rounded-xl border border-black/15 dark:border-white/15 bg-transparent outline-none focus:ring-2 ring-[#75B2D6]/30 focus:border-[#75B2D6] transition-all placeholder:text-foreground/30"
|
||||
className="w-full pl-9 pr-3 py-2.5 text-[13px] rounded-xl border border-black/15 dark:border-white/15 bg-transparent outline-none focus:ring-2 ring-[#A47148]/30 focus:border-[#A47148] transition-all placeholder:text-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
{/* Permission toggle */}
|
||||
@@ -139,11 +143,11 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
className={cn(
|
||||
'px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||
permission === p
|
||||
? 'bg-[#75B2D6] text-white'
|
||||
? 'bg-[#A47148] text-white'
|
||||
: 'text-foreground/50 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
{p === 'view' ? 'Lire' : 'Éditer'}
|
||||
{p === 'view' ? t('collaboration.accessReadCompact') : t('collaboration.accessEditCompact')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -158,14 +162,14 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
? 'bg-black/5 dark:bg-white/5 text-foreground/30 cursor-not-allowed'
|
||||
: sent
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-[#75B2D6] text-white hover:opacity-90 shadow-sm shadow-[#75B2D6]/30'
|
||||
: 'bg-[#A47148] text-white hover:opacity-90 shadow-sm shadow-[#A47148]/30'
|
||||
)}
|
||||
>
|
||||
{sending
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: sent
|
||||
? <><Check size={13} /> Invitation envoyée</>
|
||||
: <><UserPlus size={13} /> Envoyer l'invitation</>
|
||||
? <><Check size={13} /> {t('collaboration.invitationSentBadge')}</>
|
||||
: <><UserPlus size={13} /> {t('collaboration.sendInvitation')}</>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
@@ -175,7 +179,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
|
||||
<span className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/30 flex items-center gap-1.5">
|
||||
<Users size={10} /> Accès partagé
|
||||
<Users size={10} /> {t('collaboration.sharedAccessLabel')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
@@ -186,7 +190,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
</div>
|
||||
) : collaborators.length === 0 ? (
|
||||
<p className="text-center text-[11px] text-foreground/30 py-4">
|
||||
Aucun collaborateur pour l'instant.
|
||||
{t('collaboration.noCollaboratorsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
@@ -199,14 +203,14 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || 'Utilisateur'}</p>
|
||||
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || t('collaboration.userFallback')}</p>
|
||||
<p className="text-[10px] text-foreground/40 truncate">{c.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(c.id, c.email)}
|
||||
disabled={removingId === c.id}
|
||||
className="p-1.5 rounded-lg text-foreground/30 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
|
||||
title="Retirer l'accès"
|
||||
title={t('collaboration.removeAccessTitle')}
|
||||
>
|
||||
{removingId === c.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
||||
</button>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NoteTitleBlock() {
|
||||
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||
if (wordCount < 10) {
|
||||
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
actions.setIsProcessingAI(true)
|
||||
@@ -76,20 +76,20 @@ export function NoteTitleBlock() {
|
||||
const s = data.suggestions?.[0]?.title ?? ''
|
||||
if (s) {
|
||||
actions.setTitle(s)
|
||||
toast.success('Titre généré !')
|
||||
toast.success(t('ai.titleApplied'))
|
||||
} else {
|
||||
toast.error('Impossible de générer un titre.')
|
||||
toast.error(t('ai.titleGenerationFailed'))
|
||||
}
|
||||
} else {
|
||||
toast.error('Erreur lors de la génération du titre.')
|
||||
toast.error(t('ai.titleGenerationError'))
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Erreur réseau.')
|
||||
toast.error(t('ai.networkErrorShort'))
|
||||
} finally { actions.setIsProcessingAI(false) }
|
||||
}}
|
||||
disabled={state.isProcessingAI}
|
||||
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
|
||||
title="Générer un titre automatique avec l'IA"
|
||||
title={t('ai.generateTitlesTooltip')}
|
||||
>
|
||||
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user