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:
2026-04-08 20:46:43 -04:00
parent cac201a4d2
commit 513aafcebc
18 changed files with 881 additions and 238 deletions

View File

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

View File

@@ -19,13 +19,13 @@ export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-16 text-center"
>
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
<h3 className="text-sm font-medium text-muted-foreground">
No events yet
</h3>
<p className="text-xs text-muted-foreground/60 mt-1">
Create your first event to get started
</p>
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
<h3 className="text-sm font-medium text-muted-foreground">
No events yet
</h3>
<p className="text-xs text-muted-foreground/60 mt-1">
Create your first event to get started
</p>
</motion.div>
);
}

View File

@@ -5,23 +5,28 @@ import { ImageIcon } from "lucide-react";
import type React from "react";
import { useImperativeHandle, useRef } from "react";
import { Button, type buttonVariants } from "@/components/ui/button";
import { IMAGE_ACCEPT } from "@/lib/constants";
interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
onFileSelect?: (file: File) => void;
/** Called with ALL selected files (array of 1..N). */
onFilesSelect?: (files: File[]) => void;
className?: string;
children?: React.ReactNode;
disabled?: boolean;
/** Allow selecting multiple images at once (native OS picker multi-select). */
multiple?: boolean;
/** Expose an imperative trigger so parents can open the file dialog via ref */
triggerRef?: React.Ref<{ open: () => void }>;
}
export function ImagePicker({
onFileSelect,
onFilesSelect,
className,
children,
variant = "ghost",
size = "icon",
disabled = false,
multiple = false,
triggerRef,
}: ImagePickerProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -38,10 +43,11 @@ export function ImagePicker({
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && onFileSelect) {
onFileSelect(file);
const fileList = event.target.files;
if (fileList && fileList.length > 0 && onFilesSelect) {
onFilesSelect(Array.from(fileList));
}
// Reset so the same file(s) can be re-selected
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
@@ -53,7 +59,8 @@ export function ImagePicker({
ref={fileInputRef}
type="file"
name="image-upload"
accept="image/png,image/jpeg,image/webp"
accept={IMAGE_ACCEPT}
multiple={multiple}
onChange={handleFileChange}
className="hidden"
/>

View File

@@ -1,44 +1,44 @@
"use client"
"use client";
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { HoverCard as HoverCardPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function HoverCard({
...props
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
export { HoverCard, HoverCardContent, HoverCardTrigger };

View File

@@ -1,28 +1,28 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup }
export { Kbd, KbdGroup };