feat: multimodal AI event creation with image support #1
148
src/app/page.tsx
148
src/app/page.tsx
@@ -1,26 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AIToolbar } from "@/components/ai-toolbar";
|
||||
import { DragDropContainer } from "@/components/drag-drop-container";
|
||||
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
|
||||
import { EventDialog } from "@/components/event-dialog";
|
||||
import { EventsList } from "@/components/events-list";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { IMAGE_MIME_TYPES, MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
|
||||
import {
|
||||
saveEvent as addEvent,
|
||||
clearEvents,
|
||||
deleteEvent,
|
||||
getEvents as getAllEvents,
|
||||
clearEvents,
|
||||
updateEvent,
|
||||
} from "@/lib/events-db";
|
||||
import { parseICS, generateICS } from "@/lib/ical";
|
||||
import { generateICS, parseICS } from "@/lib/ical";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
import { AIToolbar } from "@/components/ai-toolbar";
|
||||
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
|
||||
import { EventsList } from "@/components/events-list";
|
||||
import { EventDialog } from "@/components/event-dialog";
|
||||
import { DragDropContainer } from "@/components/drag-drop-container";
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -29,6 +28,16 @@ const fileToBase64 = (file: File): Promise<string> =>
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const validateImageFile = (file: File): string | null => {
|
||||
if (!IMAGE_MIME_TYPES.includes(file.type)) {
|
||||
return "Only PNG, JPEG, and WebP images are supported.";
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
||||
return "Image must be less than 10MB.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -79,6 +88,11 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
const handleImageSelect = async (file: File) => {
|
||||
const error = validateImageFile(file);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
const base64 = await fileToBase64(file);
|
||||
setImageBase64(base64);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
@@ -154,14 +168,34 @@ export default function HomePage() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// AI Create Event
|
||||
const handleAiCreate = async () => {
|
||||
if (!aiPrompt.trim() && !imageBase64) return;
|
||||
setAiLoading(true);
|
||||
const populateEventForm = (ev: CalendarEvent) => {
|
||||
setTitle(ev.title || "");
|
||||
setDescription(ev.description || "");
|
||||
setLocation(ev.location || "");
|
||||
setUrl(ev.url || "");
|
||||
setStart(ev.start || "");
|
||||
setEnd(ev.end || "");
|
||||
setAllDay(ev.allDay || false);
|
||||
setEditingId(null);
|
||||
setRecurrenceRule(ev.recurrenceRule || undefined);
|
||||
};
|
||||
|
||||
const promise = (): Promise<{ message: string }> =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const persistAiEvents = async (data: CalendarEvent[]) => {
|
||||
for (const ev of data) {
|
||||
const { id: _existingId, ...rest } = ev;
|
||||
const newEvent = {
|
||||
id: nanoid(),
|
||||
...rest,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
await addEvent(newEvent);
|
||||
}
|
||||
const stored = await getAllEvents();
|
||||
setEvents(stored);
|
||||
};
|
||||
|
||||
const sendAiRequest = async (): Promise<CalendarEvent[]> => {
|
||||
const res = await fetch("/api/ai-event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -172,77 +206,47 @@ export default function HomePage() {
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
setAiLoading(false);
|
||||
reject({
|
||||
message: "Please sign in to use AI features.",
|
||||
});
|
||||
return;
|
||||
throw new Error("Please sign in to use AI features.");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error("AI did not return event data.");
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleAiCreate = async () => {
|
||||
if (!aiPrompt.trim() && !imageBase64) return;
|
||||
setAiLoading(true);
|
||||
|
||||
const promise = async (): Promise<{ message: string }> => {
|
||||
const data = await sendAiRequest();
|
||||
|
||||
if (data.length === 1) {
|
||||
const ev = data[0];
|
||||
setTitle(ev.title || "");
|
||||
setDescription(ev.description || "");
|
||||
setLocation(ev.location || "");
|
||||
setUrl(ev.url || "");
|
||||
setStart(ev.start || "");
|
||||
setEnd(ev.end || "");
|
||||
setAllDay(ev.allDay || false);
|
||||
setEditingId(null);
|
||||
populateEventForm(data[0]);
|
||||
setAiPrompt("");
|
||||
setDialogOpen(true);
|
||||
setRecurrenceRule(ev.recurrenceRule || undefined);
|
||||
handleImageClear();
|
||||
resolve({
|
||||
message: "Event has been created!",
|
||||
});
|
||||
} else {
|
||||
for (const ev of data) {
|
||||
const newEvent = {
|
||||
id: nanoid(),
|
||||
...ev,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
await addEvent(newEvent);
|
||||
return { message: "Event has been created!" };
|
||||
}
|
||||
const stored = await getAllEvents();
|
||||
setEvents(stored);
|
||||
|
||||
await persistAiEvents(data);
|
||||
setAiPrompt("");
|
||||
setSummary(`Added ${data.length} AI-generated events.`);
|
||||
setSummaryUpdated(new Date().toLocaleString());
|
||||
handleImageClear();
|
||||
resolve({
|
||||
message: "Events have been created!",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
reject({
|
||||
message: "AI did not return event data.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
reject({
|
||||
message: "Error from AI service.",
|
||||
});
|
||||
}
|
||||
});
|
||||
return { message: "Events have been created!" };
|
||||
};
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: "Generating event...",
|
||||
success: ({ message }) => {
|
||||
return message;
|
||||
},
|
||||
error: ({ message }) => {
|
||||
return message;
|
||||
},
|
||||
success: ({ message }) => message,
|
||||
error: ({ message }) => message,
|
||||
finally: () => setAiLoading(false),
|
||||
});
|
||||
|
||||
setAiLoading(false);
|
||||
};
|
||||
|
||||
// AI Summarize Events
|
||||
|
||||
Reference in New Issue
Block a user