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

@@ -11,7 +11,7 @@ import {
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2,
Maximize2, ImageIcon,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -52,9 +52,10 @@ interface ActionDef {
id: string
icon: any
apiPath: string
body: (content: string) => object
body: (content: string, images?: string[], lang?: string) => object
resultKey: string
i18nKey: string
isImageAction?: boolean
}
const ACTION_IDS = [
@@ -62,6 +63,7 @@ const ACTION_IDS = [
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -155,6 +157,40 @@ export function ContextualAIChat({
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef) => {
// Image-specific action
if (action.isImageAction) {
if (!noteImages || noteImages.length === 0) {
toast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
return
}
setActionLoading(action.id)
setActionPreview(null)
try {
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body('', noteImages, language)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
// Format image descriptions for preview
const descs = data.descriptions || []
let resultText = descs.map((d: any) =>
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
).join('\n\n')
if (data.combinedSummary) {
resultText += `\n\n---\n**${t('ai.overview') || 'Résumé'}:** ${data.combinedSummary}`
}
setActionPreview({ label: t(action.i18nKey), text: resultText })
} catch (e: any) {
toast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
return
}
// Text-based actions
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
if (!noteContent || wc < 5) {
toast.error(t('ai.minWordsError'))
@@ -166,7 +202,7 @@ export function ContextualAIChat({
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body(noteContent)),
body: JSON.stringify(action.body(noteContent, undefined, language)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
@@ -491,6 +527,33 @@ export function ContextualAIChat({
{t('ai.transformationsDesc')}
</p>
{/* Image actions — shown when note has images */}
{noteImages && noteImages.length > 0 && ACTION_IDS.filter(a => a.isImageAction).map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (
<button
key={action.id}
onClick={() => handleAction(action)}
disabled={!!actionLoading}
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
>
{loading
? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" />
: <Icon className="h-4 w-4 text-primary shrink-0" />
}
<div className="flex flex-col">
<span>{t(action.i18nKey)}</span>
{noteImages.length > 1 && (
<span className="text-[10px] text-muted-foreground">{noteImages.length} images</span>
)}
</div>
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
</button>
)
})}
{/* Text actions — shown when note has sufficient text */}
{!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
<div className="flex items-start gap-2 p-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
@@ -499,7 +562,7 @@ export function ContextualAIChat({
</p>
</div>
) : (
ACTION_IDS.map(action => {
ACTION_IDS.filter(a => !a.isImageAction).map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (

View File

@@ -399,23 +399,26 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
{!isTabs && (
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
)}
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<NotesMainSection
viewMode={notesViewMode}
notes={notes.filter((note) => !note.isPinned)}
notes={isTabs ? notes : notes.filter((note) => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
noteHistoryMode={noteHistoryMode}
onOpenHistory={handleOpenHistory}
onEnableHistory={handleEnableHistory}
onNoteCreated={handleNoteCreated}
/>
</div>
)}

View File

@@ -299,6 +299,7 @@ export const NoteCard = memo(function NoteCard({
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
triggerRefresh()
if (!note.isPinned) {
toast.success(t('notes.pinned') || 'Note pinned')
@@ -312,6 +313,7 @@ export const NoteCard = memo(function NoteCard({
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
triggerRefresh()
})
}
@@ -516,7 +518,7 @@ export const NoteCard = memo(function NoteCard({
{/* Title */}
{optimisticNote.title && (
<h3 className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
{optimisticNote.title}
</h3>
)}

View File

@@ -632,6 +632,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Title */}
<div className="relative">
<Input
dir="auto"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -705,6 +706,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
/>
) : (
<Textarea
dir="auto"
placeholder={isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
value={content}
onChange={(e) => setContent(e.target.value)}

View File

@@ -24,7 +24,7 @@ import { createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note } from '@/lib/types'
import { ContextualAIChat } from './contextual-ai-chat'
import { Maximize2, Minimize2, Sparkles } from 'lucide-react'
import { Maximize2, Minimize2, Sparkles, Loader2 } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -98,7 +98,7 @@ export function NoteInput({
}
}, [session?.user?.id])
const { t } = useLanguage()
const { t, language: uiLanguage } = useLanguage()
const searchParams = useSearchParams()
const currentNotebookId = searchParams.get('notebook') || undefined // Get current notebook from URL
const [isExpanded, setIsExpanded] = useState(defaultExpanded || forceExpanded)
@@ -466,6 +466,33 @@ export function NoteInput({
return () => document.removeEventListener('paste', handlePaste)
}, [t])
// AI title from images
const [isGeneratingImageTitle, setIsGeneratingImageTitle] = useState(false)
const [imageTitleSuggestions, setImageTitleSuggestions] = useState<any[]>([])
const handleImageTitleSuggestion = async (imageUrls?: string[]) => {
const urls = imageUrls || images
if (urls.length === 0) return
setIsGeneratingImageTitle(true)
setImageTitleSuggestions([])
try {
const res = await fetch('/api/ai/describe-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrls: urls, mode: 'title', language: uiLanguage }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'AI error')
if (data.suggestions?.length > 0) {
setImageTitleSuggestions(data.suggestions)
}
} catch (e: any) {
toast.error(e.message || t('ai.genericError'))
} finally {
setIsGeneratingImageTitle(false)
}
}
const handleAddLink = async () => {
if (!linkUrl) return
@@ -582,6 +609,7 @@ export function NoteInput({
setSelectedLabels([])
setCollaborators([])
setDismissedTitleSuggestions(false)
setImageTitleSuggestions([])
toast.success(t('notes.noteCreated'))
} catch (error) {
@@ -626,6 +654,7 @@ export function NoteInput({
setSelectedLabels([])
setCollaborators([])
setDismissedTitleSuggestions(false)
setImageTitleSuggestions([])
}
const collapsedWidthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
@@ -708,7 +737,7 @@ export function NoteInput({
className="w-full bg-transparent text-lg font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => { setTitle(e.target.value); setImageTitleSuggestions([]) }}
/>
</div>
@@ -723,6 +752,17 @@ export function NoteInput({
</div>
)}
{/* Image title suggestions */}
{!title && imageTitleSuggestions.length > 0 && (
<div className="px-5">
<TitleSuggestions
suggestions={imageTitleSuggestions}
onSelect={(s) => { setTitle(s); setImageTitleSuggestions([]) }}
onDismiss={() => setImageTitleSuggestions([])}
/>
</div>
)}
{/* Content area — scrolls internally when constrained by max-h */}
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
{type === 'text' ? (
@@ -746,6 +786,34 @@ export function NoteInput({
autoFocus
/>
)}
{/* Images — rendered between content and tag suggestions */}
{images.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{images.map((img, idx) => (
<div key={idx} className="relative group inline-block w-fit">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain block" />
{!title && !content.trim() && aiAssistantEnabled && (
<button
type="button"
className="absolute bottom-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/60 backdrop-blur-sm text-primary-foreground animate-pulse hover:animate-none hover:bg-primary hover:w-auto hover:gap-1.5 hover:px-2.5 transition-all overflow-hidden group/ai"
onClick={() => handleImageTitleSuggestion()}
disabled={isGeneratingImageTitle}
title={t('notes.generateTitleFromImage') || 'Générer un titre'}>
{isGeneratingImageTitle
? <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
: <Sparkles className="h-3.5 w-3.5 shrink-0" />}
<span className="text-[10px] font-medium whitespace-nowrap opacity-0 group-hover/ai:opacity-100 transition-opacity">{t('notes.suggestTitle') || 'Titre'}</span>
</button>
)}
<Button variant="ghost" size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
@@ -756,6 +824,34 @@ export function NoteInput({
</>
) : (
<div className="space-y-1.5 py-2">
{/* Images — rendered before checklist items */}
{images.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{images.map((img, idx) => (
<div key={idx} className="relative group inline-block w-fit">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain block" />
{!title && !content.trim() && aiAssistantEnabled && (
<button
type="button"
className="absolute bottom-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/60 backdrop-blur-sm text-primary-foreground animate-pulse hover:animate-none hover:bg-primary hover:w-auto hover:gap-1.5 hover:px-2.5 transition-all overflow-hidden group/ai"
onClick={() => handleImageTitleSuggestion()}
disabled={isGeneratingImageTitle}
title={t('notes.generateTitleFromImage') || 'Générer un titre'}>
{isGeneratingImageTitle
? <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
: <Sparkles className="h-3.5 w-3.5 shrink-0" />}
<span className="text-[10px] font-medium whitespace-nowrap opacity-0 group-hover/ai:opacity-100 transition-opacity">{t('notes.suggestTitle') || 'Titre'}</span>
</button>
)}
<Button variant="ghost" size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{checkItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 group">
<Checkbox className="shrink-0" />
@@ -788,22 +884,6 @@ export function NoteInput({
)}
</div>
{/* Images */}
{images.length > 0 && (
<div className="flex flex-col gap-2 px-5 pb-3">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain" />
<Button variant="ghost" size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{/* Link previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 px-5 pb-3">

View File

@@ -28,6 +28,7 @@ interface NotesMainSectionProps {
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
onNoteCreated?: (note: Note) => void
}
export function NotesMainSection({
@@ -39,6 +40,7 @@ export function NotesMainSection({
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
onNoteCreated,
}: NotesMainSectionProps) {
if (viewMode === 'tabs') {
return (
@@ -50,6 +52,7 @@ export function NotesMainSection({
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
onNoteCreated={onNoteCreated}
/>
</div>
)

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))