diff --git a/memento-note/app/api/ai/describe-image/route.ts b/memento-note/app/api/ai/describe-image/route.ts new file mode 100644 index 0000000..3d42064 --- /dev/null +++ b/memento-note/app/api/ai/describe-image/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { getAISettings } from '@/app/actions/ai-settings' +import { describeImages } from '@/lib/ai/services/image-description.service' + +export async function POST(req: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userSettings = await getAISettings(session.user.id) + if (userSettings.paragraphRefactor === false) { + return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }) + } + + const { imageUrls, mode, language } = await req.json() + + if (!Array.isArray(imageUrls) || imageUrls.length === 0) { + return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 }) + } + + const result = await describeImages( + imageUrls, + mode === 'title' ? 'title' : 'description', + language || 'fr' + ) + + // For title mode, return suggestions in same format as /api/ai/title-suggestions + if (mode === 'title' && result.suggestions) { + return NextResponse.json({ suggestions: result.suggestions }) + } + + return NextResponse.json(result) + } catch (error: any) { + console.error('[describe-image] Error:', error) + return NextResponse.json( + { error: error.message || 'Failed to describe image' }, + { status: 500 } + ) + } +} diff --git a/memento-note/components/contextual-ai-chat.tsx b/memento-note/components/contextual-ai-chat.tsx index d4f90f4..5cf6303 100644 --- a/memento-note/components/contextual-ai-chat.tsx +++ b/memento-note/components/contextual-ai-chat.tsx @@ -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')}

+ {/* 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 ( + + ) + })} + + {/* Text actions — shown when note has sufficient text */} {!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
@@ -499,7 +562,7 @@ export function ContextualAIChat({

) : ( - ACTION_IDS.map(action => { + ACTION_IDS.filter(a => !a.isImageAction).map(action => { const Icon = action.icon const loading = actionLoading === action.id return ( diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index edcb0db..0aead66 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -399,23 +399,26 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
{t('general.loading')}
) : ( <> - setEditingNote({ note, readOnly })} - onSizeChange={handleSizeChange} - /> + {!isTabs && ( + setEditingNote({ note, readOnly })} + onSizeChange={handleSizeChange} + /> + )} {(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
!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} />
)} diff --git a/memento-note/components/note-card.tsx b/memento-note/components/note-card.tsx index 3fda3bf..e4987c8 100644 --- a/memento-note/components/note-card.tsx +++ b/memento-note/components/note-card.tsx @@ -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 && ( -

+

{optimisticNote.title}

)} diff --git a/memento-note/components/note-editor.tsx b/memento-note/components/note-editor.tsx index 1a5e2af..4447476 100644 --- a/memento-note/components/note-editor.tsx +++ b/memento-note/components/note-editor.tsx @@ -632,6 +632,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {/* Title */}
setTitle(e.target.value)} @@ -705,6 +706,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) /> ) : (