UI Stabilization: Global color theme updates (#75B2D6), AI Assistant styling refactor, and navigation fixes

This commit is contained in:
Antigravity
2026-05-09 12:58:16 +00:00
parent 1446463f04
commit 60a3fe5453
47 changed files with 3585 additions and 2149 deletions

View File

@@ -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">

View File

@@ -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"

View 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&apos;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&apos;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
)
}

View File

@@ -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">