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:
Sepehr Ramezani
2026-04-19 23:01:04 +02:00
parent c2a4c22e5f
commit 402e88b788
208 changed files with 493 additions and 318 deletions

View File

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