fix: i18n system overhaul and sidebar UI bugs

- Fix LanguageProvider: add RTL support (ar/fa), translation caching,
  prevent blank flash during load, browser language detection
- Fix detect-user-language: extend whitelist from 5 to all 15 languages
- Remove hardcoded initialLanguage="fr" from auth layout
- Complete fr.json translation (all sections translated from English)
- Add missing admin.ai keys to all 13 non-English locales
- Translate ai.autoLabels, ai.batchOrganization, memoryEcho sections
  for all locales
- Remove duplicate top-level autoLabels/batchOrganization from en.json
- Fix notebook creation: replace window.location.reload() with
  createNotebookOptimistic + router.refresh()
- Fix notebook name truncation in sidebar with min-w-0
- Remove redundant router.refresh() after note creation in page.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-03-29 22:14:05 +02:00
parent 8bf56cd8cd
commit 8daf50ac3f
27 changed files with 1210 additions and 936 deletions

View File

@@ -1,9 +1,10 @@
'use client'
import { useState, useEffect } from 'react'
import { memo, useState, useEffect } from 'react'
import { Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { getConnectionsCount } from '@/lib/connections-cache'
interface ConnectionsBadgeProps {
noteId: string
@@ -11,54 +12,44 @@ interface ConnectionsBadgeProps {
className?: string
}
interface ConnectionData {
noteId: string
title: string | null
content: string
createdAt: Date
similarity: number
daysApart: number
}
interface ConnectionsResponse {
connections: ConnectionData[]
pagination: {
total: number
page: number
limit: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
const { t } = useLanguage()
const [connectionCount, setConnectionCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [fetchAttempted, setFetchAttempted] = useState(false)
useEffect(() => {
if (fetchAttempted) return
setFetchAttempted(true)
let isMounted = true
const fetchConnections = async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=1`)
if (!res.ok) {
throw new Error('Failed to fetch connections')
const count = await getConnectionsCount(noteId)
if (isMounted) {
setConnectionCount(count)
}
const data: ConnectionsResponse = await res.json()
setConnectionCount(data.pagination.total || 0)
} catch (error) {
console.error('[ConnectionsBadge] Failed to fetch connections:', error)
setConnectionCount(0)
if (isMounted) {
setConnectionCount(0)
}
} finally {
setIsLoading(false)
if (isMounted) {
setIsLoading(false)
}
}
}
fetchConnections()
}, [noteId])
return () => {
isMounted = false
}
}, [noteId]) // eslint-disable-line react-hooks/exhaustive-deps
// Don't render if no connections or still loading
if (connectionCount === 0 || isLoading) {
@@ -69,19 +60,11 @@ export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadg
const badgeText = t('memoryEcho.connectionsBadge', { count: connectionCount, plural })
return (
<div
className={cn(
'px-1.5 py-0.5 rounded',
'bg-amber-100 dark:bg-amber-900/30',
'text-amber-700 dark:text-amber-400',
'text-[10px] font-medium',
'border border-amber-200 dark:border-amber-800',
'cursor-pointer',
'transition-all duration-150 ease-out',
'hover:bg-amber-200 dark:hover:bg-amber-800/50',
isHovered && 'scale-105',
className
)}
<div className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 transition-all duration-150 ease-out',
isHovered && 'scale-105',
className
)}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -91,4 +74,4 @@ export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadg
{badgeText}
</div>
)
}
})

View File

@@ -14,6 +14,7 @@ import {
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
const NOTEBOOK_ICONS = [
{ icon: Folder, name: 'folder' },
@@ -48,6 +49,7 @@ interface CreateNotebookDialogProps {
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
const router = useRouter()
const { t } = useLanguage()
const { createNotebookOptimistic } = useNotebooks()
const [name, setName] = useState('')
const [selectedIcon, setSelectedIcon] = useState('folder')
const [selectedColor, setSelectedColor] = useState('#3B82F6')
@@ -61,24 +63,14 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
setIsSubmitting(true)
try {
const response = await fetch('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
icon: selectedIcon,
color: selectedColor,
}),
await createNotebookOptimistic({
name: name.trim(),
icon: selectedIcon,
color: selectedColor,
})
if (response.ok) {
// Close dialog and reload
onOpenChange?.(false)
window.location.reload()
} else {
const error = await response.json()
console.error('Failed to create notebook:', error)
}
// Close dialog — no full page reload needed
onOpenChange?.(false)
router.refresh()
} catch (error) {
console.error('Failed to create notebook:', error)
} finally {

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
@@ -11,7 +12,7 @@ interface MarkdownContentProps {
className?: string
}
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
@@ -20,11 +21,11 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
components={{
a: ({ node, ...props }) => (
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
)
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}
})

View File

@@ -26,7 +26,7 @@ interface MasonryItemProps {
isDragging?: boolean;
}
const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
const resizeRef = useResizeObserver(onResize);
useEffect(() => {
@@ -55,7 +55,7 @@ const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeCha
</div>
</div>
);
};
})
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
const { t } = useLanguage();

View File

@@ -87,6 +87,21 @@ export function NoteActions({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Pin/Unpin Option */}
<DropdownMenuItem onClick={onTogglePin}>
{isPinned ? (
<>
<Pin className="h-4 w-4 mr-2" />
{t('notes.unpin')}
</>
) : (
<>
<Pin className="h-4 w-4 mr-2" />
{t('notes.pin')}
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleArchive}>
{isArchived ? (
<>

View File

@@ -10,14 +10,28 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Tag } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic } from 'react'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import * as dateFnsLocales from 'date-fns/locale'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { es } from 'date-fns/locale/es'
import { de } from 'date-fns/locale/de'
import { faIR } from 'date-fns/locale/fa-IR'
import { it } from 'date-fns/locale/it'
import { pt } from 'date-fns/locale/pt'
import { ru } from 'date-fns/locale/ru'
import { zhCN } from 'date-fns/locale/zh-CN'
import { ja } from 'date-fns/locale/ja'
import { ko } from 'date-fns/locale/ko'
import { ar } from 'date-fns/locale/ar'
import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl'
import { MarkdownContent } from './markdown-content'
import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
@@ -36,25 +50,25 @@ import { toast } from 'sonner'
// Mapping of supported languages to date-fns locales
const localeMap: Record<string, Locale> = {
en: dateFnsLocales.enUS,
fr: dateFnsLocales.fr,
es: dateFnsLocales.es,
de: dateFnsLocales.de,
fa: dateFnsLocales.faIR,
it: dateFnsLocales.it,
pt: dateFnsLocales.pt,
ru: dateFnsLocales.ru,
zh: dateFnsLocales.zhCN,
ja: dateFnsLocales.ja,
ko: dateFnsLocales.ko,
ar: dateFnsLocales.ar,
hi: dateFnsLocales.hi,
nl: dateFnsLocales.nl,
pl: dateFnsLocales.pl,
en: enUS,
fr: fr,
es: es,
de: de,
fa: faIR,
it: it,
pt: pt,
ru: ru,
zh: zhCN,
ja: ja,
ko: ko,
ar: ar,
hi: hi,
nl: nl,
pl: pl,
}
function getDateLocale(language: string): Locale {
return localeMap[language] || dateFnsLocales.enUS
return localeMap[language] || enUS
}
// Map icon names to lucide-react components
@@ -118,7 +132,7 @@ function getAvatarColor(name: string): string {
return colors[hash % colors.length]
}
export function NoteCard({
export const NoteCard = memo(function NoteCard({
note,
onEdit,
onDragStart,
@@ -133,7 +147,7 @@ export function NoteCard({
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [isPending, startTransition] = useTransition()
const [, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState<any[]>([])
@@ -172,23 +186,33 @@ export function NoteCard({
// Load collaborators when note changes
useEffect(() => {
let isMounted = true
const loadCollaborators = async () => {
if (note.userId) {
if (note.userId && isMounted) {
try {
const users = await getNoteAllUsers(note.id)
setCollaborators(users)
// Owner is always first in the list
if (users.length > 0) {
setOwner(users[0])
if (isMounted) {
setCollaborators(users)
// Owner is always first in the list
if (users.length > 0) {
setOwner(users[0])
}
}
} catch (error) {
console.error('Failed to load collaborators:', error)
setCollaborators([])
if (isMounted) {
setCollaborators([])
}
}
}
}
loadCollaborators()
return () => {
isMounted = false
}
}, [note.id, note.userId])
const handleDelete = async () => {
@@ -525,47 +549,12 @@ export function NoteCard({
/>
)}
{/* Labels - ONLY show if note belongs to a notebook (labels are contextual per PRD) */}
{/* Labels - using shared LabelBadge component */}
{optimisticNote.notebookId && optimisticNote.labels && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{optimisticNote.labels.map((label) => {
// Map label names to Keep style colors
const getLabelColor = (labelName: string) => {
if (labelName.includes('hôtels') || labelName.includes('réservations')) {
return 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
} else if (labelName.includes('vols') || labelName.includes('flight')) {
return 'bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300'
} else if (labelName.includes('restos') || labelName.includes('restaurant')) {
return 'bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300'
} else {
return 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
}
}
// Map label names to Keep style icons
const getLabelIcon = (labelName: string) => {
if (labelName.includes('hôtels')) return 'label'
else if (labelName.includes('vols')) return 'flight'
else if (labelName.includes('restos')) return 'restaurant'
else return 'label'
}
const icon = getLabelIcon(label)
const colorClass = getLabelColor(label)
return (
<span
key={label}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold",
colorClass
)}
>
<Tag className="w-3 h-3" />
{label}
</span>
)
})}
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
@@ -655,4 +644,4 @@ export function NoteCard({
)}
</Card>
)
}
})

View File

@@ -234,7 +234,7 @@ export function NotebooksList() {
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate flex-1 text-left">{notebook.name}</span>
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-left">{notebook.name}</span>
{notebook.notesCount > 0 && (
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({notebook.notesCount})</span>
)}

View File

@@ -1,9 +1,18 @@
'use client'
import { useState, useTransition, useOptimistic } from 'react'
import { Note } from '@/lib/types'
import { Clock } from 'lucide-react'
import { Clock, Pin, FolderOpen, Trash2, Folder, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { Button } from './ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'
import { togglePin, deleteNote, dismissFromRecent } from '@/app/actions/notes'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { toast } from 'sonner'
import { StickyNote } from 'lucide-react'
interface RecentNotesSectionProps {
recentNotes: Note[]
@@ -53,16 +62,89 @@ function CompactCard({
onEdit?: (note: Note, readOnly?: boolean) => void
}) {
const { t } = useLanguage()
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, t)
const router = useRouter()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const { triggerRefresh } = useNoteRefresh()
const [isDeleting, setIsDeleting] = useState(false)
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const [, startTransition] = useTransition()
// Optimistic UI state
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state: Note, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
const timeAgo = getCompactTime(optimisticNote.contentUpdatedAt || optimisticNote.updatedAt, t)
const isFirstNote = index === 0
const handleTogglePin = async (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
const newPinnedState = !optimisticNote.isPinned
addOptimisticNote({ isPinned: newPinnedState })
await togglePin(note.id, newPinnedState)
// Trigger global refresh to update lists
triggerRefresh()
router.refresh()
if (newPinnedState) {
toast.success(t('notes.pinned') || 'Note pinned')
} else {
toast.info(t('notes.unpinned') || 'Note unpinned')
}
})
}
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
triggerRefresh()
}
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation()
if (confirm(t('notes.confirmDelete'))) {
setIsDeleting(true)
try {
await deleteNote(note.id)
triggerRefresh()
router.refresh()
} catch (error) {
console.error('Failed to delete note:', error)
setIsDeleting(false)
}
}
}
const handleDismiss = async (e: React.MouseEvent) => {
e.stopPropagation()
// Optimistic removal
setIsDeleting(true)
try {
await dismissFromRecent(note.id)
// Don't refresh list to prevent immediate replacement
// triggerRefresh()
// router.refresh()
toast.success(t('notes.dismissed') || 'Note dismissed from recent')
} catch (error) {
console.error('Failed to dismiss note:', error)
setIsDeleting(false)
toast.error(t('common.error') || 'Failed to dismiss')
}
}
if (isDeleting) return null
return (
<button
onClick={() => onEdit?.(note)}
<div
className={cn(
"group relative text-left p-4 bg-card border rounded-xl shadow-sm hover:shadow-md transition-all duration-200 min-h-[44px]",
isFirstNote && "ring-2 ring-primary/20"
"group relative flex flex-col p-4 bg-card border rounded-xl shadow-sm hover:shadow-md transition-all duration-200 min-h-[44px]",
isFirstNote && "ring-2 ring-primary/20",
"cursor-pointer"
)}
onClick={() => onEdit?.(note)}
>
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
@@ -73,14 +155,83 @@ function CompactCard({
: "bg-muted dark:bg-muted/60"
)} />
{/* Action Buttons Overlay - Ensure visible on hover and touch devices often handle this with tap */}
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity z-10">
{/* Pin Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={handleTogglePin}
title={optimisticNote.isPinned ? t('notes.unpin') : t('notes.pin')}
>
<Pin className={cn("h-3.5 w-3.5", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")} />
</Button>
{/* Move to Notebook */}
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={(e) => e.stopPropagation()}
title={t('notebookSuggestion.moveToNotebook')}
>
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" onClick={(e) => e.stopPropagation()}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notebookSuggestion.moveToNotebook')}
</div>
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
<StickyNote className="h-4 w-4 mr-2" />
{t('notebookSuggestion.generalNotes')}
</DropdownMenuItem>
{notebooks.map((notebook: any) => (
<DropdownMenuItem
key={notebook.id}
onClick={() => handleMoveToNotebook(notebook.id)}
>
<Folder className="h-4 w-4 mr-2" />
{notebook.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Dismiss Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:text-destructive bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={handleDismiss}
title={t('common.close') || 'Dismiss'}
>
<X className="h-3.5 w-3.5" />
</Button>
{/* Delete Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:text-destructive bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={handleDelete}
title={t('notes.delete')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="pl-2">
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
{note.title || t('notes.untitled')}
{optimisticNote.title || t('notes.untitled')}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
{note.content?.substring(0, 80) || ''}
{note.content && note.content.length > 80 && '...'}
{(optimisticNote.content && typeof optimisticNote.content === 'string') ? optimisticNote.content.substring(0, 80) : ''}
{optimisticNote.content && typeof optimisticNote.content === 'string' && optimisticNote.content.length > 80 && '...'}
</p>
<div className="flex items-center justify-between pt-2 border-t border-border">
@@ -90,18 +241,19 @@ function CompactCard({
</span>
<div className="flex items-center gap-1.5">
{note.notebookId && (
{optimisticNote.isPinned && (
<Pin className="w-3 h-3 text-primary fill-current" />
)}
{optimisticNote.notebookId && (
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title={t('notes.inNotebook')} />
)}
{note.labels && note.labels.length > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: note.labels.length })} />
{optimisticNote.labels && optimisticNote.labels.length > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: optimisticNote.labels.length })} />
)}
</div>
</div>
</div>
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</button>
</div>
)
}