diff --git a/src/app/api/ai-event/route.ts b/src/app/api/ai-event/route.ts index 651b851..a394a39 100644 --- a/src/app/api/ai-event/route.ts +++ b/src/app/api/ai-event/route.ts @@ -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,13 +110,14 @@ 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) - : await callTextOnly(systemPrompt, prompt ?? ""); + const result = + images && images.length > 0 + ? await callMultimodal(systemPrompt, prompt, images) + : await callTextOnly(systemPrompt, prompt ?? ""); const rawJson = extractJsonFromText(result.rawResponse); const validated = AiEventResponseSchema.safeParse(rawJson); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4c46eb6..6f0039b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -35,42 +35,42 @@ export default function RootLayout({ - - -
-
- - - {(metadata.title as string) || "iCal PWA"} - -
- - + + +
+
+ + + {(metadata.title as string) || "iCal PWA"} + +
+ + +
-
-
-
-
- {children} -
-
- -
-
+ +
+
+ {children} +
+
+ + + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 7cbc523..25014f8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 => @@ -29,7 +30,7 @@ const fileToBase64 = (file: File): Promise => 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(null); const [summaryUpdated, setSummaryUpdated] = useState(null); - // Image - const [imageBase64, setImageBase64] = useState(null); - const [imagePreview, setImagePreview] = useState(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([]); + const [imageBase64s, setImageBase64s] = useState([]); + const [imagePreviews, setImagePreviews] = useState([]); useEffect(() => { (async () => { @@ -83,23 +88,53 @@ export default function HomePage() { setRecurrenceRule(undefined); }; - const handleImageSelect = async (file: File) => { - const error = validateImageFile(file); - if (error) { - toast.error(error); - return; + /** + * 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); + } 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])} > setSummary(null)} diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index ce4711f..de18e05 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -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 (
{/* ── 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 ── */} - {imagePreview && ( + {hasImages && ( - Attached event flyer - +
+ {imagePreviews.map((preview, index) => ( + + {`Attached + + + ))} +
)}
@@ -343,10 +379,11 @@ export const AIToolbar = ({ * Attach aligns to the START (left), Info+Generate to the END (right). */}
- {/* LEFT: Attach image — labeled ghost button */} + {/* LEFT: Attach image — labeled ghost button, multiple=true for native multi-select */} - + @@ -545,7 +583,9 @@ export const AIToolbar = ({
-

{summary}

+

+ {summary} +

)} diff --git a/src/components/events-list.tsx b/src/components/events-list.tsx index 86da381..239e942 100644 --- a/src/components/events-list.tsx +++ b/src/components/events-list.tsx @@ -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" > - -

- No events yet -

-

- Create your first event to get started -

+ +

+ No events yet +

+

+ Create your first event to get started +

); } diff --git a/src/components/image-picker.tsx b/src/components/image-picker.tsx index 02fa56a..9391ff3 100644 --- a/src/components/image-picker.tsx +++ b/src/components/image-picker.tsx @@ -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 { - 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(null); @@ -38,10 +43,11 @@ export function ImagePicker({ }; const handleFileChange = (event: React.ChangeEvent) => { - 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" /> diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx index 91e869c..520d26f 100644 --- a/src/components/ui/hover-card.tsx +++ b/src/components/ui/hover-card.tsx @@ -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) { - return + return ; } function HoverCardTrigger({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function HoverCardContent({ - className, - align = "center", - sideOffset = 4, - ...props + className, + align = "center", + sideOffset = 4, + ...props }: React.ComponentProps) { - return ( - - - - ) + return ( + + + + ); } -export { HoverCard, HoverCardTrigger, HoverCardContent } +export { HoverCard, HoverCardContent, HoverCardTrigger }; diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx index 2459224..715b674 100644 --- a/src/components/ui/kbd.tsx +++ b/src/components/ui/kbd.tsx @@ -1,28 +1,28 @@ -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { - return ( - - ) + return ( + + ); } function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( - - ) + return ( + + ); } -export { Kbd, KbdGroup } +export { Kbd, KbdGroup }; diff --git a/src/lib/ai-event-messages.ts b/src/lib/ai-event-messages.ts new file mode 100644 index 0000000..602ecc0 --- /dev/null +++ b/src/lib/ai-event-messages.ts @@ -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 }, + ]; +} diff --git a/src/lib/clipboard-image.ts b/src/lib/clipboard-image.ts index e917416..841212e 100644 --- a/src/lib/clipboard-image.ts +++ b/src/lib/clipboard-image.ts @@ -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; } diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 13a5d63..a6f3dc4 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -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"; } diff --git a/src/lib/multi-image.ts b/src/lib/multi-image.ts new file mode 100644 index 0000000..1b5e5e3 --- /dev/null +++ b/src/lib/multi-image.ts @@ -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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0833a38..5a20b27 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,30 +3,35 @@ 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; }; +/** Single image data-URL validator (reused inside the array schema). */ +const imageDataUrl = z + .string() + .regex( + /^data:image\/(png|jpeg|webp);base64,/, + "Must be a valid image data URL (PNG, JPEG, or WebP)", + ) + .refine(isValidImageSize, { + message: "Image must be less than 10MB", + }); + export const AiEventRequestSchema = z .object({ prompt: z.string().trim().max(2000).optional(), - imageBase64: z - .string() - .regex( - /^data:image\/(png|jpeg|webp);base64,/, - "Must be a valid image data URL (PNG, JPEG, or WebP)", - ) - .refine(isValidImageSize, { - message: "Image must be less than 10MB", - }) - .optional(), + /** Array of base64-encoded image data URLs (PNG, JPEG, WebP). */ + images: z.array(imageDataUrl).optional(), }) - .refine((data) => data.prompt || data.imageBase64, { - message: "Either a prompt or an image is required", - }); + .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; diff --git a/tests/ai-event-route.test.ts b/tests/ai-event-route.test.ts new file mode 100644 index 0000000..d0c2f82 --- /dev/null +++ b/tests/ai-event-route.test.ts @@ -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); + }); +}); diff --git a/tests/ai-event-schema.test.ts b/tests/ai-event-schema.test.ts new file mode 100644 index 0000000..39b42ad --- /dev/null +++ b/tests/ai-event-schema.test.ts @@ -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); + }); +}); diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index 3d54ead..8753669 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -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", () => { diff --git a/tests/image-picker.test.ts b/tests/image-picker.test.ts new file mode 100644 index 0000000..6b52252 --- /dev/null +++ b/tests/image-picker.test.ts @@ -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 . +// 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 . 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"); + }); +}); diff --git a/tests/multi-image.test.ts b/tests/multi-image.test.ts new file mode 100644 index 0000000..9ea948a --- /dev/null +++ b/tests/multi-image.test.ts @@ -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"]); + }); +});