🚸 feat: redesign AI toolbar with two-zone layout and HoverCard shortcuts popover
- Split composer into AI zone (primary accent) and data actions zone (neutral) - Move Attach/Generate to labeled footer bar below textarea (left/right aligned) - Add info icon with HoverCard (hover preview) + Popover (pinned click) showing identical keyboard shortcuts content using shadcn HoverCard to fix theme inconsistency vs Tooltip - Expose imperative triggerRef on ImagePicker for keyboard shortcut access - Wire TooltipProvider in root layout; install shadcn kbd and hover-card - Unauthenticated state shows locked CTA with real sign-in button weight - Add behavioral contract tests for footer bar, info trigger, and zone layout
This commit is contained in:
@@ -7,21 +7,82 @@ import {
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
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 {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
detectOs,
|
||||
resolveKeys,
|
||||
type Os,
|
||||
} 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;
|
||||
@@ -44,6 +105,8 @@ interface AIToolbarProps {
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const AIToolbar = ({
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
@@ -64,77 +127,185 @@ export const AIToolbar = ({
|
||||
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();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="glass-card p-4 mb-6">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<div className="mb-6 space-y-2">
|
||||
<Skeleton className="h-[90px] w-full rounded-lg" />
|
||||
<Skeleton className="h-9 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="glass-card p-3">
|
||||
{/* AI command — only shown when authenticated */}
|
||||
<div className="mb-6 space-y-2">
|
||||
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Textarea fills full width; action icons sit below on mobile, to the right on sm+ */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
AI Command
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 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 or paste details..."
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{imagePreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative inline-block"
|
||||
>
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Attached event flyer"
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-border"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
|
||||
onClick={onImageClear}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
/* ── 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>
|
||||
|
||||
{/* On mobile: horizontal row below textarea. On sm+: vertical column beside textarea */}
|
||||
<div className="flex flex-row gap-1.5 sm:flex-col sm:pt-6">
|
||||
<ImagePicker
|
||||
onFileSelect={onImageSelect}
|
||||
disabled={aiLoading}
|
||||
className="h-8 w-8"
|
||||
{/* 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() || imagePreview)
|
||||
) {
|
||||
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("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Attached image preview */}
|
||||
<AnimatePresence>
|
||||
{imagePreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="relative inline-block ml-3"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
</ImagePicker>
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Attached event flyer"
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
|
||||
onClick={onImageClear}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</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 */}
|
||||
<ImagePicker
|
||||
onFileSelect={onImageSelect}
|
||||
disabled={aiLoading}
|
||||
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="icon"
|
||||
className="h-8 w-8"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={onAiCreate}
|
||||
disabled={aiLoading || (!aiPrompt.trim() && !imagePreview)}
|
||||
>
|
||||
@@ -143,110 +314,118 @@ export const AIToolbar = ({
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{aiLoading ? "Creating..." : "Create event"}
|
||||
</span>
|
||||
{aiLoading ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3 opacity-50" />
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-border/50">
|
||||
<Bot className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Sign in to create events with AI
|
||||
</p>
|
||||
/* ── 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>
|
||||
|
||||
{/* Action buttons — always visible */}
|
||||
{/* On mobile: two rows — actions top, meta (badge + summarize) below */}
|
||||
{/* On sm+: single row with meta pushed to the right */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button size="sm" onClick={onAddEvent} className="text-xs">
|
||||
<CalendarPlus className="h-3.5 w-3.5" />
|
||||
Add Event
|
||||
</Button>
|
||||
{/* ── 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="text-xs"
|
||||
>
|
||||
<FileUp className="h-3.5 w-3.5" />
|
||||
Import
|
||||
</IcsFilePicker>
|
||||
<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="text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<div className="flex items-center gap-3 sm:ml-auto">
|
||||
{events.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{events.length} event{events.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading}
|
||||
className="text-xs h-7"
|
||||
onClick={onClearAll}
|
||||
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
{aiLoading ? "Summarizing..." : "Summarize"}
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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="glass-card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
AI Summary
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{summaryUpdated}
|
||||
<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"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onSummaryDismiss}
|
||||
aria-label="Dismiss AI summary"
|
||||
>
|
||||
@@ -254,7 +433,7 @@ export const AIToolbar = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{summary}</p>
|
||||
<p className="text-sm leading-relaxed text-foreground/90">{summary}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user