🚸 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:
2026-04-08 13:08:36 -04:00
parent 650d1d5f95
commit 722c0f0f7d
8 changed files with 881 additions and 151 deletions

View File

@@ -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>
)}