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 = "";
|
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();
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
|
@ -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'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue