import React, { useState, useEffect, useCallback, useMemo, memo } from "react"; import { Icon } from "@iconify/react"; import { Get } from "../../../scripts/pocketbase/Get"; import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Update } from "../../../scripts/pocketbase/Update"; import { FileManager } from "../../../scripts/pocketbase/FileManager"; import { SendLog } from "../../../scripts/pocketbase/SendLog"; import FilePreview from "../universal/FilePreview"; import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase"; import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { Collections } from '../../../schemas/pocketbase/schema'; import toast from "react-hot-toast"; // Note: Date conversion is now handled automatically by the Get and Update classes. // When fetching events, UTC dates are converted to local time by the Get class. // When saving events, local dates are converted back to UTC by the Update class. // For datetime-local inputs, we format dates without seconds (YYYY-MM-DDThh:mm). // Extended Event interface with optional created and updated fields interface Event extends Omit { created?: string; updated?: string; } // Extend Window interface declare global { interface Window { showLoading?: () => void; hideLoading?: () => void; lastCacheUpdate?: number; fetchEvents?: () => void; } } interface EventEditorProps { onEventSaved?: () => void; } // Memoize the FilePreview component const MemoizedFilePreview = memo(FilePreview); // Define EventForm props interface interface EventFormProps { event: Event | null; setEvent: (field: keyof Event, value: any) => void; selectedFiles: Map; setSelectedFiles: React.Dispatch>>; filesToDelete: Set; setFilesToDelete: React.Dispatch>>; handlePreviewFile: (url: string, filename: string) => void; isSubmitting: boolean; fileManager: FileManager; onSubmit: (e: React.FormEvent) => void; onCancel: () => void; } // Create a memoized form component const EventForm = memo(({ event, setEvent, selectedFiles, setSelectedFiles, filesToDelete, setFilesToDelete, handlePreviewFile, isSubmitting, fileManager, onSubmit, onCancel }: EventFormProps): React.ReactElement => { const handleChange = (field: keyof Event, value: any) => { setEvent(field, value); }; return (

{event?.id ? 'Edit Event' : 'Add New Event'}

{/* Event Name */}
handleChange('event_name', e.target.value)} required />
{/* Event Code */}
handleChange('event_code', e.target.value)} required />
{/* Location */}
handleChange('location', e.target.value)} required />
{/* Points to Reward */}
handleChange('points_to_reward', Number(e.target.value))} min="0" required />
{/* Start Date */}
handleChange('start_date', e.target.value)} required />
{/* End Date */}
handleChange('end_date', e.target.value)} required />
{/* Description */}
{/* Files */}
{ if (e.target.files) { const newFiles = new Map(selectedFiles); const rejectedFiles: { name: string, reason: string }[] = []; const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB Array.from(e.target.files).forEach(file => { // Validate file size if (file.size > MAX_FILE_SIZE) { const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2); rejectedFiles.push({ name: file.name, reason: `exceeds size limit (${fileSizeMB}MB > 200MB)` }); return; } // Validate file type const validation = fileManager.validateFileType(file); if (!validation.valid) { rejectedFiles.push({ name: file.name, reason: validation.reason || 'unsupported file type' }); return; } // Only add valid files newFiles.set(file.name, file); }); // Show error for rejected files if (rejectedFiles.length > 0) { const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`; toast.error(errorMessage); } setSelectedFiles(newFiles); } }} className="file-input file-input-bordered" multiple />
{/* New Files */} {Array.from(selectedFiles.entries()).map(([name, file]) => (
{name}
New
))} {/* Current Files */} {event?.files && event.files.length > 0 && ( <>
Current Files
{event.files.map((filename) => (
{filename}
{filesToDelete.has(filename) ? ( ) : ( )}
))} )}
{/* Published */}
{/* Has Food */}
{/* Action Buttons */}
); }); // Add new interfaces for change tracking interface EventChanges { event_name?: string; event_description?: string; event_code?: string; location?: string; points_to_reward?: number; start_date?: string; end_date?: string; published?: boolean; has_food?: boolean; } interface FileChanges { added: Map; deleted: Set; unchanged: string[]; } // Add queue management for large datasets class UploadQueue { private queue: Array<() => Promise> = []; private processing = false; private readonly BATCH_SIZE = 5; async add(task: () => Promise) { this.queue.push(task); if (!this.processing) { await this.process(); } } private async process() { if (this.processing || this.queue.length === 0) return; this.processing = true; try { while (this.queue.length > 0) { const batch = this.queue.splice(0, this.BATCH_SIZE); await Promise.all(batch.map(task => task())); } } finally { this.processing = false; } } } // Add change tracking utility class ChangeTracker { private initialState: Event | null = null; private currentState: Event | null = null; private fileChanges: FileChanges = { added: new Map(), deleted: new Set(), unchanged: [] }; initialize(event: Event | null) { this.initialState = event ? { ...event } : null; this.currentState = event ? { ...event } : null; this.fileChanges = { added: new Map(), deleted: new Set(), unchanged: event?.files || [] }; } trackChange(field: keyof Event, value: any) { if (!this.currentState) { this.currentState = {} as Event; } (this.currentState as any)[field] = value; } trackFileChange(added: Map, deleted: Set) { this.fileChanges.added = added; this.fileChanges.deleted = deleted; if (this.initialState?.files) { this.fileChanges.unchanged = this.initialState.files.filter( file => !deleted.has(file) ); } } getChanges(): EventChanges { if (!this.initialState || !this.currentState) return {}; const changes: EventChanges = {}; const fields: (keyof EventChanges)[] = [ 'event_name', 'event_description', 'event_code', 'location', 'points_to_reward', 'start_date', 'end_date', 'published', 'has_food' ]; for (const field of fields) { if (this.initialState[field] !== this.currentState[field]) { (changes[field] as any) = this.currentState[field]; } } return changes; } getFileChanges(): FileChanges { return this.fileChanges; } hasChanges(): boolean { return Object.keys(this.getChanges()).length > 0 || this.fileChanges.added.size > 0 || this.fileChanges.deleted.size > 0; } } // Add new interfaces for loading states interface LoadingState { isLoading: boolean; error: string | null; timeoutId: NodeJS.Timeout | null; } // Add loading spinner component const LoadingSpinner = memo(() => (

Loading event data...

)); // Add error display component const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (

