feat(ux): epic UX design improvements across agents, chat, notes, and i18n
Comprehensive UI/UX updates including agent card redesign, chat container improvements, note editor enhancements, memory echo notifications, and updated translations for all 15 locales. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,8 +27,6 @@ import {
|
||||
toggleArchive,
|
||||
updateColor,
|
||||
deleteNote,
|
||||
removeImageFromNote,
|
||||
leaveSharedNote,
|
||||
createNote,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
@@ -55,8 +53,6 @@ import {
|
||||
RotateCcw,
|
||||
Languages,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
@@ -108,12 +104,10 @@ export function NoteInlineEditor({
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const [, startTransition] = useTransition()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const isSharedNote = !!(note as any)._isShared
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content || '')
|
||||
@@ -132,34 +126,6 @@ export function NoteInlineEditor({
|
||||
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
||||
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
||||
|
||||
// Textarea ref for formatting toolbar
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
||||
const textarea = textAreaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = content.substring(start, end)
|
||||
const before = content.substring(0, start)
|
||||
const after = content.substring(end)
|
||||
|
||||
const newContent = before + prefix + selected + suffix + after
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
|
||||
// Restore cursor position after React re-renders
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = selected ? end + prefix.length + suffix.length : start + prefix.length
|
||||
textarea.setSelectionRange(
|
||||
selected ? start + prefix.length : start + prefix.length,
|
||||
selected ? end + prefix.length : newCursorPos
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
@@ -272,78 +238,30 @@ export function NoteInlineEditor({
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try {
|
||||
await addLabel(tag)
|
||||
// Refresh labels to get the new color assignment
|
||||
await refreshLabels()
|
||||
} catch {}
|
||||
try { await addLabel(tag) } catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = async (label: string) => {
|
||||
const newLabels = (note.labels || []).filter((l) => l !== label)
|
||||
// Optimistic UI
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
toast.success(t('labels.labelRemoved', { label }))
|
||||
}
|
||||
|
||||
// ── Shared note actions ────────────────────────────────────────────────────
|
||||
const handleMakeCopy = async () => {
|
||||
try {
|
||||
await createNote({
|
||||
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||
content,
|
||||
color: note.color,
|
||||
type: note.type,
|
||||
checkItems: note.checkItems ?? undefined,
|
||||
labels: note.labels ?? undefined,
|
||||
images: note.images ?? undefined,
|
||||
links: note.links ?? undefined,
|
||||
isMarkdown,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
triggerRefresh()
|
||||
} catch (error) {
|
||||
toast.error(t('notes.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onDelete?.(note.id)
|
||||
} catch (error) {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleCompareNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setComparisonNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
setComparisonNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
||||
@@ -357,11 +275,12 @@ export function NoteInlineEditor({
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
@@ -395,21 +314,10 @@ export function NoteInlineEditor({
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
toast(t('notes.confirmDelete'), {
|
||||
action: {
|
||||
label: t('notes.delete'),
|
||||
onClick: () => {
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t('common.cancel'),
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 5000,
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -437,7 +345,7 @@ export function NoteInlineEditor({
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await removeImageFromNote(note.id, index)
|
||||
await updateNote(note.id, { images: newImages })
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
@@ -581,27 +489,7 @@ export function NoteInlineEditor({
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
|
||||
{/* ── Shared note banner ──────────────────────────────────────────── */}
|
||||
{isSharedNote && (
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-primary/5 dark:bg-primary/10 px-4 py-2">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{t('notes.sharedReadOnly') || 'Lecture seule — note partagée'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="default" size="sm" className="h-7 gap-1.5 text-xs" onClick={handleMakeCopy}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('notes.makeCopy') || 'Copier'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={handleLeaveShare}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t('notes.leaveShare') || 'Quitter'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toolbar (hidden for shared notes) ────────────────────────────── */}
|
||||
{!isSharedNote && (
|
||||
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image upload */}
|
||||
@@ -789,6 +677,12 @@ export function NoteInlineEditor({
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Pin */}
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
|
||||
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
|
||||
</Button>
|
||||
|
||||
{/* Color picker */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -835,7 +729,6 @@ export function NoteInlineEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
@@ -863,11 +756,7 @@ export function NoteInlineEditor({
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
||||
{/* Existing labels */}
|
||||
{(note.labels ?? []).map((label) => (
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => handleRemoveLabel(label)}
|
||||
/>
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
@@ -891,7 +780,6 @@ export function NoteInlineEditor({
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
/>
|
||||
{/* AI title suggestion — show when title is empty and there's content */}
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
@@ -976,21 +864,17 @@ export function NoteInlineEditor({
|
||||
<MarkdownContent content={content || ''} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
</>
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
@@ -1049,17 +933,18 @@ export function NoteInlineEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
|
||||
{!isSharedNote && (
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section ── */}
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onOpenNote={(connNoteId) => {
|
||||
window.open(`/?note=${connNoteId}`, '_blank')
|
||||
}}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
/>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
<div className="shrink-0 border-t border-border/20 px-8 py-2">
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
|
||||
|
||||
Reference in New Issue
Block a user