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:
@@ -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);
|
||||
|
||||
@@ -35,42 +35,42 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased min-h-screen flex flex-col`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<header className="sticky top-0 z-50 glass-strong">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between px-4 sm:px-6 h-14">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 font-semibold text-foreground tracking-tight"
|
||||
>
|
||||
<CalendarDays className="h-5 w-5 text-primary" />
|
||||
<span>{(metadata.title as string) || "iCal PWA"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<SignIn />
|
||||
<ModeToggle />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<header className="sticky top-0 z-50 glass-strong">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between px-4 sm:px-6 h-14">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 font-semibold text-foreground tracking-tight"
|
||||
>
|
||||
<CalendarDays className="h-5 w-5 text-primary" />
|
||||
<span>{(metadata.title as string) || "iCal PWA"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<SignIn />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Toaster
|
||||
closeButton
|
||||
richColors
|
||||
toastOptions={{
|
||||
className: "glass-strong",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Toaster
|
||||
closeButton
|
||||
richColors
|
||||
toastOptions={{
|
||||
className: "glass-strong",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
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])}
|
||||
>
|
||||
<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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
47
src/lib/ai-event-messages.ts
Normal file
47
src/lib/ai-event-messages.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
43
src/lib/multi-image.ts
Normal 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;
|
||||
}
|
||||
@@ -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<typeof AiEventRequestSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user