fix uploading issues
This commit is contained in:
parent
0ba3142792
commit
825b914f79
5 changed files with 904 additions and 739 deletions
|
@ -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 = `<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
|
||||
document.getElementById("editEventModal")?.addEventListener("close", () => {
|
||||
selectedFileStorage.clear();
|
||||
|
|
|
@ -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<string, {
|
||||
data: User;
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
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<string>('');
|
||||
const [eventName, setEventName] = useState<string>('');
|
||||
|
@ -66,41 +98,24 @@ export default function Attendees() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [attendeesList, setAttendeesList] = useState<AttendeeEntry[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredAttendees, setFilteredAttendees] = useState<AttendeeEntry[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
|
||||
|
||||
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<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
|
||||
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<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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
@ -342,7 +428,7 @@ export default function Attendees() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updated table with highlighting */}
|
||||
{/* Table with pagination */}
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead className="sticky top-0 bg-base-100">
|
||||
|
@ -359,7 +445,7 @@ export default function Attendees() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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() {
|
|||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<React.SetStateAction<Event | null>>;
|
||||
setEvent: (field: keyof Event, value: any) => void;
|
||||
selectedFiles: Map<string, File>;
|
||||
setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>;
|
||||
filesToDelete: Set<string>;
|
||||
|
@ -71,6 +73,10 @@ const EventForm = memo(({
|
|||
onSubmit,
|
||||
onCancel
|
||||
}: EventFormProps): React.ReactElement => {
|
||||
const handleChange = (field: keyof Event, value: any) => {
|
||||
setEvent(field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="editFormSection">
|
||||
<h3 className="font-bold text-lg mb-4" id="editModalTitle">
|
||||
|
@ -79,12 +85,7 @@ const EventForm = memo(({
|
|||
<form
|
||||
id="editEventForm"
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isSubmitting) {
|
||||
onSubmit(e);
|
||||
}
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
|
@ -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
|
||||
></textarea>
|
||||
|
@ -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)}
|
||||
/>
|
||||
<span className="label-text">Publish Event</span>
|
||||
</label>
|
||||
|
@ -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)}
|
||||
/>
|
||||
<span className="label-text">Has Food</span>
|
||||
</label>
|
||||
|
@ -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<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 [previewFilename, setPreviewFilename] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map());
|
||||
const [filesToDelete, setFilesToDelete] = useState<Set<string>>(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<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
|
||||
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<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setSelectedFiles(prev => {
|
||||
const newFiles = new Map(prev);
|
||||
Array.from(e.target.files!).forEach(file => {
|
||||
newFiles.set(file.name, file);
|
||||
// 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;
|
||||
});
|
||||
return newFiles;
|
||||
}, []);
|
||||
|
||||
// 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: []
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileDelete = useCallback((filename: string) => {
|
||||
if (confirm("Are you sure you want to remove this file?")) {
|
||||
setFilesToDelete(prev => new Set([...prev, filename]));
|
||||
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]);
|
||||
|
||||
const handleUndoFileDelete = useCallback((filename: string) => {
|
||||
setFilesToDelete(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(filename);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
// 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<HTMLFormElement>) => {
|
||||
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,54 +676,9 @@ 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<any>[] = [];
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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}`
|
||||
);
|
||||
}
|
||||
|
||||
// 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"),
|
||||
|
@ -697,29 +690,75 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
end_date: new Date(formData.get("editEventEndDate") as string).toISOString(),
|
||||
published: formData.get("editEventPublished") === "on",
|
||||
has_food: formData.get("editEventHasFood") === "on",
|
||||
attendees: []
|
||||
attendees: event.attendees || []
|
||||
};
|
||||
|
||||
// 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);
|
||||
})
|
||||
)
|
||||
]);
|
||||
if (event.id) {
|
||||
// Update existing event
|
||||
console.log('Updating event:', event.id);
|
||||
await services.update.updateFields("events", event.id, eventData);
|
||||
|
||||
await services.sendLog.send(
|
||||
"create",
|
||||
"event",
|
||||
`Created event: ${eventData.event_name}`
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Show success state
|
||||
// Update record with remaining files
|
||||
await pb.collection("events").update(event.id, {
|
||||
files: remainingFiles
|
||||
});
|
||||
}
|
||||
|
||||
// Handle file additions
|
||||
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));
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new event
|
||||
console.log('Creating new event');
|
||||
const newEvent = await pb.collection("events").create(eventData);
|
||||
console.log('New event created:', newEvent);
|
||||
|
||||
// Upload files if any
|
||||
if (selectedFiles.size > 0) {
|
||||
try {
|
||||
const filesToUpload = Array.from(selectedFiles.values());
|
||||
console.log('Uploading files:', filesToUpload.map(f => f.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 message
|
||||
if (submitButton) {
|
||||
submitButton.classList.remove("btn-disabled");
|
||||
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"/>
|
||||
</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?.();
|
||||
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) {
|
|||
<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.");
|
||||
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<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]);
|
||||
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]);
|
||||
|
||||
return (
|
||||
<dialog id="editEventModal" className="modal">
|
||||
{showPreview ? (
|
||||
<div className="modal-box max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<h3 className="font-bold text-lg truncate">
|
||||
{previewFilename}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<MemoizedFilePreview
|
||||
<FilePreview
|
||||
url={previewUrl}
|
||||
filename={previewFilename}
|
||||
isModal={false}
|
||||
|
@ -909,18 +850,9 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="modal-box max-w-2xl">
|
||||
{loadingState.isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : loadingState.error ? (
|
||||
<ErrorDisplay
|
||||
error={loadingState.error}
|
||||
onRetry={() => event?.id && initializeEventData(event.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="transition-opacity duration-300 ease-in-out">
|
||||
<EventForm
|
||||
event={event}
|
||||
setEvent={setEvent}
|
||||
setEvent={handleFieldChange}
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
filesToDelete={filesToDelete}
|
||||
|
@ -929,18 +861,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
isSubmitting={isSubmitting}
|
||||
fileManager={services.fileManager}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
onCancel={handleModalClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onClick={(e: React.MouseEvent) => handleModalClose(e)}
|
||||
>
|
||||
<button onClick={(e) => e.preventDefault()}>close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>();
|
||||
|
@ -10,7 +14,7 @@ interface FilePreviewProps {
|
|||
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 [filename, setFilename] = useState(initialFilename);
|
||||
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 [fileType, setFileType] = useState<string | null>(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<FilePreviewProps> = ({ 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<FilePreviewProps> = ({ 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,9 +165,121 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
|
|||
}
|
||||
};
|
||||
|
||||
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 `
|
||||
<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>
|
||||
`;
|
||||
}, []);
|
||||
|
||||
const highlightCode = useCallback((code: string, language: string) => {
|
||||
// Skip highlighting for CSV
|
||||
if (language === 'csv') {
|
||||
return code;
|
||||
}
|
||||
|
||||
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 `<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="space-y-4">
|
||||
<div className="flex justify-between items-center bg-base-200 p-3 rounded-lg">
|
||||
<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 && (
|
||||
|
@ -177,8 +296,122 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
|
|||
Download
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content === 'video' && (
|
||||
<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
|
||||
controls
|
||||
className="max-w-full rounded-lg"
|
||||
style={{ maxHeight: '600px' }}
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content === 'pdf' && (
|
||||
<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
|
||||
src={url}
|
||||
className="w-full h-full rounded-lg"
|
||||
title={filename}
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
|
||||
<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 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 className="overflow-x-auto max-h-[600px] bg-base-200 ">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="preview-content">
|
||||
{loading && (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
|
@ -207,51 +440,6 @@ const FilePreview: React.FC<FilePreviewProps> = ({ url: initialUrl = '', filenam
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content === 'image' && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content === 'video' && (
|
||||
<div className="flex justify-center">
|
||||
<video
|
||||
controls
|
||||
className="max-w-full rounded-lg"
|
||||
style={{ maxHeight: '600px' }}
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content === 'pdf' && (
|
||||
<div className="w-full h-[600px]">
|
||||
<iframe
|
||||
src={url}
|
||||
className="w-full h-full rounded-lg"
|
||||
title={filename}
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && content && !['image', 'video', 'pdf'].includes(content) && (
|
||||
<div className="mockup-code bg-base-200 text-base-content overflow-x-auto max-h-[600px]">
|
||||
<pre className="p-4"><code>{content}</code></pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
}
|
|
@ -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
|
||||
|
||||
const result = await pb.collection(collectionName).update<T>(recordId, formData);
|
||||
return result;
|
||||
// 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.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing record if updating
|
||||
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) {
|
||||
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<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
|
||||
* @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<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'));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue