Compare commits

...

4 Commits

5 changed files with 472 additions and 362 deletions

View File

@@ -1,11 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { CalendarDays } from "lucide-react";
import Link from "next/link";
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";
@@ -42,26 +38,7 @@ export default function RootLayout({
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
href="/"
className="flex items-center gap-2.5 font-semibold text-foreground tracking-tight"
>
<CalendarDays className="h-5 w-5 text-primary" />
<span>{(metadata.title as string) || "iCal PWA"}</span>
</Link>
<div className="flex items-center gap-2">
<SignIn />
<ModeToggle />
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6">
{children}
</div>
</main>
<main className="flex-1">{children}</main>
<Toaster
closeButton
richColors

View File

@@ -1,5 +1,6 @@
"use client";
import { CalendarDays, ListTodo, Settings, Wifi, WifiOff } from "lucide-react";
import { nanoid } from "nanoid";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -7,6 +8,10 @@ import { AIToolbar } from "@/components/ai-toolbar";
import { DragDropContainer } from "@/components/drag-drop-container";
import { EventDialog } from "@/components/event-dialog";
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 { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import {
@@ -39,7 +44,9 @@ export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [dialogSource, setDialogSource] = useState<"manual" | "ai">("manual");
const [isDragOver, setIsDragOver] = useState(false);
const [isOnline, setIsOnline] = useState(true);
// Form fields
const [title, setTitle] = useState("");
@@ -74,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 resetForm = () => {
@@ -85,6 +107,7 @@ export default function HomePage() {
setEnd("");
setAllDay(false);
setEditingId(null);
setDialogSource("manual");
setRecurrenceRule(undefined);
};
@@ -225,42 +248,48 @@ export default function HomePage() {
setEvents(stored);
};
const sendAiRequest = async (): Promise<CalendarEvent[]> => {
const res = await fetch("/api/ai-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt || undefined,
images: imageBase64s.length > 0 ? imageBase64s : undefined,
}),
});
const runAiCreate = async (promptOverride?: string) => {
const nextPrompt = promptOverride?.trim() ?? aiPrompt.trim();
if (!nextPrompt && imageBase64s.length === 0) return;
if (res.status === 401) {
throw new Error("Please sign in to use AI features.");
if (promptOverride) {
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);
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) {
populateEventForm(data[0]);
setDialogSource("ai");
setAiPrompt("");
setDialogOpen(true);
handleImagesClear();
return { message: "Event has been created!" };
return { message: "Draft event is ready for review." };
}
await persistAiEvents(data);
@@ -268,6 +297,11 @@ export default function HomePage() {
setSummary(`Added ${data.length} AI-generated events.`);
setSummaryUpdated(new Date().toLocaleString());
handleImagesClear();
if (promptOverride) {
setAiPrompt("");
} else {
setAiPrompt(originalPrompt);
}
return { message: "Events have been created!" };
};
@@ -279,6 +313,10 @@ export default function HomePage() {
});
};
const handleAiCreate = async () => {
await runAiCreate();
};
// AI Summarize Events
const handleAiSummarize = async () => {
if (!events.length) {
@@ -318,6 +356,7 @@ export default function HomePage() {
setEnd(eventData.end || "");
setAllDay(eventData.allDay || false);
setEditingId(eventData.id);
setDialogSource("manual");
setRecurrenceRule(eventData.recurrenceRule);
setDialogOpen(true);
};
@@ -329,33 +368,174 @@ export default function HomePage() {
onImport={handleImport}
onImageDrop={(file) => handleImagesSelect([file])}
>
<AIToolbar
isAuthenticated={!!session?.user}
isPending={isPending}
aiPrompt={aiPrompt}
setAiPrompt={setAiPrompt}
aiLoading={aiLoading}
imagePreviews={imagePreviews}
onImagesSelect={handleImagesSelect}
onImageRemove={handleImageRemove}
onAiCreate={handleAiCreate}
onAiSummarize={handleAiSummarize}
onSummaryDismiss={() => setSummary(null)}
summary={summary}
summaryUpdated={summaryUpdated}
events={events}
onAddEvent={() => setDialogOpen(true)}
onImport={handleImport}
onExport={handleExport}
onClearAll={handleClearAll}
/>
<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">
<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">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
Offline-first iCal editor
</p>
<h1 className="truncate text-lg font-semibold tracking-tight">
LocalCal
</h1>
</div>
<div className="flex items-center gap-2">
<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">
{isOnline ? (
<Wifi className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-3" />
)}
<span>{isOnline ? "Online ready" : "Offline mode"}</span>
</div>
<SignIn />
<ModeToggle />
</div>
</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
open={dialogOpen}
onOpenChange={setDialogOpen}
editingId={editingId}
dialogSource={dialogSource}
title={title}
setTitle={setTitle}
description={description}

View File

@@ -1,24 +1,10 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import {
Bot,
CalendarPlus,
Download,
FileUp,
ImageIcon,
Info,
Loader2,
LogIn,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { Bot, ImageIcon, Info, Loader2, Sparkles, 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 {
HoverCard,
@@ -97,16 +83,12 @@ interface AIToolbarProps {
/** Remove the image at the given index from the list. */
onImageRemove: (index: number) => void;
onAiCreate: () => void;
onAiTemplateSelect: (prompt: string) => void;
onAiSummarize: () => void;
onSummaryDismiss: () => void;
summary: string | null;
summaryUpdated: string | null;
// event actions
events: CalendarEvent[];
onAddEvent: () => void;
onImport: (file: File) => void;
onExport: () => void;
onClearAll: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
@@ -121,16 +103,19 @@ export const AIToolbar = ({
onImagesSelect,
onImageRemove,
onAiCreate,
onAiTemplateSelect,
onAiSummarize,
onSummaryDismiss,
summary,
summaryUpdated,
events,
onAddEvent,
onImport,
onExport,
onClearAll,
}: 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
const imageTriggerRef = useRef<{ open: () => void }>(null);
@@ -262,9 +247,9 @@ export const AIToolbar = ({
if (isPending) {
return (
<div className="mb-6 space-y-2">
<Skeleton className="h-[90px] w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
<div className="space-y-3">
<Skeleton className="h-[160px] w-full rounded-2xl" />
<Skeleton className="h-12 w-full rounded-2xl" />
</div>
);
}
@@ -272,24 +257,14 @@ export const AIToolbar = ({
const hasImages = imagePreviews.length > 0;
return (
<div className="mb-6 space-y-2">
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
{isAuthenticated ? (
/* ── 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>
{/* Textarea */}
<div className="space-y-3">
{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
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…"
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) => {
@@ -328,225 +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>
{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"
>
<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}
<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"
>
<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="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"
<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"
>
<ShortcutsList os={os} />
</PopoverContent>
</Popover>
<HoverCardContent
<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>
<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"
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="sm"
className="h-7 gap-1.5 text-xs"
onClick={onAiCreate}
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
</PopoverContent>
</Popover>
<HoverCardContent
align="end"
side="top"
sideOffset={6}
className="w-52 p-3"
>
{aiLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{aiLoading ? "Generating…" : "Generate"}
<ShortcutsList os={os} />
</HoverCardContent>
</HoverCard>
{events.length > 0 && (
<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>
</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">
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>
{/* ── 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
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
className="h-10 gap-1.5 rounded-xl px-4 text-xs"
onClick={onAiCreate}
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
>
<Trash2 className="h-3.5 w-3.5" />
Clear
{aiLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{aiLoading ? "Generating…" : "Generate draft"}
</Button>
</>
)}
{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>
) : (
<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 ─────────────────────────────────────────────────── */}
<AnimatePresence>
@@ -558,7 +476,7 @@ export const AIToolbar = ({
transition={{ duration: 0.2, ease: "easeOut" }}
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-center gap-1.5">
<Bot className="h-3.5 w-3.5 text-primary shrink-0 mt-px" />

View File

@@ -22,6 +22,7 @@ interface EventDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingId: string | null;
dialogSource: "manual" | "ai";
title: string;
setTitle: (title: string) => void;
description: string;
@@ -46,6 +47,7 @@ export const EventDialog = ({
open,
onOpenChange,
editingId,
dialogSource,
title,
setTitle,
description,
@@ -65,6 +67,19 @@ export const EventDialog = ({
onSave,
onReset,
}: EventDialogProps) => {
const isAiDraft = dialogSource === "ai" && !editingId;
const titleText = editingId
? "Edit Event"
: isAiDraft
? "Review AI Draft"
: "New Event";
const descriptionText = editingId
? "Update the event details below. Title and start date are required."
: isAiDraft
? "AI filled in this event from your prompt. Review each field, then save when it looks right."
: "Create an event manually. Title and start date are required.";
const saveLabel = editingId ? "Update Event" : "Save Event";
const handleOpenChange = (val: boolean) => {
if (!val) onReset();
onOpenChange(val);
@@ -94,52 +109,67 @@ export const EventDialog = ({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="glass-strong max-w-md">
<DialogHeader>
<DialogTitle className="text-base">
{editingId ? "Edit Event" : "New Event"}
</DialogTitle>
<DialogDescription className="sr-only">
Fill in the event details below. Title and start date are required.
</DialogDescription>
<DialogTitle className="text-base">{titleText}</DialogTitle>
<DialogDescription>{descriptionText}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
id="event-title"
name="title"
placeholder="Event title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
/>
{isAiDraft && (
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs leading-relaxed text-primary">
This draft was generated from natural language. Double-check
dates, times, location, recurrence, and links before saving.
</div>
)}
<Textarea
id="event-description"
name="description"
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
placeholder="Add a description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="space-y-1.5">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
name="title"
placeholder="Event title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-description">Description / notes</Label>
<Textarea
id="event-description"
name="description"
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
placeholder="Add a description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="relative">
<LucideMapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
<div className="space-y-1.5">
<Label htmlFor="event-location">Location</Label>
<div className="relative">
<LucideMapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
<Input
id="event-location"
name="location"
placeholder="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-url">URL</Label>
<Input
id="event-location"
name="location"
placeholder="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="pl-8"
id="event-url"
name="url"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<Input
id="event-url"
name="url"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<RecurrencePicker
@@ -195,10 +225,16 @@ export const EventDialog = ({
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button onClick={onSave}>{editingId ? "Update" : "Create"}</Button>
<Button type="button" onClick={onSave}>
{saveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -17,21 +17,20 @@ export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-16 text-center"
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-16 text-center"
>
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
<h3 className="text-sm font-medium text-muted-foreground">
No events yet
</h3>
<p className="text-xs text-muted-foreground/60 mt-1">
Create your first event to get started
<h3 className="text-sm font-medium text-foreground">No events yet</h3>
<p className="mt-1 max-w-sm text-xs leading-relaxed text-muted-foreground/70">
Generate a draft from natural language or add an event manually to
start building your local calendar timeline.
</p>
</motion.div>
);
}
return (
<div className="space-y-2">
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{events.map((event) => (
<EventCard