UI Stabilization: Global color theme updates (#75B2D6), AI Assistant styling refactor, and navigation fixes
This commit is contained in:
@@ -147,7 +147,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105"
|
||||
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-[#E9ECEF] text-[#1C1C1C] hover:bg-[#E9ECEF]/80 border border-black/5"
|
||||
size="icon"
|
||||
title={t('ai.openAssistant')}
|
||||
>
|
||||
@@ -202,7 +202,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||
activeTab === 'chat' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
activeTab === 'chat' ? "border-[#75B2D6] text-[#75B2D6]" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
|
||||
@@ -211,7 +211,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
onClick={() => setActiveTab('insights')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||
activeTab === 'insights' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
activeTab === 'insights' ? "border-[#75B2D6] text-[#75B2D6]" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
|
||||
@@ -220,7 +220,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||
activeTab === 'history' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
activeTab === 'history' ? "border-[#75B2D6] text-[#75B2D6]" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<History className="h-4 w-4" /> {t('ai.historyTab')}
|
||||
@@ -234,10 +234,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
{/* AI Welcome Message */}
|
||||
{messages.length === 0 && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
|
||||
<div className="w-8 h-8 rounded-full bg-[#75B2D6]/10 text-[#75B2D6] flex items-center justify-center flex-shrink-0 border border-[#75B2D6]/20">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||
<div className="bg-[#FDFDFE] border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
{t('ai.welcomeMsg')}
|
||||
</p>
|
||||
@@ -256,15 +256,15 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
||||
msg.role === 'user'
|
||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
|
||||
: 'bg-primary/10 text-primary border-primary/20',
|
||||
: 'bg-[#75B2D6]/10 text-[#75B2D6] border-[#75B2D6]/20',
|
||||
)}>
|
||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground rounded-tr-sm'
|
||||
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
|
||||
? 'bg-[#75B2D6] text-white rounded-tr-sm'
|
||||
: 'bg-[#FDFDFE] border border-border/50 rounded-tl-sm text-foreground',
|
||||
)}>
|
||||
{msg.role === 'assistant'
|
||||
? <MarkdownContent content={text} />
|
||||
@@ -276,10 +276,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
|
||||
<div className="w-8 h-8 rounded-full bg-[#75B2D6]/10 text-[#75B2D6] flex items-center justify-center flex-shrink-0 border border-[#75B2D6]/20">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||
<div className="bg-[#FDFDFE] border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
|
||||
{activeTab === 'insights' && (
|
||||
<div className="h-full">
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> {t('ai.summaryLast5')}</h3>
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-[#75B2D6]" /> {t('ai.summaryLast5')}</h3>
|
||||
{insightsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-60">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
|
||||
@@ -318,7 +318,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
history.map(conv => (
|
||||
<button
|
||||
key={conv.id}
|
||||
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-primary/30 transition-all flex flex-col gap-1"
|
||||
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-[#75B2D6]/30 transition-all flex flex-col gap-1"
|
||||
onClick={() => {
|
||||
setConversationId(conv.id)
|
||||
setMessages(conv.messages.map((m: any) => ({
|
||||
@@ -345,7 +345,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
</div>
|
||||
|
||||
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
||||
<div className={cn("p-4 border-t border-border/40 bg-muted/10 shrink-0", activeTab !== 'chat' && "hidden")}>
|
||||
<div className={cn("p-4 border-t border-border/40 bg-[#FDFDFE] shrink-0", activeTab !== 'chat' && "hidden")}>
|
||||
{/* Context Scope */}
|
||||
<div className="mb-3">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
|
||||
@@ -387,7 +387,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
className={cn(
|
||||
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
||||
? "border-[#75B2D6] bg-[#75B2D6]/10 text-[#75B2D6] shadow-sm"
|
||||
: "border-border/60 bg-card text-muted-foreground hover:bg-muted hover:border-border"
|
||||
)}
|
||||
>
|
||||
@@ -400,7 +400,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
</div>
|
||||
|
||||
{/* Text Input */}
|
||||
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all shadow-sm">
|
||||
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-[#75B2D6] focus-within:ring-1 focus-within:ring-[#75B2D6]/20 transition-all shadow-sm">
|
||||
<textarea
|
||||
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
||||
placeholder={t('ai.chatPlaceholder')}
|
||||
@@ -435,7 +435,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
|
||||
className="h-8 w-8 rounded-lg bg-[#75B2D6] text-white shadow-sm hover:shadow-md transition-all"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function BatchOrganizationDialog({
|
||||
setSelectedNotes(new Set())
|
||||
setFetchError(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1049
memento-note/components/contextual-ai-chat.tsx.bak
Normal file
1049
memento-note/components/contextual-ai-chat.tsx.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Share2 } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles } from 'lucide-react'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
@@ -262,8 +262,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
||||
: await getAllNotes(false, notebook || undefined)
|
||||
|
||||
if (!notebook && !search) {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||
const sharedOnly = searchParams.get('shared') === '1'
|
||||
|
||||
if (sharedOnly) {
|
||||
allNotes = allNotes.filter((note: any) => note._isShared)
|
||||
} else if (!notebook && !search) {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
|
||||
}
|
||||
|
||||
if (labelFilter.length > 0) {
|
||||
@@ -295,10 +299,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
return () => { cancelled.value = true }
|
||||
} else {
|
||||
let filtered = initialNotes
|
||||
const sharedOnly = searchParams.get('shared') === '1'
|
||||
if (notebook) {
|
||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||
} else if (sharedOnly) {
|
||||
filtered = initialNotes.filter((n: any) => n._isShared)
|
||||
} else {
|
||||
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
||||
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
|
||||
}
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
@@ -306,7 +313,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
})
|
||||
setPinnedNotes(filtered.filter(n => n.isPinned))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, refreshKey])
|
||||
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
@@ -384,7 +391,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
|
||||
{currentNotebook ? currentNotebook.name : t('notes.title')}
|
||||
{currentNotebook ? currentNotebook.name : (searchParams.get('shared') === '1' ? 'Partagées avec moi' : t('notes.title'))}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -463,10 +470,23 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<span>{t('notes.search') || 'Search'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
|
||||
<button
|
||||
onClick={() => setBatchOrganizationOpen(true)}
|
||||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span>Réorganiser les notes</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity">
|
||||
<Share2 size={16} />
|
||||
<span>{t('notes.share') || 'Share'}</span>
|
||||
<button
|
||||
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : 'newest')}
|
||||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<ArrowUpDown size={16} />
|
||||
<span>{sortLabels[sortOrder]}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Download, Presentation } from 'lucide-react'
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||
@@ -107,6 +108,8 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||
const [localId, setLocalId] = useState<string | null>(canvasId || null)
|
||||
const router = useRouter()
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
|
||||
const filesRef = useRef<BinaryFiles>({})
|
||||
@@ -147,12 +150,22 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
||||
await fetch('/api/canvas', {
|
||||
const res = await fetch('/api/canvas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: canvasId || null, name, data: snapshot })
|
||||
body: JSON.stringify({ id: localId || null, name, data: snapshot })
|
||||
})
|
||||
setSaveStatus('saved')
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success && data.canvas?.id) {
|
||||
if (!localId) {
|
||||
setLocalId(data.canvas.id)
|
||||
router.replace(`/lab?id=${data.canvas.id}`, { scroll: false })
|
||||
}
|
||||
setSaveStatus('saved')
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CanvasBoard] Save failure:', e)
|
||||
setSaveStatus('error')
|
||||
@@ -164,7 +177,7 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
return <PptxViewer data={scene.pptx} name={name} />
|
||||
}
|
||||
|
||||
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
|
||||
const excalKey = localId ? `excal-${localId}` : 'excal-new'
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X } from 'lucide-react'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LABEL_COLORS } from '@/lib/types'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
|
||||
interface LabelBadgeProps {
|
||||
label: string
|
||||
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling
|
||||
onRemove?: () => void
|
||||
variant?: 'default' | 'filter' | 'clickable'
|
||||
onClick?: () => void
|
||||
@@ -17,6 +18,7 @@ interface LabelBadgeProps {
|
||||
|
||||
export function LabelBadge({
|
||||
label,
|
||||
type,
|
||||
onRemove,
|
||||
variant = 'default',
|
||||
onClick,
|
||||
@@ -27,13 +29,16 @@ export function LabelBadge({
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
||||
|
||||
// AI labels get special Blueprint styling with Sparkles icon
|
||||
const isAI = type === 'ai'
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs border gap-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border,
|
||||
'text-xs border gap-1 transition-all',
|
||||
isAI
|
||||
? 'bg-blue-100/70 border-blue-200/50 text-sky-700 dark:bg-sky-900/30 dark:border-sky-700/50 dark:text-sky-300 hover:bg-blue-200/70'
|
||||
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`,
|
||||
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
||||
variant === 'clickable' && 'cursor-pointer',
|
||||
isDisabled && 'opacity-50',
|
||||
@@ -41,6 +46,7 @@ export function LabelBadge({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isAI && <Sparkles className="h-3 w-3 text-sky-500 dark:text-sky-400" />}
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button
|
||||
@@ -53,6 +59,12 @@ export function LabelBadge({
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAI && (
|
||||
<span className="relative flex h-1.5 w-1.5 ml-0.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
@@ -17,7 +18,7 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam
|
||||
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={{
|
||||
a: ({ node, ...props }) => (
|
||||
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
|
||||
|
||||
@@ -5,13 +5,14 @@ import { Note } from '@/lib/types'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight } from 'lucide-react'
|
||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteHistoryModal } from './note-history-modal'
|
||||
import { enableNoteHistory, commitNoteHistory } from '@/app/actions/notes'
|
||||
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Tab = 'info' | 'versions'
|
||||
|
||||
@@ -49,8 +50,56 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
|
||||
const [isSavingVersion, setIsSavingVersion] = useState(false)
|
||||
const [versionSaved, setVersionSaved] = useState(false)
|
||||
const [historyEntries, setHistoryEntries] = useState<any[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null)
|
||||
const [isRestoring, setIsRestoring] = useState<string | null>(null)
|
||||
const locale = getLocale(language)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'versions' && historyEnabled) {
|
||||
loadHistory()
|
||||
}
|
||||
}, [activeTab, historyEnabled, note.id])
|
||||
|
||||
const loadHistory = async () => {
|
||||
setIsLoadingHistory(true)
|
||||
try {
|
||||
const entries = await getNoteHistory(note.id, 50)
|
||||
setHistoryEntries(entries)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteVersion = async (entryId: string) => {
|
||||
if (!confirm('Supprimer cette version ?')) return
|
||||
setIsDeleting(entryId)
|
||||
try {
|
||||
await deleteNoteHistoryEntry(note.id, entryId)
|
||||
setHistoryEntries(prev => prev.filter(e => e.id !== entryId))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreVersion = async (entryId: string) => {
|
||||
setIsRestoring(entryId)
|
||||
try {
|
||||
const restored = await restoreNoteVersion(note.id, entryId)
|
||||
onNoteRestored?.(restored)
|
||||
loadHistory()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
const notebook = useMemo(
|
||||
() => notebooks.find(nb => nb.id === note.notebookId),
|
||||
[notebooks, note.notebookId]
|
||||
@@ -201,17 +250,17 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Versions sauvegardées</p>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">Versions sauvegardées</p>
|
||||
|
||||
{/* Save version button */}
|
||||
<button
|
||||
disabled={isSavingVersion}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-colors text-sm font-medium',
|
||||
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-all text-xs font-bold uppercase tracking-widest',
|
||||
versionSaved
|
||||
? 'border-emerald-500/40 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400'
|
||||
: 'border-foreground/20 bg-foreground text-background hover:opacity-80',
|
||||
: 'border-foreground/10 bg-foreground text-background hover:opacity-90 shadow-sm',
|
||||
isSavingVersion && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={async () => {
|
||||
@@ -219,6 +268,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
try {
|
||||
await commitNoteHistory(note.id)
|
||||
setVersionSaved(true)
|
||||
loadHistory()
|
||||
setTimeout(() => setVersionSaved(false), 3000)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -228,24 +278,95 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
}}
|
||||
>
|
||||
{isSavingVersion ? (
|
||||
<><span className="h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent animate-spin" />Sauvegarde…</>
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" />Sauvegarde…</>
|
||||
) : versionSaved ? (
|
||||
<><span className="text-base">✓</span> Version sauvegardée !</>
|
||||
<><Check className="h-3.5 w-3.5" /> Version sauvegardée !</>
|
||||
) : (
|
||||
<><span className="text-base">⎘</span> Sauvegarder cette version</>
|
||||
<>Sauvegarder cette version</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* View history */}
|
||||
<div className="h-px bg-border/30 my-2" />
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoadingHistory && historyEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-40">
|
||||
<Loader2 className="h-6 w-6 animate-spin mb-2" />
|
||||
<p className="text-[10px] uppercase tracking-widest">Chargement...</p>
|
||||
</div>
|
||||
) : historyEntries.length === 0 ? (
|
||||
<div className="text-center py-8 opacity-40 border border-dashed rounded-xl">
|
||||
<Clock className="h-6 w-6 mx-auto mb-2" />
|
||||
<p className="text-[10px] uppercase tracking-widest">Aucune version</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-px before:bg-border/40">
|
||||
{historyEntries.map((entry, idx) => {
|
||||
const colors = ['#E2E8F0', '#ACB995', '#E9ECEF']
|
||||
const dotColor = colors[idx % colors.length]
|
||||
const isLatest = idx === 0
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="relative group">
|
||||
{/* Dot */}
|
||||
<div
|
||||
className="absolute -left-[19px] top-1.5 h-3 w-3 rounded-full border-2 border-background z-10 shadow-sm"
|
||||
style={{ backgroundColor: dotColor }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold font-mono">v{entry.version}</span>
|
||||
{isLatest && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-bold uppercase tracking-widest">Latest</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleRestoreVersion(entry.id)}
|
||||
disabled={!!isRestoring || !!isDeleting}
|
||||
className="p-1.5 rounded-lg hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
|
||||
title="Restaurer"
|
||||
>
|
||||
{isRestoring === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteVersion(entry.id)}
|
||||
disabled={!!isRestoring || !!isDeleting}
|
||||
className="p-1.5 rounded-lg hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
{isDeleting === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground font-medium">
|
||||
{format(new Date(entry.createdAt), 'd MMM · HH:mm', { locale })}
|
||||
<span className="mx-1.5 opacity-30">·</span>
|
||||
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true, locale })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button to open the full modal (optional, but good to keep if user wants diff) */}
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-border hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-border/40 hover:bg-muted/50 transition-colors text-left group mt-4"
|
||||
onClick={() => setShowHistory(true)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
|
||||
<div className="w-8 h-8 rounded-full bg-primary/5 flex items-center justify-center text-primary group-hover:bg-primary/10 transition-colors">
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Voir l'historique</p>
|
||||
<p className="text-[11px] text-muted-foreground">Comparer et restaurer des versions</p>
|
||||
<p className="text-xs font-bold uppercase tracking-wider">Mode Comparaison</p>
|
||||
<p className="text-[10px] text-muted-foreground">Comparer les versions côte à côte</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -34,8 +34,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
{/* TOOLBAR */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
||||
|
||||
{/* BODY — max-w-4xl, px-12, py-16 */}
|
||||
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||
{/* 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">
|
||||
|
||||
{/* Breadcrumb + Title block */}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -19,8 +19,9 @@ 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
|
||||
Trash2, LogOut, Wand2, Share2
|
||||
} from 'lucide-react'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -39,6 +40,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
@@ -187,6 +189,19 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share button */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title="Partager la note"
|
||||
aria-label="Partager la note"
|
||||
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"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Three-dot options menu */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
@@ -214,6 +229,15 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Share Dialog portal */}
|
||||
{shareOpen && (
|
||||
<NoteShareDialog
|
||||
noteId={note.id}
|
||||
noteTitle={state.title}
|
||||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info panel toggle — rightmost, icon only */}
|
||||
<button
|
||||
aria-label="Informations du document"
|
||||
|
||||
222
memento-note/components/note-editor/note-share-dialog.tsx
Normal file
222
memento-note/components/note-editor/note-share-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { createShareRequest, removeCollaborator, getNoteCollaborators } from '@/app/actions/notes'
|
||||
import { toast } from 'sonner'
|
||||
import { X, UserPlus, Users, Mail, Trash2, Loader2, Share2, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Collaborator {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string | null
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface NoteShareDialogProps {
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [permission, setPermission] = useState<'view' | 'edit'>('view')
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
// Close on Escape
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const loadCollaborators = useCallback(async () => {
|
||||
try {
|
||||
const list = await getNoteCollaborators(noteId)
|
||||
setCollaborators(list as Collaborator[])
|
||||
} catch {
|
||||
// owner-only view — silently ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [noteId])
|
||||
|
||||
useEffect(() => { loadCollaborators() }, [loadCollaborators])
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = email.trim()
|
||||
if (!trimmed) return
|
||||
setSending(true)
|
||||
try {
|
||||
await createShareRequest(noteId, trimmed, permission)
|
||||
setSent(true)
|
||||
setEmail('')
|
||||
toast.success(`Invitation envoyée à ${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.')
|
||||
else toast.error(msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (collaboratorId: string, collaboratorEmail: string | null) => {
|
||||
setRemovingId(collaboratorId)
|
||||
try {
|
||||
await removeCollaborator(noteId, collaboratorId)
|
||||
setCollaborators(prev => prev.filter(c => c.id !== collaboratorId))
|
||||
toast.success(`Accès retiré à ${collaboratorEmail || "l'utilisateur"}`)
|
||||
} catch {
|
||||
toast.error("Impossible de retirer l'accès.")
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
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="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>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-foreground/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Mail size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/30" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{/* Permission toggle */}
|
||||
<div className="flex rounded-xl border border-black/15 dark:border-white/15 overflow-hidden shrink-0">
|
||||
{(['view', 'edit'] as const).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPermission(p)}
|
||||
className={cn(
|
||||
'px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||
permission === p
|
||||
? 'bg-[#75B2D6] text-white'
|
||||
: 'text-foreground/50 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
{p === 'view' ? 'Lire' : 'Éditer'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !email.trim()}
|
||||
className={cn(
|
||||
'w-full py-2.5 rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 transition-all',
|
||||
sending || !email.trim()
|
||||
? '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'
|
||||
)}
|
||||
>
|
||||
{sending
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: sent
|
||||
? <><Check size={13} /> Invitation envoyée</>
|
||||
: <><UserPlus size={13} /> Envoyer l'invitation</>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Collaborators list */}
|
||||
<div className="px-6 pb-6 space-y-3">
|
||||
<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é
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 size={16} className="animate-spin text-foreground/30" />
|
||||
</div>
|
||||
) : collaborators.length === 0 ? (
|
||||
<p className="text-center text-[11px] text-foreground/30 py-4">
|
||||
Aucun collaborateur pour l'instant.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{collaborators.map(c => (
|
||||
<li key={c.id} className="flex items-center gap-3 p-2.5 rounded-xl bg-black/[0.03] dark:bg-white/[0.03] border border-black/[0.06] dark:border-white/[0.06]">
|
||||
<div className="h-8 w-8 rounded-full bg-[#E9ECEF]/20 flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{c.image
|
||||
? <img src={c.image} alt={c.name || ''} className="h-full w-full object-cover" />
|
||||
: <span className="text-[11px] font-bold text-[#E9ECEF]">{(c.name || c.email || '?')[0].toUpperCase()}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || 'Utilisateur'}</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"
|
||||
>
|
||||
{removingId === c.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -12,9 +12,18 @@ export function NoteTitleBlock() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (fullPage) {
|
||||
// Adaptive font size: short = big editorial, long = smaller but still premium
|
||||
const titleLen = (state.title || '').length
|
||||
const titleSizeClass =
|
||||
titleLen === 0 ? 'text-5xl md:text-6xl' :
|
||||
titleLen < 40 ? 'text-5xl md:text-6xl' :
|
||||
titleLen < 70 ? 'text-4xl md:text-5xl' :
|
||||
titleLen < 100 ? 'text-3xl md:text-4xl' :
|
||||
'text-2xl md:text-3xl'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title — auto-resizing textarea to prevent overflow */}
|
||||
{/* Title — auto-resizing textarea, adaptive size */}
|
||||
<div className="group relative">
|
||||
<textarea
|
||||
dir="auto"
|
||||
@@ -29,36 +38,41 @@ export function NoteTitleBlock() {
|
||||
}}
|
||||
disabled={readOnly}
|
||||
className={cn(
|
||||
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight break-words',
|
||||
'placeholder:text-foreground/20 resize-none overflow-hidden break-words',
|
||||
'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
|
||||
'leading-[1.15] tracking-tight',
|
||||
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
||||
titleSizeClass,
|
||||
!readOnly && 'pr-12'
|
||||
)}
|
||||
style={{ height: 'auto' }}
|
||||
ref={(el) => {
|
||||
// Force correct initial height on mount
|
||||
if (el) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* AI title generation — always visible on hover */}
|
||||
{/* AI title generation — visible on hover */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
console.log('[TITLE] Sparkles button clicked')
|
||||
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||
console.log('[TITLE] Content length:', plain.length, 'Word count:', wordCount)
|
||||
if (wordCount < 10) {
|
||||
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
|
||||
return
|
||||
}
|
||||
actions.setIsProcessingAI(true)
|
||||
try {
|
||||
console.log('[TITLE] Calling /api/ai/title-suggestions...')
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: plain }),
|
||||
})
|
||||
console.log('[TITLE] API response:', res.status)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
console.log('[TITLE] Suggestions:', data.suggestions)
|
||||
const s = data.suggestions?.[0]?.title ?? ''
|
||||
if (s) {
|
||||
actions.setTitle(s)
|
||||
@@ -67,12 +81,9 @@ export function NoteTitleBlock() {
|
||||
toast.error('Impossible de générer un titre.')
|
||||
}
|
||||
} else {
|
||||
const err = await res.text()
|
||||
console.error('[TITLE] API error:', err)
|
||||
toast.error('Erreur lors de la génération du titre.')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[TITLE] Fetch failed:', e)
|
||||
toast.error('Erreur réseau.')
|
||||
} finally { actions.setIsProcessingAI(false) }
|
||||
}}
|
||||
@@ -97,6 +108,7 @@ export function NoteTitleBlock() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Dialog mode title block
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -486,34 +486,34 @@ export function NoteHistoryModal({
|
||||
{enabled && !isLoading && entries.length >= 2 && (
|
||||
<div className="flex items-center justify-end border-t border-border/60 px-5 py-2.5">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border/60 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setViewMode('preview'); setDiffLeftId(null); setDiffRightId(null) }}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
viewMode === 'preview' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('notes.history') || 'Historique'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode('diff')
|
||||
if (!diffLeftId && entries.length >= 2) {
|
||||
setDiffLeftId(entries[1].id)
|
||||
setDiffRightId(entries[0].id)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors inline-flex items-center gap-1',
|
||||
viewMode === 'diff' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<GitCompare className="h-3 w-3" />
|
||||
{t('notes.compareVersions') || 'Comparer'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setViewMode('preview'); setDiffLeftId(null); setDiffRightId(null) }}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
viewMode === 'preview' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('notes.history') || 'Historique'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode('diff')
|
||||
if (!diffLeftId && entries.length >= 2) {
|
||||
setDiffLeftId(entries[1].id)
|
||||
setDiffRightId(entries[0].id)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors inline-flex items-center gap-1',
|
||||
viewMode === 'diff' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<GitCompare className="h-3 w-3" />
|
||||
{t('notes.compareVersions') || 'Comparer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -107,16 +107,15 @@ export function NoteInlineEditor({
|
||||
const { data: session } = useSession()
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
const userId = session.user.id
|
||||
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
|
||||
getAISettings(userId).then(settings => {
|
||||
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||
})
|
||||
getAISettings(session.user.id).then((settings) => {
|
||||
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||
setAutoSaveEnabled(settings.autoSave !== false)
|
||||
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||
}
|
||||
}, [session?.user?.id])
|
||||
const { labels: globalLabels, addLabel } = useNotebooks()
|
||||
@@ -207,6 +206,10 @@ export function NoteInlineEditor({
|
||||
|
||||
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (!autoSaveEnabled) {
|
||||
setIsDirty(true)
|
||||
return
|
||||
}
|
||||
setIsDirty(true)
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
@@ -567,10 +570,11 @@ export function NoteInlineEditor({
|
||||
)}
|
||||
|
||||
{previousContent !== null && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30 font-medium"
|
||||
title={t('ai.undoAI') }
|
||||
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">{t('general.undo') || 'Annuler'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -601,7 +605,31 @@ export function NoteInlineEditor({
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
||||
) : isDirty ? (
|
||||
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
||||
!autoSaveEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[11px] text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||
onClick={() => {
|
||||
setIsSaving(true)
|
||||
saveInline(note.id, { title, content, checkItems, type: noteType, isMarkdown: showMarkdownPreview && noteType === 'markdown' })
|
||||
.then(() => {
|
||||
setIsSaving(false)
|
||||
setIsDirty(false)
|
||||
toast.success(t('notes.savedStatus'))
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSaving(false)
|
||||
toast.error(t('general.error'))
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 mr-1.5" />
|
||||
{t('notes.saveNow') || 'Enregistrer'}
|
||||
</Button>
|
||||
) : (
|
||||
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
||||
)
|
||||
) : (
|
||||
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
|
||||
)}
|
||||
@@ -707,13 +735,22 @@ export function NoteInlineEditor({
|
||||
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||
{/* Title */}
|
||||
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40 resize-none overflow-hidden min-h-[1.5em]"
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
onChange={(e) => {
|
||||
changeTitle(e.target.value);
|
||||
scheduleSave();
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
/>
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
<button type="button"
|
||||
@@ -920,9 +957,20 @@ export function NoteInlineEditor({
|
||||
noteImages={allImages}
|
||||
noteId={note.id}
|
||||
onApplyToNote={(newContent) => {
|
||||
setPreviousContent(content)
|
||||
const current = content
|
||||
setPreviousContent(current)
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
toast.success(t('ai.appliedToNote') || 'Applied to note', {
|
||||
action: {
|
||||
label: t('general.undo') || 'Undo',
|
||||
onClick: () => {
|
||||
changeContent(current)
|
||||
setPreviousContent(null)
|
||||
scheduleSave()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
onUndoLastAction={previousContent !== null ? () => {
|
||||
changeContent(previousContent)
|
||||
|
||||
@@ -1070,13 +1070,22 @@ export function NoteInput({
|
||||
noteContent={content}
|
||||
noteImages={allImages}
|
||||
onApplyToNote={(newContent) => {
|
||||
// Save current state to history before applying AI content
|
||||
setHistory(prev => [...prev.slice(0, historyIndex + 1), { title, content }])
|
||||
setHistoryIndex(prev => prev + 1)
|
||||
|
||||
if (type === 'richtext') {
|
||||
// If content looks like markdown, convert to HTML before injecting into richtext
|
||||
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
|
||||
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
|
||||
} else {
|
||||
setContent(newContent)
|
||||
}
|
||||
toast.success(t('ai.appliedToNote'), {
|
||||
action: {
|
||||
label: t('general.undo'),
|
||||
onClick: () => handleUndo()
|
||||
}
|
||||
})
|
||||
}}
|
||||
lastActionApplied={false}
|
||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||
|
||||
@@ -46,6 +46,15 @@ interface ReminderNote {
|
||||
isReminderDone: boolean
|
||||
}
|
||||
|
||||
// ── Memento brand tokens ──────────────────────────────────────────────────────
|
||||
const C = {
|
||||
blue: '#E9ECEF',
|
||||
gold: '#D4A373',
|
||||
green: '#A3B18A',
|
||||
dark: '#1C1C1C',
|
||||
beige: '#F2F0E9',
|
||||
}
|
||||
|
||||
export function NotificationPanel() {
|
||||
const { refreshNotes } = useRefresh()
|
||||
const { t } = useLanguage()
|
||||
@@ -100,7 +109,6 @@ export function NotificationPanel() {
|
||||
refreshNotes(null)
|
||||
setOpen(false)
|
||||
} catch (error: any) {
|
||||
console.error('[NOTIFICATION] Error:', error)
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
@@ -112,7 +120,6 @@ export function NotificationPanel() {
|
||||
toast.info(t('notification.declined'))
|
||||
if (requests.length <= 1) setOpen(false)
|
||||
} catch (error: any) {
|
||||
console.error('[NOTIFICATION] Error:', error)
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
@@ -139,6 +146,23 @@ export function NotificationPanel() {
|
||||
|
||||
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
||||
|
||||
// ── icon bg/color per notification type ──────────────────────────────────
|
||||
const notifIconStyle = (type: string) => {
|
||||
if (type === 'agent_success') return { bg: `${C.green}20`, color: C.green }
|
||||
if (type === 'agent_slides_ready') return { bg: `${C.blue}20`, color: C.blue }
|
||||
if (type === 'agent_canvas_ready') return { bg: `${C.blue}20`, color: C.blue }
|
||||
if (type === 'agent_failure') return { bg: '#EF444420', color: '#EF4444' }
|
||||
return { bg: `${C.gold}20`, color: C.gold }
|
||||
}
|
||||
|
||||
const notifLabelColor = (type: string) => {
|
||||
if (type === 'agent_success') return C.green
|
||||
if (type === 'agent_slides_ready') return C.blue
|
||||
if (type === 'agent_canvas_ready') return C.blue
|
||||
if (type === 'agent_failure') return '#EF4444'
|
||||
return C.gold
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -147,163 +171,172 @@ export function NotificationPanel() {
|
||||
>
|
||||
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
||||
{pendingCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full bg-rose-500 text-white text-[9px] font-bold border border-white shadow-sm">
|
||||
<span
|
||||
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm"
|
||||
style={{ background: C.gold }}
|
||||
>
|
||||
{pendingCount > 9 ? '9+' : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
||||
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{appNotifications.length > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('notification.markAllRead') || 'Mark all read'}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
||||
{pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-xl border border-black/10">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: `${C.beige}` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" style={{ color: C.dark }} />
|
||||
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
|
||||
{t('notification.notifications')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{appNotifications.length > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
|
||||
title={t('notification.markAllRead') || 'Mark all read'}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold"
|
||||
style={{ background: C.gold }}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
|
||||
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
|
||||
</div>
|
||||
) : !hasContent ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{/* App notifications (agents, system) */}
|
||||
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
|
||||
|
||||
{/* ── App notifications (agents, system) ── */}
|
||||
{appNotifications.map((notif) => {
|
||||
const isSlides = notif.type === 'agent_slides_ready'
|
||||
const isCanvas = notif.type === 'agent_canvas_ready'
|
||||
const canvasId = notif.relatedId
|
||||
const iconStyle = notifIconStyle(notif.type)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notif.id}
|
||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (notif.actionUrl) {
|
||||
handleMarkNotifRead(notif.id)
|
||||
setOpen(false)
|
||||
router.push(notif.actionUrl)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mt-0.5 flex-none rounded-full p-1",
|
||||
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
|
||||
notif.type === 'agent_slides_ready' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-600',
|
||||
notif.type === 'agent_canvas_ready' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
|
||||
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
|
||||
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
|
||||
)}>
|
||||
{isSlides ? (
|
||||
<Presentation className="w-3.5 h-3.5" />
|
||||
) : isCanvas ? (
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
) : notif.type.startsWith('agent') ? (
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-wider",
|
||||
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
|
||||
notif.type === 'agent_slides_ready' && 'text-purple-600 dark:text-purple-400',
|
||||
notif.type === 'agent_canvas_ready' && 'text-blue-600 dark:text-blue-400',
|
||||
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
|
||||
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
|
||||
)}>
|
||||
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Slides Ready')}
|
||||
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagram Ready')}
|
||||
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
|
||||
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
|
||||
{notif.type === 'system' && 'System'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">{notif.title}</p>
|
||||
{notif.message && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
||||
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||
title={t('notification.dismiss') || 'Dismiss'}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{isSlides && canvasId && (
|
||||
<div className="mt-2 ml-8">
|
||||
<button
|
||||
onClick={async () => {
|
||||
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||
<div
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (notif.actionUrl) {
|
||||
handleMarkNotifRead(notif.id)
|
||||
window.open(`/api/canvas/download?id=${canvasId}`, '_blank')
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-purple-500 text-white hover:bg-purple-600 shadow-sm transition-all active:scale-95"
|
||||
setOpen(false)
|
||||
router.push(notif.actionUrl)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Icon badge */}
|
||||
<div
|
||||
className="mt-0.5 flex-none rounded-lg p-1.5"
|
||||
style={{ background: iconStyle.bg, color: iconStyle.color }}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
{t('notification.downloadPptx') || 'Download .pptx'}
|
||||
{isSlides ? <Presentation className="w-3.5 h-3.5" />
|
||||
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
||||
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
|
||||
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.2em]"
|
||||
style={{ color: notifLabelColor(notif.type) }}
|
||||
>
|
||||
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
|
||||
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
|
||||
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
|
||||
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
|
||||
{notif.type === 'system' && 'Système'}
|
||||
</span>
|
||||
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
||||
{notif.message && (
|
||||
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
||||
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download PPTX button */}
|
||||
{isSlides && canvasId && (
|
||||
<div className="mt-2 ml-8">
|
||||
<button
|
||||
onClick={async () => {
|
||||
handleMarkNotifRead(notif.id)
|
||||
try {
|
||||
const res = await fetch(`/api/canvas?id=${canvasId}`)
|
||||
const data = await res.json()
|
||||
if (!data.canvas?.data) throw new Error()
|
||||
const parsed = JSON.parse(data.canvas.data)
|
||||
if (!parsed.base64) throw new Error()
|
||||
const bytes = Uint8Array.from(atob(parsed.base64), c => c.charCodeAt(0))
|
||||
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
|
||||
document.body.appendChild(a); a.click()
|
||||
document.body.removeChild(a); URL.revokeObjectURL(url)
|
||||
} catch { toast.error('Échec du téléchargement') }
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
||||
style={{ background: C.blue }}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
{t('notification.downloadPptx') || 'Télécharger .pptx'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Overdue reminders */}
|
||||
{/* ── Overdue reminders ── */}
|
||||
{overdueReminders.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => handleToggleReminder(note.id, true)}
|
||||
className="mt-0.5 flex-none text-amber-500 hover:text-green-500 transition-colors"
|
||||
className="mt-0.5 flex-none transition-colors hover:opacity-70"
|
||||
style={{ color: C.gold }}
|
||||
title={t('reminders.markDone')}
|
||||
>
|
||||
<Circle className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<AlertCircle className="w-3 h-3 text-amber-500" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="w-3 h-3" style={{ color: C.gold }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.gold }}>
|
||||
{t('reminders.overdue')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
||||
<Clock className="w-3 h-3" />
|
||||
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
||||
</div>
|
||||
@@ -312,17 +345,14 @@ export function NotificationPanel() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Upcoming reminders */}
|
||||
{/* ── Upcoming reminders ── */}
|
||||
{upcomingReminders.slice(0, 5).map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-4 h-4 mt-0.5 flex-none text-primary" />
|
||||
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
||||
<div className="text-[11px] text-foreground/40 mt-0.5">
|
||||
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
@@ -332,56 +362,48 @@ export function NotificationPanel() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Share requests */}
|
||||
{/* ── Share requests ── */}
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="h-7 w-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-[10px] shadow-md shrink-0">
|
||||
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
||||
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
|
||||
>
|
||||
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
|
||||
Partage
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-semibold truncate">
|
||||
{request.sharer.name || request.sharer.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
<p className="text-[11px] text-foreground/50 truncate">
|
||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="flex gap-2 ml-11">
|
||||
<button
|
||||
onClick={() => handleDecline(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
|
||||
"border border-border bg-background",
|
||||
"text-muted-foreground",
|
||||
"hover:bg-muted hover:text-foreground",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1",
|
||||
"active:scale-95"
|
||||
)}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t('notification.decline') || t('general.cancel')}
|
||||
{t('notification.decline') || 'Refuser'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAccept(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
|
||||
"bg-primary text-primary-foreground",
|
||||
"hover:bg-primary/90",
|
||||
"shadow-sm",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1",
|
||||
"active:scale-95"
|
||||
)}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
||||
style={{ background: C.blue }}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t('notification.accept') || t('general.confirm')}
|
||||
{t('notification.accept') || 'Accepter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,14 +411,15 @@ export function NotificationPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer link to reminders page */}
|
||||
{/* Footer */}
|
||||
{activeReminders.length > 0 && (
|
||||
<div className="px-4 py-2 border-t bg-muted/30">
|
||||
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
|
||||
<a
|
||||
href="/reminders"
|
||||
className="text-[11px] font-medium text-primary hover:underline"
|
||||
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
|
||||
style={{ color: C.blue }}
|
||||
>
|
||||
{t('reminders.viewAll') || t('reminders.title') || 'Voir tous les rappels'}
|
||||
{t('reminders.viewAll') || 'Voir tous les rappels →'}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,10 @@ import Image from '@tiptap/extension-image'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { Table } from '@tiptap/extension-table'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import Superscript from '@tiptap/extension-superscript'
|
||||
import Subscript from '@tiptap/extension-subscript'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
@@ -26,7 +30,8 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen } from 'lucide-react'
|
||||
SpellCheck, Languages, BookOpen, Presentation
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -45,13 +50,13 @@ interface RichTextEditorProps {
|
||||
type SlashItem = {
|
||||
title: string
|
||||
description: string
|
||||
icon: typeof Bold
|
||||
icon: any
|
||||
category?: string
|
||||
shortcut?: string
|
||||
isImage?: boolean
|
||||
isAi?: boolean
|
||||
aiOption?: 'clarify' | 'shorten' | 'improve'
|
||||
command: (editor: Editor) => void
|
||||
command: (editor: Editor, range?: any) => void
|
||||
}
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
@@ -71,28 +76,50 @@ const CustomImage = Image.extend({
|
||||
})
|
||||
|
||||
const slashCommands: SlashItem[] = [
|
||||
// Basic blocks (indices 0-9)
|
||||
// Basic blocks
|
||||
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
|
||||
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
|
||||
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
|
||||
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
|
||||
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
|
||||
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
||||
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
|
||||
// Media (index 10)
|
||||
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} },
|
||||
// Formatting (indices 11-13) — super/subscript removed, use BubbleMenu
|
||||
// Media
|
||||
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
|
||||
// Formatting
|
||||
{ title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() },
|
||||
{ title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() },
|
||||
{ title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() },
|
||||
// IA Note (indices 14-17)
|
||||
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
||||
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => {} },
|
||||
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => {} },
|
||||
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
||||
// IA Note
|
||||
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
|
||||
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } },
|
||||
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } },
|
||||
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
|
||||
// Formatting extensions
|
||||
{ title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() },
|
||||
{ title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() },
|
||||
{ title: 'Underline', description: 'Underline text', icon: UnderlineIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleUnderline().run() },
|
||||
{ title: 'Strike', description: 'Strikethrough text', icon: Strikethrough, category: 'Formatting', command: (e) => e.chain().focus().toggleStrike().run() },
|
||||
{ title: 'Highlight', description: 'Highlight text', icon: Highlighter, category: 'Formatting', command: (e) => e.chain().focus().toggleHighlight().run() },
|
||||
{ title: 'Superscript', description: 'Text above the baseline', icon: SuperscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSuperscript().run() },
|
||||
{ title: 'Subscript', description: 'Text below the baseline', icon: SubscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSubscript().run() },
|
||||
// AI Tools
|
||||
{
|
||||
title: 'Diagramme', description: 'Générer un diagramme Excalidraw', icon: BookOpen, category: 'IA Note', command: (e) => {
|
||||
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'diagram' } })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Présentation', description: 'Générer des slides HTML/PPTX', icon: Presentation, category: 'IA Note', command: (e) => {
|
||||
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'slides' } })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -146,6 +173,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Table.configure({ resizable: true }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Superscript,
|
||||
Subscript,
|
||||
Typography,
|
||||
@@ -202,7 +233,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
editor={editor}
|
||||
className="notion-bubble-menu"
|
||||
{...({
|
||||
tippyOptions: {
|
||||
tippyOptions: {
|
||||
appendTo: () => document.body,
|
||||
zIndex: 99999,
|
||||
fallbackPlacements: ['bottom', 'top']
|
||||
@@ -278,7 +309,7 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
)
|
||||
}
|
||||
|
||||
const AI_LANGS = ['Francais','English','Espanol','Deutsch','Persan','Portugais','Italiano','Chinois','Japonais']
|
||||
const AI_LANGS = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais']
|
||||
|
||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const { t, language } = useLanguage()
|
||||
@@ -472,10 +503,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
||||
// Flag: true while user is interacting with the menu (prevents selectionUpdate from closing it)
|
||||
const menuInteracting = useRef(false)
|
||||
|
||||
// Translated category names (keys match slashCommands category field)
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
'Basic blocks': t('richTextEditor.slashCatBasic'),
|
||||
'Media': t('richTextEditor.slashCatMedia'),
|
||||
@@ -483,26 +512,35 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
'IA Note': t('richTextEditor.slashCatAi'),
|
||||
}
|
||||
|
||||
// Translated command list (keeps same order/icons/shortcuts as global slashCommands)
|
||||
const localCommands: SlashItem[] = [
|
||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[4], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[5], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[6], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[7], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[8], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[9], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: 'Basic blocks' },
|
||||
{ ...slashCommands[10], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: 'Media' },
|
||||
{ ...slashCommands[11], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: 'Formatting' },
|
||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: 'Formatting' },
|
||||
{ ...slashCommands[13], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: 'Formatting' },
|
||||
{ ...slashCommands[14], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: 'IA Note' },
|
||||
{ ...slashCommands[15], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: 'IA Note' },
|
||||
{ ...slashCommands[16], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: 'IA Note' },
|
||||
{ ...slashCommands[17], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: 'IA Note' },
|
||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') },
|
||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -536,13 +574,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage])
|
||||
|
||||
// All category names in order
|
||||
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks')))
|
||||
|
||||
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
||||
const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered
|
||||
|
||||
// Compute categories based on full search to keep tabs visible even when one is selected
|
||||
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||
const cat = item.category || 'Basic blocks'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
@@ -571,8 +607,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
e.preventDefault()
|
||||
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
|
||||
const currentIndex = availableTabs.indexOf(activeCategory)
|
||||
const nextIndex = e.key === 'ArrowRight'
|
||||
? (currentIndex + 1) % availableTabs.length
|
||||
const nextIndex = e.key === 'ArrowRight'
|
||||
? (currentIndex + 1) % availableTabs.length
|
||||
: (currentIndex - 1 + availableTabs.length) % availableTabs.length
|
||||
setActiveCategory(availableTabs[nextIndex])
|
||||
setSelectedIndex(0)
|
||||
@@ -602,8 +638,17 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
if (!isOpen) return
|
||||
const { from } = editor.state.selection
|
||||
const c = editor.view.coordsAtPos(from)
|
||||
setCoords({ top: c.bottom + 8, left: c.left })
|
||||
}, [isOpen, editor, query])
|
||||
|
||||
// Check if menu would overflow bottom
|
||||
const menuHeight = menuRef.current?.offsetHeight || 300
|
||||
const wouldOverflow = c.bottom + menuHeight + 20 > window.innerHeight
|
||||
|
||||
if (wouldOverflow) {
|
||||
setCoords({ top: c.top - menuHeight - 8, left: c.left })
|
||||
} else {
|
||||
setCoords({ top: c.bottom + 8, left: c.left })
|
||||
}
|
||||
}, [isOpen, editor, query, filtered.length])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
|
||||
@@ -40,10 +40,10 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
key={section.id}
|
||||
href={section.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 pb-3 pt-4 text-xs font-semibold uppercase tracking-wider transition-colors whitespace-nowrap border-b-2',
|
||||
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
|
||||
isActive(section.href)
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
? 'border-[#D4A373] text-[#1C1C1C]'
|
||||
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
|
||||
)}
|
||||
>
|
||||
{section.icon}
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
LogOut,
|
||||
Shield,
|
||||
GripVertical,
|
||||
Users,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -60,7 +62,7 @@ function NoteLink({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg',
|
||||
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
|
||||
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
||||
)}
|
||||
>
|
||||
@@ -68,7 +70,7 @@ function NoteLink({
|
||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
||||
)} />
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="break-words line-clamp-2 leading-tight">{title}</span>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
@@ -333,7 +335,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<aside
|
||||
className={cn(
|
||||
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
||||
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
||||
'border-e border-border/40 bg-[#F6F4F0] backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -346,11 +348,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
className="shrink-0 rounded-full outline-none ring-offset-background transition-shadow hover:ring-2 hover:ring-primary/30 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('sidebar.accountMenu') || 'Menu du compte'}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-muted border border-border flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
|
||||
<div className="w-10 h-10 rounded-full bg-[#E9ECEF] border border-black/10 flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
|
||||
{user?.image ? (
|
||||
<Avatar className="size-10 ring-1 ring-border/60">
|
||||
<AvatarImage src={user.image} alt="" />
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
|
||||
<AvatarFallback className="bg-[#E9ECEF] text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
@@ -485,6 +487,48 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{t('sidebar.inbox') || 'Inbox'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Partagées avec moi */}
|
||||
<Link
|
||||
href="/shared"
|
||||
className={cn('sidebar-inbox-item', pathname === '/shared' && 'active')}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
pathname === '/shared'
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-white/60 text-foreground border-border'
|
||||
)}>
|
||||
<Users size={14} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[13px] font-medium truncate',
|
||||
pathname === '/shared' ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{t('sidebar.sharedWithMe') || 'Partagées avec moi'}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Rappels */}
|
||||
<Link
|
||||
href="/reminders"
|
||||
className={cn('sidebar-inbox-item', pathname === '/reminders' && 'active')}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
pathname === '/reminders'
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-white/60 text-foreground border-border'
|
||||
)}>
|
||||
<Bell size={14} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[13px] font-medium truncate',
|
||||
pathname === '/reminders' ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{t('sidebar.reminders') || 'Rappels'}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-4 my-3 h-px bg-border/40" />
|
||||
@@ -500,27 +544,37 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const notes = notebookNotes[notebook.id] || []
|
||||
const isDragging = draggedId === notebook.id
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={notebook.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
layout
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 0.8
|
||||
}}
|
||||
>
|
||||
<SidebarCarnetItem
|
||||
carnet={{
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
initial: notebook.name.charAt(0).toUpperCase(),
|
||||
}}
|
||||
isActive={isActive}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||
onNoteClick={handleNoteClick}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SidebarCarnetItem
|
||||
carnet={{
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
initial: notebook.name.charAt(0).toUpperCase(),
|
||||
}}
|
||||
isActive={isActive}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||
onNoteClick={handleNoteClick}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -548,7 +602,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{[
|
||||
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
|
||||
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
|
||||
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
|
||||
].map(item => {
|
||||
const isActive = pathname.startsWith(item.href)
|
||||
return (
|
||||
@@ -572,17 +625,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* General Chat button (opens floating panel) */}
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
|
||||
<Sparkles size={16} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -593,21 +635,21 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="pt-4 p-5 border-t border-border space-y-1">
|
||||
<Link
|
||||
href="/archive"
|
||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||
>
|
||||
<Archive size={16} />
|
||||
<span>{t('sidebar.archive') || 'Archives'}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/trash"
|
||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>{t('sidebar.trash') || 'Corbeille'}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>{t('nav.settings') || 'Paramètres'}</span>
|
||||
|
||||
@@ -36,7 +36,7 @@ function AlertDialogOverlay({
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-[#1C1C1C]/40 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -39,7 +39,7 @@ const AvatarFallback = React.forwardRef<
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-[#E9ECEF]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-[#1C1C1C]/40 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -18,18 +18,18 @@ export function Toaster() {
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: [
|
||||
'toast pointer-events-auto',
|
||||
'!bg-[#1C1C1C] !text-[#F2F0E9] !border !border-white/10',
|
||||
'!rounded-xl !shadow-xl !shadow-black/30',
|
||||
'!text-[13px] !font-medium !py-3 !px-4',
|
||||
'toast pointer-events-auto border-none',
|
||||
'bg-[var(--color-memento-ink)] text-[var(--color-memento-paper)]',
|
||||
'rounded-xl shadow-acrylic',
|
||||
'text-[13px] font-medium py-3 px-4',
|
||||
].join(' '),
|
||||
description: '!text-[#F2F0E9]/70 !text-[12px]',
|
||||
actionButton: '!bg-[#F2F0E9] !text-[#1C1C1C] !text-[11px] !font-bold !rounded-lg !px-3 !py-1',
|
||||
closeButton: '!bg-white/10 !text-[#F2F0E9]/70 !border-white/10 hover:!bg-white/20',
|
||||
success: '!border-l-4 !border-l-emerald-400/70',
|
||||
error: '!border-l-4 !border-l-red-400/70',
|
||||
warning: '!border-l-4 !border-l-amber-400/70',
|
||||
info: '!border-l-4 !border-l-sky-400/70',
|
||||
description: 'text-[var(--color-memento-paper)]/70 text-[12px]',
|
||||
actionButton: 'bg-[var(--color-memento-paper)] text-[var(--color-memento-ink)] text-[11px] font-bold rounded-lg px-4 py-1.5 hover:opacity-90 transition-opacity',
|
||||
closeButton: 'bg-white/10 text-[var(--color-memento-paper)]/70 border-white/10 hover:bg-white/20',
|
||||
success: 'border-l-4 border-l-emerald-400',
|
||||
error: 'border-l-4 border-l-red-400',
|
||||
warning: 'border-l-4 border-l-amber-400',
|
||||
info: 'border-l-4 border-l-sky-400',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user