- 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>
279 lines
9.9 KiB
TypeScript
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 })
|
|
}
|