🚸 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,6 +7,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import SignIn from "@/components/sign-in";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin", "cyrillic"],
|
||||
@@ -34,12 +35,13 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased min-h-screen flex flex-col`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<header className="sticky top-0 z-50 glass-strong">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between px-4 sm:px-6 h-14">
|
||||
<Link
|
||||
@@ -60,14 +62,15 @@ export default function RootLayout({
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Toaster
|
||||
closeButton
|
||||
richColors
|
||||
toastOptions={{
|
||||
className: "glass-strong",
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
<Toaster
|
||||
closeButton
|
||||
richColors
|
||||
toastOptions={{
|
||||
className: "glass-strong",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useRef } from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Button, type buttonVariants } from "@/components/ui/button";
|
||||
|
||||
interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
|
||||
@@ -11,6 +11,8 @@ interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Expose an imperative trigger so parents can open the file dialog via ref */
|
||||
triggerRef?: React.Ref<{ open: () => void }>;
|
||||
}
|
||||
|
||||
export function ImagePicker({
|
||||
@@ -20,9 +22,17 @@ export function ImagePicker({
|
||||
variant = "ghost",
|
||||
size = "icon",
|
||||
disabled = false,
|
||||
triggerRef,
|
||||
}: ImagePickerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Expose `.open()` to parent through triggerRef
|
||||
useImperativeHandle(triggerRef, () => ({
|
||||
open() {
|
||||
fileInputRef.current?.click();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
44
src/components/ui/hover-card.tsx
Normal file
44
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
28
src/components/ui/kbd.tsx
Normal file
28
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
89
src/lib/keyboard-shortcuts.ts
Normal file
89
src/lib/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts – OS-aware key resolution
|
||||
//
|
||||
// Design:
|
||||
// - SHORTCUT_DEFINITIONS: abstract schema using modifier tokens
|
||||
// - resolveKeys(): pure function, safe to call anywhere (including tests)
|
||||
// - useOs(): client-only React hook that detects Mac vs other after hydration
|
||||
//
|
||||
// Modifier tokens:
|
||||
// "mod" → ⌘ (Mac) | Ctrl (other)
|
||||
// "shift" → ⇧ (Mac) | Shift (other)
|
||||
// "alt" → ⌥ (Mac) | Alt (other)
|
||||
// "enter" → ↵ (Mac) | Enter (other)
|
||||
// "esc" → Esc (both)
|
||||
// anything else passes through as-is (plain letter keys like "A")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Os = "mac" | "other" | "unknown";
|
||||
export type Modifier = string; // "mod" | "shift" | "alt" | "enter" | "esc" | plain key
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
modifiers: readonly Modifier[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── Abstract shortcut definitions ───────────────────────────────────────────
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||||
{ modifiers: ["esc"], label: "Clear prompt" },
|
||||
];
|
||||
|
||||
// ─── Key resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
const MAC_MAP: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
enter: "↵",
|
||||
esc: "Esc",
|
||||
};
|
||||
|
||||
const OTHER_MAP: Record<string, string> = {
|
||||
mod: "Ctrl",
|
||||
shift: "Shift",
|
||||
alt: "Alt",
|
||||
enter: "Enter",
|
||||
esc: "Esc",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — maps abstract modifier tokens to display glyphs.
|
||||
* "unknown" falls back to Mac (most common dev/user base).
|
||||
*/
|
||||
export function resolveKeys(modifiers: readonly Modifier[], os: Os): string[] {
|
||||
const map = os === "other" ? OTHER_MAP : MAC_MAP;
|
||||
return modifiers.map((m) => map[m] ?? m);
|
||||
}
|
||||
|
||||
// ─── OS detection hook (client-only) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects the user's OS after hydration.
|
||||
* Returns "unknown" on the server or before the effect runs.
|
||||
*
|
||||
* Detection order (most → least reliable):
|
||||
* 1. navigator.userAgentData.platform (modern browsers, Chromium)
|
||||
* 2. navigator.platform (legacy, still widely supported)
|
||||
* 3. navigator.userAgent string match (last resort)
|
||||
*/
|
||||
export function detectOs(): Os {
|
||||
if (typeof navigator === "undefined") return "unknown";
|
||||
|
||||
// Modern API — Chromium 90+
|
||||
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
|
||||
.userAgentData;
|
||||
if (uaData?.platform) {
|
||||
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
|
||||
}
|
||||
|
||||
// Legacy API — still reliable on Safari and Firefox
|
||||
if (navigator.platform) {
|
||||
return navigator.platform.toLowerCase().startsWith("mac") ? "mac" : "other";
|
||||
}
|
||||
|
||||
// Last resort — UA string
|
||||
return /mac/i.test(navigator.userAgent) ? "mac" : "other";
|
||||
}
|
||||
Reference in New Issue
Block a user