feat: redesign

Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
This commit is contained in:
2026-04-21 20:23:15 -04:00
parent 420a971ff7
commit 915e0b7cf8
21 changed files with 1401 additions and 537 deletions

View File

@@ -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">