feat: redesign local calendar workspace
This commit is contained in:
267
src/app/page.tsx
267
src/app/page.tsx
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { CalendarDays, ListTodo, Settings, Wifi, WifiOff } from "lucide-react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -7,6 +8,10 @@ import { AIToolbar } from "@/components/ai-toolbar";
|
|||||||
import { DragDropContainer } from "@/components/drag-drop-container";
|
import { DragDropContainer } from "@/components/drag-drop-container";
|
||||||
import { EventDialog } from "@/components/event-dialog";
|
import { EventDialog } from "@/components/event-dialog";
|
||||||
import { EventsList } from "@/components/events-list";
|
import { EventsList } from "@/components/events-list";
|
||||||
|
import { IcsFilePicker } from "@/components/ics-file-picker";
|
||||||
|
import { ModeToggle } from "@/components/mode-toggle";
|
||||||
|
import SignIn from "@/components/sign-in";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
|
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +46,7 @@ export default function HomePage() {
|
|||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [dialogSource, setDialogSource] = useState<"manual" | "ai">("manual");
|
const [dialogSource, setDialogSource] = useState<"manual" | "ai">("manual");
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
|
|
||||||
// Form fields
|
// Form fields
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -75,6 +81,21 @@ export default function HomePage() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOnline(window.navigator.onLine);
|
||||||
|
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -227,35 +248,40 @@ export default function HomePage() {
|
|||||||
setEvents(stored);
|
setEvents(stored);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAiRequest = async (): Promise<CalendarEvent[]> => {
|
const runAiCreate = async (promptOverride?: string) => {
|
||||||
const res = await fetch("/api/ai-event", {
|
const nextPrompt = promptOverride?.trim() ?? aiPrompt.trim();
|
||||||
method: "POST",
|
if (!nextPrompt && imageBase64s.length === 0) return;
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt: aiPrompt || undefined,
|
|
||||||
images: imageBase64s.length > 0 ? imageBase64s : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (promptOverride) {
|
||||||
throw new Error("Please sign in to use AI features.");
|
setAiPrompt(nextPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!Array.isArray(data) || data.length === 0) {
|
|
||||||
throw new Error("AI did not return event data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiCreate = async () => {
|
|
||||||
if (!aiPrompt.trim() && imageBase64s.length === 0) return;
|
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
|
|
||||||
const promise = async (): Promise<{ message: string }> => {
|
const promise = async (): Promise<{ message: string }> => {
|
||||||
const data = await sendAiRequest();
|
const originalPrompt = aiPrompt;
|
||||||
|
if (promptOverride) {
|
||||||
|
setAiPrompt(nextPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/ai-event", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: nextPrompt || undefined,
|
||||||
|
images: imageBase64s.length > 0 ? imageBase64s : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new Error("Please sign in to use AI features.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
throw new Error("AI did not return event data.");
|
||||||
|
}
|
||||||
|
|
||||||
if (data.length === 1) {
|
if (data.length === 1) {
|
||||||
populateEventForm(data[0]);
|
populateEventForm(data[0]);
|
||||||
@@ -271,6 +297,11 @@ export default function HomePage() {
|
|||||||
setSummary(`Added ${data.length} AI-generated events.`);
|
setSummary(`Added ${data.length} AI-generated events.`);
|
||||||
setSummaryUpdated(new Date().toLocaleString());
|
setSummaryUpdated(new Date().toLocaleString());
|
||||||
handleImagesClear();
|
handleImagesClear();
|
||||||
|
if (promptOverride) {
|
||||||
|
setAiPrompt("");
|
||||||
|
} else {
|
||||||
|
setAiPrompt(originalPrompt);
|
||||||
|
}
|
||||||
return { message: "Events have been created!" };
|
return { message: "Events have been created!" };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,6 +313,10 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAiCreate = async () => {
|
||||||
|
await runAiCreate();
|
||||||
|
};
|
||||||
|
|
||||||
// AI Summarize Events
|
// AI Summarize Events
|
||||||
const handleAiSummarize = async () => {
|
const handleAiSummarize = async () => {
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
@@ -333,32 +368,168 @@ export default function HomePage() {
|
|||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onImageDrop={(file) => handleImagesSelect([file])}
|
onImageDrop={(file) => handleImagesSelect([file])}
|
||||||
>
|
>
|
||||||
<AIToolbar
|
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8">
|
||||||
isAuthenticated={!!session?.user}
|
<header className="mb-4 flex items-center justify-between rounded-2xl border border-border/70 bg-background/80 px-4 py-3 shadow-sm backdrop-blur-sm">
|
||||||
isPending={isPending}
|
<div className="min-w-0">
|
||||||
aiPrompt={aiPrompt}
|
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
setAiPrompt={setAiPrompt}
|
Offline-first iCal editor
|
||||||
aiLoading={aiLoading}
|
</p>
|
||||||
imagePreviews={imagePreviews}
|
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||||
onImagesSelect={handleImagesSelect}
|
LocalCal
|
||||||
onImageRemove={handleImageRemove}
|
</h1>
|
||||||
onAiCreate={handleAiCreate}
|
</div>
|
||||||
onAiSummarize={handleAiSummarize}
|
<div className="flex items-center gap-2">
|
||||||
onSummaryDismiss={() => setSummary(null)}
|
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-2.5 py-1 text-xs text-muted-foreground">
|
||||||
summary={summary}
|
{isOnline ? (
|
||||||
summaryUpdated={summaryUpdated}
|
<Wifi className="h-3 w-3" />
|
||||||
events={events}
|
) : (
|
||||||
onAddEvent={() => {
|
<WifiOff className="h-3 w-3" />
|
||||||
resetForm();
|
)}
|
||||||
setDialogSource("manual");
|
<span>{isOnline ? "Online ready" : "Offline mode"}</span>
|
||||||
setDialogOpen(true);
|
</div>
|
||||||
}}
|
<SignIn />
|
||||||
onImport={handleImport}
|
<ModeToggle />
|
||||||
onExport={handleExport}
|
</div>
|
||||||
onClearAll={handleClearAll}
|
</header>
|
||||||
/>
|
|
||||||
|
|
||||||
<EventsList events={events} onEdit={handleEdit} onDelete={handleDelete} />
|
<main className="flex-1 space-y-4">
|
||||||
|
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5">
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
|
||||||
|
Create with AI
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">
|
||||||
|
Paste details. Generate draft. Review before saving.
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Type or paste a natural-language description, then generate a
|
||||||
|
draft event for review in the event modal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AIToolbar
|
||||||
|
isAuthenticated={!!session?.user}
|
||||||
|
isPending={isPending}
|
||||||
|
aiPrompt={aiPrompt}
|
||||||
|
setAiPrompt={setAiPrompt}
|
||||||
|
aiLoading={aiLoading}
|
||||||
|
imagePreviews={imagePreviews}
|
||||||
|
onImagesSelect={handleImagesSelect}
|
||||||
|
onImageRemove={handleImageRemove}
|
||||||
|
onAiCreate={handleAiCreate}
|
||||||
|
onAiTemplateSelect={runAiCreate}
|
||||||
|
onAiSummarize={handleAiSummarize}
|
||||||
|
onSummaryDismiss={() => setSummary(null)}
|
||||||
|
summary={summary}
|
||||||
|
summaryUpdated={summaryUpdated}
|
||||||
|
events={events}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Events
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">
|
||||||
|
Your local calendar timeline
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{events.length} item{events.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-2xl border border-border/70 bg-muted/35 p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<IcsFilePicker
|
||||||
|
onFileSelect={handleImport}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 rounded-xl gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</IcsFilePicker>
|
||||||
|
|
||||||
|
{events.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
className="h-9 rounded-xl gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="h-9 rounded-xl gap-1.5 text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setDialogSource("manual");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
Manual event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventsList
|
||||||
|
events={events}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav className="fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-2xl border border-border/70 bg-background/90 px-3 py-2 shadow-lg backdrop-blur-sm sm:inset-x-6 lg:inset-x-8">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 gap-2 text-primary"
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
List
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 gap-2 text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<ListTodo className="h-4 w-4" />
|
||||||
|
Tasks
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 gap-2 text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EventDialog
|
<EventDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import { Bot, ImageIcon, Info, Loader2, Sparkles, X } from "lucide-react";
|
||||||
Bot,
|
|
||||||
CalendarPlus,
|
|
||||||
Download,
|
|
||||||
FileUp,
|
|
||||||
ImageIcon,
|
|
||||||
Info,
|
|
||||||
Loader2,
|
|
||||||
LogIn,
|
|
||||||
Sparkles,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { IcsFilePicker } from "@/components/ics-file-picker";
|
|
||||||
import { ImagePicker } from "@/components/image-picker";
|
import { ImagePicker } from "@/components/image-picker";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
@@ -97,16 +83,12 @@ interface AIToolbarProps {
|
|||||||
/** Remove the image at the given index from the list. */
|
/** Remove the image at the given index from the list. */
|
||||||
onImageRemove: (index: number) => void;
|
onImageRemove: (index: number) => void;
|
||||||
onAiCreate: () => void;
|
onAiCreate: () => void;
|
||||||
|
onAiTemplateSelect: (prompt: string) => void;
|
||||||
onAiSummarize: () => void;
|
onAiSummarize: () => void;
|
||||||
onSummaryDismiss: () => void;
|
onSummaryDismiss: () => void;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
summaryUpdated: string | null;
|
summaryUpdated: string | null;
|
||||||
// event actions
|
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
onAddEvent: () => void;
|
|
||||||
onImport: (file: File) => void;
|
|
||||||
onExport: () => void;
|
|
||||||
onClearAll: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
@@ -121,16 +103,19 @@ export const AIToolbar = ({
|
|||||||
onImagesSelect,
|
onImagesSelect,
|
||||||
onImageRemove,
|
onImageRemove,
|
||||||
onAiCreate,
|
onAiCreate,
|
||||||
|
onAiTemplateSelect,
|
||||||
onAiSummarize,
|
onAiSummarize,
|
||||||
onSummaryDismiss,
|
onSummaryDismiss,
|
||||||
summary,
|
summary,
|
||||||
summaryUpdated,
|
summaryUpdated,
|
||||||
events,
|
events,
|
||||||
onAddEvent,
|
|
||||||
onImport,
|
|
||||||
onExport,
|
|
||||||
onClearAll,
|
|
||||||
}: AIToolbarProps) => {
|
}: AIToolbarProps) => {
|
||||||
|
const examplePrompts = [
|
||||||
|
"Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.",
|
||||||
|
"Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.",
|
||||||
|
"Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.",
|
||||||
|
];
|
||||||
|
|
||||||
// Ref to imperatively open the file picker from the keyboard shortcut
|
// Ref to imperatively open the file picker from the keyboard shortcut
|
||||||
const imageTriggerRef = useRef<{ open: () => void }>(null);
|
const imageTriggerRef = useRef<{ open: () => void }>(null);
|
||||||
|
|
||||||
@@ -262,9 +247,9 @@ export const AIToolbar = ({
|
|||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 space-y-2">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-[90px] w-full rounded-lg" />
|
<Skeleton className="h-[160px] w-full rounded-2xl" />
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
<Skeleton className="h-12 w-full rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,38 +257,14 @@ export const AIToolbar = ({
|
|||||||
const hasImages = imagePreviews.length > 0;
|
const hasImages = imagePreviews.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 space-y-2">
|
<div className="space-y-3">
|
||||||
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
|
{isAuthenticated ? (
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
|
<div className="space-y-4">
|
||||||
{isAuthenticated ? (
|
<div className="rounded-2xl border border-border/70 bg-background/90 shadow-sm focus-within:ring-2 focus-within:ring-primary/30">
|
||||||
/* ── 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 draft
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label
|
|
||||||
htmlFor="ai-event-prompt"
|
|
||||||
className="text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
Describe or paste the event details
|
|
||||||
</label>
|
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
||||||
Type or paste a natural-language description, then generate a
|
|
||||||
draft event for review in the event modal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Textarea */}
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="ai-event-prompt"
|
id="ai-event-prompt"
|
||||||
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"
|
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="Example: Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before."
|
placeholder="Type or paste event details…"
|
||||||
value={aiPrompt}
|
value={aiPrompt}
|
||||||
onChange={(e) => setAiPrompt(e.target.value)}
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -342,226 +303,168 @@ export const AIToolbar = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<span className="truncate">{prompt}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Multi-image thumbnail strip ── */}
|
<AnimatePresence>
|
||||||
<AnimatePresence>
|
{hasImages && (
|
||||||
{hasImages && (
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, height: 0 }}
|
||||||
initial={{ opacity: 0, height: 0 }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
transition={{ duration: 0.15 }}
|
||||||
transition={{ duration: 0.15 }}
|
className="overflow-hidden"
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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, multiple=true for native multi-select */}
|
|
||||||
<ImagePicker
|
|
||||||
onFilesSelect={onImagesSelect}
|
|
||||||
disabled={aiLoading}
|
|
||||||
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" />
|
<div className="flex gap-2 overflow-x-auto py-1 ml-3">
|
||||||
Attach image
|
{imagePreviews.map((preview, index) => (
|
||||||
</ImagePicker>
|
<motion.div
|
||||||
|
key={preview}
|
||||||
{/* RIGHT: Info popover + Generate button */}
|
initial={{ opacity: 0, scale: 0.85 }}
|
||||||
<div className="flex items-center gap-1.5">
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
{/* Info icon — HoverCard (transient preview) + Popover (pinned) */}
|
exit={{ opacity: 0, scale: 0.85 }}
|
||||||
{/* Both use bg-popover surface → identical appearance, correct theming */}
|
transition={{ duration: 0.12 }}
|
||||||
<HoverCard
|
className="relative inline-block shrink-0"
|
||||||
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-6 w-6 text-muted-foreground/50 hover:text-muted-foreground md:inline-flex"
|
|
||||||
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} />
|
<Image
|
||||||
</PopoverContent>
|
src={preview}
|
||||||
</Popover>
|
alt={`Attached image ${index + 1}`}
|
||||||
<HoverCardContent
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<ImagePicker
|
||||||
|
onFilesSelect={onImagesSelect}
|
||||||
|
disabled={aiLoading}
|
||||||
|
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"
|
align="end"
|
||||||
side="top"
|
side="top"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
className="w-52 p-3"
|
className="w-52 p-3"
|
||||||
>
|
>
|
||||||
<ShortcutsList os={os} />
|
<ShortcutsList os={os} />
|
||||||
</HoverCardContent>
|
</PopoverContent>
|
||||||
</HoverCard>
|
</Popover>
|
||||||
|
<HoverCardContent
|
||||||
{/* Summarize — ghost, only visible when events exist */}
|
align="end"
|
||||||
{events.length > 0 && (
|
side="top"
|
||||||
<Button
|
sideOffset={6}
|
||||||
variant="ghost"
|
className="w-52 p-3"
|
||||||
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="sm"
|
|
||||||
className="h-7 gap-1.5 text-xs"
|
|
||||||
onClick={onAiCreate}
|
|
||||||
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
|
|
||||||
>
|
>
|
||||||
{aiLoading ? (
|
<ShortcutsList os={os} />
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
</HoverCardContent>
|
||||||
) : (
|
</HoverCard>
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
)}
|
{events.length > 0 && (
|
||||||
{aiLoading ? "Generating…" : "Generate draft"}
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAiSummarize}
|
||||||
|
disabled={aiLoading}
|
||||||
|
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>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ── 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">
|
|
||||||
Generate event drafts with AI
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Paste natural language or a flyer, then review the filled event
|
|
||||||
before saving.
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* ── 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="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="h-8 text-xs gap-1.5"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClearAll}
|
className="h-10 gap-1.5 rounded-xl px-4 text-xs"
|
||||||
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
onClick={onAiCreate}
|
||||||
|
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
{aiLoading ? (
|
||||||
Clear
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{aiLoading ? "Generating…" : "Generate draft"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{events.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
|
|
||||||
{events.length} event{events.length !== 1 ? "s" : ""}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<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">
|
||||||
|
Generate event drafts with AI
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Paste natural language or a flyer, then review the filled event
|
||||||
|
before saving.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── AI Summary panel ─────────────────────────────────────────────────── */}
|
{/* ── AI Summary panel ─────────────────────────────────────────────────── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -573,7 +476,7 @@ export const AIToolbar = ({
|
|||||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg border border-primary/15 bg-primary/5 p-3.5">
|
<div className="rounded-2xl 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-start justify-between gap-2 mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Bot className="h-3.5 w-3.5 text-primary shrink-0 mt-px" />
|
<Bot className="h-3.5 w-3.5 text-primary shrink-0 mt-px" />
|
||||||
|
|||||||
Reference in New Issue
Block a user