Files
local-cal/src/components/ai-toolbar.tsx
Dmytro Stanchiev 513aafcebc 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
2026-04-08 20:46:43 -04:00

596 lines
19 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import {
Bot,
CalendarPlus,
Download,
FileUp,
ImageIcon,
Info,
Loader2,
LogIn,
Sparkles,
Trash2,
X,
} from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { IcsFilePicker } from "@/components/ics-file-picker";
import { ImagePicker } from "@/components/image-picker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
import {
detectOs,
type Os,
resolveKeys,
SHORTCUT_DEFINITIONS,
} from "@/lib/keyboard-shortcuts";
import type { CalendarEvent } from "@/lib/types";
// ─── OS detection hook ────────────────────────────────────────────────────────
function useOs(): Os {
// Start with "unknown" for SSR — effect sets the real value after hydration
const [os, setOs] = useState<Os>("unknown");
useEffect(() => {
setOs(detectOs());
}, []);
return os;
}
// ─── Shared shortcuts list (rendered in both HoverCard and Popover) ───────────
function ShortcutsList({ os }: { os: Os }) {
return (
<>
<p className="text-xs font-semibold text-foreground mb-2.5">
Keyboard shortcuts
</p>
<ul className="space-y-2">
{SHORTCUT_DEFINITIONS.map((shortcut) => (
<li
key={shortcut.label}
className="flex items-center justify-between gap-3"
>
<span className="text-xs text-muted-foreground">
{shortcut.label}
</span>
<KbdGroup>
{resolveKeys(shortcut.modifiers, os).map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</li>
))}
</ul>
</>
);
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface AIToolbarProps {
isAuthenticated: boolean;
isPending: boolean;
aiPrompt: string;
setAiPrompt: (prompt: string) => void;
aiLoading: boolean;
/** 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;
summary: string | null;
summaryUpdated: string | null;
// event actions
events: CalendarEvent[];
onAddEvent: () => void;
onImport: (file: File) => void;
onExport: () => void;
onClearAll: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export const AIToolbar = ({
isAuthenticated,
isPending,
aiPrompt,
setAiPrompt,
aiLoading,
imagePreviews,
onImagesSelect,
onImageRemove,
onAiCreate,
onAiSummarize,
onSummaryDismiss,
summary,
summaryUpdated,
events,
onAddEvent,
onImport,
onExport,
onClearAll,
}: AIToolbarProps) => {
// Ref to imperatively open the file picker from the keyboard shortcut
const imageTriggerRef = useRef<{ open: () => void }>(null);
// When the popover is pinned open, suppress the hover card so they don't overlap
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// Detect OS after hydration for keyboard shortcut glyphs
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) {
return (
<div className="mb-6 space-y-2">
<Skeleton className="h-[90px] w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
</div>
);
}
const hasImages = imagePreviews.length > 0;
return (
<div className="mb-6 space-y-2">
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
{isAuthenticated ? (
/* ── Authenticated: full prompt composer ── */
<div className="space-y-2">
{/* Header */}
<div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-primary uppercase">
AI
</span>
</div>
{/* Textarea */}
<Textarea
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-48 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic"
placeholder="Describe an event, paste details, or attach a flyer…"
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
onKeyDown={(e) => {
// ⌘↵ — generate
if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey) &&
(aiPrompt.trim() || hasImages)
) {
e.preventDefault();
onAiCreate();
}
// ⌘⇧A — attach image
if (
e.key === "A" &&
e.shiftKey &&
(e.metaKey || e.ctrlKey) &&
!aiLoading
) {
e.preventDefault();
imageTriggerRef.current?.open();
}
// Esc — clear prompt (only when not composing a native action)
if (e.key === "Escape" && aiPrompt) {
e.preventDefault();
setAiPrompt("");
}
}}
onPaste={(e) => {
const images = extractAllImagesFromClipboard(
e.clipboardData ?? null,
);
if (images.length > 0) {
e.preventDefault();
onImagesSelect(images);
}
}}
/>
{/* ── Multi-image thumbnail strip ── */}
<AnimatePresence>
{hasImages && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex gap-2 overflow-x-auto py-1 ml-3">
{imagePreviews.map((preview, index) => (
<motion.div
key={preview}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.12 }}
className="relative inline-block shrink-0"
>
<Image
src={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>
{/* ── Footer bar: Attach (left) · Info + Generate (right) ── */}
{/*
* Layout contract: flex items-center justify-between gap-2
* 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, multiple=true for native multi-select */}
<ImagePicker
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"
triggerRef={imageTriggerRef}
>
<ImageIcon className="h-3.5 w-3.5" />
Attach image
</ImagePicker>
{/* RIGHT: Info popover + Generate button */}
<div className="flex items-center gap-1.5">
{/* Info icon — HoverCard (transient preview) + Popover (pinned) */}
{/* Both use bg-popover surface → identical appearance, correct theming */}
<HoverCard
openDelay={300}
closeDelay={100}
open={isPopoverOpen ? false : undefined}
>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<HoverCardTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:text-muted-foreground"
aria-label="Keyboard shortcuts"
>
<Info className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
</HoverCardTrigger>
<PopoverContent
align="end"
side="top"
sideOffset={6}
className="w-52 p-3"
>
<ShortcutsList os={os} />
</PopoverContent>
</Popover>
<HoverCardContent
align="end"
side="top"
sideOffset={6}
className="w-52 p-3"
>
<ShortcutsList os={os} />
</HoverCardContent>
</HoverCard>
{/* Summarize — ghost, only visible when events exist */}
{events.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onAiSummarize}
disabled={aiLoading}
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-primary px-2"
>
<Bot className="h-3 w-3" />
Summarize
</Button>
)}
{/* Generate — primary, labeled */}
<Button
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={onAiCreate}
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
>
{aiLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{aiLoading ? "Generating…" : "Generate"}
</Button>
</div>
</div>
</div>
) : (
/* ── Unauthenticated: locked CTA ── */
<div className="flex items-center gap-3 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground leading-tight">
Create events with AI
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Describe in plain language, attach a flyer done.
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5 h-8 text-xs border-primary/30 text-primary hover:bg-primary/10 hover:text-primary"
asChild
>
<a href="/auth/signin">
<LogIn className="h-3.5 w-3.5" />
Sign in
</a>
</Button>
</div>
)}
</div>
{/* ── 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"
>
<CalendarPlus className="h-3.5 w-3.5" />
Add Event
</Button>
<IcsFilePicker
onFileSelect={onImport}
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5"
>
<FileUp className="h-3.5 w-3.5" />
Import
</IcsFilePicker>
{events.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={onExport}
className="h-8 text-xs gap-1.5"
>
<Download className="h-3.5 w-3.5" />
Export
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Clear
</Button>
</>
)}
{events.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
{events.length} event{events.length !== 1 ? "s" : ""}
</Badge>
)}
</div>
</div>
{/* ── AI Summary panel ─────────────────────────────────────────────────── */}
<AnimatePresence>
{summary && (
<motion.div
initial={{ opacity: 0, y: -4, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -4, height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-primary/15 bg-primary/5 p-3.5">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5">
<Bot className="h-3.5 w-3.5 text-primary shrink-0 mt-px" />
<span className="text-xs font-semibold text-primary">
AI Summary
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{summaryUpdated && (
<span className="text-xs text-muted-foreground/60">
{summaryUpdated}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={onSummaryDismiss}
aria-label="Dismiss AI summary"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<p className="text-sm leading-relaxed text-foreground/90">
{summary}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};