From 825b914f793ef4839fee5eae71294b014d1325d0 Mon Sep 17 00:00:00 2001 From: chark1es Date: Mon, 17 Feb 2025 02:03:25 -0800 Subject: [PATCH] fix uploading issues --- .../dashboard/Officer_EventManagement.astro | 233 -------- .../Officer_EventManagement/Attendees.tsx | 367 ++++++++---- .../Officer_EventManagement/EventEditor.tsx | 560 ++++++++---------- .../Officer_EventManagement/FilePreview.tsx | 306 ++++++++-- src/scripts/pocketbase/FileManager.ts | 177 +++++- 5 files changed, 904 insertions(+), 739 deletions(-) diff --git a/src/components/dashboard/Officer_EventManagement.astro b/src/components/dashboard/Officer_EventManagement.astro index 04b44f1..ec284f1 100644 --- a/src/components/dashboard/Officer_EventManagement.astro +++ b/src/components/dashboard/Officer_EventManagement.astro @@ -2068,239 +2068,6 @@ const currentPage = eventResponse.page; fileInput.value = ""; }); - // Modify form submission handler to use selectedFileStorage - document - .getElementById("editEventForm") - ?.addEventListener("submit", async function (e) { - e.preventDefault(); - - const form = e.target as HTMLFormElement; - const modalAction = document.querySelector(".modal-action"); - const submitButton = modalAction?.querySelector( - ".btn-primary" - ) as HTMLButtonElement; - const cancelButton = modalAction?.querySelector( - ".btn:not(.btn-primary)" - ) as HTMLButtonElement; - - if (!submitButton || !cancelButton) { - console.error("Could not find submit or cancel buttons"); - return; - } - - // Store original button content - const originalText = submitButton.innerHTML; - - // Immediately disable buttons and show loading state - submitButton.disabled = true; - cancelButton.disabled = true; - submitButton.classList.add("btn-disabled"); - submitButton.innerHTML = ``; - - try { - const formData = new FormData(form); - const eventId = ( - document.getElementById("editEventId") as HTMLInputElement - )?.value; - - // Get files from storage - const selectedFiles = Array.from(selectedFileStorage.values()); - - window.showLoading?.(); - - // Prepare event data - const eventData = { - event_name: formData.get("editEventName"), - event_code: formData.get("editEventCode"), - event_description: formData.get("editEventDescription"), - location: formData.get("editEventLocation"), - points_to_reward: Number(formData.get("editEventPoints")), - start_date: new Date( - formData.get("editEventStartDate") as string - ).toISOString(), - end_date: new Date( - formData.get("editEventEndDate") as string - ).toISOString(), - published: formData.get("editEventPublished") === "on", - has_food: formData.get("editEventHasFood") === "on", - }; - - const pb = auth.getPocketBase(); - - try { - if (eventId) { - // Update existing event - submitButton.innerHTML = ``; - - // Get current event data - const currentEvent = await pb - .collection("events") - .getOne(eventId); - const currentFiles = currentEvent.files || []; - - // Filter out files marked for deletion - const remainingFiles = currentFiles.filter( - (filename: string) => !filesToDelete.has(filename) - ); - - // Create a single FormData instance for the entire update - const updateFormData = new FormData(); - - // Add all event data fields - Object.entries(eventData).forEach(([key, value]) => { - updateFormData.append(key, String(value)); - }); - - // Handle files - // First, fetch all remaining files as blobs - const filePromises = remainingFiles.map( - async (filename: string) => { - try { - const response = await fetch( - fileManager.getFileUrl( - "events", - eventId, - filename - ) - ); - const blob = await response.blob(); - return new File([blob], filename, { - type: blob.type, - }); - } catch (error) { - console.error( - `Failed to fetch file ${filename}:`, - error - ); - return null; - } - } - ); - - try { - const existingFiles = ( - await Promise.all(filePromises) - ).filter((file): file is File => file !== null); - - // Add all files (both existing and new) to FormData - [...existingFiles, ...selectedFiles].forEach( - (file: File) => { - updateFormData.append("files", file); - } - ); - - // Perform single update operation - const updatedEvent = await pb - .collection("events") - .update(eventId, updateFormData); - - // Log the update - await sendLog.send( - "update", - "event", - `Updated event: ${eventData.event_name}` - ); - - // Log file deletions - for (const filename of filesToDelete) { - await sendLog.send( - "delete", - "event_file", - `Deleted file ${filename} from event ${eventData.event_name}` - ); - } - } catch (error) { - console.error("Failed to process files:", error); - throw error; - } - } else { - // Create new event with files in a single operation - const createFormData = new FormData(); - - // Add all event data fields - Object.entries(eventData).forEach(([key, value]) => { - createFormData.append(key, String(value)); - }); - - // Initialize attendees as empty array - createFormData.append("attendees", JSON.stringify([])); - - // Add new files - selectedFiles.forEach((file: File) => { - createFormData.append("files", file); - }); - - // Create event with files in a single operation - const newEvent = await pb - .collection("events") - .create(createFormData); - - await sendLog.send( - "create", - "event", - `Created event: ${eventData.event_name}` - ); - } - - // Show success state briefly - submitButton.classList.remove("btn-disabled"); - submitButton.classList.add("btn-success"); - submitButton.innerHTML = ` - - - - `; - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Close modal and refresh events list - const modal = document.getElementById( - "editEventModal" - ) as HTMLDialogElement; - modal?.close(); - - // Force cache refresh and update events list - lastCacheUpdate = 0; // Reset cache timestamp to force refresh - await refreshCache(); // Refresh the cache - await fetchEvents(); // Update the UI - - // Clear form inputs and storage - const formFileInput = document.getElementById( - "editEventFiles" - ) as HTMLInputElement; - const newFiles = document.getElementById("newFiles"); - if (formFileInput) formFileInput.value = ""; - if (newFiles) newFiles.innerHTML = ""; - - // Clear storages after successful save - selectedFileStorage.clear(); - filesToDelete.clear(); - } catch (error) { - throw error; - } - } catch (error) { - console.error("Failed to save event:", error); - submitButton.classList.remove("btn-disabled"); - submitButton.classList.add("btn-error"); - submitButton.innerHTML = ` - - - - `; - await new Promise((resolve) => setTimeout(resolve, 2000)); - alert("Failed to save event. Please try again."); - } finally { - submitButton.disabled = false; - cancelButton.disabled = false; - submitButton.classList.remove( - "btn-disabled", - "btn-success", - "btn-error" - ); - submitButton.innerHTML = originalText; - window.hideLoading?.(); - } - }); - // Clear both storages when modal is closed document.getElementById("editEventModal")?.addEventListener("close", () => { selectedFileStorage.clear(); diff --git a/src/components/dashboard/Officer_EventManagement/Attendees.tsx b/src/components/dashboard/Officer_EventManagement/Attendees.tsx index 23b519d..0ab5d6c 100644 --- a/src/components/dashboard/Officer_EventManagement/Attendees.tsx +++ b/src/components/dashboard/Officer_EventManagement/Attendees.tsx @@ -1,6 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { Get } from '../../../scripts/pocketbase/Get'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import { SendLog } from '../../../scripts/pocketbase/SendLog'; + +// Cache for storing user data +const userCache = new Map(); + +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const ITEMS_PER_PAGE = 50; // Add HighlightText component const HighlightText = ({ text, searchTerms }: { text: string | number | null | undefined, searchTerms: string[] }) => { @@ -58,6 +68,28 @@ interface Event { attendees: AttendeeEntry[]; } +// Add new interface for selected fields +interface EventFields { + id: true; + event_name: true; + attendees: true; +} + +interface UserFields { + id: true; + name: true; + email: true; + pid: true; + member_id: true; + member_type: true; + graduation_year: true; + major: true; +} + +// Constants for field selection +const EVENT_FIELDS: (keyof EventFields)[] = ['id', 'event_name', 'attendees']; +const USER_FIELDS: (keyof UserFields)[] = ['id', 'name', 'email', 'pid', 'member_id', 'member_type', 'graduation_year', 'major']; + export default function Attendees() { const [eventId, setEventId] = useState(''); const [eventName, setEventName] = useState(''); @@ -66,41 +98,24 @@ export default function Attendees() { const [error, setError] = useState(null); const [attendeesList, setAttendeesList] = useState([]); const [searchTerm, setSearchTerm] = useState(''); - const [filteredAttendees, setFilteredAttendees] = useState([]); + const [currentPage, setCurrentPage] = useState(1); const [processedSearchTerms, setProcessedSearchTerms] = useState([]); const get = Get.getInstance(); const auth = Authentication.getInstance(); - // Listen for the custom event - useEffect(() => { - const handleUpdateAttendees = (e: CustomEvent<{ eventId: string; eventName: string }>) => { - console.log('Received updateAttendees event:', e.detail); - setEventId(e.detail.eventId); - setEventName(e.detail.eventName); - }; - - // Add event listener - window.addEventListener('updateAttendees', handleUpdateAttendees as EventListener); - - // Cleanup - return () => { - window.removeEventListener('updateAttendees', handleUpdateAttendees as EventListener); - }; - }, []); - - // Filter attendees when search term or attendees list changes - useEffect(() => { - if (!searchTerm.trim()) { - setFilteredAttendees(attendeesList); - setProcessedSearchTerms([]); - return; - } - + // Memoize search terms processing + const updateProcessedSearchTerms = useCallback((searchTerm: string) => { const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean); setProcessedSearchTerms(terms); + setCurrentPage(1); // Reset to first page on new search + }, []); - const filtered = attendeesList.filter(attendee => { + // Memoize filtered attendees + const filteredAttendees = useMemo(() => { + if (!searchTerm.trim()) return attendeesList; + + return attendeesList.filter(attendee => { const user = users.get(attendee.user_id); if (!user) return false; @@ -116,13 +131,172 @@ export default function Attendees() { new Date(attendee.time_checked_in).toLocaleString(), ].map(value => (value || '').toString().toLowerCase()); - return terms.every(term => + return processedSearchTerms.every(term => searchableValues.some(value => value.includes(term)) ); }); + }, [attendeesList, users, processedSearchTerms]); - setFilteredAttendees(filtered); - }, [searchTerm, attendeesList, users]); + // Memoize paginated attendees + const paginatedAttendees = useMemo(() => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + return filteredAttendees.slice(startIndex, startIndex + ITEMS_PER_PAGE); + }, [filteredAttendees, currentPage]); + + // Memoize pagination info + const paginationInfo = useMemo(() => { + const totalPages = Math.ceil(filteredAttendees.length / ITEMS_PER_PAGE); + return { + totalPages, + hasNextPage: currentPage < totalPages, + hasPrevPage: currentPage > 1 + }; + }, [filteredAttendees.length, currentPage]); + + // Optimized user data fetching with cache + const fetchUserData = useCallback(async (userIds: string[]) => { + const now = Date.now(); + const uncachedIds: string[] = []; + const cachedUsers = new Map(); + + // Check cache first + userIds.forEach(id => { + const cached = userCache.get(id); + if (cached && now - cached.timestamp < CACHE_DURATION) { + cachedUsers.set(id, cached.data); + } else { + uncachedIds.push(id); + } + }); + + // If we have all users in cache, return early + if (uncachedIds.length === 0) { + return cachedUsers; + } + + // Fetch uncached users + try { + const users = await get.getMany('users', uncachedIds, { + fields: USER_FIELDS, + disableAutoCancellation: false + }); + + // Update cache and merge with cached users + users.forEach(user => { + if (user) { + userCache.set(user.id, { data: user, timestamp: now }); + cachedUsers.set(user.id, user); + } + }); + } catch (error) { + console.error('Failed to fetch uncached users:', error); + } + + return cachedUsers; + }, [get]); + + // Listen for the custom event + useEffect(() => { + const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => { + setEventId(e.detail.eventId); + setEventName(e.detail.eventName); + setCurrentPage(1); // Reset pagination on new event + + // Log the attendees view action + try { + const sendLog = SendLog.getInstance(); + await sendLog.send( + "view", + "event_attendees", + `Viewed attendees for event: ${e.detail.eventName} (${e.detail.eventId})` + ); + } catch (error) { + console.error('Failed to log attendees view:', error); + } + }; + + window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener); + return () => { + window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener); + }; + }, []); + + // Update search terms when search input changes + useEffect(() => { + updateProcessedSearchTerms(searchTerm); + }, [searchTerm, updateProcessedSearchTerms]); + + // Fetch event data when eventId changes + useEffect(() => { + let isMounted = true; + const fetchEventData = async () => { + if (!eventId || !auth.isAuthenticated()) { + if (!eventId) console.log('No eventId provided'); + if (!auth.isAuthenticated()) { + console.log('User not authenticated'); + setError('Authentication required'); + } + return; + } + + try { + setLoading(true); + setError(null); + + const event = await get.getOne('events', eventId, { + fields: EVENT_FIELDS, + disableAutoCancellation: false + }); + + if (!isMounted) return; + + if (!event.attendees?.length) { + setAttendeesList([]); + setUsers(new Map()); + return; + } + + setAttendeesList(event.attendees); + + // Fetch user details with cache + const userIds = [...new Set(event.attendees.map(a => a.user_id))]; + const userMap = await fetchUserData(userIds); + + if (isMounted) { + setUsers(userMap); + } + } catch (error) { + if (isMounted) { + console.error('Failed to fetch event data:', error); + setError('Failed to load event data'); + setAttendeesList([]); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchEventData(); + return () => { isMounted = false; }; + }, [eventId, auth, get, fetchUserData]); + + // Reset state when modal is closed + useEffect(() => { + const handleModalClose = () => { + setEventId(''); + setAttendeesList([]); + setUsers(new Map()); + setError(null); + }; + + const modal = document.getElementById('attendeesModal'); + if (modal) { + modal.addEventListener('close', handleModalClose); + return () => modal.removeEventListener('close', handleModalClose); + } + }, []); // Function to download attendees as CSV const downloadAttendeesCSV = () => { @@ -175,94 +349,6 @@ export default function Attendees() { document.body.removeChild(link); }; - // Fetch event data when eventId changes - useEffect(() => { - const fetchEventData = async () => { - if (!eventId) { - console.log('No eventId provided'); - return; - } - - if (!auth.isAuthenticated()) { - console.log('User not authenticated'); - setError('Authentication required'); - return; - } - - try { - setLoading(true); - setError(null); - - console.log('Fetching event data for:', eventId); - const event = await get.getOne('events', eventId); - - if (!event.attendees || !Array.isArray(event.attendees)) { - console.log('No attendees found or invalid format'); - setAttendeesList([]); - return; - } - - console.log('Found attendees:', { - count: event.attendees.length, - sample: event.attendees.slice(0, 2) - }); - setAttendeesList(event.attendees); - - // Fetch user details - const userIds = [...new Set(event.attendees.map(a => a.user_id))]; - console.log('Fetching details for users:', userIds); - - const userPromises = userIds.map(async (userId) => { - try { - return await get.getOne('users', userId); - } catch (error) { - console.error(`Failed to fetch user ${userId}:`, error); - return null; - } - }); - - const userResults = await Promise.all(userPromises); - const userMap = new Map(); - - userResults.forEach(user => { - if (user) { - userMap.set(user.id, user); - } - }); - - console.log('Fetched user details:', { - totalUsers: userMap.size, - userIds: Array.from(userMap.keys()) - }); - setUsers(userMap); - } catch (error) { - console.error('Failed to fetch event data:', error); - setError('Failed to load event data'); - setAttendeesList([]); - } finally { - setLoading(false); - } - }; - - fetchEventData(); - }, [eventId]); // Re-run when eventId changes - - // Reset state when modal is closed - useEffect(() => { - const handleModalClose = () => { - setEventId(''); - setAttendeesList([]); - setUsers(new Map()); - setError(null); - }; - - const modal = document.getElementById('attendeesModal'); - if (modal) { - modal.addEventListener('close', handleModalClose); - return () => modal.removeEventListener('close', handleModalClose); - } - }, []); - if (loading) { return (
@@ -342,7 +428,7 @@ export default function Attendees() {
- {/* Updated table with highlighting */} + {/* Table with pagination */}
@@ -359,7 +445,7 @@ export default function Attendees() { - {filteredAttendees.map((attendee, index) => { + {paginatedAttendees.map((attendee, index) => { const user = users.get(attendee.user_id); const checkInTime = new Date(attendee.time_checked_in).toLocaleString(); @@ -380,6 +466,45 @@ export default function Attendees() {
+ + {/* Pagination Controls */} + {paginationInfo.totalPages > 1 && ( +
+
+ + + + + +
+
+ )} ); } diff --git a/src/components/dashboard/Officer_EventManagement/EventEditor.tsx b/src/components/dashboard/Officer_EventManagement/EventEditor.tsx index 005ba5f..e9a63f6 100644 --- a/src/components/dashboard/Officer_EventManagement/EventEditor.tsx +++ b/src/components/dashboard/Officer_EventManagement/EventEditor.tsx @@ -11,6 +11,8 @@ declare global { interface Window { showLoading?: () => void; hideLoading?: () => void; + lastCacheUpdate?: number; + fetchEvents?: () => void; } } @@ -45,7 +47,7 @@ const MemoizedFilePreview = memo(FilePreview); // Define EventForm props interface interface EventFormProps { event: Event | null; - setEvent: React.Dispatch>; + setEvent: (field: keyof Event, value: any) => void; selectedFiles: Map; setSelectedFiles: React.Dispatch>>; filesToDelete: Set; @@ -71,6 +73,10 @@ const EventForm = memo(({ onSubmit, onCancel }: EventFormProps): React.ReactElement => { + const handleChange = (field: keyof Event, value: any) => { + setEvent(field, value); + }; + return (

@@ -79,12 +85,7 @@ const EventForm = memo(({
{ - e.preventDefault(); - if (!isSubmitting) { - onSubmit(e); - } - }} + onSubmit={onSubmit} >
@@ -99,7 +100,7 @@ const EventForm = memo(({ name="editEventName" className="input input-bordered" value={event?.event_name || ""} - onChange={(e) => setEvent(prev => prev ? { ...prev, event_name: e.target.value } : null)} + onChange={(e) => handleChange('event_name', e.target.value)} required />
@@ -115,7 +116,7 @@ const EventForm = memo(({ name="editEventCode" className="input input-bordered" value={event?.event_code || ""} - onChange={(e) => setEvent(prev => prev ? { ...prev, event_code: e.target.value } : null)} + onChange={(e) => handleChange('event_code', e.target.value)} required />

@@ -131,7 +132,7 @@ const EventForm = memo(({ name="editEventLocation" className="input input-bordered" value={event?.location || ""} - onChange={(e) => setEvent(prev => prev ? { ...prev, location: e.target.value } : null)} + onChange={(e) => handleChange('location', e.target.value)} required /> @@ -147,7 +148,7 @@ const EventForm = memo(({ name="editEventPoints" className="input input-bordered" value={event?.points_to_reward || 0} - onChange={(e) => setEvent(prev => prev ? { ...prev, points_to_reward: Number(e.target.value) } : null)} + onChange={(e) => handleChange('points_to_reward', Number(e.target.value))} min="0" required /> @@ -164,7 +165,7 @@ const EventForm = memo(({ name="editEventStartDate" className="input input-bordered" value={event?.start_date ? new Date(event.start_date).toISOString().slice(0, 16) : ""} - onChange={(e) => setEvent(prev => prev ? { ...prev, start_date: e.target.value } : null)} + onChange={(e) => handleChange('start_date', e.target.value)} required /> @@ -180,7 +181,7 @@ const EventForm = memo(({ name="editEventEndDate" className="input input-bordered" value={event?.end_date ? new Date(event.end_date).toISOString().slice(0, 16) : ""} - onChange={(e) => setEvent(prev => prev ? { ...prev, end_date: e.target.value } : null)} + onChange={(e) => handleChange('end_date', e.target.value)} required /> @@ -196,7 +197,7 @@ const EventForm = memo(({ name="editEventDescription" className="textarea textarea-bordered" value={event?.event_description || ""} - onChange={(e) => setEvent(prev => prev ? { ...prev, event_description: e.target.value } : null)} + onChange={(e) => handleChange('event_description', e.target.value)} rows={3} required > @@ -316,7 +317,7 @@ const EventForm = memo(({ name="editEventPublished" className="toggle" checked={event?.published || false} - onChange={(e) => setEvent(prev => prev ? { ...prev, published: e.target.checked } : null)} + onChange={(e) => handleChange('published', e.target.checked)} /> Publish Event @@ -336,7 +337,7 @@ const EventForm = memo(({ name="editEventHasFood" className="toggle" checked={event?.has_food || false} - onChange={(e) => setEvent(prev => prev ? { ...prev, has_food: e.target.checked } : null)} + onChange={(e) => handleChange('has_food', e.target.checked)} /> Has Food @@ -523,30 +524,29 @@ const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => v // Modify EventEditor component export default function EventEditor({ onEventSaved }: EventEditorProps) { // State for form data and UI - const [event, setEvent] = useState(null); + const [event, setEvent] = useState({ + id: '', + event_name: '', + event_description: '', + event_code: '', + location: '', + files: [], + points_to_reward: 0, + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + published: false, + has_food: false, + attendees: [] + }); + 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); - - // Add new state and utilities - const changeTracker = useMemo(() => new ChangeTracker(), []); - const uploadQueue = useMemo(() => new UploadQueue(), []); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - // Add loading state - const [loadingState, setLoadingState] = useState({ - isLoading: false, - error: null, - timeoutId: null - }); - - // Add constants for timeouts - const FETCH_TIMEOUT = 15000; // 15 seconds - const TRANSITION_DURATION = 300; // 300ms for smooth transitions - // Memoize service instances const services = useMemo(() => ({ get: Get.getInstance(), @@ -556,67 +556,110 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { sendLog: SendLog.getInstance() }), []); - // Memoize handlers - const handleFileSelect = useCallback((e: React.ChangeEvent) => { - if (e.target.files) { - setSelectedFiles(prev => { - const newFiles = new Map(prev); - Array.from(e.target.files!).forEach(file => { - newFiles.set(file.name, file); - }); - return newFiles; - }); - } - }, []); - - const handleFileDelete = useCallback((filename: string) => { - if (confirm("Are you sure you want to remove this file?")) { - setFilesToDelete(prev => new Set([...prev, filename])); - } - }, []); - - const handleUndoFileDelete = useCallback((filename: string) => { - setFilesToDelete(prev => { - const newSet = new Set(prev); - newSet.delete(filename); - return newSet; + // 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) { + const eventData = await services.get.getOne("events", eventId); + setEvent(eventData); + } else { + setEvent({ + id: '', + event_name: '', + event_description: '', + event_code: '', + location: '', + files: [], + points_to_reward: 0, + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + published: false, + has_food: false, + attendees: [] + }); + } + setSelectedFiles(new Map()); + setFilesToDelete(new Set()); + setShowPreview(false); + setHasUnsavedChanges(false); + } catch (error) { + console.error("Failed to initialize event data:", error); + alert("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) { + await initializeEventData(event.id); + } else { + await initializeEventData(''); + } + modal.showModal(); + } catch (error) { + console.error("Failed to open edit modal:", error); + alert("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); }, []); - // Add modal close handling - const handleModalClose = useCallback(async (e?: MouseEvent | React.MouseEvent) => { - if (e) e.preventDefault(); - + const handleModalClose = useCallback(() => { if (hasUnsavedChanges) { const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?'); if (!confirmed) return; } - // Reset all state - setEvent(null); + setEvent({ + id: '', + event_name: '', + event_description: '', + event_code: '', + location: '', + files: [], + points_to_reward: 0, + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + published: false, + has_food: false, + attendees: [] + }); setSelectedFiles(new Map()); setFilesToDelete(new Set()); setShowPreview(false); setHasUnsavedChanges(false); - changeTracker.initialize(null); - // Close the modal const modal = document.getElementById("editEventModal") as HTMLDialogElement; if (modal) modal.close(); - }, [hasUnsavedChanges, changeTracker]); + }, [hasUnsavedChanges]); - // Update the EventForm cancel button handler - const handleCancel = useCallback(() => { - handleModalClose(); - }, [handleModalClose]); - - // Modify form submission to use the new close handler const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); if (isSubmitting) return; @@ -625,11 +668,6 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; const cancelButton = form.querySelector('button[type="button"]') as HTMLButtonElement; - if (!changeTracker.hasChanges()) { - handleModalClose(); - return; - } - setIsSubmitting(true); if (submitButton) submitButton.disabled = true; if (cancelButton) cancelButton.disabled = true; @@ -638,88 +676,89 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { window.showLoading?.(); const pb = services.auth.getPocketBase(); - if (event?.id) { - // Handle existing event update - const changes = changeTracker.getChanges(); - const fileChanges = changeTracker.getFileChanges(); + console.log('Form submission started'); + console.log('Event data:', event); - // Process files in parallel - const fileProcessingTasks: Promise[] = []; + const formData = new FormData(form); + const eventData = { + event_name: formData.get("editEventName"), + event_code: formData.get("editEventCode"), + event_description: formData.get("editEventDescription"), + location: formData.get("editEventLocation"), + points_to_reward: Number(formData.get("editEventPoints")), + start_date: new Date(formData.get("editEventStartDate") as string).toISOString(), + end_date: new Date(formData.get("editEventEndDate") as string).toISOString(), + published: formData.get("editEventPublished") === "on", + has_food: formData.get("editEventHasFood") === "on", + attendees: event.attendees || [] + }; - // Handle file deletions - if (fileChanges.deleted.size > 0) { - const deletePromises = Array.from(fileChanges.deleted).map(filename => - uploadQueue.add(async () => { - await services.sendLog.send( - "delete", - "event_file", - `Deleted file ${filename} from event ${event.event_name}` - ); - }) - ); - fileProcessingTasks.push(...deletePromises); + if (event.id) { + // Update existing event + console.log('Updating event:', event.id); + await services.update.updateFields("events", event.id, eventData); + + // Handle file deletions first + if (filesToDelete.size > 0) { + console.log('Deleting files:', Array.from(filesToDelete)); + // Get current files + const currentRecord = await pb.collection("events").getOne(event.id); + let remainingFiles = [...currentRecord.files]; + + // Remove files marked for deletion + for (const filename of filesToDelete) { + const fileIndex = remainingFiles.indexOf(filename); + if (fileIndex > -1) { + remainingFiles.splice(fileIndex, 1); + } + } + + // Update record with remaining files + await pb.collection("events").update(event.id, { + files: remainingFiles + }); } // Handle file additions - if (fileChanges.added.size > 0) { - const uploadTasks = Array.from(fileChanges.added.values()).map(file => - uploadQueue.add(async () => { - const formData = new FormData(); - formData.append("files", file); - await pb.collection("events").update(event.id, formData); - }) - ); - fileProcessingTasks.push(...uploadTasks); - } + if (selectedFiles.size > 0) { + try { + // Convert Map to array of Files + const filesToUpload = Array.from(selectedFiles.values()); + console.log('Uploading files:', filesToUpload.map(f => f.name)); - // Update event data if there are changes - if (Object.keys(changes).length > 0) { - await services.update.updateFields("events", event.id, changes); - await services.sendLog.send( - "update", - "event", - `Updated event: ${changes.event_name || event.event_name}` - ); + // Use appendFiles to preserve existing files + await services.fileManager.appendFiles("events", event.id, "files", filesToUpload); + } catch (error: any) { + if (error.status === 413) { + throw new Error("Files are too large. Please try uploading smaller files or fewer files at once."); + } + throw error; + } } - - // Wait for all file operations to complete - await Promise.all(fileProcessingTasks); } else { - // Handle new event creation - const formData = new FormData(form); - const eventData = { - event_name: formData.get("editEventName"), - event_code: formData.get("editEventCode"), - event_description: formData.get("editEventDescription"), - location: formData.get("editEventLocation"), - points_to_reward: Number(formData.get("editEventPoints")), - start_date: new Date(formData.get("editEventStartDate") as string).toISOString(), - end_date: new Date(formData.get("editEventEndDate") as string).toISOString(), - published: formData.get("editEventPublished") === "on", - has_food: formData.get("editEventHasFood") === "on", - attendees: [] - }; + // Create new event + console.log('Creating new event'); + const newEvent = await pb.collection("events").create(eventData); + console.log('New event created:', newEvent); - // Create event and upload files in parallel - const [newEvent] = await Promise.all([ - pb.collection("events").create(eventData), - ...Array.from(selectedFiles.values()).map(file => - uploadQueue.add(async () => { - const fileFormData = new FormData(); - fileFormData.append("files", file); - await pb.collection("events").update(newEvent.id, fileFormData); - }) - ) - ]); + // Upload files if any + if (selectedFiles.size > 0) { + try { + const filesToUpload = Array.from(selectedFiles.values()); + console.log('Uploading files:', filesToUpload.map(f => f.name)); - await services.sendLog.send( - "create", - "event", - `Created event: ${eventData.event_name}` - ); + // Use uploadFiles for new event + await services.fileManager.uploadFiles("events", newEvent.id, "files", filesToUpload); + } catch (error: any) { + if (error.status === 413) { + throw new Error("Files are too large. Please try uploading smaller files or fewer files at once."); + } + throw error; + } + } } - // Show success state + // Show success message if (submitButton) { submitButton.classList.remove("btn-disabled"); submitButton.classList.add("btn-success"); @@ -728,13 +767,44 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { `; - await new Promise(resolve => setTimeout(resolve, 1000)); } - form.reset(); + // Reset all state + setHasUnsavedChanges(false); + setSelectedFiles(new Map()); + setFilesToDelete(new Set()); + setEvent({ + id: '', + event_name: '', + event_description: '', + event_code: '', + location: '', + files: [], + points_to_reward: 0, + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + published: false, + has_food: false, + attendees: [] + }); + + // Reset cache timestamp to force refresh + if (window.lastCacheUpdate) { + window.lastCacheUpdate = 0; + } + + // Trigger the callback onEventSaved?.(); - handleModalClose(); - } catch (error) { + + // Close modal directly instead of using handleModalClose + const modal = document.getElementById("editEventModal") as HTMLDialogElement; + if (modal) modal.close(); + + // Force refresh of events list + if (typeof window.fetchEvents === 'function') { + window.fetchEvents(); + } + } catch (error: any) { console.error("Failed to save event:", error); if (submitButton) { submitButton.classList.remove("btn-disabled"); @@ -744,9 +814,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { `; - await new Promise(resolve => setTimeout(resolve, 2000)); } - alert("Failed to save event. Please try again."); + alert(error.message || "Failed to save event. Please try again."); } finally { setIsSubmitting(false); if (submitButton) { @@ -757,150 +826,22 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { if (cancelButton) cancelButton.disabled = false; window.hideLoading?.(); } - }, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, changeTracker, uploadQueue, handleModalClose]); - - // Update change tracking when event data changes - useEffect(() => { - changeTracker.initialize(event); - setHasUnsavedChanges(false); - }, [event, changeTracker]); - - // Add change detection to form inputs - const handleFieldChange = useCallback((field: keyof Event, value: any) => { - changeTracker.trackChange(field, value); - setHasUnsavedChanges(true); - setEvent(prev => prev ? { ...prev, [field]: value } : null); - }, [changeTracker]); - - // Add change detection to file operations - const handleFileChange = useCallback((files: Map, deletedFiles: Set) => { - changeTracker.trackFileChange(files, deletedFiles); - setHasUnsavedChanges(true); - setSelectedFiles(files); - setFilesToDelete(deletedFiles); - }, [changeTracker]); - - // Add unsaved changes warning - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasUnsavedChanges) { - e.preventDefault(); - e.returnValue = ''; - } - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [hasUnsavedChanges]); - - // Method to initialize form with event data - const initializeEventData = useCallback(async (eventId: string) => { - // Clear any existing timeouts - if (loadingState.timeoutId) { - clearTimeout(loadingState.timeoutId); - } - - // Set loading state - setLoadingState(prev => ({ - ...prev, - isLoading: true, - error: null - })); - - // Set timeout for fetch request - const timeoutId = setTimeout(() => { - setLoadingState(prev => ({ - ...prev, - isLoading: false, - error: "Request timed out. Please try again." - })); - }, FETCH_TIMEOUT); - - try { - const eventData = await services.get.getOne("events", eventId); - - // Add a small delay for smooth transition - await new Promise(resolve => setTimeout(resolve, TRANSITION_DURATION)); - - setEvent(eventData); - setSelectedFiles(new Map()); - setFilesToDelete(new Set()); - setShowPreview(false); - setLoadingState(prev => ({ - ...prev, - isLoading: false, - error: null - })); - } catch (error) { - console.error("Failed to fetch event data:", error); - setLoadingState(prev => ({ - ...prev, - isLoading: false, - error: "Failed to load event data. Please try again." - })); - } finally { - clearTimeout(timeoutId); - } - }, [services.get, loadingState.timeoutId]); - - // Expose initializeEventData to window with loading states - useEffect(() => { - (window as any).openEditModal = async (event?: Event) => { - const modal = document.getElementById("editEventModal") as HTMLDialogElement; - if (!modal) return; - - // Reset states - setLoadingState({ - isLoading: false, - error: null, - timeoutId: null - }); - - if (event?.id) { - modal.showModal(); - await initializeEventData(event.id); - } else { - setEvent(null); - setSelectedFiles(new Map()); - setFilesToDelete(new Set()); - setShowPreview(false); - modal.showModal(); - } - }; - - return () => { - delete (window as any).openEditModal; - }; - }, [initializeEventData]); - - // Add cleanup for timeouts - useEffect(() => { - return () => { - if (loadingState.timeoutId) { - clearTimeout(loadingState.timeoutId); - } - }; - }, [loadingState.timeoutId]); + }, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]); return ( {showPreview ? (
-
- -

- {previewFilename} -

-
+
- ) : (
- {loadingState.isLoading ? ( - - ) : loadingState.error ? ( - event?.id && initializeEventData(event.id)} - /> - ) : ( -
- -
- )} +
)} -
handleModalClose(e)} - > - -
); } diff --git a/src/components/dashboard/Officer_EventManagement/FilePreview.tsx b/src/components/dashboard/Officer_EventManagement/FilePreview.tsx index a9f242a..ad75499 100644 --- a/src/components/dashboard/Officer_EventManagement/FilePreview.tsx +++ b/src/components/dashboard/Officer_EventManagement/FilePreview.tsx @@ -1,4 +1,8 @@ +'use client'; + import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import hljs from 'highlight.js'; +import 'highlight.js/styles/github-dark.css'; // Cache for file content const contentCache = new Map(); @@ -10,7 +14,7 @@ interface FilePreviewProps { isModal?: boolean; } -const FilePreview: React.FC = ({ url: initialUrl = '', filename: initialFilename = '', isModal = false }) => { +export default function FilePreview({ url: initialUrl = '', filename: initialFilename = '', isModal = false }: FilePreviewProps) { const [url, setUrl] = useState(initialUrl); const [filename, setFilename] = useState(initialFilename); const [content, setContent] = useState(null); @@ -18,6 +22,8 @@ const FilePreview: React.FC = ({ url: initialUrl = '', filenam const [loading, setLoading] = useState(false); const [fileType, setFileType] = useState(null); const [isVisible, setIsVisible] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const INITIAL_LINES_TO_SHOW = 20; // Memoize the truncated filename const truncatedFilename = useMemo(() => { @@ -39,7 +45,8 @@ const FilePreview: React.FC = ({ url: initialUrl = '', filenam { threshold: 0.1 } ); - const previewElement = document.querySelector('.preview-content'); + // Target the entire component instead of just preview-content + const previewElement = document.querySelector('.file-preview-container'); if (previewElement) { observer.observe(previewElement); } @@ -107,10 +114,10 @@ const FilePreview: React.FC = ({ url: initialUrl = '', filenam }, [url, filename]); useEffect(() => { - if (isVisible) { + if (isVisible || !isModal) { // Load content immediately if not in modal loadContent(); } - }, [isVisible, loadContent]); + }, [isVisible, loadContent, isModal]); useEffect(() => { console.log('FilePreview component mounted'); @@ -158,58 +165,138 @@ const FilePreview: React.FC = ({ url: initialUrl = '', filenam } }; - return ( -
-
-
- {truncatedFilename} - {fileType && ( - {fileType.split('/')[1]} - )} -
- + const getLanguageFromFilename = (filename: string): string => { + const extension = filename.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'js': + case 'jsx': + return 'javascript'; + case 'ts': + case 'tsx': + return 'typescript'; + case 'py': + return 'python'; + case 'html': + return 'html'; + case 'css': + return 'css'; + case 'json': + return 'json'; + case 'md': + return 'markdown'; + case 'yml': + case 'yaml': + return 'yaml'; + case 'csv': + return 'csv'; + default: + return extension || 'plaintext'; + } + }; + + const parseCSV = useCallback((csvContent: string) => { + const lines = csvContent.split('\n').map(line => + line.split(',').map(cell => + cell.trim().replace(/^["'](.*)["']$/, '$1') + ) + ); + const headers = lines[0]; + const rows = lines.slice(1).filter(row => row.some(cell => cell.length > 0)); // Skip empty rows + return { headers, rows }; + }, []); + + const renderCSVTable = useCallback((csvContent: string) => { + const { headers, rows } = parseCSV(csvContent); + + return ` +
+ + + + ${headers.map(header => ``).join('')} + + + + ${rows.map(row => ` + + ${row.map(cell => ``).join('')} + + `).join('')} + +
${header}
${cell}
+ `; + }, []); -
- {loading && ( -
- -
- )} + const highlightCode = useCallback((code: string, language: string) => { + // Skip highlighting for CSV + if (language === 'csv') { + return code; + } - {error && ( -
-
- - - -
-
-

Preview Unavailable

-

{error}

+ try { + return hljs.highlight(code, { language }).value; + } catch (error) { + console.warn(`Failed to highlight code for language ${language}:`, error); + return code; + } + }, []); + + const formatCodeWithLineNumbers = useCallback((code: string, language: string) => { + // Special handling for CSV files + if (language === 'csv') { + return renderCSVTable(code); + } + + const highlighted = highlightCode(code, language); + const lines = code.split('\n'); + const totalLines = lines.length; + const linesToShow = isExpanded ? totalLines : Math.min(INITIAL_LINES_TO_SHOW, totalLines); + + let formattedCode = lines + .slice(0, linesToShow) + .map((line, index) => { + const lineNumber = index + 1; + const highlightedLine = highlightCode(line, language); + return `
+
${lineNumber}
+
${highlightedLine || ' '}
+
`; + }) + .join(''); + + if (!isExpanded && totalLines > INITIAL_LINES_TO_SHOW) { + formattedCode += `
+
+
... ${totalLines - INITIAL_LINES_TO_SHOW} more lines
+
`; + } + + return formattedCode; + }, [highlightCode, isExpanded, renderCSVTable]); + + return ( +
+ {!loading && !error && content === 'image' && ( +
+
+
+ {truncatedFilename} + {fileType && ( + {fileType.split('/')[1]} + )}
- )} - - {!loading && !error && content === 'image' && ( -
+
{filename} = ({ url: initialUrl = '', filenam loading="lazy" />
- )} +
+ )} - {!loading && !error && content === 'video' && ( -
+ {!loading && !error && content === 'video' && ( +
+
+
+ {truncatedFilename} + {fileType && ( + {fileType.split('/')[1]} + )} +
+ +
+
- )} +
+ )} - {!loading && !error && content === 'pdf' && ( -
+ {!loading && !error && content === 'pdf' && ( +
+
+
+ {truncatedFilename} + {fileType && ( + {fileType.split('/')[1]} + )} +
+ +
+
- )} +
+ )} - {!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && ( -
-
{content}
+ {!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && ( +
+
+
+ {truncatedFilename} + {fileType && ( + {fileType.split('/')[1]} + )} +
+
+ {content.split('\n').length > INITIAL_LINES_TO_SHOW && ( + + )} + +
- )} -
+
+
+
+
+
+
+ )} + + {loading && ( +
+ +
+ )} + + {error && ( +
+
+ + + +
+
+

Preview Unavailable

+

{error}

+
+ +
+ )}
); -}; - -export default FilePreview; \ No newline at end of file +} \ No newline at end of file diff --git a/src/scripts/pocketbase/FileManager.ts b/src/scripts/pocketbase/FileManager.ts index 2602b67..9ee4a4e 100644 --- a/src/scripts/pocketbase/FileManager.ts +++ b/src/scripts/pocketbase/FileManager.ts @@ -53,7 +53,7 @@ export class FileManager { } /** - * Upload multiple files to a record + * Upload multiple files to a record with chunked upload support * @param collectionName The name of the collection * @param recordId The ID of the record to attach the files to * @param field The field name for the files @@ -73,14 +73,73 @@ export class FileManager { try { this.auth.setUpdating(true); const pb = this.auth.getPocketBase(); - const formData = new FormData(); - files.forEach(file => { - formData.append(field, file); - }); + const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit + const MAX_BATCH_SIZE = 25 * 1024 * 1024; // 25MB per batch + + // Validate file sizes first + for (const file of files) { + if (file.size > MAX_FILE_SIZE) { + throw new Error(`File ${file.name} is too large. Maximum size is 50MB.`); + } + } - const result = await pb.collection(collectionName).update(recordId, formData); - return result; + // Get existing record if updating + let existingFiles: string[] = []; + if (recordId) { + try { + const record = await pb.collection(collectionName).getOne(recordId); + existingFiles = (record as any)[field] || []; + } catch (error) { + console.warn('Failed to fetch existing record:', error); + } + } + + // Process files in batches + let currentBatchSize = 0; + let currentBatch: File[] = []; + let allProcessedFiles: File[] = []; + + // Process each file + for (const file of files) { + let processedFile = file; + + try { + // Try to compress image files if needed + if (file.type.startsWith('image/')) { + processedFile = await this.compressImageIfNeeded(file, 50); // 50MB max size + } + } catch (error) { + console.warn(`Failed to process file ${file.name}:`, error); + processedFile = file; // Use original file if processing fails + } + + // Check if adding this file would exceed batch size + if (currentBatchSize + processedFile.size > MAX_BATCH_SIZE) { + // Upload current batch + if (currentBatch.length > 0) { + await this.uploadBatch(collectionName, recordId, field, currentBatch); + allProcessedFiles.push(...currentBatch); + } + // Reset batch + currentBatch = [processedFile]; + currentBatchSize = processedFile.size; + } else { + // Add to current batch + currentBatch.push(processedFile); + currentBatchSize += processedFile.size; + } + } + + // Upload any remaining files + if (currentBatch.length > 0) { + await this.uploadBatch(collectionName, recordId, field, currentBatch); + allProcessedFiles.push(...currentBatch); + } + + // Get the final record state + const finalRecord = await pb.collection(collectionName).getOne(recordId); + return finalRecord; } catch (err) { console.error(`Failed to upload files to ${collectionName}:`, err); throw err; @@ -89,6 +148,34 @@ export class FileManager { } } + /** + * Upload a batch of files + * @private + */ + private async uploadBatch( + collectionName: string, + recordId: string, + field: string, + files: File[] + ): Promise { + const pb = this.auth.getPocketBase(); + const formData = new FormData(); + + // Add new files + for (const file of files) { + formData.append(field, file); + } + + try { + await pb.collection(collectionName).update(recordId, formData); + } catch (error: any) { + if (error.status === 413) { + throw new Error(`Upload failed: Batch size too large. Please try uploading smaller files.`); + } + throw error; + } + } + /** * Append multiple files to a record without overriding existing ones * @param collectionName The name of the collection @@ -160,7 +247,9 @@ export class FileManager { filename: string ): string { const pb = this.auth.getPocketBase(); - return `${pb.baseUrl}/api/files/${collectionName}/${recordId}/${filename}`; + const token = pb.authStore.token; + const url = `${pb.baseUrl}/api/files/${collectionName}/${recordId}/${filename}`; + return url; } /** @@ -267,4 +356,76 @@ export class FileManager { this.auth.setUpdating(false); } } + + /** + * Compress an image file if it's too large + * @param file The image file to compress + * @param maxSizeInMB Maximum size in MB + * @returns Promise The compressed file + */ + public async compressImageIfNeeded(file: File, maxSizeInMB: number = 50): Promise { + if (!file.type.startsWith('image/')) { + return file; + } + + const maxSizeInBytes = maxSizeInMB * 1024 * 1024; + if (file.size <= maxSizeInBytes) { + return file; + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (e) => { + const img = new Image(); + img.src = e.target?.result as string; + + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // Calculate new dimensions while maintaining aspect ratio + const maxDimension = 3840; // Higher quality for larger files + if (width > height && width > maxDimension) { + height *= maxDimension / width; + width = maxDimension; + } else if (height > maxDimension) { + width *= maxDimension / height; + height = maxDimension; + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, width, height); + + // Convert to blob with higher quality for larger files + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to compress image')); + return; + } + resolve(new File([blob], file.name, { + type: 'image/jpeg', + lastModified: Date.now(), + })); + }, + 'image/jpeg', + 0.85 // Higher quality setting for larger files + ); + }; + + img.onerror = () => { + reject(new Error('Failed to load image for compression')); + }; + }; + + reader.onerror = () => { + reject(new Error('Failed to read file for compression')); + }; + }); + } } \ No newline at end of file