Files
Keep/keep-notes/components/recent-notes-section.tsx
Sepehr Ramezani 8daf50ac3f 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>
2026-03-29 22:14:05 +02:00

279 lines
9.9 KiB
TypeScript

'use client'
import { useState, useTransition, useOptimistic } from 'react'
import { Note } from '@/lib/types'
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[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
const { t } = useLanguage()
const topThree = recentNotes.slice(0, 3)
if (topThree.length === 0) return null
return (
<section data-testid="recent-notes-section" className="mb-6">
<div className="flex items-center gap-2 mb-3 px-1">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t('notes.recent')}
</span>
<span className="text-xs text-muted-foreground">
· {topThree.length}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{topThree.map((note, index) => (
<CompactCard
key={note.id}
note={note}
index={index}
onEdit={onEdit}
/>
))}
</div>
</section>
)
}
function CompactCard({
note,
index,
onEdit
}: {
note: Note
index: number
onEdit?: (note: Note, readOnly?: boolean) => void
}) {
const { t } = useLanguage()
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 (
<div
className={cn(
"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",
isFirstNote
? "bg-gradient-to-b from-primary to-primary/70"
: index === 1
? "bg-primary/80 dark:bg-primary/70"
: "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">
{optimisticNote.title || t('notes.untitled')}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
{(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">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
<span className="font-medium">{timeAgo}</span>
</span>
<div className="flex items-center gap-1.5">
{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')} />
)}
{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>
)
}
function getCompactTime(date: Date | string, t: (key: string, params?: Record<string, any>) => string): string {
const now = new Date()
const then = date instanceof Date ? date : new Date(date)
if (isNaN(then.getTime())) {
console.warn('Invalid date provided to getCompactTime:', date)
return t('common.error')
}
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (seconds < 60) return t('time.justNow')
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
if (hours < 24) return t('time.hoursAgo', { count: hours })
const days = Math.floor(hours / 24)
return t('time.daysAgo', { count: days })
}