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:
184
src/app/page.tsx
184
src/app/page.tsx
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user