617 lines
20 KiB
TypeScript
617 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Bot, ImageIcon, Info, Loader2, Sparkles, X } from "lucide-react";
|
|
import Image from "next/image";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ImagePicker } from "@/components/image-picker";
|
|
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 { useIsMobile } from "@/hooks/use-mobile";
|
|
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;
|
|
}
|
|
|
|
async function getClipboardBlobIdentity(blob: Blob): Promise<string> {
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
let hash = 2166136261;
|
|
|
|
for (const byte of bytes) {
|
|
hash ^= byte;
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
|
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
}
|
|
|
|
async function normalizeClipboardImageFile(blob: Blob): Promise<File> {
|
|
const identity = await getClipboardBlobIdentity(blob);
|
|
return new File([blob], `clipboard-image-${identity}`, {
|
|
type: blob.type,
|
|
lastModified: 0,
|
|
});
|
|
}
|
|
|
|
async function normalizeClipboardImageFiles(files: File[]): Promise<File[]> {
|
|
return Promise.all(files.map((file) => normalizeClipboardImageFile(file)));
|
|
}
|
|
|
|
// ─── 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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function isEditableTarget(target: EventTarget | null): target is HTMLElement {
|
|
const element = target as HTMLElement | null;
|
|
return !!element && (
|
|
element.tagName === "TEXTAREA" ||
|
|
element.tagName === "INPUT" ||
|
|
element.isContentEditable
|
|
);
|
|
}
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface AIToolbarProps {
|
|
adminAiEnabled: boolean;
|
|
aiEnabled: boolean;
|
|
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;
|
|
onAiTemplateSelect: (prompt: string) => void;
|
|
onAiSummarize: () => void;
|
|
onSummaryDismiss: () => void;
|
|
summary: string | null;
|
|
summaryUpdated: string | null;
|
|
events: CalendarEvent[];
|
|
}
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
export const AIToolbar = ({
|
|
adminAiEnabled,
|
|
aiEnabled,
|
|
isAuthenticated,
|
|
isPending,
|
|
aiPrompt,
|
|
setAiPrompt,
|
|
aiLoading,
|
|
imagePreviews,
|
|
onImagesSelect,
|
|
onImageRemove,
|
|
onAiCreate,
|
|
onAiTemplateSelect,
|
|
onAiSummarize,
|
|
onSummaryDismiss,
|
|
summary,
|
|
summaryUpdated,
|
|
events,
|
|
}: AIToolbarProps) => {
|
|
const isMobile = useIsMobile();
|
|
const examplePrompts = [
|
|
"Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.",
|
|
"Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.",
|
|
"Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.",
|
|
];
|
|
const canUseAi = adminAiEnabled && aiEnabled;
|
|
|
|
// 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 && canUseAi && !aiLoading))
|
|
return;
|
|
|
|
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
|
|
const handleDocumentPaste = (e: ClipboardEvent) => {
|
|
if (isEditableTarget(e.target)) return; // textarea's own onPaste covers this
|
|
|
|
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
|
if (images.length > 0) {
|
|
e.preventDefault();
|
|
void normalizeClipboardImageFiles(images).then((normalizedImages) => {
|
|
onImagesSelectRef.current(normalizedImages);
|
|
});
|
|
}
|
|
};
|
|
|
|
// ── 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;
|
|
if (e.ctrlKey && e.metaKey) return;
|
|
if (isEditableTarget(e.target)) 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(await normalizeClipboardImageFile(blob));
|
|
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) => {
|
|
if (isEditableTarget(e.target)) return;
|
|
|
|
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, canUseAi, aiLoading]); // onImagesSelect intentionally omitted — ref stays current
|
|
|
|
if (isPending) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-[160px] w-full rounded-2xl" />
|
|
<Skeleton className="h-12 w-full rounded-2xl" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasImages = imagePreviews.length > 0;
|
|
const showDisabledState = isAuthenticated && !canUseAi;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{showDisabledState ? (
|
|
<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-muted text-muted-foreground">
|
|
<Sparkles className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium leading-tight text-foreground">
|
|
AI integrations are unavailable
|
|
</p>
|
|
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
|
{adminAiEnabled
|
|
? "AI has been turned off in this browser from Settings."
|
|
: "AI integrations are currently disabled by the administrator."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : isAuthenticated ? (
|
|
<div
|
|
className={
|
|
isMobile
|
|
? "grid gap-3"
|
|
: "grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]"
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="rounded-[10px] bg-card shadow focus-within:ring-[3px] focus-within:ring-ring/20">
|
|
<Textarea
|
|
id="ai-event-prompt"
|
|
className="wrap-anywhere field-sizing-content min-h-48 w-full resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
placeholder="Type or paste event details..."
|
|
value={aiPrompt}
|
|
onChange={(e) => setAiPrompt(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
e.key === "Enter" &&
|
|
(e.metaKey || e.ctrlKey) &&
|
|
!(e.metaKey && e.ctrlKey) &&
|
|
!e.shiftKey &&
|
|
!e.altKey &&
|
|
!aiLoading &&
|
|
canUseAi &&
|
|
(aiPrompt.trim() || hasImages)
|
|
) {
|
|
e.preventDefault();
|
|
onAiCreate();
|
|
}
|
|
if (
|
|
e.key === "A" &&
|
|
e.shiftKey &&
|
|
(e.metaKey || e.ctrlKey) &&
|
|
!(e.metaKey && e.ctrlKey) &&
|
|
!e.altKey &&
|
|
!aiLoading
|
|
) {
|
|
e.preventDefault();
|
|
imageTriggerRef.current?.open();
|
|
}
|
|
if (e.key === "Escape" && aiPrompt) {
|
|
e.preventDefault();
|
|
setAiPrompt("");
|
|
}
|
|
}}
|
|
onPaste={(e) => {
|
|
const images = extractAllImagesFromClipboard(
|
|
e.clipboardData ?? null,
|
|
);
|
|
if (images.length > 0) {
|
|
e.preventDefault();
|
|
void normalizeClipboardImageFiles(images).then(
|
|
(normalizedImages) => {
|
|
onImagesSelect(normalizedImages);
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="border-t border-border px-3 py-3">
|
|
<div className="space-y-2">
|
|
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
Try:
|
|
</span>
|
|
<div className="columns-2 gap-2">
|
|
{examplePrompts.map((prompt) => (
|
|
<div key={prompt} className="mb-2 break-inside-avoid">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed"
|
|
onClick={() => onAiTemplateSelect(prompt)}
|
|
disabled={aiLoading || !canUseAi}
|
|
>
|
|
{prompt}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
{!isMobile ? (
|
|
<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-8 w-8 text-muted-foreground/70 hover:text-foreground"
|
|
aria-label="Keyboard shortcuts"
|
|
>
|
|
<Info className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</HoverCardTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
side="top"
|
|
sideOffset={6}
|
|
className="w-52 p-3"
|
|
>
|
|
<ShortcutsList os={os} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
<HoverCardContent
|
|
align="start"
|
|
side="top"
|
|
sideOffset={6}
|
|
className="w-52 p-3"
|
|
>
|
|
<ShortcutsList os={os} />
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
) : null}
|
|
|
|
{events.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onAiSummarize}
|
|
disabled={aiLoading || !canUseAi}
|
|
className="h-9 gap-1.5 px-3 text-xs text-muted-foreground hover:text-primary"
|
|
>
|
|
<Bot className="h-3 w-3" />
|
|
Summarize
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
className="h-10 gap-1.5 px-4 text-xs"
|
|
onClick={onAiCreate}
|
|
disabled={
|
|
aiLoading || !canUseAi || (!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 event"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[10px] bg-card p-3 shadow-sm">
|
|
<div className="mb-3 flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
Attachments
|
|
</p>
|
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
|
Add screenshots, flyers, or pasted images alongside the
|
|
prompt.
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
|
{imagePreviews.length} file
|
|
{imagePreviews.length === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
|
|
<ImagePicker
|
|
onFilesSelect={onImagesSelect}
|
|
disabled={aiLoading || !canUseAi}
|
|
multiple
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-10 w-full justify-center gap-2"
|
|
triggerRef={imageTriggerRef}
|
|
>
|
|
<ImageIcon className="h-4 w-4" />
|
|
Attach images
|
|
</ImagePicker>
|
|
|
|
<AnimatePresence>
|
|
{hasImages ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 4 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="mt-3 grid gap-2"
|
|
>
|
|
{imagePreviews.map((preview, index) => (
|
|
<motion.div
|
|
key={preview}
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.96 }}
|
|
transition={{ duration: 0.12 }}
|
|
className="relative overflow-hidden rounded-[8px] bg-muted"
|
|
>
|
|
<Image
|
|
src={preview}
|
|
alt={`Attached image ${index + 1}`}
|
|
className="h-32 w-full object-cover"
|
|
width={256}
|
|
height={160}
|
|
unoptimized
|
|
/>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="absolute top-2 right-2 h-7 w-7 rounded-full"
|
|
onClick={() => onImageRemove(index)}
|
|
aria-label={`Remove image ${index + 1}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
) : (
|
|
<div className="mt-3 rounded-[8px] border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
|
Drop or paste images here to pair them with the prompt.
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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">
|
|
Sign in required to generate event drafts with AI
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Sign in to turn natural language or flyers into event drafts, then
|
|
review or save them from your calendar workflow.
|
|
</p>
|
|
</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-2xl 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>
|
|
);
|
|
};
|