@@ -281,183 +281,126 @@ export const AIToolbar = ({
|
||||
</div>
|
||||
</div>
|
||||
) : isAuthenticated ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/90 shadow-sm focus-within:ring-2 focus-within:ring-primary/30">
|
||||
<Textarea
|
||||
id="ai-event-prompt"
|
||||
className="wrap-anywhere field-sizing-content min-h-40 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) => {
|
||||
// ⌘↵ — 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="sticky bottom-0 z-10 border-t border-border/60 bg-background/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Try:
|
||||
</span>
|
||||
{examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 max-w-full rounded-full px-2.5 text-[11px]"
|
||||
onClick={() => onAiTemplateSelect(prompt)}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
>
|
||||
<span className="truncate">{prompt}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[10px] bg-card shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] 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) &&
|
||||
(aiPrompt.trim() || hasImages)
|
||||
) {
|
||||
e.preventDefault();
|
||||
onAiCreate();
|
||||
}
|
||||
if (
|
||||
e.key === "A" &&
|
||||
e.shiftKey &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!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();
|
||||
onImagesSelect(images);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Try:
|
||||
</span>
|
||||
{examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 max-w-full rounded-full px-2.5 text-[11px]"
|
||||
onClick={() => onAiTemplateSelect(prompt)}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
>
|
||||
<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>
|
||||
<span className="truncate">{prompt}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ImagePicker
|
||||
onFilesSelect={onImagesSelect}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HoverCard
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
open={isPopoverOpen ? false : undefined}
|
||||
>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</HoverCardTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HoverCard
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
open={isPopoverOpen ? false : undefined}
|
||||
>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex"
|
||||
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} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-52 p-3"
|
||||
>
|
||||
<ShortcutsList os={os} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
{events.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
className="h-9 gap-1.5 rounded-xl px-3 text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
Summarize
|
||||
</Button>
|
||||
)}
|
||||
{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 rounded-xl px-4 text-xs"
|
||||
className="h-10 gap-1.5 px-4 text-xs"
|
||||
onClick={onAiCreate}
|
||||
disabled={
|
||||
aiLoading || !canUseAi || (!aiPrompt.trim() && !hasImages)
|
||||
@@ -468,10 +411,84 @@ export const AIToolbar = ({
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiLoading ? "Generating…" : "Generate draft"}
|
||||
{aiLoading ? "Generating..." : "Generate event"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
|
||||
<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 sm:grid-cols-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">
|
||||
|
||||
Reference in New Issue
Block a user