Files
Keep/keep-notes/components/notes-tabs-view.tsx
Sepehr Ramezani b6a548acd8 feat: RTL/i18n, AI translate+undo, no-refresh saves, settings perf
- RTL: force dir=rtl on LabelFilter, NotesViewToggle, LabelManagementDialog
- i18n: add missing keys (notifications, privacy, edit/preview, AI translate/undo)
- Settings pages: convert to Server Components (general, appearance) + loading skeleton
- AI menu: add Translate option (10 languages) + Undo AI button in toolbar
- Fix: saveInline uses REST API instead of Server Action → eliminates all implicit refreshes in list mode
- Fix: NotesTabsView notes sync effect preserves selected note on content changes
- Fix: auto-tag suggestions now filter already-assigned labels
- Fix: color change in card view uses local state (no refresh)
- Fix: nav links use <Link> for prefetching (Settings, Admin)
- Fix: suppress duplicate label suggestions already on note
- Route: add /api/ai/translate endpoint
2026-04-15 23:48:28 +02:00

436 lines
14 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState, useTransition } from 'react'
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { updateFullOrderWithoutRevalidation, createNote } from '@/app/actions/notes'
import {
GripVertical,
Hash,
ListChecks,
Pin,
FileText,
Clock,
Plus,
Loader2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
}
// Color accent strip for each note
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
red: 'bg-red-400',
orange: 'bg-orange-400',
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
}
// Background tint gradient for selected note panel
const COLOR_PANEL_BG: Record<NoteColor, string> = {
default: 'from-background to-background',
red: 'from-red-50/60 dark:from-red-950/20 to-background',
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
}
const COLOR_ICON: Record<NoteColor, string> = {
default: 'text-primary',
red: 'text-red-500',
orange: 'text-orange-500',
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
}
function getColorKey(note: Note): NoteColor {
return (typeof note.color === 'string' && note.color in NOTE_COLORS
? note.color
: 'default') as NoteColor
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
function SortableNoteListItem({
note,
selected,
onSelect,
reorderLabel,
language,
untitledLabel,
}: {
note: Note
selected: boolean
onSelect: () => void
reorderLabel: string
language: string
untitledLabel: string
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: note.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}
const ck = getColorKey(note)
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
: (note.content || '').substring(0, 150)
const dateLocale = getDateLocale(language)
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: dateLocale,
})
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 rounded-xl transition-all duration-150',
'border',
selected
? 'border-primary/20 bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'border-transparent hover:border-border/60 hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Color accent bar */}
<div
className={cn(
'w-1 shrink-0 rounded-s-xl transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
)}
/>
{/* Drag handle */}
<button
type="button"
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
{/* Note type icon */}
<div className="flex items-center py-4 pe-1">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
) : (
<FileText
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
)}
</div>
{/* Text content */}
<div className="min-w-0 flex-1 py-3.5 pe-3">
<div className="flex items-center gap-2">
<p
className={cn(
'truncate text-sm font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
)}
>
{title}
</p>
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
)}
</div>
{snippet && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
<Clock className="h-2.5 w-2.5" />
{timeAgo}
</span>
{note.labels && note.labels.length > 0 && (
<>
<span className="text-muted-foreground/30">·</span>
<div className="flex items-center gap-1">
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
<span className="truncate text-[11px] text-muted-foreground/50">
{note.labels.slice(0, 2).join(', ')}
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
</span>
</div>
</>
)}
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
const { t, language } = useLanguage()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
useEffect(() => {
// Only reset when notes are added or removed, NOT on content/field changes
// Field changes arrive through onChange -> setItems already
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
// Same set of notes: merge only structural fields (pin, color, archive)
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
return { ...fresh, title: p.title, content: p.content, labels: p.labels }
})
}
// Different set (add/remove): full sync
return notes
})
}, [notes])
useEffect(() => {
if (items.length === 0) {
setSelectedId(null)
return
}
setSelectedId((prev) =>
prev && items.some((n) => n.id === prev) ? prev : items[0].id
)
}, [items])
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((n) => n.id === active.id)
const newIndex = items.findIndex((n) => n.id === over.id)
if (oldIndex < 0 || newIndex < 0) return
const reordered = arrayMove(items, oldIndex, newIndex)
setItems(reordered)
try {
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
} catch {
setItems(notes)
toast.error(t('notes.moveFailed'))
}
},
[items, notes, t]
)
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
/** Create a new blank note, add it to the sidebar and select it immediately */
const handleCreateNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
title: null,
notebookId: currentNotebookId || null,
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => [newNote, ...prev])
setSelectedId(newNote.id)
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
}
})
}
if (items.length === 0) {
return (
<div
className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-12 text-center"
data-testid="notes-grid-tabs-empty"
>
<p className="max-w-md text-sm text-muted-foreground">{t('notes.emptyStateTabs')}</p>
</div>
)
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left sidebar: note list ── */}
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
{/* Sidebar header with note count + new note button */}
<div className="border-b border-border/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
{t('notes.title')}
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{items.length}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote') || 'Nouvelle note'}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain p-2"
role="listbox"
aria-label={t('notes.viewTabs')}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
reorderLabel={t('notes.reorderTabs')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* ── Right content panel — always in edit mode ── */}
{selected ? (
<div
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
/>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground/40">
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
</div>
)}
</div>
)
}