{error}

)); // Modify EventEditor component export default function EventEditor({ onEventSaved }: EventEditorProps) { // State for form data and UI const [event, setEvent] = useState({ id: "", created: "", updated: "", event_name: "", event_description: "", event_code: "", location: "", files: [], points_to_reward: 0, start_date: "", end_date: "", published: false, has_food: false }); const [previewUrl, setPreviewUrl] = useState(""); const [previewFilename, setPreviewFilename] = useState(""); const [showPreview, setShowPreview] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Map()); const [filesToDelete, setFilesToDelete] = useState>(new Set()); const [isSubmitting, setIsSubmitting] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // Memoize service instances const services = useMemo(() => ({ get: Get.getInstance(), auth: Authentication.getInstance(), update: Update.getInstance(), fileManager: FileManager.getInstance(), sendLog: SendLog.getInstance() }), []); // Handle field changes const handleFieldChange = useCallback((field: keyof Event, value: any) => { setEvent(prev => { const newEvent = { ...prev, [field]: value }; // Only set hasUnsavedChanges if the value actually changed if (prev[field] !== value) { setHasUnsavedChanges(true); } return newEvent; }); }, []); // Initialize event data const initializeEventData = useCallback(async (eventId: string) => { try { if (eventId) { // Clear cache to ensure fresh data const dataSync = DataSyncService.getInstance(); await dataSync.clearCache(); // Fetch fresh event data const eventData = await services.get.getOne(Collections.EVENTS, eventId); if (!eventData) { throw new Error("Event not found"); } // Ensure dates are properly formatted for datetime-local input if (eventData.start_date) { // Convert to Date object first to ensure proper formatting const startDate = new Date(eventData.start_date); eventData.start_date = Get.formatLocalDate(startDate, false); } if (eventData.end_date) { // Convert to Date object first to ensure proper formatting const endDate = new Date(eventData.end_date); eventData.end_date = Get.formatLocalDate(endDate, false); } // Ensure all fields are properly set setEvent({ id: eventData.id || '', created: eventData.created || '', updated: eventData.updated || '', event_name: eventData.event_name || '', event_description: eventData.event_description || '', event_code: eventData.event_code || '', location: eventData.location || '', files: eventData.files || [], points_to_reward: eventData.points_to_reward || 0, start_date: eventData.start_date || '', end_date: eventData.end_date || '', published: eventData.published || false, has_food: eventData.has_food || false }); console.log("Event data loaded successfully:", eventData); } else { setEvent({ id: '', created: '', updated: '', event_name: '', event_description: '', event_code: '', location: '', files: [], points_to_reward: 0, start_date: '', end_date: '', published: false, has_food: false }); } setSelectedFiles(new Map()); setFilesToDelete(new Set()); setShowPreview(false); setHasUnsavedChanges(false); } catch (error) { console.error("Failed to initialize event data:", error); toast.error("Failed to load event data. Please try again."); } }, [services.get]); // Expose initializeEventData to window useEffect(() => { (window as any).openEditModal = async (event?: Event) => { const modal = document.getElementById("editEventModal") as HTMLDialogElement; if (!modal) return; try { if (event?.id) { // Always fetch fresh data from PocketBase for the event await initializeEventData(event.id); } else { // Reset form for new event await initializeEventData(''); } modal.showModal(); } catch (error) { console.error("Failed to open edit modal:", error); toast.error("Failed to open edit modal. Please try again."); } }; return () => { delete (window as any).openEditModal; }; }, [initializeEventData]); // Handler functions const handlePreviewFile = useCallback((url: string, filename: string) => { setPreviewUrl(url); setPreviewFilename(filename); setShowPreview(true); }, []); const handleModalClose = useCallback(() => { if (hasUnsavedChanges) { const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?'); if (!confirmed) return; } setEvent({ id: "", created: "", updated: "", event_name: "", event_description: "", event_code: "", location: "", files: [], points_to_reward: 0, start_date: "", end_date: "", published: false, has_food: false }); setSelectedFiles(new Map()); setFilesToDelete(new Set()); setShowPreview(false); setPreviewUrl(""); setPreviewFilename(""); const modal = document.getElementById("editEventModal") as HTMLDialogElement; if (modal) modal.close(); }, [hasUnsavedChanges]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); if (isSubmitting) return; try { setIsSubmitting(true); window.showLoading?.(); const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement; const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement; if (submitButton) { submitButton.disabled = true; submitButton.classList.add("btn-disabled"); } if (cancelButton) cancelButton.disabled = true; // Get form data const formData = new FormData(e.currentTarget); // Create updated event object const updatedEvent: Omit = { id: event.id, event_name: formData.get("editEventName") as string, event_description: formData.get("editEventDescription") as string, event_code: formData.get("editEventCode") as string, location: formData.get("editEventLocation") as string, files: event.files || [], points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0, start_date: formData.get("editEventStartDate") as string, end_date: formData.get("editEventEndDate") as string, published: formData.get("editEventPublished") === "on", has_food: formData.get("editEventHasFood") === "on" }; // Log the update attempt await services.sendLog.send( "update", "event", `${event.id ? "Updating" : "Creating"} event: ${updatedEvent.event_name} (${event.id || "new"})` ); if (event.id) { // We're updating an existing event // First, update the event data without touching files const { files, ...cleanPayload } = updatedEvent; await services.update.updateFields( Collections.EVENTS, event.id, cleanPayload ); // Handle file operations if (filesToDelete.size > 0 || selectedFiles.size > 0) { // Get the current event with its files const currentEvent = await services.get.getOne(Collections.EVENTS, event.id); let currentFiles = currentEvent?.files || []; // 1. Remove files marked for deletion if (filesToDelete.size > 0) { console.log(`Removing ${filesToDelete.size} files from event ${event.id}`); currentFiles = currentFiles.filter(file => !filesToDelete.has(file)); // Update the files field first to remove deleted files await services.update.updateFields( Collections.EVENTS, event.id, { files: currentFiles } ); } // 2. Add new files one by one to preserve existing ones if (selectedFiles.size > 0) { console.log(`Adding ${selectedFiles.size} new files to event ${event.id}`); // Convert Map to array of File objects const newFiles = Array.from(selectedFiles.values()); // Use FileManager to upload each file individually for (const file of newFiles) { // Use the FileManager to upload this file await services.fileManager.uploadFile( Collections.EVENTS, event.id, 'files', file, true // Set append mode to true to preserve existing files ); } } } // Get the final updated event with all changes const savedEvent = await services.get.getOne(Collections.EVENTS, event.id); // Clear cache to ensure fresh data const dataSync = DataSyncService.getInstance(); await dataSync.clearCache(); // Update the window object with the latest event data const eventDataId = `event_${event.id}`; if ((window as any)[eventDataId]) { (window as any)[eventDataId] = savedEvent; } toast.success("Event updated successfully!"); // Call the onEventSaved callback if provided if (onEventSaved) onEventSaved(); // Close the modal handleModalClose(); } else { // We're creating a new event // Create the event first without files const { files, ...cleanPayload } = updatedEvent; const newEvent = await services.update.create( Collections.EVENTS, cleanPayload ); // Then upload files if any if (selectedFiles.size > 0 && newEvent?.id) { console.log(`Adding ${selectedFiles.size} files to new event ${newEvent.id}`); // Convert Map to array of File objects const newFiles = Array.from(selectedFiles.values()); // Upload files to the new event for (const file of newFiles) { await services.fileManager.uploadFile( Collections.EVENTS, newEvent.id, 'files', file, true // Set append mode to true ); } } // Clear cache to ensure fresh data const dataSync = DataSyncService.getInstance(); await dataSync.clearCache(); toast.success("Event created successfully!"); // Call the onEventSaved callback if provided if (onEventSaved) onEventSaved(); // Close the modal handleModalClose(); } // Refresh events list if available if (window.fetchEvents) window.fetchEvents(); } catch (error) { console.error("Failed to save event:", error); toast.error(`Failed to save event: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsSubmitting(false); window.hideLoading?.(); } }, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, handleModalClose]); return ( {showPreview ? (
) : (
)}
); }