feat: chat stop button, image paste, vision AI, search fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
- Add stop button to all chat interfaces (floating, contextual, full-page) - Add conversation sliding window (50 messages) to prevent context overflow - Add chat timeout warning (30s toast) - Force response language in chat system prompt (mandatory per-locale) - Add image paste from clipboard in all note editors (card, list, input) - Fix upload API to infer extension from MIME type for clipboard images - Add image description support in note AI chat (base64 vision) - Fix search regex crash on special characters (escape user input) - Fix search case-insensitivity on PostgreSQL (mode: 'insensitive') - Add try/catch around semantic search in chat route (prevent blocking) - Add new chat button to floating AI assistant - Fix empty thinking bubbles for reasoning models (filter non-text parts) - Remove duplicate AI assistant toggle from note editor header - Improve link metadata scraping (timeout, content-type check, relative URLs) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,21 @@ import { DefaultChatTransport } from 'ai'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers } from 'lucide-react'
|
||||
import { X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers, Square, Plus } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function getTextContent(msg: UIMessage): string {
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts.filter((p: any) => p.type === 'text' && typeof p.text === 'string').map((p: any) => p.text).join('')
|
||||
}
|
||||
if (typeof (msg as any).content === 'string') return (msg as any).content
|
||||
return ''
|
||||
}
|
||||
|
||||
const TONES = [
|
||||
{ id: 'professional', label: 'Professional', icon: Briefcase },
|
||||
@@ -45,10 +54,11 @@ export function AIChat() {
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||||
|
||||
const { messages, setMessages, sendMessage, status } = useChat({
|
||||
const { messages, setMessages, sendMessage, status, stop } = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
console.error('Chat error:', error)
|
||||
toast.error(t('chat.assistantError') || 'Chat error')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -142,6 +152,9 @@ export function AIChat() {
|
||||
<p className="text-xs text-muted-foreground font-medium">{t('ai.poweredByMomento')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => { setMessages([]); setConversationId(undefined) }} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted" title={t('ai.newDiscussion')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
||||
{isExpanded ? (
|
||||
@@ -217,28 +230,33 @@ export function AIChat() {
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.map((msg: UIMessage) => (
|
||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
||||
msg.role === 'user'
|
||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
|
||||
: 'bg-primary/10 text-primary border-primary/20',
|
||||
)}>
|
||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||
{messages.map((msg: UIMessage) => {
|
||||
const text = getTextContent(msg)
|
||||
// Skip empty assistant messages (thinking/reasoning phase)
|
||||
if (msg.role === 'assistant' && !text) return null
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
||||
msg.role === 'user'
|
||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
|
||||
: 'bg-primary/10 text-primary border-primary/20',
|
||||
)}>
|
||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground rounded-tr-sm'
|
||||
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
|
||||
)}>
|
||||
{msg.role === 'assistant'
|
||||
? <MarkdownContent content={text} />
|
||||
: <p>{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground rounded-tr-sm'
|
||||
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
|
||||
)}>
|
||||
{msg.role === 'assistant'
|
||||
? <MarkdownContent content={msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''} />
|
||||
: <p>{msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
@@ -317,10 +335,7 @@ export function AIChat() {
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
|
||||
<Select value={chatScope} onValueChange={setChatScope}>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
|
||||
<div className="flex items-center gap-2">
|
||||
{chatScope === 'all' ? <Layers className="h-3.5 w-3.5 text-primary" /> : <BookOpen className="h-3.5 w-3.5 text-primary" />}
|
||||
<SelectValue placeholder={t('ai.selectNotebook')} />
|
||||
</div>
|
||||
<SelectValue placeholder={t('ai.selectNotebook')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
@@ -393,14 +408,24 @@ export function AIChat() {
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Web{webSearchAvailable ? '' : ' ⚠'}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim()}
|
||||
>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4 ml-0.5" />}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all"
|
||||
onClick={() => stop()}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
||||
sendMessage,
|
||||
status,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
@@ -49,6 +50,15 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
// Timeout warning: show toast if response takes > 30s
|
||||
useEffect(() => {
|
||||
if (!isLoading) return
|
||||
const timer = setTimeout(() => {
|
||||
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
|
||||
}, 30000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isLoading, t])
|
||||
|
||||
// Sync historyMessages after each completed streaming response
|
||||
// so the display doesn't revert to stale history
|
||||
useEffect(() => {
|
||||
@@ -190,6 +200,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={stop}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={selectedNotebook || null}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X, Globe } from 'lucide-react'
|
||||
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -19,6 +19,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, notebookId?: string) => void
|
||||
isLoading?: boolean
|
||||
onStop?: () => void
|
||||
notebooks: any[]
|
||||
currentNotebookId?: string | null
|
||||
webSearchEnabled?: boolean
|
||||
@@ -26,7 +27,7 @@ interface ChatInputProps {
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||
@@ -123,18 +124,28 @@ export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId, web
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full h-8 w-8 transition-all duration-200",
|
||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
{/* Send / Stop Button */}
|
||||
{isLoading && onStop ? (
|
||||
<Button
|
||||
onClick={onStop}
|
||||
size="icon"
|
||||
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full h-8 w-8 transition-all duration-200",
|
||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { UIMessage } from 'ai'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
X, Bot, Sparkles, Send, Loader2,
|
||||
X, Bot, Sparkles, Send, Loader2, Square,
|
||||
Briefcase, Palette, GraduationCap, Coffee,
|
||||
Lightbulb, Minimize2, AlignLeft, Wand2,
|
||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||
@@ -70,6 +70,7 @@ interface ContextualAIChatProps {
|
||||
onClose: () => void
|
||||
noteTitle?: string
|
||||
noteContent?: string
|
||||
noteImages?: string[]
|
||||
/** Called when an action result should be injected into the note */
|
||||
onApplyToNote?: (newContent: string) => void
|
||||
/** Called when the user wants to undo the last injected action */
|
||||
@@ -88,6 +89,7 @@ export function ContextualAIChat({
|
||||
onClose,
|
||||
noteTitle,
|
||||
noteContent,
|
||||
noteImages,
|
||||
onApplyToNote,
|
||||
onUndoLastAction,
|
||||
lastActionApplied = false,
|
||||
@@ -115,7 +117,12 @@ export function ContextualAIChat({
|
||||
const buildChatBody = () => {
|
||||
const body: Record<string, any> = { language, webSearch }
|
||||
if (chatScope === 'note') {
|
||||
body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone }
|
||||
body.noteContext = {
|
||||
title: noteTitle || '',
|
||||
content: noteContent || '',
|
||||
tone: selectedTone,
|
||||
images: noteImages || [],
|
||||
}
|
||||
} else if (chatScope !== 'all') {
|
||||
// scope is a notebook ID
|
||||
body.notebookId = chatScope
|
||||
@@ -123,7 +130,7 @@ export function ContextualAIChat({
|
||||
return body
|
||||
}
|
||||
|
||||
const { messages, sendMessage, status } = useChat({ transport })
|
||||
const { messages, sendMessage, status, stop } = useChat({ transport })
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
@@ -273,6 +280,8 @@ export function ContextualAIChat({
|
||||
|
||||
{messages.map((msg: UIMessage) => {
|
||||
const content = getMessageContent(msg)
|
||||
// Skip empty assistant messages (thinking/reasoning phase)
|
||||
if (msg.role === 'assistant' && !content) return null
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-2', msg.role === 'user' && 'flex-row-reverse')}>
|
||||
<div className={cn(
|
||||
@@ -414,14 +423,24 @@ export function ContextualAIChat({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim()}
|
||||
>
|
||||
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600"
|
||||
onClick={() => stop()}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/40 text-center mt-1">{t('ai.newLineHint')}</p>
|
||||
|
||||
@@ -173,24 +173,23 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||
})
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
|
||||
const data = await response.json()
|
||||
setImages(prev => [...prev, data.url])
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => [...prev, url])
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||
@@ -198,6 +197,29 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => [...prev, url])
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [t])
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const removedUrl = images[index]
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
@@ -597,21 +619,6 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
||||
{/* AI Copilot Toggle Button next to title */}
|
||||
{note.type === 'text' && !readOnly && aiAssistantEnabled && (
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-2 text-xs transition-colors ml-2',
|
||||
aiOpen && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
onClick={() => setAiOpen(!aiOpen)}
|
||||
title="Toggle AI Copilot"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Assistant IA</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{readOnly && (
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
||||
@@ -978,6 +985,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
onClose={() => setAiOpen(false)}
|
||||
noteTitle={title}
|
||||
noteContent={content}
|
||||
noteImages={images}
|
||||
onApplyToNote={(newContent) => {
|
||||
setPreviousContentForCopilot(content)
|
||||
setContent(newContent)
|
||||
|
||||
@@ -348,13 +348,9 @@ export function NoteInlineEditor({
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!res.ok) throw new Error('Upload failed')
|
||||
const data = await res.json()
|
||||
const newImages = [...(note.images || []), data.url]
|
||||
const url = await uploadImageFile(file)
|
||||
const newImages = [...(note.images || []), url]
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
} catch {
|
||||
@@ -364,6 +360,40 @@ export function NoteInlineEditor({
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!res.ok) throw new Error('Upload failed')
|
||||
const data = await res.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
const newImages = [...(note.images || []), url]
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [note.id, note.images, onChange, t])
|
||||
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
@@ -783,6 +813,7 @@ export function NoteInlineEditor({
|
||||
onClose={() => setAiOpen(false)}
|
||||
noteTitle={title}
|
||||
noteContent={content}
|
||||
noteImages={note.images || undefined}
|
||||
onApplyToNote={(newContent) => {
|
||||
setPreviousContent(content)
|
||||
changeContent(newContent)
|
||||
|
||||
@@ -434,6 +434,38 @@ export function NoteInput({
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => [...prev, url])
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [t])
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!linkUrl) return
|
||||
|
||||
@@ -927,6 +959,7 @@ export function NoteInput({
|
||||
onClose={() => setAiOpen(false)}
|
||||
noteTitle={title}
|
||||
noteContent={content}
|
||||
noteImages={images}
|
||||
onApplyToNote={(newContent) => setContent(newContent)}
|
||||
lastActionApplied={false}
|
||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||
|
||||
Reference in New Issue
Block a user