From 22224bebc681e47c69386412c5a234dadfb781d6 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 9 Apr 2026 13:25:30 -0400 Subject: [PATCH] feat: redesign local calendar workspace --- src/app/page.tsx | 267 +++++++++++++++++---- src/components/ai-toolbar.tsx | 425 +++++++++++++--------------------- 2 files changed, 383 insertions(+), 309 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index e4254f3..77defb3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 { @@ -41,6 +46,7 @@ export default function HomePage() { const [editingId, setEditingId] = useState(null); const [dialogSource, setDialogSource] = useState<"manual" | "ai">("manual"); const [isDragOver, setIsDragOver] = useState(false); + const [isOnline, setIsOnline] = useState(true); // Form fields 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 resetForm = () => { @@ -227,35 +248,40 @@ export default function HomePage() { setEvents(stored); }; - const sendAiRequest = async (): Promise => { - 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]); @@ -271,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!" }; }; @@ -282,6 +313,10 @@ export default function HomePage() { }); }; + const handleAiCreate = async () => { + await runAiCreate(); + }; + // AI Summarize Events const handleAiSummarize = async () => { if (!events.length) { @@ -333,32 +368,168 @@ export default function HomePage() { onImport={handleImport} onImageDrop={(file) => handleImagesSelect([file])} > - setSummary(null)} - summary={summary} - summaryUpdated={summaryUpdated} - events={events} - onAddEvent={() => { - resetForm(); - setDialogSource("manual"); - setDialogOpen(true); - }} - onImport={handleImport} - onExport={handleExport} - onClearAll={handleClearAll} - /> +
+
+
+

+ Offline-first iCal editor +

+

+ LocalCal +

+
+
+
+ {isOnline ? ( + + ) : ( + + )} + {isOnline ? "Online ready" : "Offline mode"} +
+ + +
+
- +
+
+
+
+

+ Create with AI +

+

+ Paste details. Generate draft. Review before saving. +

+

+ Type or paste a natural-language description, then generate a + draft event for review in the event modal. +

+
+
+ + setSummary(null)} + summary={summary} + summaryUpdated={summaryUpdated} + events={events} + /> +
+ +
+
+
+

+ Events +

+

+ Your local calendar timeline +

+
+
+ {events.length} item{events.length === 1 ? "" : "s"} +
+
+ +
+
+ + Import + + + {events.length > 0 && ( + <> + + + + + )} + + +
+
+ + +
+
+ + +
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 ( -
- - +
+ +
); } @@ -272,38 +257,14 @@ export const AIToolbar = ({ const hasImages = imagePreviews.length > 0; return ( -
- {/* ── Zone 1: AI ───────────────────────────────────────────────────────── */} -
- {isAuthenticated ? ( - /* ── Authenticated: full prompt composer ── */ -
- {/* Header */} -
- - - AI draft - -
- -
- -

- Type or paste a natural-language description, then generate a - draft event for review in the event modal. -

-
- - {/* Textarea */} +
+ {isAuthenticated ? ( +
+