fix uploading issues

This commit is contained in:
chark1es 2025-02-17 02:03:25 -08:00
parent 0ba3142792
commit 825b914f79
5 changed files with 904 additions and 739 deletions

View file

@ -2068,239 +2068,6 @@ const currentPage = eventResponse.page;
fileInput.value = ""; 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 = `<span class="loading loading-spinner"></span>`;
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 = `<span class="loading loading-spinner"></span>`;
// 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 = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
`;
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 = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
`;
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 // Clear both storages when modal is closed
document.getElementById("editEventModal")?.addEventListener("close", () => { document.getElementById("editEventModal")?.addEventListener("close", () => {
selectedFileStorage.clear(); selectedFileStorage.clear();

View file

@ -1,6 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog';
// Cache for storing user data
const userCache = new Map<string, {
data: User;
timestamp: number;
}>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const ITEMS_PER_PAGE = 50;
// Add HighlightText component // Add HighlightText component
const HighlightText = ({ text, searchTerms }: { text: string | number | null | undefined, searchTerms: string[] }) => { const HighlightText = ({ text, searchTerms }: { text: string | number | null | undefined, searchTerms: string[] }) => {
@ -58,6 +68,28 @@ interface Event {
attendees: AttendeeEntry[]; 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() { export default function Attendees() {
const [eventId, setEventId] = useState<string>(''); const [eventId, setEventId] = useState<string>('');
const [eventName, setEventName] = useState<string>(''); const [eventName, setEventName] = useState<string>('');
@ -66,41 +98,24 @@ export default function Attendees() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [attendeesList, setAttendeesList] = useState<AttendeeEntry[]>([]); const [attendeesList, setAttendeesList] = useState<AttendeeEntry[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filteredAttendees, setFilteredAttendees] = useState<AttendeeEntry[]>([]); const [currentPage, setCurrentPage] = useState(1);
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]); const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
const get = Get.getInstance(); const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
// Listen for the custom event // Memoize search terms processing
useEffect(() => { const updateProcessedSearchTerms = useCallback((searchTerm: string) => {
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;
}
const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean); const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean);
setProcessedSearchTerms(terms); 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); const user = users.get(attendee.user_id);
if (!user) return false; if (!user) return false;
@ -116,13 +131,172 @@ export default function Attendees() {
new Date(attendee.time_checked_in).toLocaleString(), new Date(attendee.time_checked_in).toLocaleString(),
].map(value => (value || '').toString().toLowerCase()); ].map(value => (value || '').toString().toLowerCase());
return terms.every(term => return processedSearchTerms.every(term =>
searchableValues.some(value => value.includes(term)) searchableValues.some(value => value.includes(term))
); );
}); });
}, [attendeesList, users, processedSearchTerms]);
setFilteredAttendees(filtered); // Memoize paginated attendees
}, [searchTerm, attendeesList, users]); 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<string, User>();
// 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<User>('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<Event>('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 // Function to download attendees as CSV
const downloadAttendeesCSV = () => { const downloadAttendeesCSV = () => {
@ -175,94 +349,6 @@ export default function Attendees() {
document.body.removeChild(link); 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<Event>('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<User>('users', userId);
} catch (error) {
console.error(`Failed to fetch user ${userId}:`, error);
return null;
}
});
const userResults = await Promise.all(userPromises);
const userMap = new Map<string, User>();
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) { if (loading) {
return ( return (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
@ -342,7 +428,7 @@ export default function Attendees() {
</div> </div>
</div> </div>
{/* Updated table with highlighting */} {/* Table with pagination */}
<div className="overflow-x-auto flex-1"> <div className="overflow-x-auto flex-1">
<table className="table table-zebra w-full"> <table className="table table-zebra w-full">
<thead className="sticky top-0 bg-base-100"> <thead className="sticky top-0 bg-base-100">
@ -359,7 +445,7 @@ export default function Attendees() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredAttendees.map((attendee, index) => { {paginatedAttendees.map((attendee, index) => {
const user = users.get(attendee.user_id); const user = users.get(attendee.user_id);
const checkInTime = new Date(attendee.time_checked_in).toLocaleString(); const checkInTime = new Date(attendee.time_checked_in).toLocaleString();
@ -380,6 +466,45 @@ export default function Attendees() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */}
{paginationInfo.totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasPrevPage}
onClick={() => setCurrentPage(1)}
>
«
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasPrevPage}
onClick={() => setCurrentPage(p => p - 1)}
>
</button>
<button className="join-item btn btn-sm">
Page {currentPage} of {paginationInfo.totalPages}
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasNextPage}
onClick={() => setCurrentPage(p => p + 1)}
>
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasNextPage}
onClick={() => setCurrentPage(paginationInfo.totalPages)}
>
»
</button>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -11,6 +11,8 @@ declare global {
interface Window { interface Window {
showLoading?: () => void; showLoading?: () => void;
hideLoading?: () => void; hideLoading?: () => void;
lastCacheUpdate?: number;
fetchEvents?: () => void;
} }
} }
@ -45,7 +47,7 @@ const MemoizedFilePreview = memo(FilePreview);
// Define EventForm props interface // Define EventForm props interface
interface EventFormProps { interface EventFormProps {
event: Event | null; event: Event | null;
setEvent: React.Dispatch<React.SetStateAction<Event | null>>; setEvent: (field: keyof Event, value: any) => void;
selectedFiles: Map<string, File>; selectedFiles: Map<string, File>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>; setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>;
filesToDelete: Set<string>; filesToDelete: Set<string>;
@ -71,6 +73,10 @@ const EventForm = memo(({
onSubmit, onSubmit,
onCancel onCancel
}: EventFormProps): React.ReactElement => { }: EventFormProps): React.ReactElement => {
const handleChange = (field: keyof Event, value: any) => {
setEvent(field, value);
};
return ( return (
<div id="editFormSection"> <div id="editFormSection">
<h3 className="font-bold text-lg mb-4" id="editModalTitle"> <h3 className="font-bold text-lg mb-4" id="editModalTitle">
@ -79,12 +85,7 @@ const EventForm = memo(({
<form <form
id="editEventForm" id="editEventForm"
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={onSubmit}
e.preventDefault();
if (!isSubmitting) {
onSubmit(e);
}
}}
> >
<input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} /> <input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -99,7 +100,7 @@ const EventForm = memo(({
name="editEventName" name="editEventName"
className="input input-bordered" className="input input-bordered"
value={event?.event_name || ""} 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 required
/> />
</div> </div>
@ -115,7 +116,7 @@ const EventForm = memo(({
name="editEventCode" name="editEventCode"
className="input input-bordered" className="input input-bordered"
value={event?.event_code || ""} 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 required
/> />
</div> </div>
@ -131,7 +132,7 @@ const EventForm = memo(({
name="editEventLocation" name="editEventLocation"
className="input input-bordered" className="input input-bordered"
value={event?.location || ""} value={event?.location || ""}
onChange={(e) => setEvent(prev => prev ? { ...prev, location: e.target.value } : null)} onChange={(e) => handleChange('location', e.target.value)}
required required
/> />
</div> </div>
@ -147,7 +148,7 @@ const EventForm = memo(({
name="editEventPoints" name="editEventPoints"
className="input input-bordered" className="input input-bordered"
value={event?.points_to_reward || 0} 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" min="0"
required required
/> />
@ -164,7 +165,7 @@ const EventForm = memo(({
name="editEventStartDate" name="editEventStartDate"
className="input input-bordered" className="input input-bordered"
value={event?.start_date ? new Date(event.start_date).toISOString().slice(0, 16) : ""} 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 required
/> />
</div> </div>
@ -180,7 +181,7 @@ const EventForm = memo(({
name="editEventEndDate" name="editEventEndDate"
className="input input-bordered" className="input input-bordered"
value={event?.end_date ? new Date(event.end_date).toISOString().slice(0, 16) : ""} 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 required
/> />
</div> </div>
@ -196,7 +197,7 @@ const EventForm = memo(({
name="editEventDescription" name="editEventDescription"
className="textarea textarea-bordered" className="textarea textarea-bordered"
value={event?.event_description || ""} 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} rows={3}
required required
></textarea> ></textarea>
@ -316,7 +317,7 @@ const EventForm = memo(({
name="editEventPublished" name="editEventPublished"
className="toggle" className="toggle"
checked={event?.published || false} checked={event?.published || false}
onChange={(e) => setEvent(prev => prev ? { ...prev, published: e.target.checked } : null)} onChange={(e) => handleChange('published', e.target.checked)}
/> />
<span className="label-text">Publish Event</span> <span className="label-text">Publish Event</span>
</label> </label>
@ -336,7 +337,7 @@ const EventForm = memo(({
name="editEventHasFood" name="editEventHasFood"
className="toggle" className="toggle"
checked={event?.has_food || false} 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)}
/> />
<span className="label-text">Has Food</span> <span className="label-text">Has Food</span>
</label> </label>
@ -523,30 +524,29 @@ const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => v
// Modify EventEditor component // Modify EventEditor component
export default function EventEditor({ onEventSaved }: EventEditorProps) { export default function EventEditor({ onEventSaved }: EventEditorProps) {
// State for form data and UI // State for form data and UI
const [event, setEvent] = useState<Event | null>(null); const [event, setEvent] = useState<Event>({
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 [previewUrl, setPreviewUrl] = useState("");
const [previewFilename, setPreviewFilename] = useState(""); const [previewFilename, setPreviewFilename] = useState("");
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map()); const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map());
const [filesToDelete, setFilesToDelete] = useState<Set<string>>(new Set()); const [filesToDelete, setFilesToDelete] = useState<Set<string>>(new Set());
const [isSubmitting, setIsSubmitting] = useState(false); 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); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Add loading state
const [loadingState, setLoadingState] = useState<LoadingState>({
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 // Memoize service instances
const services = useMemo(() => ({ const services = useMemo(() => ({
get: Get.getInstance(), get: Get.getInstance(),
@ -556,67 +556,110 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
sendLog: SendLog.getInstance() sendLog: SendLog.getInstance()
}), []); }), []);
// Memoize handlers // Handle field changes
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleFieldChange = useCallback((field: keyof Event, value: any) => {
if (e.target.files) { setEvent(prev => {
setSelectedFiles(prev => { const newEvent = { ...prev, [field]: value };
const newFiles = new Map(prev); // Only set hasUnsavedChanges if the value actually changed
Array.from(e.target.files!).forEach(file => { if (prev[field] !== value) {
newFiles.set(file.name, file); setHasUnsavedChanges(true);
}); }
return newFiles; return newEvent;
});
}
}, []);
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;
}); });
}, []); }, []);
// Initialize event data
const initializeEventData = useCallback(async (eventId: string) => {
try {
if (eventId) {
const eventData = await services.get.getOne<Event>("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) => { const handlePreviewFile = useCallback((url: string, filename: string) => {
setPreviewUrl(url); setPreviewUrl(url);
setPreviewFilename(filename); setPreviewFilename(filename);
setShowPreview(true); setShowPreview(true);
}, []); }, []);
// Add modal close handling const handleModalClose = useCallback(() => {
const handleModalClose = useCallback(async (e?: MouseEvent | React.MouseEvent) => {
if (e) e.preventDefault();
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?'); const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?');
if (!confirmed) return; if (!confirmed) return;
} }
// Reset all state setEvent({
setEvent(null); 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()); setSelectedFiles(new Map());
setFilesToDelete(new Set()); setFilesToDelete(new Set());
setShowPreview(false); setShowPreview(false);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
changeTracker.initialize(null);
// Close the modal
const modal = document.getElementById("editEventModal") as HTMLDialogElement; const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close(); 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<HTMLFormElement>) => { const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return; if (isSubmitting) return;
@ -625,11 +668,6 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
const cancelButton = form.querySelector('button[type="button"]') as HTMLButtonElement; const cancelButton = form.querySelector('button[type="button"]') as HTMLButtonElement;
if (!changeTracker.hasChanges()) {
handleModalClose();
return;
}
setIsSubmitting(true); setIsSubmitting(true);
if (submitButton) submitButton.disabled = true; if (submitButton) submitButton.disabled = true;
if (cancelButton) cancelButton.disabled = true; if (cancelButton) cancelButton.disabled = true;
@ -638,88 +676,89 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
window.showLoading?.(); window.showLoading?.();
const pb = services.auth.getPocketBase(); const pb = services.auth.getPocketBase();
if (event?.id) { console.log('Form submission started');
// Handle existing event update console.log('Event data:', event);
const changes = changeTracker.getChanges();
const fileChanges = changeTracker.getFileChanges();
// Process files in parallel const formData = new FormData(form);
const fileProcessingTasks: Promise<any>[] = []; 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 (event.id) {
if (fileChanges.deleted.size > 0) { // Update existing event
const deletePromises = Array.from(fileChanges.deleted).map(filename => console.log('Updating event:', event.id);
uploadQueue.add(async () => { await services.update.updateFields("events", event.id, eventData);
await services.sendLog.send(
"delete", // Handle file deletions first
"event_file", if (filesToDelete.size > 0) {
`Deleted file ${filename} from event ${event.event_name}` console.log('Deleting files:', Array.from(filesToDelete));
); // Get current files
}) const currentRecord = await pb.collection("events").getOne(event.id);
); let remainingFiles = [...currentRecord.files];
fileProcessingTasks.push(...deletePromises);
// 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 // Handle file additions
if (fileChanges.added.size > 0) { if (selectedFiles.size > 0) {
const uploadTasks = Array.from(fileChanges.added.values()).map(file => try {
uploadQueue.add(async () => { // Convert Map to array of Files
const formData = new FormData(); const filesToUpload = Array.from(selectedFiles.values());
formData.append("files", file); console.log('Uploading files:', filesToUpload.map(f => f.name));
await pb.collection("events").update(event.id, formData);
})
);
fileProcessingTasks.push(...uploadTasks);
}
// Update event data if there are changes // Use appendFiles to preserve existing files
if (Object.keys(changes).length > 0) { await services.fileManager.appendFiles("events", event.id, "files", filesToUpload);
await services.update.updateFields("events", event.id, changes); } catch (error: any) {
await services.sendLog.send( if (error.status === 413) {
"update", throw new Error("Files are too large. Please try uploading smaller files or fewer files at once.");
"event", }
`Updated event: ${changes.event_name || event.event_name}` throw error;
); }
} }
// Wait for all file operations to complete
await Promise.all(fileProcessingTasks);
} else { } else {
// Handle new event creation // Create new event
const formData = new FormData(form); console.log('Creating new event');
const eventData = { const newEvent = await pb.collection("events").create(eventData);
event_name: formData.get("editEventName"), console.log('New event created:', newEvent);
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 event and upload files in parallel // Upload files if any
const [newEvent] = await Promise.all([ if (selectedFiles.size > 0) {
pb.collection("events").create(eventData), try {
...Array.from(selectedFiles.values()).map(file => const filesToUpload = Array.from(selectedFiles.values());
uploadQueue.add(async () => { console.log('Uploading files:', filesToUpload.map(f => f.name));
const fileFormData = new FormData();
fileFormData.append("files", file);
await pb.collection("events").update(newEvent.id, fileFormData);
})
)
]);
await services.sendLog.send( // Use uploadFiles for new event
"create", await services.fileManager.uploadFiles("events", newEvent.id, "files", filesToUpload);
"event", } catch (error: any) {
`Created event: ${eventData.event_name}` 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) { if (submitButton) {
submitButton.classList.remove("btn-disabled"); submitButton.classList.remove("btn-disabled");
submitButton.classList.add("btn-success"); submitButton.classList.add("btn-success");
@ -728,13 +767,44 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
`; `;
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?.(); 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); console.error("Failed to save event:", error);
if (submitButton) { if (submitButton) {
submitButton.classList.remove("btn-disabled"); submitButton.classList.remove("btn-disabled");
@ -744,9 +814,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg> </svg>
`; `;
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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
if (submitButton) { if (submitButton) {
@ -757,150 +826,22 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
if (cancelButton) cancelButton.disabled = false; if (cancelButton) cancelButton.disabled = false;
window.hideLoading?.(); window.hideLoading?.();
} }
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, changeTracker, uploadQueue, handleModalClose]); }, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]);
// 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<string, File>, deletedFiles: Set<string>) => {
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<Event>("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]);
return ( return (
<dialog id="editEventModal" className="modal"> <dialog id="editEventModal" className="modal">
{showPreview ? ( {showPreview ? (
<div className="modal-box max-w-4xl"> <div className="modal-box max-w-4xl">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3"> <button
<button className="btn btn-ghost btn-sm"
className="btn btn-ghost btn-sm" onClick={() => setShowPreview(false)}
onClick={() => setShowPreview(false)} >
> Close
Close </button>
</button>
<h3 className="font-bold text-lg truncate">
{previewFilename}
</h3>
</div>
</div> </div>
<div className="relative"> <div className="relative">
<MemoizedFilePreview <FilePreview
url={previewUrl} url={previewUrl}
filename={previewFilename} filename={previewFilename}
isModal={false} isModal={false}
@ -909,38 +850,21 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
</div> </div>
) : ( ) : (
<div className="modal-box max-w-2xl"> <div className="modal-box max-w-2xl">
{loadingState.isLoading ? ( <EventForm
<LoadingSpinner /> event={event}
) : loadingState.error ? ( setEvent={handleFieldChange}
<ErrorDisplay selectedFiles={selectedFiles}
error={loadingState.error} setSelectedFiles={setSelectedFiles}
onRetry={() => event?.id && initializeEventData(event.id)} filesToDelete={filesToDelete}
/> setFilesToDelete={setFilesToDelete}
) : ( handlePreviewFile={handlePreviewFile}
<div className="transition-opacity duration-300 ease-in-out"> isSubmitting={isSubmitting}
<EventForm fileManager={services.fileManager}
event={event} onSubmit={handleSubmit}
setEvent={setEvent} onCancel={handleModalClose}
selectedFiles={selectedFiles} />
setSelectedFiles={setSelectedFiles}
filesToDelete={filesToDelete}
setFilesToDelete={setFilesToDelete}
handlePreviewFile={handlePreviewFile}
isSubmitting={isSubmitting}
fileManager={services.fileManager}
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
</div>
)}
</div> </div>
)} )}
<div
className="modal-backdrop"
onClick={(e: React.MouseEvent) => handleModalClose(e)}
>
<button onClick={(e) => e.preventDefault()}>close</button>
</div>
</dialog> </dialog>
); );
} }

View file

@ -1,4 +1,8 @@
'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
// Cache for file content // Cache for file content
const contentCache = new Map<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>(); const contentCache = new Map<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>();
@ -10,7 +14,7 @@ interface FilePreviewProps {
isModal?: boolean; isModal?: boolean;
} }
const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filename: initialFilename = '', isModal = false }) => { export default function FilePreview({ url: initialUrl = '', filename: initialFilename = '', isModal = false }: FilePreviewProps) {
const [url, setUrl] = useState(initialUrl); const [url, setUrl] = useState(initialUrl);
const [filename, setFilename] = useState(initialFilename); const [filename, setFilename] = useState(initialFilename);
const [content, setContent] = useState<string | null>(null); const [content, setContent] = useState<string | null>(null);
@ -18,6 +22,8 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fileType, setFileType] = useState<string | null>(null); const [fileType, setFileType] = useState<string | null>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const INITIAL_LINES_TO_SHOW = 20;
// Memoize the truncated filename // Memoize the truncated filename
const truncatedFilename = useMemo(() => { const truncatedFilename = useMemo(() => {
@ -39,7 +45,8 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
{ threshold: 0.1 } { 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) { if (previewElement) {
observer.observe(previewElement); observer.observe(previewElement);
} }
@ -107,10 +114,10 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
}, [url, filename]); }, [url, filename]);
useEffect(() => { useEffect(() => {
if (isVisible) { if (isVisible || !isModal) { // Load content immediately if not in modal
loadContent(); loadContent();
} }
}, [isVisible, loadContent]); }, [isVisible, loadContent, isModal]);
useEffect(() => { useEffect(() => {
console.log('FilePreview component mounted'); console.log('FilePreview component mounted');
@ -158,58 +165,138 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
} }
}; };
return ( const getLanguageFromFilename = (filename: string): string => {
<div className="space-y-4"> const extension = filename.split('.').pop()?.toLowerCase();
<div className="flex justify-between items-center bg-base-200 p-3 rounded-lg"> switch (extension) {
<div className="flex items-center gap-2 flex-1 min-w-0"> case 'js':
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span> case 'jsx':
{fileType && ( return 'javascript';
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span> case 'ts':
)} case 'tsx':
</div> return 'typescript';
<button case 'py':
onClick={handleDownload} return 'python';
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap" case 'html':
> return 'html';
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> case 'css':
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> return 'css';
</svg> case 'json':
Download return 'json';
</button> 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 `
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
${headers.map(header => `<th class="bg-base-200">${header}</th>`).join('')}
</tr>
</thead>
<tbody>
${rows.map(row => `
<tr>
${row.map(cell => `<td>${cell}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div> </div>
`;
}, []);
<div className="preview-content"> const highlightCode = useCallback((code: string, language: string) => {
{loading && ( // Skip highlighting for CSV
<div className="flex justify-center items-center p-8"> if (language === 'csv') {
<span className="loading loading-spinner loading-lg"></span> return code;
</div> }
)}
{error && ( try {
<div className="flex flex-col items-center justify-center p-8 bg-base-200 rounded-lg text-center space-y-4"> return hljs.highlight(code, { language }).value;
<div className="bg-warning/20 p-4 rounded-full"> } catch (error) {
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"> console.warn(`Failed to highlight code for language ${language}:`, error);
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> return code;
</svg> }
</div> }, []);
<div className="space-y-2">
<h3 className="text-lg font-semibold">Preview Unavailable</h3> const formatCodeWithLineNumbers = useCallback((code: string, language: string) => {
<p className="text-base-content/70 max-w-md">{error}</p> // 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 `<div class="table-row ">
<div class="table-cell text-right pr-4 select-none text-base-content/50 text-sm border-r border-base-content/10">${lineNumber}</div>
<div class="table-cell pl-4 whitespace-pre">${highlightedLine || ' '}</div>
</div>`;
})
.join('');
if (!isExpanded && totalLines > INITIAL_LINES_TO_SHOW) {
formattedCode += `<div class="table-row ">
<div class="table-cell"></div>
<div class="table-cell pl-4 pt-2 text-base-content/70">... ${totalLines - INITIAL_LINES_TO_SHOW} more lines</div>
</div>`;
}
return formattedCode;
}, [highlightCode, isExpanded, renderCSVTable]);
return (
<div className="file-preview-container space-y-4">
{!loading && !error && content === 'image' && (
<div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div> </div>
<button <button
onClick={handleDownload} onClick={handleDownload}
className="btn btn-warning btn-sm gap-2 mt-4" className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
> >
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> <path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg> </svg>
Download File Instead Download
</button> </button>
</div> </div>
)} <div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
{!loading && !error && content === 'image' && (
<div className="flex justify-center">
<img <img
src={url} src={url}
alt={filename} alt={filename}
@ -217,10 +304,29 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
loading="lazy" loading="lazy"
/> />
</div> </div>
)} </div>
)}
{!loading && !error && content === 'video' && ( {!loading && !error && content === 'video' && (
<div className="flex justify-center"> <div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Download
</button>
</div>
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<video <video
controls controls
className="max-w-full rounded-lg" className="max-w-full rounded-lg"
@ -231,10 +337,29 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> </div>
)} </div>
)}
{!loading && !error && content === 'pdf' && ( {!loading && !error && content === 'pdf' && (
<div className="w-full h-[600px]"> <div>
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Download
</button>
</div>
<div className="w-full h-[600px] bg-base-200 p-4 rounded-b-lg">
<iframe <iframe
src={url} src={url}
className="w-full h-full rounded-lg" className="w-full h-full rounded-lg"
@ -242,16 +367,79 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
loading="lazy" loading="lazy"
></iframe> ></iframe>
</div> </div>
)} </div>
)}
{!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && ( {!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
<div className="mockup-code bg-base-200 text-base-content overflow-x-auto max-h-[600px]"> <div>
<pre className="p-4"><code>{content}</code></pre> <div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={filename}>{truncatedFilename}</span>
{fileType && (
<span className="badge badge-sm whitespace-nowrap">{fileType.split('/')[1]}</span>
)}
</div>
<div className="flex items-center gap-2">
{content.split('\n').length > INITIAL_LINES_TO_SHOW && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="btn btn-sm btn-ghost"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
)}
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Download
</button>
</div>
</div> </div>
)} <div className="overflow-x-auto max-h-[600px] bg-base-200 ">
</div> <div className="p-1">
<div
className="hljs table w-full font-mono text-sm rounded-lg py-4 px-2"
dangerouslySetInnerHTML={{
__html: formatCodeWithLineNumbers(content, getLanguageFromFilename(filename))
}}
/>
</div>
</div>
</div>
)}
{loading && (
<div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center p-8 bg-base-200 rounded-lg text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Preview Unavailable</h3>
<p className="text-base-content/70 max-w-md">{error}</p>
</div>
<button
onClick={handleDownload}
className="btn btn-warning btn-sm gap-2 mt-4"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Download File Instead
</button>
</div>
)}
</div> </div>
); );
}; }
export default FilePreview;

View file

@ -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 collectionName The name of the collection
* @param recordId The ID of the record to attach the files to * @param recordId The ID of the record to attach the files to
* @param field The field name for the files * @param field The field name for the files
@ -73,14 +73,73 @@ export class FileManager {
try { try {
this.auth.setUpdating(true); this.auth.setUpdating(true);
const pb = this.auth.getPocketBase(); const pb = this.auth.getPocketBase();
const formData = new FormData();
files.forEach(file => { const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit
formData.append(field, file); 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<T>(recordId, formData); // Get existing record if updating
return result; let existingFiles: string[] = [];
if (recordId) {
try {
const record = await pb.collection(collectionName).getOne<T>(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<T>(recordId);
return finalRecord;
} catch (err) { } catch (err) {
console.error(`Failed to upload files to ${collectionName}:`, err); console.error(`Failed to upload files to ${collectionName}:`, err);
throw err; throw err;
@ -89,6 +148,34 @@ export class FileManager {
} }
} }
/**
* Upload a batch of files
* @private
*/
private async uploadBatch<T = any>(
collectionName: string,
recordId: string,
field: string,
files: File[]
): Promise<void> {
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 * Append multiple files to a record without overriding existing ones
* @param collectionName The name of the collection * @param collectionName The name of the collection
@ -160,7 +247,9 @@ export class FileManager {
filename: string filename: string
): string { ): string {
const pb = this.auth.getPocketBase(); 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); 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<File> The compressed file
*/
public async compressImageIfNeeded(file: File, maxSizeInMB: number = 50): Promise<File> {
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'));
};
});
}
} }