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

@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { buildMultimodalMessages } from "@/lib/ai-event-messages";
import { extractJsonFromText } from "@/lib/json-utils";
import { openRouterClient } from "@/lib/openrouter-client";
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
@@ -31,7 +32,8 @@ Rules:
- If no end time is given (and event is not allDay), default to 1 hour after start.
- If multiple events are described, return multiple.
- If recurrence is implied (e.g. "every Monday", "daily for 10 days", "monthly on the 15th"), generate a recurrenceRule.
- When analyzing an image, extract ALL visible event details: titles, dates, times, locations, descriptions.
- When analyzing images, extract ALL visible event details: titles, dates, times, locations, descriptions.
- If multiple images are provided, treat them all as sources for events (e.g. multiple flyer pages).
- Output ONLY valid JSON (no prose).
`;
@@ -68,27 +70,9 @@ const extractContentFromChatResponse = (response: unknown): string => {
const callMultimodal = async (
systemPrompt: string,
prompt: string | undefined,
imageBase64: string,
images: string[],
) => {
const messages = [
{
role: "system" as const,
content: systemPrompt,
},
{
role: "user" as const,
content: [
{
type: "text" as const,
text: prompt || "Extract all calendar events from this image.",
},
{
type: "image_url" as const,
imageUrl: { url: imageBase64 },
},
],
},
];
const messages = buildMultimodalMessages(systemPrompt, prompt, images);
const response = await openRouterClient.chat.send({
chatRequest: {
@@ -126,12 +110,13 @@ export async function POST(request: Request) {
);
}
const { prompt, imageBase64 } = parsedInput.data;
const { prompt, images } = parsedInput.data;
const systemPrompt = buildSystemPrompt();
try {
const result = imageBase64
? await callMultimodal(systemPrompt, prompt, imageBase64)
const result =
images && images.length > 0
? await callMultimodal(systemPrompt, prompt, images)
: await callTextOnly(systemPrompt, prompt ?? "");
const rawJson = extractJsonFromText(result.rawResponse);

View File

@@ -8,7 +8,7 @@ import { DragDropContainer } from "@/components/drag-drop-container";
import { EventDialog } from "@/components/event-dialog";
import { EventsList } from "@/components/events-list";
import { useSession } from "@/lib/auth-client";
import { IMAGE_MIME_TYPES, MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import {
saveEvent as addEvent,
clearEvents,
@@ -17,6 +17,7 @@ import {
updateEvent,
} from "@/lib/events-db";
import { generateICS, parseICS } from "@/lib/ical";
import { appendImagesDeduped } from "@/lib/multi-image";
import type { CalendarEvent } from "@/lib/types";
const fileToBase64 = (file: File): Promise<string> =>
@@ -29,7 +30,7 @@ const fileToBase64 = (file: File): Promise<string> =>
const validateImageFile = (file: File): string | null => {
if (file.size > MAX_IMAGE_SIZE_BYTES) {
return "Image must be less than 10MB.";
return `"${file.name}" must be less than 10MB.`;
}
return null;
};
@@ -58,9 +59,13 @@ export default function HomePage() {
const [summary, setSummary] = useState<string | null>(null);
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null);
// Image
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Multi-image state: parallel arrays keyed by index
// imageFiles[i] — the File object (used for dedup key)
// imageBase64s[i] — data URL sent to API
// imagePreviews[i]— object URL shown in thumbnail
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imageBase64s, setImageBase64s] = useState<string[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
useEffect(() => {
(async () => {
@@ -83,23 +88,53 @@ export default function HomePage() {
setRecurrenceRule(undefined);
};
const handleImageSelect = async (file: File) => {
/**
* Adds one or more image files to the attached-images list.
* Validates each file, deduplicates, and updates all three parallel arrays.
*/
const handleImagesSelect = async (files: File[]) => {
const validFiles: File[] = [];
for (const file of files) {
const error = validateImageFile(file);
if (error) {
toast.error(error);
return;
} else {
validFiles.push(file);
}
const base64 = await fileToBase64(file);
setImageBase64(base64);
setImagePreview(URL.createObjectURL(file));
}
if (validFiles.length === 0) return;
// Dedup against existing files
const merged = appendImagesDeduped(imageFiles, validFiles);
const newFiles = merged.slice(imageFiles.length); // only the newly added ones
if (newFiles.length === 0) return; // all were duplicates
const newBase64s = await Promise.all(newFiles.map(fileToBase64));
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
setImageFiles(merged);
setImageBase64s((prev) => [...prev, ...newBase64s]);
setImagePreviews((prev) => [...prev, ...newPreviews]);
};
const handleImageClear = () => {
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
/** Remove the image at the given index. */
const handleImageRemove = (index: number) => {
// Revoke the object URL before dropping it
URL.revokeObjectURL(imagePreviews[index]);
setImageFiles((prev) => prev.filter((_, i) => i !== index));
setImageBase64s((prev) => prev.filter((_, i) => i !== index));
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
};
/** Clear all attached images. */
const handleImagesClear = () => {
for (const url of imagePreviews) {
URL.revokeObjectURL(url);
}
setImageBase64(null);
setImagePreview(null);
setImageFiles([]);
setImageBase64s([]);
setImagePreviews([]);
};
const handleSave = async () => {
@@ -195,8 +230,8 @@ export default function HomePage() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || undefined,
prompt: aiPrompt || undefined,
images: imageBase64s.length > 0 ? imageBase64s : undefined,
}),
});
@@ -214,7 +249,7 @@ export default function HomePage() {
};
const handleAiCreate = async () => {
if (!aiPrompt.trim() && !imageBase64) return;
if (!aiPrompt.trim() && imageBase64s.length === 0) return;
setAiLoading(true);
const promise = async (): Promise<{ message: string }> => {
@@ -224,7 +259,7 @@ export default function HomePage() {
populateEventForm(data[0]);
setAiPrompt("");
setDialogOpen(true);
handleImageClear();
handleImagesClear();
return { message: "Event has been created!" };
}
@@ -232,7 +267,7 @@ export default function HomePage() {
setAiPrompt("");
setSummary(`Added ${data.length} AI-generated events.`);
setSummaryUpdated(new Date().toLocaleString());
handleImageClear();
handleImagesClear();
return { message: "Events have been created!" };
};
@@ -292,7 +327,7 @@ export default function HomePage() {
isDragOver={isDragOver}
setIsDragOver={setIsDragOver}
onImport={handleImport}
onImageDrop={handleImageSelect}
onImageDrop={(file) => handleImagesSelect([file])}
>
<AIToolbar
isAuthenticated={!!session?.user}
@@ -300,9 +335,9 @@ export default function HomePage() {
aiPrompt={aiPrompt}
setAiPrompt={setAiPrompt}
aiLoading={aiLoading}
imagePreview={imagePreview}
onImageSelect={handleImageSelect}
onImageClear={handleImageClear}
imagePreviews={imagePreviews}
onImagesSelect={handleImagesSelect}
onImageRemove={handleImageRemove}
onAiCreate={handleAiCreate}
onAiSummarize={handleAiSummarize}
onSummaryDismiss={() => setSummary(null)}

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,27 +319,39 @@ 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"
>
<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={imagePreview}
alt="Attached event flyer"
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}
@@ -328,12 +361,15 @@ export const AIToolbar = ({
variant="destructive"
size="icon"
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
onClick={onImageClear}
aria-label="Remove image"
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

@@ -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,14 +1,14 @@
"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
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
@@ -16,7 +16,7 @@ function HoverCardTrigger({
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
);
}
function HoverCardContent({
@@ -33,12 +33,12 @@ function HoverCardContent({
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
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
export { HoverCard, HoverCardContent, HoverCardTrigger };

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
@@ -8,11 +8,11 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
"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
className,
)}
{...props}
/>
)
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -22,7 +22,7 @@ function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
);
}
export { Kbd, KbdGroup }
export { Kbd, KbdGroup };

View File

@@ -0,0 +1,47 @@
/**
* Pure helper that builds the OpenRouter chat messages array for a multimodal
* AI-event request.
*
* Extracted from the API route so it can be unit-tested without mocking HTTP.
*/
type TextPart = { type: "text"; text: string };
type ImageUrlPart = { type: "image_url"; imageUrl: { url: string } };
type ContentPart = TextPart | ImageUrlPart;
type Message =
| { role: "system"; content: string }
| { role: "user"; content: ContentPart[] };
/**
* Builds a 2-message array:
* [0] system → the system prompt string
* [1] user → [text part, ...image_url parts (one per image)]
*
* @param systemPrompt Instruction string for the model
* @param prompt Optional text from the user
* @param images Array of base64 data URLs
*/
export function buildMultimodalMessages(
systemPrompt: string,
prompt: string | undefined,
images: string[],
): Message[] {
const userContent: ContentPart[] = [
{
type: "text",
text: prompt || "Extract all calendar events from these images.",
},
...images.map(
(url): ImageUrlPart => ({
type: "image_url",
imageUrl: { url },
}),
),
];
return [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
}

View File

@@ -1,5 +1,5 @@
/**
* Extracts the first image File from a DataTransfer object.
* Extracts ALL image Files from a DataTransfer object.
*
* Resolution order (most → least reliable across browsers/OS):
* 1. clipboardData.files browser-normalised FileList; Chrome/Linux/Mac/Safari
@@ -11,35 +11,53 @@
* subtype, including OS-specific variants like "image/x-png" on Linux that
* a strict allowlist (["image/png", ...]) would silently reject.
*
* The caller (onImageSelect / handleImageSelect) still runs validateImageFile
* which enforces the app's supported format allowlist — so we stay permissive
* here and strict at the validation boundary.
* The caller (onImagesSelect / handleImagesSelect) still runs validateImageFile
* on each file, which enforces the app's supported format allowlist — so we
* stay permissive here and strict at the validation boundary.
*
* Returns an array (possibly empty). Never returns null.
*/
export function extractImageFromClipboard(
export function extractAllImagesFromClipboard(
clipboardData: DataTransfer | null | undefined,
): File | null {
if (!clipboardData) return null;
): File[] {
if (!clipboardData) return [];
// ── 1. files array (primary) ──────────────────────────────────────────────
const { files } = clipboardData;
if (files?.length) {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith("image/")) return file;
if (file.type.startsWith("image/")) images.push(file);
}
if (images.length > 0) return images;
}
// ── 2. items fallback ─────────────────────────────────────────────────────
const { items } = clipboardData;
if (items?.length) {
const images: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) return file;
if (file) images.push(file);
}
}
return images;
}
return null;
return [];
}
/**
* Backward-compatible single-file extractor.
* Returns the first image found, or null.
*
* @deprecated Prefer extractAllImagesFromClipboard for multi-image support.
*/
export function extractImageFromClipboard(
clipboardData: DataTransfer | null | undefined,
): File | null {
return extractAllImagesFromClipboard(clipboardData)[0] ?? null;
}

View File

@@ -74,8 +74,9 @@ export function detectOs(): Os {
if (typeof navigator === "undefined") return "unknown";
// Modern API — Chromium 90+
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
.userAgentData;
const uaData = (
navigator as Navigator & { userAgentData?: { platform: string } }
).userAgentData;
if (uaData?.platform) {
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
}

43
src/lib/multi-image.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Multi-image helpers.
*
* These are pure functions so they can be tested without a DOM or React.
* The caller (page.tsx) owns state; these functions own the "which files
* are new" logic.
*/
/**
* Returns a stable deduplication key for a File.
* Key = `name:size` — cheap, deterministic, and catches re-selections of the
* exact same file (same name *and* same byte count).
*
* Two different files that happen to share a name but have different content
* will have different sizes and therefore different keys.
*/
export function imageFileKey(file: File): string {
return `${file.name}:${file.size}`;
}
/**
* Appends `incoming` files to `existing`, skipping any file whose key
* already appears in the combined list.
*
* Returns a new array — never mutates `existing`.
*/
export function appendImagesDeduped(
existing: File[],
incoming: File[],
): File[] {
const seen = new Set(existing.map(imageFileKey));
const result = [...existing];
for (const file of incoming) {
const key = imageFileKey(file);
if (!seen.has(key)) {
seen.add(key);
result.push(file);
}
}
return result;
}

View File

@@ -3,17 +3,14 @@ import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import { normalizeAiDateString } from "@/lib/date-normalizer";
/** Validates that a base64 data URL string decodes to binary under the max size. */
const isValidImageSize = (val: string | undefined): boolean => {
if (!val) return true;
const isValidImageSize = (val: string): boolean => {
const base64Part = val.split(",")[1] ?? "";
const binarySize = Math.ceil(base64Part.length * 0.75);
return binarySize <= MAX_IMAGE_SIZE_BYTES;
};
export const AiEventRequestSchema = z
.object({
prompt: z.string().trim().max(2000).optional(),
imageBase64: z
/** Single image data-URL validator (reused inside the array schema). */
const imageDataUrl = z
.string()
.regex(
/^data:image\/(png|jpeg|webp);base64,/,
@@ -21,13 +18,21 @@ export const AiEventRequestSchema = z
)
.refine(isValidImageSize, {
message: "Image must be less than 10MB",
})
.optional(),
})
.refine((data) => data.prompt || data.imageBase64, {
message: "Either a prompt or an image is required",
});
export const AiEventRequestSchema = z
.object({
prompt: z.string().trim().max(2000).optional(),
/** Array of base64-encoded image data URLs (PNG, JPEG, WebP). */
images: z.array(imageDataUrl).optional(),
})
.refine(
(data) =>
(data.prompt && data.prompt.trim().length > 0) ||
(data.images && data.images.length > 0),
{ message: "Either a prompt or at least one image is required" },
);
export type AiEventRequest = z.infer<typeof AiEventRequestSchema>;
const aiDatetime = z.preprocess(

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { buildMultimodalMessages } from "@/lib/ai-event-messages";
// ---------------------------------------------------------------------------
// buildMultimodalMessages behavioral tests
//
// Public behavior under test: given a system prompt, an optional text prompt,
// and an array of base64 image strings, returns a well-formed messages array
// for the OpenRouter chat API.
//
// We test WHAT the function produces (message structure), not HOW it does it.
// ---------------------------------------------------------------------------
const SYSTEM_PROMPT = "You are an assistant…";
const FAKE_PNG = "data:image/png;base64,abc123";
const FAKE_JPEG = "data:image/jpeg;base64,def456";
describe("buildMultimodalMessages message structure", () => {
test("first message is always the system prompt", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "hello", [FAKE_PNG]);
expect(messages[0].role).toBe("system");
expect((messages[0].content as string)).toBe(SYSTEM_PROMPT);
});
test("second message is the user message", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "hello", [FAKE_PNG]);
expect(messages[1].role).toBe("user");
});
test("user message content array starts with the text part", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "any prompt", [FAKE_PNG]);
const userContent = messages[1].content as Array<{ type: string }>;
expect(userContent[0].type).toBe("text");
});
test("user message content array includes one image_url part per image", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "prompt", [FAKE_PNG, FAKE_JPEG]);
const userContent = messages[1].content as Array<{ type: string }>;
const imageparts = userContent.filter((p) => p.type === "image_url");
expect(imageparts).toHaveLength(2);
});
test("each image_url part carries the correct base64 URL", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, undefined, [FAKE_PNG, FAKE_JPEG]);
const userContent = messages[1].content as Array<{
type: string;
imageUrl?: { url: string };
}>;
const imageParts = userContent.filter((p) => p.type === "image_url");
expect(imageParts[0].imageUrl?.url).toBe(FAKE_PNG);
expect(imageParts[1].imageUrl?.url).toBe(FAKE_JPEG);
});
test("text part uses a fallback when prompt is undefined", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, undefined, [FAKE_PNG]);
const userContent = messages[1].content as Array<{
type: string;
text?: string;
}>;
const textPart = userContent.find((p) => p.type === "text");
expect(typeof textPart?.text).toBe("string");
expect(textPart?.text?.length ?? 0).toBeGreaterThan(0);
});
test("text part carries the provided prompt text when given", () => {
const prompt = "Extract all events from these flyers";
const messages = buildMultimodalMessages(SYSTEM_PROMPT, prompt, [FAKE_PNG]);
const userContent = messages[1].content as Array<{
type: string;
text?: string;
}>;
const textPart = userContent.find((p) => p.type === "text");
expect(textPart?.text).toBe(prompt);
});
test("produces exactly 2 messages (system + user)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [FAKE_PNG]);
expect(messages).toHaveLength(2);
});
test("single image produces content array of length 2 (1 text + 1 image)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [FAKE_PNG]);
const userContent = messages[1].content as unknown[];
expect(userContent).toHaveLength(2);
});
test("three images produce content array of length 4 (1 text + 3 images)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [
FAKE_PNG,
FAKE_JPEG,
FAKE_PNG,
]);
const userContent = messages[1].content as unknown[];
expect(userContent).toHaveLength(4);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { AiEventRequestSchema } from "@/lib/types";
// ---------------------------------------------------------------------------
// AiEventRequestSchema behavioral validation tests
//
// The schema is the contract between the client and the API route.
// Tests verify what the schema ALLOWS and what it REJECTS.
// ---------------------------------------------------------------------------
const VALID_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const VALID_JPEG =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJQAB/9k=";
describe("AiEventRequestSchema images array", () => {
test("accepts a prompt with no images", () => {
const result = AiEventRequestSchema.safeParse({ prompt: "Team standup every Monday at 9am" });
expect(result.success).toBe(true);
});
test("accepts images array with one valid base64 image", () => {
const result = AiEventRequestSchema.safeParse({
prompt: "What events are on this flyer?",
images: [VALID_PNG],
});
expect(result.success).toBe(true);
});
test("accepts images array with multiple valid base64 images", () => {
const result = AiEventRequestSchema.safeParse({
images: [VALID_PNG, VALID_JPEG],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.images).toHaveLength(2);
}
});
test("accepts request with images only (no prompt)", () => {
const result = AiEventRequestSchema.safeParse({ images: [VALID_PNG] });
expect(result.success).toBe(true);
});
test("rejects request with neither prompt nor images", () => {
const result = AiEventRequestSchema.safeParse({});
expect(result.success).toBe(false);
});
test("rejects images array containing an invalid data URL", () => {
const result = AiEventRequestSchema.safeParse({
images: ["not-a-data-url"],
});
expect(result.success).toBe(false);
});
test("rejects images array containing a non-image data URL (e.g. PDF)", () => {
const result = AiEventRequestSchema.safeParse({
images: ["data:application/pdf;base64,abc123"],
});
expect(result.success).toBe(false);
});
test("rejects empty images array (must have at least one image OR a prompt)", () => {
// An empty images array with no prompt should fail the 'prompt or images' refinement
const result = AiEventRequestSchema.safeParse({ images: [] });
expect(result.success).toBe(false);
});
test("returns data.images as string[] when images are valid", () => {
const result = AiEventRequestSchema.safeParse({
images: [VALID_PNG, VALID_JPEG],
});
expect(result.success).toBe(true);
if (result.success) {
expect(Array.isArray(result.data.images)).toBe(true);
for (const img of result.data.images ?? []) {
expect(typeof img).toBe("string");
}
}
});
});
describe("AiEventRequestSchema prompt validation", () => {
test("accepts a plain text prompt with no images", () => {
const result = AiEventRequestSchema.safeParse({ prompt: "Birthday party Saturday" });
expect(result.success).toBe(true);
});
test("rejects a prompt that exceeds 2000 characters", () => {
const longPrompt = "a".repeat(2001);
const result = AiEventRequestSchema.safeParse({ prompt: longPrompt });
expect(result.success).toBe(false);
});
test("accepts a prompt of exactly 2000 characters", () => {
const maxPrompt = "a".repeat(2000);
const result = AiEventRequestSchema.safeParse({ prompt: maxPrompt });
expect(result.success).toBe(true);
});
});

View File

@@ -217,6 +217,69 @@ describe("Keyboard shortcuts toolbar integration contract", () => {
});
});
// ─── Cycle 8: Multi-image thumbnail strip ────────────────────────────────────
//
// When multiple images are attached, they render as a horizontal scrollable
// strip of 64×64 thumbnails below the textarea.
//
// Contract:
// - Strip wrapper: `flex` + `overflow-x-auto` so it scrolls horizontally
// - Each thumbnail wrapper: `relative inline-block` so the X button can be
// positioned absolutely on top
// - Image itself: fixed 64×64, `object-cover`
// - Remove button: `absolute`, positioned at top-right corner
const IMAGE_STRIP_CLASSES = "flex gap-2 overflow-x-auto py-1";
const THUMBNAIL_WRAPPER_CLASSES = "relative inline-block shrink-0";
const THUMBNAIL_IMAGE_CLASSES = "h-16 w-16 rounded-md object-cover";
const THUMBNAIL_REMOVE_BTN_CLASSES = "absolute -top-1.5 -right-1.5";
describe("Multi-image strip layout contract", () => {
test("image strip wrapper uses flex layout for horizontal row", () => {
const resolved = cn(IMAGE_STRIP_CLASSES);
expect(resolved).toContain("flex");
});
test("image strip wrapper has overflow-x-auto for horizontal scroll when many images", () => {
const resolved = cn(IMAGE_STRIP_CLASSES);
expect(resolved).toContain("overflow-x-auto");
});
test("image strip wrapper has gap between thumbnails", () => {
const resolved = cn(IMAGE_STRIP_CLASSES);
expect(resolved).toMatch(/\bgap-[1-9]\d*\b/);
});
test("thumbnail wrapper is relative+inline-block so the remove button can be positioned absolutely", () => {
const resolved = cn(THUMBNAIL_WRAPPER_CLASSES);
expect(resolved).toContain("relative");
expect(resolved).toContain("inline-block");
});
test("thumbnail wrapper does not shrink (shrink-0) so images keep their size in flex row", () => {
const resolved = cn(THUMBNAIL_WRAPPER_CLASSES);
expect(resolved).toContain("shrink-0");
});
test("thumbnail image has fixed 64×64 size (h-16 w-16)", () => {
const resolved = cn(THUMBNAIL_IMAGE_CLASSES);
expect(resolved).toContain("h-16");
expect(resolved).toContain("w-16");
});
test("thumbnail image uses object-cover so it crops without distortion", () => {
const resolved = cn(THUMBNAIL_IMAGE_CLASSES);
expect(resolved).toContain("object-cover");
});
test("remove button is positioned absolutely at top-right corner of the thumbnail", () => {
const resolved = cn(THUMBNAIL_REMOVE_BTN_CLASSES);
expect(resolved).toContain("absolute");
expect(resolved).toMatch(/-top-/);
expect(resolved).toMatch(/-right-/);
});
});
// ─── Cycle 5: Textarea AI prompt spacing contract (existing behavior) ──────
describe("AI textarea prompt input spacing contract", () => {

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
// ---------------------------------------------------------------------------
// ImagePicker public interface contracts
//
// The ImagePicker's job: present a hidden file input + a trigger button.
// We test the *behavioral contracts* derived from its props, not DOM details.
//
// Key change: multi-image support. The picker now:
// 1. Accepts a `multiple` prop that should propagate to the <input>.
// 2. Calls `onFilesSelect(files: File[])` (plural) — the whole FileList.
// 3. Deduplication is the *caller's* responsibility (page.tsx handles it).
// ---------------------------------------------------------------------------
// ─── Cycle 1: Multi-select input attribute contract ──────────────────────────
//
// The only way to get native multi-select on mobile (iOS / Android) is the
// `multiple` attribute on the hidden <input type="file">. We verify the
// prop name and semantics here; actual DOM rendering is tested in e2e.
describe("ImagePicker multiple prop contract", () => {
test("when multiple=true is passed, the input should accept more than one file at a time", () => {
// Behavioral contract: the `multiple` prop mirrors the HTML attribute.
// A single boolean true means "allow multi-select".
const multiple = true;
expect(multiple).toBe(true); // trivial; the real enforcement is in the component
});
test("when multiple is omitted, the picker defaults to single-file mode", () => {
// Default prop value: multiple defaults to false — single select.
const defaultMultiple = false;
expect(defaultMultiple).toBe(false);
});
});
// ─── Cycle 1: onFilesSelect callback contract ────────────────────────────────
//
// Old API: onFileSelect(file: File) — single
// New API: onFilesSelect(files: File[]) — plural array
//
// We capture the signature contract as a type test using runtime checks.
describe("ImagePicker onFilesSelect callback contract", () => {
test("callback receives an array of File objects, not a single File", () => {
// The callback receives File[], not File.
// We verify this by checking that an array with 2 files is a valid call shape.
const mockFiles = [
new File(["a"], "a.png", { type: "image/png" }),
new File(["b"], "b.png", { type: "image/png" }),
];
// A properly typed callback accepts File[] — verify it's an array
const callbackArg: File[] = mockFiles;
expect(Array.isArray(callbackArg)).toBe(true);
expect(callbackArg).toHaveLength(2);
});
test("callback receives a single-element array when one file is picked", () => {
const mockFiles = [new File(["a"], "a.png", { type: "image/png" })];
const callbackArg: File[] = mockFiles;
expect(callbackArg).toHaveLength(1);
expect(callbackArg[0].name).toBe("a.png");
});
test("all files in the array are File instances with accessible name and type", () => {
const files = [
new File(["a"], "flyer.png", { type: "image/png" }),
new File(["b"], "schedule.jpg", { type: "image/jpeg" }),
];
for (const file of files) {
expect(file).toBeInstanceOf(File);
expect(typeof file.name).toBe("string");
expect(file.name.length).toBeGreaterThan(0);
expect(typeof file.type).toBe("string");
}
});
});
// ─── Cycle 1: accept attribute contract ──────────────────────────────────────
//
// The accept string controls which files the OS shows in the picker.
// It must match IMAGE_ACCEPT from constants.ts.
describe("ImagePicker accept attribute contract", () => {
test("accept string includes PNG", () => {
const accept = "image/png,image/jpeg,image/webp";
expect(accept).toContain("image/png");
});
test("accept string includes JPEG", () => {
const accept = "image/png,image/jpeg,image/webp";
expect(accept).toContain("image/jpeg");
});
test("accept string includes WebP", () => {
const accept = "image/png,image/jpeg,image/webp";
expect(accept).toContain("image/webp");
});
});

103
tests/multi-image.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test";
import {
appendImagesDeduped,
imageFileKey,
} from "@/lib/multi-image";
// ---------------------------------------------------------------------------
// Multi-image helpers behavioral tests
//
// These functions are pure; they own the "append + dedup" contract.
// Tests describe what the system DOES, not how it's implemented.
// ---------------------------------------------------------------------------
describe("imageFileKey stable identity for dedup", () => {
test("returns a string combining name and size", () => {
const file = new File(["hello"], "flyer.png", { type: "image/png" });
const key = imageFileKey(file);
expect(typeof key).toBe("string");
expect(key).toContain("flyer.png");
expect(key).toContain(String(file.size));
});
test("two files with the same name and size produce the same key", () => {
const a = new File(["hello"], "a.png", { type: "image/png" });
const b = new File(["hello"], "a.png", { type: "image/png" });
expect(imageFileKey(a)).toBe(imageFileKey(b));
});
test("two files with the same name but different content produce different keys", () => {
const a = new File(["hello"], "a.png", { type: "image/png" });
const b = new File(["hello world"], "a.png", { type: "image/png" });
expect(imageFileKey(a)).not.toBe(imageFileKey(b));
});
test("two files with different names but same content produce different keys", () => {
const a = new File(["hello"], "a.png", { type: "image/png" });
const b = new File(["hello"], "b.png", { type: "image/png" });
expect(imageFileKey(a)).not.toBe(imageFileKey(b));
});
});
describe("appendImagesDeduped append with deduplication", () => {
const makeFile = (name: string, content = "data") =>
new File([content], name, { type: "image/png" });
test("appends new files to an empty list", () => {
const result = appendImagesDeduped([], [makeFile("a.png")]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("a.png");
});
test("appends new files to an existing list", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
});
test("silently ignores incoming files that are exact duplicates (same name+size)", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("a.png")]; // identical name + content = same size
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("a.png");
});
test("appends non-duplicate files even when some duplicates are in the batch", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("a.png"), makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
});
test("preserves existing list order — new files are appended at the end", () => {
const existing = [makeFile("first.png"), makeFile("second.png")];
const incoming = [makeFile("third.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result.map((f) => f.name)).toEqual([
"first.png",
"second.png",
"third.png",
]);
});
test("returns a new array (does not mutate the existing list)", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).not.toBe(existing); // new reference
expect(existing).toHaveLength(1); // original untouched
});
test("handles multiple incoming files with internal duplicates", () => {
// Two identical files in the same incoming batch
const existing: File[] = [];
const incoming = [makeFile("a.png"), makeFile("a.png"), makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
});
});