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
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user