feat: support multiple image uploads for AI event generation 🖼️
- Updated OpenRouter integration to accept an array of image URLs - Updated ImagePicker to use the `multiple` attribute natively - Added `appendImagesDeduped` for handling client-side image deduplication - Enhanced clipboard pasting to extract multiple images at once - Rendered multiple images in a horizontal thumbnail strip in the AIToolbar - Added tests to cover multi-image logic and AI request mapping
This commit is contained in:
@@ -33,12 +33,12 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { extractImageFromClipboard } from "@/lib/clipboard-image";
|
||||
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
|
||||
import {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
detectOs,
|
||||
resolveKeys,
|
||||
type Os,
|
||||
resolveKeys,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
} from "@/lib/keyboard-shortcuts";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
@@ -90,9 +90,12 @@ interface AIToolbarProps {
|
||||
aiPrompt: string;
|
||||
setAiPrompt: (prompt: string) => void;
|
||||
aiLoading: boolean;
|
||||
imagePreview: string | null;
|
||||
onImageSelect: (file: File) => void;
|
||||
onImageClear: () => void;
|
||||
/** Ordered list of object-URL preview strings for each attached image. */
|
||||
imagePreviews: string[];
|
||||
/** Called with one or more new files to append (dedup handled by parent). */
|
||||
onImagesSelect: (files: File[]) => void;
|
||||
/** Remove the image at the given index from the list. */
|
||||
onImageRemove: (index: number) => void;
|
||||
onAiCreate: () => void;
|
||||
onAiSummarize: () => void;
|
||||
onSummaryDismiss: () => void;
|
||||
@@ -114,9 +117,9 @@ export const AIToolbar = ({
|
||||
aiPrompt,
|
||||
setAiPrompt,
|
||||
aiLoading,
|
||||
imagePreview,
|
||||
onImageSelect,
|
||||
onImageClear,
|
||||
imagePreviews,
|
||||
onImagesSelect,
|
||||
onImageRemove,
|
||||
onAiCreate,
|
||||
onAiSummarize,
|
||||
onSummaryDismiss,
|
||||
@@ -138,11 +141,11 @@ export const AIToolbar = ({
|
||||
const os = useOs();
|
||||
|
||||
// Stable ref so the document listener never needs to re-register when
|
||||
// onImageSelect identity changes between renders (it's an inline async fn).
|
||||
const onImageSelectRef = useRef(onImageSelect);
|
||||
// onImagesSelect identity changes between renders (it's an inline async fn).
|
||||
const onImagesSelectRef = useRef(onImagesSelect);
|
||||
useEffect(() => {
|
||||
onImageSelectRef.current = onImageSelect;
|
||||
}, [onImageSelect]);
|
||||
onImagesSelectRef.current = onImagesSelect;
|
||||
}, [onImagesSelect]);
|
||||
|
||||
// Document-level paste + Ctrl/Cmd+V keydown handler.
|
||||
//
|
||||
@@ -170,10 +173,10 @@ export const AIToolbar = ({
|
||||
target.isContentEditable;
|
||||
if (isEditableTarget) return; // textarea's own onPaste covers this
|
||||
|
||||
const image = extractImageFromClipboard(e.clipboardData ?? null);
|
||||
if (image) {
|
||||
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
||||
if (images.length > 0) {
|
||||
e.preventDefault();
|
||||
onImageSelectRef.current(image);
|
||||
onImagesSelectRef.current(images);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +186,14 @@ export const AIToolbar = ({
|
||||
// more reliable when called from a user gesture (keydown).
|
||||
let pasteHandledByEvent = false;
|
||||
|
||||
const PROBE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff"];
|
||||
const PROBE_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
const handleDocumentKeydown = async (e: KeyboardEvent) => {
|
||||
const isV = e.key === "v" || e.key === "V";
|
||||
@@ -199,8 +209,12 @@ export const AIToolbar = ({
|
||||
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
const files: File[] = [];
|
||||
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
const declaredType = clipboardItem.types.find((t) => t.startsWith("image/"));
|
||||
const declaredType = clipboardItem.types.find((t) =>
|
||||
t.startsWith("image/"),
|
||||
);
|
||||
const typesToTry = declaredType
|
||||
? [declaredType, ...PROBE_TYPES.filter((t) => t !== declaredType)]
|
||||
: PROBE_TYPES;
|
||||
@@ -208,24 +222,29 @@ export const AIToolbar = ({
|
||||
for (const mimeType of typesToTry) {
|
||||
try {
|
||||
const blob = await clipboardItem.getType(mimeType);
|
||||
const file = new File([blob], "clipboard-image", { type: mimeType });
|
||||
onImageSelectRef.current(file);
|
||||
return;
|
||||
files.push(
|
||||
new File([blob], "clipboard-image", { type: mimeType }),
|
||||
);
|
||||
break; // got this item, move to next clipboardItem
|
||||
} catch {
|
||||
// NotFoundError — type not present, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
onImagesSelectRef.current(files);
|
||||
}
|
||||
} catch {
|
||||
// clipboard.read() failed (permissions denied, etc.) — ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Mark that the synchronous paste event handled an image so keydown
|
||||
// Mark that the synchronous paste event handled images so keydown
|
||||
// doesn't double-fire
|
||||
const handlePasteHandled = (e: ClipboardEvent) => {
|
||||
const image = extractImageFromClipboard(e.clipboardData ?? null);
|
||||
if (image) pasteHandledByEvent = true;
|
||||
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
||||
if (images.length > 0) pasteHandledByEvent = true;
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handleDocumentPaste);
|
||||
@@ -239,7 +258,7 @@ export const AIToolbar = ({
|
||||
});
|
||||
document.removeEventListener("keydown", handleDocumentKeydown);
|
||||
};
|
||||
}, [isAuthenticated, isPending]); // onImageSelect intentionally omitted — ref stays current
|
||||
}, [isAuthenticated, isPending]); // onImagesSelect intentionally omitted — ref stays current
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -250,6 +269,8 @@ export const AIToolbar = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasImages = imagePreviews.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-2">
|
||||
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
|
||||
@@ -276,7 +297,7 @@ export const AIToolbar = ({
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
(aiPrompt.trim() || imagePreview)
|
||||
(aiPrompt.trim() || hasImages)
|
||||
) {
|
||||
e.preventDefault();
|
||||
onAiCreate();
|
||||
@@ -298,41 +319,56 @@ export const AIToolbar = ({
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const image = extractImageFromClipboard(e.clipboardData ?? null);
|
||||
if (image) {
|
||||
const images = extractAllImagesFromClipboard(
|
||||
e.clipboardData ?? null,
|
||||
);
|
||||
if (images.length > 0) {
|
||||
e.preventDefault();
|
||||
onImageSelect(image);
|
||||
onImagesSelect(images);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Attached image preview */}
|
||||
{/* ── Multi-image thumbnail strip ── */}
|
||||
<AnimatePresence>
|
||||
{imagePreview && (
|
||||
{hasImages && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="relative inline-block ml-3"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Attached event flyer"
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
|
||||
onClick={onImageClear}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<div className="flex gap-2 overflow-x-auto py-1 ml-3">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.85 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="relative inline-block shrink-0"
|
||||
>
|
||||
<Image
|
||||
src={preview}
|
||||
alt={`Attached image ${index + 1}`}
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
|
||||
onClick={() => onImageRemove(index)}
|
||||
aria-label={`Remove image ${index + 1}`}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -343,10 +379,11 @@ export const AIToolbar = ({
|
||||
* Attach aligns to the START (left), Info+Generate to the END (right).
|
||||
*/}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* LEFT: Attach image — labeled ghost button */}
|
||||
{/* LEFT: Attach image — labeled ghost button, multiple=true for native multi-select */}
|
||||
<ImagePicker
|
||||
onFileSelect={onImageSelect}
|
||||
onFilesSelect={onImagesSelect}
|
||||
disabled={aiLoading}
|
||||
multiple
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
@@ -365,10 +402,7 @@ export const AIToolbar = ({
|
||||
closeDelay={100}
|
||||
open={isPopoverOpen ? false : undefined}
|
||||
>
|
||||
<Popover
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -419,7 +453,7 @@ export const AIToolbar = ({
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={onAiCreate}
|
||||
disabled={aiLoading || (!aiPrompt.trim() && !imagePreview)}
|
||||
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
|
||||
>
|
||||
{aiLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -463,7 +497,11 @@ export const AIToolbar = ({
|
||||
{/* ── Zone 2: Data management ──────────────────────────────────────────── */}
|
||||
<div className="glass-card px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button size="sm" onClick={onAddEvent} className="h-8 text-xs gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAddEvent}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<CalendarPlus className="h-3.5 w-3.5" />
|
||||
Add Event
|
||||
</Button>
|
||||
@@ -545,7 +583,9 @@ export const AIToolbar = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-foreground/90">{summary}</p>
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user