feat: image AI titles (3 suggestions), describe-images action, pin/list fixes, i18n
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s

- Add image description service + API route for AI-powered image analysis
- Image title generation returns 3 selectable suggestions via TitleSuggestions component
- Add "Describe images" action in AI assistant (individual + collective)
- Fix pin refresh propagation in card and tabs view
- Fix note creation refresh in tabs mode, pass all notes to tabs view
- Add RTL support (dir="auto") on note content elements
- Pass UI language dynamically to AI endpoints instead of hardcoded 'fr'
- Add 18 missing i18n keys in both en.json and fr.json
- Sparkles button on images for AI title generation (bottom-right, pulse animation)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 22:34:13 +02:00
parent fc06519f56
commit d91072ed6b
11 changed files with 453 additions and 59 deletions

View File

@@ -86,6 +86,7 @@ interface NotesTabsViewProps {
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
onNoteCreated?: (note: Note) => void
}
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
@@ -256,6 +257,7 @@ function SortableNoteListItem({
{/* Row 2: title */}
<p
dir="auto"
className={cn(
'mb-1.5 text-[13.5px] leading-snug transition-colors',
selected
@@ -268,7 +270,7 @@ function SortableNoteListItem({
{/* Row 3: snippet */}
{snippet && (
<p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
<p dir="auto" className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{snippet}
</p>
)}
@@ -560,7 +562,6 @@ function NoteMetaSidebar({
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
onClick={() => onPinToggle(note)}
disabled
/>
{/* Archive */}
@@ -593,10 +594,11 @@ export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
onNoteCreated,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
@@ -667,21 +669,18 @@ export function NotesTabsView({
return () => window.removeEventListener('label-deleted', handler)
}, [])
// Sorted display items (does NOT affect persisted order)
// Sorted display items — pinned notes always float to the top
const sortedItems = useMemo(() => {
if (sortOrder === 'date-desc') return [...items]
return [...items].sort((a, b) => {
if (sortOrder === 'date-asc') {
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
}
if (sortOrder === 'title-asc') {
return (a.title || '').localeCompare(b.title || '')
}
if (sortOrder === 'title-desc') {
return (b.title || '').localeCompare(a.title || '')
}
const pinned = items.filter(n => n.isPinned)
const unpinned = items.filter(n => !n.isPinned)
const sortFn = (a: Note, b: Note) => {
if (sortOrder === 'date-desc') return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
if (sortOrder === 'date-asc') return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
if (sortOrder === 'title-asc') return (a.title || '').localeCompare(b.title || '')
if (sortOrder === 'title-desc') return (b.title || '').localeCompare(a.title || '')
return 0
})
}
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)]
}, [items, sortOrder])
const sensors = useSensors(
@@ -727,6 +726,7 @@ export function NotesTabsView({
return [...pinned, newNote, ...unpinned]
})
setSelectedId(newNote.id)
onNoteCreated?.(newNote)
triggerRefresh()
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
@@ -739,6 +739,7 @@ export function NotesTabsView({
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
try {
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
triggerRefresh()
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
} catch {
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))