refactor(page): extract AI handler helpers and add client-side image validation

Break down the monolithic handleAiCreate into focused helpers
(sendAiRequest, persistAiEvents, populateEventForm), add client-side
image file validation before upload, and use toast.promise finally
callback for loading state cleanup.
This commit is contained in:
2026-04-07 13:11:26 -04:00
parent 79f98ebfd3
commit 096f548ec3

View File

@@ -1,26 +1,25 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useSession } from "@/lib/auth-client"; import { useEffect, useState } from "react";
import { toast } from "sonner"; 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 { import {
saveEvent as addEvent, saveEvent as addEvent,
clearEvents,
deleteEvent, deleteEvent,
getEvents as getAllEvents, getEvents as getAllEvents,
clearEvents,
updateEvent, updateEvent,
} from "@/lib/events-db"; } from "@/lib/events-db";
import { parseICS, generateICS } from "@/lib/ical"; import { generateICS, parseICS } from "@/lib/ical";
import type { CalendarEvent } from "@/lib/types"; 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> => const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -29,6 +28,16 @@ const fileToBase64 = (file: File): Promise<string> =>
reader.readAsDataURL(file); 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() { export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -79,6 +88,11 @@ export default function HomePage() {
}; };
const handleImageSelect = async (file: File) => { const handleImageSelect = async (file: File) => {
const error = validateImageFile(file);
if (error) {
toast.error(error);
return;
}
const base64 = await fileToBase64(file); const base64 = await fileToBase64(file);
setImageBase64(base64); setImageBase64(base64);
setImagePreview(URL.createObjectURL(file)); setImagePreview(URL.createObjectURL(file));
@@ -154,95 +168,85 @@ export default function HomePage() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// AI Create Event 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 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" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || 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.");
}
return data;
};
const handleAiCreate = async () => { const handleAiCreate = async () => {
if (!aiPrompt.trim() && !imageBase64) return; if (!aiPrompt.trim() && !imageBase64) return;
setAiLoading(true); setAiLoading(true);
const promise = (): Promise<{ message: string }> => const promise = async (): Promise<{ message: string }> => {
new Promise(async (resolve, reject) => { const data = await sendAiRequest();
try {
const res = await fetch("/api/ai-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || undefined,
}),
});
if (res.status === 401) { if (data.length === 1) {
setAiLoading(false); populateEventForm(data[0]);
reject({ setAiPrompt("");
message: "Please sign in to use AI features.", setDialogOpen(true);
}); handleImageClear();
return; return { message: "Event has been created!" };
} }
const data = await res.json(); await persistAiEvents(data);
setAiPrompt("");
if (Array.isArray(data) && data.length > 0) { setSummary(`Added ${data.length} AI-generated events.`);
if (data.length === 1) { setSummaryUpdated(new Date().toLocaleString());
const ev = data[0]; handleImageClear();
setTitle(ev.title || ""); return { message: "Events have been created!" };
setDescription(ev.description || ""); };
setLocation(ev.location || "");
setUrl(ev.url || "");
setStart(ev.start || "");
setEnd(ev.end || "");
setAllDay(ev.allDay || false);
setEditingId(null);
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);
}
const stored = await getAllEvents();
setEvents(stored);
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.",
});
}
});
toast.promise(promise, { toast.promise(promise, {
loading: "Generating event...", loading: "Generating event...",
success: ({ message }) => { success: ({ message }) => message,
return message; error: ({ message }) => message,
}, finally: () => setAiLoading(false),
error: ({ message }) => {
return message;
},
}); });
setAiLoading(false);
}; };
// AI Summarize Events // AI Summarize Events