Compare commits
6 Commits
722c0f0f7d
...
513aafcebc
| Author | SHA1 | Date | |
|---|---|---|---|
| 513aafcebc | |||
| cac201a4d2 | |||
| 1c864f162e | |||
| b3121ed532 | |||
| d850d88a3a | |||
| 2d34bbebc4 |
@@ -1,6 +1,7 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { buildMultimodalMessages } from "@/lib/ai-event-messages";
|
||||||
import { extractJsonFromText } from "@/lib/json-utils";
|
import { extractJsonFromText } from "@/lib/json-utils";
|
||||||
import { openRouterClient } from "@/lib/openrouter-client";
|
import { openRouterClient } from "@/lib/openrouter-client";
|
||||||
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
|
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 no end time is given (and event is not allDay), default to 1 hour after start.
|
||||||
- If multiple events are described, return multiple.
|
- 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.
|
- 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).
|
- Output ONLY valid JSON (no prose).
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -68,27 +70,9 @@ const extractContentFromChatResponse = (response: unknown): string => {
|
|||||||
const callMultimodal = async (
|
const callMultimodal = async (
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
prompt: string | undefined,
|
prompt: string | undefined,
|
||||||
imageBase64: string,
|
images: string[],
|
||||||
) => {
|
) => {
|
||||||
const messages = [
|
const messages = buildMultimodalMessages(systemPrompt, prompt, images);
|
||||||
{
|
|
||||||
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 response = await openRouterClient.chat.send({
|
const response = await openRouterClient.chat.send({
|
||||||
chatRequest: {
|
chatRequest: {
|
||||||
@@ -126,13 +110,14 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prompt, imageBase64 } = parsedInput.data;
|
const { prompt, images } = parsedInput.data;
|
||||||
const systemPrompt = buildSystemPrompt();
|
const systemPrompt = buildSystemPrompt();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = imageBase64
|
const result =
|
||||||
? await callMultimodal(systemPrompt, prompt, imageBase64)
|
images && images.length > 0
|
||||||
: await callTextOnly(systemPrompt, prompt ?? "");
|
? await callMultimodal(systemPrompt, prompt, images)
|
||||||
|
: await callTextOnly(systemPrompt, prompt ?? "");
|
||||||
|
|
||||||
const rawJson = extractJsonFromText(result.rawResponse);
|
const rawJson = extractJsonFromText(result.rawResponse);
|
||||||
const validated = AiEventResponseSchema.safeParse(rawJson);
|
const validated = AiEventResponseSchema.safeParse(rawJson);
|
||||||
|
|||||||
@@ -35,42 +35,42 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased min-h-screen flex flex-col`}
|
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased min-h-screen flex flex-col`}
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="dark"
|
defaultTheme="dark"
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<header className="sticky top-0 z-50 glass-strong">
|
<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">
|
<div className="max-w-4xl mx-auto flex items-center justify-between px-4 sm:px-6 h-14">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2.5 font-semibold text-foreground tracking-tight"
|
className="flex items-center gap-2.5 font-semibold text-foreground tracking-tight"
|
||||||
>
|
>
|
||||||
<CalendarDays className="h-5 w-5 text-primary" />
|
<CalendarDays className="h-5 w-5 text-primary" />
|
||||||
<span>{(metadata.title as string) || "iCal PWA"}</span>
|
<span>{(metadata.title as string) || "iCal PWA"}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SignIn />
|
<SignIn />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
<main className="flex-1">
|
||||||
<main className="flex-1">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6">
|
{children}
|
||||||
{children}
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
<Toaster
|
||||||
<Toaster
|
closeButton
|
||||||
closeButton
|
richColors
|
||||||
richColors
|
toastOptions={{
|
||||||
toastOptions={{
|
className: "glass-strong",
|
||||||
className: "glass-strong",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { DragDropContainer } from "@/components/drag-drop-container";
|
|||||||
import { EventDialog } from "@/components/event-dialog";
|
import { EventDialog } from "@/components/event-dialog";
|
||||||
import { EventsList } from "@/components/events-list";
|
import { EventsList } from "@/components/events-list";
|
||||||
import { useSession } from "@/lib/auth-client";
|
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 {
|
import {
|
||||||
saveEvent as addEvent,
|
saveEvent as addEvent,
|
||||||
clearEvents,
|
clearEvents,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
updateEvent,
|
updateEvent,
|
||||||
} from "@/lib/events-db";
|
} from "@/lib/events-db";
|
||||||
import { generateICS, parseICS } from "@/lib/ical";
|
import { generateICS, parseICS } from "@/lib/ical";
|
||||||
|
import { appendImagesDeduped } from "@/lib/multi-image";
|
||||||
import type { CalendarEvent } from "@/lib/types";
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> =>
|
const fileToBase64 = (file: File): Promise<string> =>
|
||||||
@@ -28,11 +29,8 @@ const fileToBase64 = (file: File): Promise<string> =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const validateImageFile = (file: File): string | null => {
|
const validateImageFile = (file: File): string | null => {
|
||||||
if (!IMAGE_MIME_TYPES.includes(file.type)) {
|
|
||||||
return "Only PNG, JPEG, and WebP images are supported.";
|
|
||||||
}
|
|
||||||
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
return "Image must be less than 10MB.";
|
return `"${file.name}" must be less than 10MB.`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -61,9 +59,13 @@ export default function HomePage() {
|
|||||||
const [summary, setSummary] = useState<string | null>(null);
|
const [summary, setSummary] = useState<string | null>(null);
|
||||||
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null);
|
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null);
|
||||||
|
|
||||||
// Image
|
// Multi-image state: parallel arrays keyed by index
|
||||||
const [imageBase64, setImageBase64] = useState<string | null>(null);
|
// imageFiles[i] — the File object (used for dedup key)
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
// 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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -86,23 +88,53 @@ export default function HomePage() {
|
|||||||
setRecurrenceRule(undefined);
|
setRecurrenceRule(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageSelect = async (file: File) => {
|
/**
|
||||||
const error = validateImageFile(file);
|
* Adds one or more image files to the attached-images list.
|
||||||
if (error) {
|
* Validates each file, deduplicates, and updates all three parallel arrays.
|
||||||
toast.error(error);
|
*/
|
||||||
return;
|
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);
|
if (validFiles.length === 0) return;
|
||||||
setImageBase64(base64);
|
|
||||||
setImagePreview(URL.createObjectURL(file));
|
// 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 = () => {
|
/** Remove the image at the given index. */
|
||||||
if (imagePreview) {
|
const handleImageRemove = (index: number) => {
|
||||||
URL.revokeObjectURL(imagePreview);
|
// 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);
|
setImageFiles([]);
|
||||||
setImagePreview(null);
|
setImageBase64s([]);
|
||||||
|
setImagePreviews([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -198,8 +230,8 @@ export default function HomePage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: aiPrompt,
|
prompt: aiPrompt || undefined,
|
||||||
imageBase64: imageBase64 || undefined,
|
images: imageBase64s.length > 0 ? imageBase64s : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +249,7 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAiCreate = async () => {
|
const handleAiCreate = async () => {
|
||||||
if (!aiPrompt.trim() && !imageBase64) return;
|
if (!aiPrompt.trim() && imageBase64s.length === 0) return;
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
|
|
||||||
const promise = async (): Promise<{ message: string }> => {
|
const promise = async (): Promise<{ message: string }> => {
|
||||||
@@ -227,7 +259,7 @@ export default function HomePage() {
|
|||||||
populateEventForm(data[0]);
|
populateEventForm(data[0]);
|
||||||
setAiPrompt("");
|
setAiPrompt("");
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
handleImageClear();
|
handleImagesClear();
|
||||||
return { message: "Event has been created!" };
|
return { message: "Event has been created!" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +267,7 @@ export default function HomePage() {
|
|||||||
setAiPrompt("");
|
setAiPrompt("");
|
||||||
setSummary(`Added ${data.length} AI-generated events.`);
|
setSummary(`Added ${data.length} AI-generated events.`);
|
||||||
setSummaryUpdated(new Date().toLocaleString());
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
handleImageClear();
|
handleImagesClear();
|
||||||
return { message: "Events have been created!" };
|
return { message: "Events have been created!" };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -295,7 +327,7 @@ export default function HomePage() {
|
|||||||
isDragOver={isDragOver}
|
isDragOver={isDragOver}
|
||||||
setIsDragOver={setIsDragOver}
|
setIsDragOver={setIsDragOver}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onImageDrop={handleImageSelect}
|
onImageDrop={(file) => handleImagesSelect([file])}
|
||||||
>
|
>
|
||||||
<AIToolbar
|
<AIToolbar
|
||||||
isAuthenticated={!!session?.user}
|
isAuthenticated={!!session?.user}
|
||||||
@@ -303,9 +335,9 @@ export default function HomePage() {
|
|||||||
aiPrompt={aiPrompt}
|
aiPrompt={aiPrompt}
|
||||||
setAiPrompt={setAiPrompt}
|
setAiPrompt={setAiPrompt}
|
||||||
aiLoading={aiLoading}
|
aiLoading={aiLoading}
|
||||||
imagePreview={imagePreview}
|
imagePreviews={imagePreviews}
|
||||||
onImageSelect={handleImageSelect}
|
onImagesSelect={handleImagesSelect}
|
||||||
onImageClear={handleImageClear}
|
onImageRemove={handleImageRemove}
|
||||||
onAiCreate={handleAiCreate}
|
onAiCreate={handleAiCreate}
|
||||||
onAiSummarize={handleAiSummarize}
|
onAiSummarize={handleAiSummarize}
|
||||||
onSummaryDismiss={() => setSummary(null)}
|
onSummaryDismiss={() => setSummary(null)}
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
|
||||||
import {
|
import {
|
||||||
SHORTCUT_DEFINITIONS,
|
|
||||||
detectOs,
|
detectOs,
|
||||||
resolveKeys,
|
|
||||||
type Os,
|
type Os,
|
||||||
|
resolveKeys,
|
||||||
|
SHORTCUT_DEFINITIONS,
|
||||||
} from "@/lib/keyboard-shortcuts";
|
} from "@/lib/keyboard-shortcuts";
|
||||||
import type { CalendarEvent } from "@/lib/types";
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
@@ -89,9 +90,12 @@ interface AIToolbarProps {
|
|||||||
aiPrompt: string;
|
aiPrompt: string;
|
||||||
setAiPrompt: (prompt: string) => void;
|
setAiPrompt: (prompt: string) => void;
|
||||||
aiLoading: boolean;
|
aiLoading: boolean;
|
||||||
imagePreview: string | null;
|
/** Ordered list of object-URL preview strings for each attached image. */
|
||||||
onImageSelect: (file: File) => void;
|
imagePreviews: string[];
|
||||||
onImageClear: () => void;
|
/** 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;
|
onAiCreate: () => void;
|
||||||
onAiSummarize: () => void;
|
onAiSummarize: () => void;
|
||||||
onSummaryDismiss: () => void;
|
onSummaryDismiss: () => void;
|
||||||
@@ -113,9 +117,9 @@ export const AIToolbar = ({
|
|||||||
aiPrompt,
|
aiPrompt,
|
||||||
setAiPrompt,
|
setAiPrompt,
|
||||||
aiLoading,
|
aiLoading,
|
||||||
imagePreview,
|
imagePreviews,
|
||||||
onImageSelect,
|
onImagesSelect,
|
||||||
onImageClear,
|
onImageRemove,
|
||||||
onAiCreate,
|
onAiCreate,
|
||||||
onAiSummarize,
|
onAiSummarize,
|
||||||
onSummaryDismiss,
|
onSummaryDismiss,
|
||||||
@@ -136,6 +140,126 @@ export const AIToolbar = ({
|
|||||||
// Detect OS after hydration for keyboard shortcut glyphs
|
// Detect OS after hydration for keyboard shortcut glyphs
|
||||||
const os = useOs();
|
const os = useOs();
|
||||||
|
|
||||||
|
// Stable ref so the document listener never needs to re-register when
|
||||||
|
// onImagesSelect identity changes between renders (it's an inline async fn).
|
||||||
|
const onImagesSelectRef = useRef(onImagesSelect);
|
||||||
|
useEffect(() => {
|
||||||
|
onImagesSelectRef.current = onImagesSelect;
|
||||||
|
}, [onImagesSelect]);
|
||||||
|
|
||||||
|
// Document-level paste + Ctrl/Cmd+V keydown handler.
|
||||||
|
//
|
||||||
|
// Two-pronged approach because Linux/Chrome does not reliably include image
|
||||||
|
// data in clipboardData on trusted paste events when no input is focused:
|
||||||
|
//
|
||||||
|
// 1. paste event — works when the textarea IS focused (clipboardData has
|
||||||
|
// the image). The textarea's own onPaste handles that
|
||||||
|
// case; here we only handle non-editable targets.
|
||||||
|
//
|
||||||
|
// 2. keydown Ctrl+V — user gesture that explicitly reads the async
|
||||||
|
// Clipboard API (navigator.clipboard.read()), which
|
||||||
|
// always has the full clipboard contents regardless of
|
||||||
|
// focused element or OS clipboard model (X11/Wayland).
|
||||||
|
// This is the approach used by Excalidraw's actionPaste.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || isPending) return;
|
||||||
|
|
||||||
|
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
|
||||||
|
const handleDocumentPaste = (e: ClipboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isEditableTarget =
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.isContentEditable;
|
||||||
|
if (isEditableTarget) return; // textarea's own onPaste covers this
|
||||||
|
|
||||||
|
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
||||||
|
if (images.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onImagesSelectRef.current(images);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Handler 2: keydown Ctrl/Cmd+V → async Clipboard API fallback ─────
|
||||||
|
// On Linux/Chrome, clipboardData is often empty in paste events when the
|
||||||
|
// clipboard was set by an external app. navigator.clipboard.read() is
|
||||||
|
// 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 handleDocumentKeydown = async (e: KeyboardEvent) => {
|
||||||
|
const isV = e.key === "v" || e.key === "V";
|
||||||
|
const isModifier = e.ctrlKey || e.metaKey;
|
||||||
|
if (!isV || !isModifier || e.shiftKey || e.altKey) return;
|
||||||
|
|
||||||
|
pasteHandledByEvent = false;
|
||||||
|
|
||||||
|
// Defer one tick so the synchronous paste event can fire first and
|
||||||
|
// set pasteHandledByEvent if it already handled an image.
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 0));
|
||||||
|
if (pasteHandledByEvent) return;
|
||||||
|
|
||||||
|
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 typesToTry = declaredType
|
||||||
|
? [declaredType, ...PROBE_TYPES.filter((t) => t !== declaredType)]
|
||||||
|
: PROBE_TYPES;
|
||||||
|
|
||||||
|
for (const mimeType of typesToTry) {
|
||||||
|
try {
|
||||||
|
const blob = await clipboardItem.getType(mimeType);
|
||||||
|
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 images so keydown
|
||||||
|
// doesn't double-fire
|
||||||
|
const handlePasteHandled = (e: ClipboardEvent) => {
|
||||||
|
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
||||||
|
if (images.length > 0) pasteHandledByEvent = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("paste", handleDocumentPaste);
|
||||||
|
document.addEventListener("paste", handlePasteHandled, { capture: true });
|
||||||
|
document.addEventListener("keydown", handleDocumentKeydown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("paste", handleDocumentPaste);
|
||||||
|
document.removeEventListener("paste", handlePasteHandled, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
document.removeEventListener("keydown", handleDocumentKeydown);
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, isPending]); // onImagesSelect intentionally omitted — ref stays current
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 space-y-2">
|
<div className="mb-6 space-y-2">
|
||||||
@@ -145,6 +269,8 @@ export const AIToolbar = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasImages = imagePreviews.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 space-y-2">
|
<div className="mb-6 space-y-2">
|
||||||
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
|
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
|
||||||
@@ -171,7 +297,7 @@ export const AIToolbar = ({
|
|||||||
if (
|
if (
|
||||||
e.key === "Enter" &&
|
e.key === "Enter" &&
|
||||||
(e.metaKey || e.ctrlKey) &&
|
(e.metaKey || e.ctrlKey) &&
|
||||||
(aiPrompt.trim() || imagePreview)
|
(aiPrompt.trim() || hasImages)
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onAiCreate();
|
onAiCreate();
|
||||||
@@ -192,35 +318,57 @@ export const AIToolbar = ({
|
|||||||
setAiPrompt("");
|
setAiPrompt("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const images = extractAllImagesFromClipboard(
|
||||||
|
e.clipboardData ?? null,
|
||||||
|
);
|
||||||
|
if (images.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onImagesSelect(images);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Attached image preview */}
|
{/* ── Multi-image thumbnail strip ── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{imagePreview && (
|
{hasImages && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="relative inline-block ml-3"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<Image
|
<div className="flex gap-2 overflow-x-auto py-1 ml-3">
|
||||||
src={imagePreview}
|
{imagePreviews.map((preview, index) => (
|
||||||
alt="Attached event flyer"
|
<motion.div
|
||||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
key={preview}
|
||||||
width={64}
|
initial={{ opacity: 0, scale: 0.85 }}
|
||||||
height={64}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
unoptimized
|
exit={{ opacity: 0, scale: 0.85 }}
|
||||||
/>
|
transition={{ duration: 0.12 }}
|
||||||
<Button
|
className="relative inline-block shrink-0"
|
||||||
variant="destructive"
|
>
|
||||||
size="icon"
|
<Image
|
||||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
|
src={preview}
|
||||||
onClick={onImageClear}
|
alt={`Attached image ${index + 1}`}
|
||||||
aria-label="Remove image"
|
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
||||||
>
|
width={64}
|
||||||
<X className="h-2.5 w-2.5" />
|
height={64}
|
||||||
</Button>
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -231,10 +379,11 @@ export const AIToolbar = ({
|
|||||||
* Attach aligns to the START (left), Info+Generate to the END (right).
|
* Attach aligns to the START (left), Info+Generate to the END (right).
|
||||||
*/}
|
*/}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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
|
<ImagePicker
|
||||||
onFileSelect={onImageSelect}
|
onFilesSelect={onImagesSelect}
|
||||||
disabled={aiLoading}
|
disabled={aiLoading}
|
||||||
|
multiple
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
className="gap-1.5 text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||||
@@ -253,10 +402,7 @@ export const AIToolbar = ({
|
|||||||
closeDelay={100}
|
closeDelay={100}
|
||||||
open={isPopoverOpen ? false : undefined}
|
open={isPopoverOpen ? false : undefined}
|
||||||
>
|
>
|
||||||
<Popover
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||||
open={isPopoverOpen}
|
|
||||||
onOpenChange={setIsPopoverOpen}
|
|
||||||
>
|
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -307,7 +453,7 @@ export const AIToolbar = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 gap-1.5 text-xs"
|
className="h-7 gap-1.5 text-xs"
|
||||||
onClick={onAiCreate}
|
onClick={onAiCreate}
|
||||||
disabled={aiLoading || (!aiPrompt.trim() && !imagePreview)}
|
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
|
||||||
>
|
>
|
||||||
{aiLoading ? (
|
{aiLoading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -351,7 +497,11 @@ export const AIToolbar = ({
|
|||||||
{/* ── Zone 2: Data management ──────────────────────────────────────────── */}
|
{/* ── Zone 2: Data management ──────────────────────────────────────────── */}
|
||||||
<div className="glass-card px-3 py-2">
|
<div className="glass-card px-3 py-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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" />
|
<CalendarPlus className="h-3.5 w-3.5" />
|
||||||
Add Event
|
Add Event
|
||||||
</Button>
|
</Button>
|
||||||
@@ -433,7 +583,9 @@ export const AIToolbar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="flex flex-col items-center justify-center py-16 text-center"
|
className="flex flex-col items-center justify-center py-16 text-center"
|
||||||
>
|
>
|
||||||
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
No events yet
|
No events yet
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||||
Create your first event to get started
|
Create your first event to get started
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,28 @@ import { ImageIcon } from "lucide-react";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useImperativeHandle, useRef } from "react";
|
import { useImperativeHandle, useRef } from "react";
|
||||||
import { Button, type buttonVariants } from "@/components/ui/button";
|
import { Button, type buttonVariants } from "@/components/ui/button";
|
||||||
|
import { IMAGE_ACCEPT } from "@/lib/constants";
|
||||||
|
|
||||||
interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
|
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;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
disabled?: boolean;
|
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 */
|
/** Expose an imperative trigger so parents can open the file dialog via ref */
|
||||||
triggerRef?: React.Ref<{ open: () => void }>;
|
triggerRef?: React.Ref<{ open: () => void }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImagePicker({
|
export function ImagePicker({
|
||||||
onFileSelect,
|
onFilesSelect,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
size = "icon",
|
size = "icon",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
multiple = false,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
}: ImagePickerProps) {
|
}: ImagePickerProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -38,10 +43,11 @@ export function ImagePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const fileList = event.target.files;
|
||||||
if (file && onFileSelect) {
|
if (fileList && fileList.length > 0 && onFilesSelect) {
|
||||||
onFileSelect(file);
|
onFilesSelect(Array.from(fileList));
|
||||||
}
|
}
|
||||||
|
// Reset so the same file(s) can be re-selected
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -53,7 +59,8 @@ export function ImagePicker({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
name="image-upload"
|
name="image-upload"
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept={IMAGE_ACCEPT}
|
||||||
|
multiple={multiple}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
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({
|
function HoverCard({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoverCardTrigger({
|
function HoverCardTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoverCardContent({
|
function HoverCardContent({
|
||||||
className,
|
className,
|
||||||
align = "center",
|
align = "center",
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
<HoverCardPrimitive.Content
|
<HoverCardPrimitive.Content
|
||||||
data-slot="hover-card-content"
|
data-slot="hover-card-content"
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</HoverCardPrimitive.Portal>
|
</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">) {
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
return (
|
return (
|
||||||
<kbd
|
<kbd
|
||||||
data-slot="kbd"
|
data-slot="kbd"
|
||||||
className={cn(
|
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",
|
"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",
|
"[&_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",
|
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<kbd
|
<kbd
|
||||||
data-slot="kbd-group"
|
data-slot="kbd-group"
|
||||||
className={cn("inline-flex items-center gap-1", className)}
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
{...props}
|
{...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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
63
src/lib/clipboard-image.ts
Normal file
63
src/lib/clipboard-image.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* all populate this for real paste events. Most reliable.
|
||||||
|
* 2. clipboardData.items – DataTransferItemList fallback for edge cases where
|
||||||
|
* files is empty but items contains a "file" kind entry.
|
||||||
|
*
|
||||||
|
* MIME matching: type.startsWith("image/") intentionally accepts any image
|
||||||
|
* subtype, including OS-specific variants like "image/x-png" on Linux that
|
||||||
|
* a strict allowlist (["image/png", ...]) would silently reject.
|
||||||
|
*
|
||||||
|
* 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 extractAllImagesFromClipboard(
|
||||||
|
clipboardData: DataTransfer | null | undefined,
|
||||||
|
): 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/")) 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) images.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export interface ShortcutDefinition {
|
|||||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||||||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||||||
|
{ modifiers: ["mod", "V"], label: "Paste image" },
|
||||||
{ modifiers: ["esc"], label: "Clear prompt" },
|
{ modifiers: ["esc"], label: "Clear prompt" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,8 +74,9 @@ export function detectOs(): Os {
|
|||||||
if (typeof navigator === "undefined") return "unknown";
|
if (typeof navigator === "undefined") return "unknown";
|
||||||
|
|
||||||
// Modern API — Chromium 90+
|
// Modern API — Chromium 90+
|
||||||
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
|
const uaData = (
|
||||||
.userAgentData;
|
navigator as Navigator & { userAgentData?: { platform: string } }
|
||||||
|
).userAgentData;
|
||||||
if (uaData?.platform) {
|
if (uaData?.platform) {
|
||||||
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
|
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";
|
import { normalizeAiDateString } from "@/lib/date-normalizer";
|
||||||
|
|
||||||
/** Validates that a base64 data URL string decodes to binary under the max size. */
|
/** Validates that a base64 data URL string decodes to binary under the max size. */
|
||||||
const isValidImageSize = (val: string | undefined): boolean => {
|
const isValidImageSize = (val: string): boolean => {
|
||||||
if (!val) return true;
|
|
||||||
const base64Part = val.split(",")[1] ?? "";
|
const base64Part = val.split(",")[1] ?? "";
|
||||||
const binarySize = Math.ceil(base64Part.length * 0.75);
|
const binarySize = Math.ceil(base64Part.length * 0.75);
|
||||||
return binarySize <= MAX_IMAGE_SIZE_BYTES;
|
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
|
export const AiEventRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
prompt: z.string().trim().max(2000).optional(),
|
prompt: z.string().trim().max(2000).optional(),
|
||||||
imageBase64: z
|
/** Array of base64-encoded image data URLs (PNG, JPEG, WebP). */
|
||||||
.string()
|
images: z.array(imageDataUrl).optional(),
|
||||||
.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(),
|
|
||||||
})
|
})
|
||||||
.refine((data) => data.prompt || data.imageBase64, {
|
.refine(
|
||||||
message: "Either a prompt or an image is required",
|
(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>;
|
export type AiEventRequest = z.infer<typeof AiEventRequestSchema>;
|
||||||
|
|
||||||
|
|||||||
96
tests/ai-event-route.test.ts
Normal file
96
tests/ai-event-route.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
tests/ai-event-schema.test.ts
Normal file
101
tests/ai-event-schema.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) ──────
|
// ─── Cycle 5: Textarea AI prompt – spacing contract (existing behavior) ──────
|
||||||
|
|
||||||
describe("AI textarea – prompt input spacing contract", () => {
|
describe("AI textarea – prompt input spacing contract", () => {
|
||||||
|
|||||||
168
tests/clipboard-image.test.ts
Normal file
168
tests/clipboard-image.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { extractImageFromClipboard } from "@/lib/clipboard-image";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clipboard image extraction – pure function tests
|
||||||
|
//
|
||||||
|
// Public interface under test: extractImageFromClipboard(clipboardData)
|
||||||
|
// Takes a DataTransfer (or null) and returns the first image File, or null.
|
||||||
|
//
|
||||||
|
// Two resolution paths (in priority order):
|
||||||
|
// 1. clipboardData.files – browser-normalised FileList, most reliable
|
||||||
|
// 2. clipboardData.items – DataTransferItemList fallback
|
||||||
|
//
|
||||||
|
// MIME matching uses startsWith("image/") to handle OS-specific variants
|
||||||
|
// like "image/x-png" that a strict allowlist would miss.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ─── Fakes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeItem(type: string, file: File | null = null): DataTransferItem {
|
||||||
|
return {
|
||||||
|
kind: file ? "file" : "string",
|
||||||
|
type,
|
||||||
|
getAsFile: () => file,
|
||||||
|
getAsString: () => {},
|
||||||
|
webkitGetAsEntry: () => null,
|
||||||
|
} as unknown as DataTransferItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItemList(...items: DataTransferItem[]): DataTransferItemList {
|
||||||
|
return {
|
||||||
|
length: items.length,
|
||||||
|
...Object.fromEntries(items.map((item, i) => [i, item])),
|
||||||
|
[Symbol.iterator]: function* () { yield* items; },
|
||||||
|
} as unknown as DataTransferItemList;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFileList(...files: File[]): FileList {
|
||||||
|
return {
|
||||||
|
length: files.length,
|
||||||
|
...Object.fromEntries(files.map((f, i) => [i, f])),
|
||||||
|
item: (i: number) => files[i] ?? null,
|
||||||
|
[Symbol.iterator]: function* () { yield* files; },
|
||||||
|
} as unknown as FileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDataTransfer({
|
||||||
|
files = [] as File[],
|
||||||
|
items = [] as DataTransferItem[],
|
||||||
|
} = {}): DataTransfer {
|
||||||
|
return {
|
||||||
|
files: makeFileList(...files),
|
||||||
|
items: makeItemList(...items),
|
||||||
|
} as unknown as DataTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_FILE = new File(["png"], "image.png", { type: "image/png" });
|
||||||
|
const JPEG_FILE = new File(["jpg"], "photo.jpg", { type: "image/jpeg" });
|
||||||
|
const WEBP_FILE = new File(["webp"], "img.webp", { type: "image/webp" });
|
||||||
|
const GIF_FILE = new File(["gif"], "anim.gif", { type: "image/gif" });
|
||||||
|
|
||||||
|
// ─── Path 1: files array (primary) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("extractImageFromClipboard – files array (primary path)", () => {
|
||||||
|
test("returns PNG from files array", () => {
|
||||||
|
const dt = makeDataTransfer({ files: [PNG_FILE] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(PNG_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns JPEG from files array", () => {
|
||||||
|
const dt = makeDataTransfer({ files: [JPEG_FILE] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(JPEG_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns WebP from files array", () => {
|
||||||
|
const dt = makeDataTransfer({ files: [WEBP_FILE] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(WEBP_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns first image when files array has multiple images", () => {
|
||||||
|
const dt = makeDataTransfer({ files: [PNG_FILE, JPEG_FILE] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(PNG_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers files array over items when both present", () => {
|
||||||
|
const dt = makeDataTransfer({
|
||||||
|
files: [JPEG_FILE],
|
||||||
|
items: [makeItem("image/png", PNG_FILE)],
|
||||||
|
});
|
||||||
|
// files is primary — JPEG should win
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(JPEG_FILE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Path 2: items fallback ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("extractImageFromClipboard – items fallback", () => {
|
||||||
|
test("returns PNG from items when files array is empty", () => {
|
||||||
|
const dt = makeDataTransfer({ items: [makeItem("image/png", PNG_FILE)] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(PNG_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns JPEG from items when files array is empty", () => {
|
||||||
|
const dt = makeDataTransfer({ items: [makeItem("image/jpeg", JPEG_FILE)] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(JPEG_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips text items, returns image from items", () => {
|
||||||
|
const dt = makeDataTransfer({
|
||||||
|
items: [makeItem("text/plain", null), makeItem("image/png", PNG_FILE)],
|
||||||
|
});
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(PNG_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when getAsFile() returns null", () => {
|
||||||
|
const dt = makeDataTransfer({ items: [makeItem("image/png", null)] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── startsWith("image/") broadening ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("extractImageFromClipboard – broad image/* MIME matching", () => {
|
||||||
|
test("accepts image/gif (any image/* accepted now)", () => {
|
||||||
|
const dt = makeDataTransfer({ files: [GIF_FILE] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(GIF_FILE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts image/x-png (Linux-specific variant)", () => {
|
||||||
|
const xpng = new File(["x"], "x.png", { type: "image/x-png" });
|
||||||
|
const dt = makeDataTransfer({ files: [xpng] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(xpng);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts image/bmp", () => {
|
||||||
|
const bmp = new File(["b"], "b.bmp", { type: "image/bmp" });
|
||||||
|
const dt = makeDataTransfer({ files: [bmp] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBe(bmp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects text/plain even if misidentified", () => {
|
||||||
|
const fake = new File(["t"], "t.txt", { type: "text/plain" });
|
||||||
|
const dt = makeDataTransfer({ files: [fake] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Null / empty cases ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("extractImageFromClipboard – null / empty", () => {
|
||||||
|
test("returns null when clipboardData is null", () => {
|
||||||
|
expect(extractImageFromClipboard(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when clipboardData is undefined", () => {
|
||||||
|
expect(extractImageFromClipboard(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when both files and items are empty", () => {
|
||||||
|
const dt = makeDataTransfer();
|
||||||
|
expect(extractImageFromClipboard(dt)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for text-only clipboard", () => {
|
||||||
|
const dt = makeDataTransfer({ items: [makeItem("text/plain", null)] });
|
||||||
|
expect(extractImageFromClipboard(dt)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
tests/image-picker.test.ts
Normal file
99
tests/image-picker.test.ts
Normal 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
103
tests/multi-image.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user