fix duplicate updates

This commit is contained in:
chark1es 2025-02-16 16:15:15 -08:00
parent 2712522bf6
commit ee49347ff0

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
import { Get } from "../../pocketbase/Get";
import { Authentication } from "../../pocketbase/Authentication";
import { Update } from "../../pocketbase/Update";
@ -39,257 +39,51 @@ interface EventEditorProps {
onEventSaved?: () => void;
}
export default function EventEditor({ onEventSaved }: EventEditorProps) {
// State for form data and UI
const [event, setEvent] = useState<Event | null>(null);
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);
// Memoize the FilePreview component
const MemoizedFilePreview = memo(FilePreview);
// Initialize services
const get = Get.getInstance();
const auth = Authentication.getInstance();
const update = Update.getInstance();
const fileManager = FileManager.getInstance();
const sendLog = SendLog.getInstance();
// Method to initialize form with event data
const initializeEventData = async (eventId: string) => {
try {
const eventData = await get.getOne<Event>("events", eventId);
setEvent(eventData);
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
} catch (error) {
console.error("Failed to fetch event data:", error);
alert("Failed to load event data. Please try again.");
}
};
// Expose initializeEventData to window
useEffect(() => {
(window as any).openEditModal = async (event?: Event) => {
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (!modal) return;
if (event?.id) {
await initializeEventData(event.id);
} else {
setEvent(null);
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
// Define EventForm props interface
interface EventFormProps {
event: Event | null;
setEvent: React.Dispatch<React.SetStateAction<Event | null>>;
selectedFiles: Map<string, File>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>;
filesToDelete: Set<string>;
setFilesToDelete: React.Dispatch<React.SetStateAction<Set<string>>>;
handlePreviewFile: (url: string, filename: string) => void;
isSubmitting: boolean;
fileManager: FileManager;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
modal.showModal();
};
return () => {
delete (window as any).openEditModal;
};
}, []);
// Handle file selection
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
const updatedFiles = new Map(selectedFiles);
newFiles.forEach(file => {
updatedFiles.set(file.name, file);
});
setSelectedFiles(updatedFiles);
}
};
// Handle file deletion
const handleFileDelete = (filename: string) => {
if (confirm("Are you sure you want to remove this file?")) {
const updatedFilesToDelete = new Set(filesToDelete);
updatedFilesToDelete.add(filename);
setFilesToDelete(updatedFilesToDelete);
}
};
// Handle file deletion undo
const handleUndoFileDelete = (filename: string) => {
const updatedFilesToDelete = new Set(filesToDelete);
updatedFilesToDelete.delete(filename);
setFilesToDelete(updatedFilesToDelete);
};
// Handle file preview
const handlePreviewFile = (url: string, filename: string) => {
setPreviewUrl(url);
setPreviewFilename(filename);
setShowPreview(true);
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
setIsSubmitting(true);
try {
window.showLoading?.();
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();
if (event?.id) {
// Update existing event
const currentEvent = await pb.collection("events").getOne(event.id);
const currentFiles = currentEvent.files || [];
// Filter out files marked for deletion
const remainingFiles = currentFiles.filter(
(filename: string) => !filesToDelete.has(filename)
);
// Create FormData for update
const updateFormData = new FormData();
// Add event data
Object.entries(eventData).forEach(([key, value]) => {
updateFormData.append(key, String(value));
});
// Add remaining and new files
const filePromises = remainingFiles.map(async (filename: string) => {
try {
const response = await fetch(
fileManager.getFileUrl("events", event.id, 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;
}
});
const existingFiles = (await Promise.all(filePromises)).filter(
(file): file is File => file !== null
);
[...existingFiles, ...Array.from(selectedFiles.values())].forEach(
(file: File) => {
updateFormData.append("files", file);
}
);
await pb.collection("events").update(event.id, updateFormData);
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}`
);
}
} else {
// Create new event
const createFormData = new FormData();
// Add event data
Object.entries(eventData).forEach(([key, value]) => {
createFormData.append(key, String(value));
});
// Initialize empty attendees array
createFormData.append("attendees", JSON.stringify([]));
// Add new files
Array.from(selectedFiles.values()).forEach((file: File) => {
createFormData.append("files", file);
});
await pb.collection("events").create(createFormData);
await sendLog.send(
"create",
"event",
`Created event: ${eventData.event_name}`
);
}
// Close modal and reset state
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close();
setEvent(null);
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
form.reset();
// Notify parent component
onEventSaved?.();
} catch (error) {
console.error("Failed to save event:", error);
alert("Failed to save event. Please try again.");
} finally {
setIsSubmitting(false);
window.hideLoading?.();
}
};
// Create a memoized form component
const EventForm = memo(({
event,
setEvent,
selectedFiles,
setSelectedFiles,
filesToDelete,
setFilesToDelete,
handlePreviewFile,
isSubmitting,
fileManager,
onSubmit
}: EventFormProps): React.ReactElement => {
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">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
</div>
</div>
) : (
<div className="modal-box max-w-2xl">
{/* Main Edit Form Section */}
<div id="editFormSection">
<h3 className="font-bold text-lg mb-4" id="editModalTitle">
{event?.id ? 'Edit Event' : 'Add New Event'}
</h3>
<form id="editEventForm" onSubmit={handleSubmit} className="space-y-4">
<form
id="editEventForm"
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!isSubmitting) {
onSubmit(e);
}
}}
>
<input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Event Name */}
@ -413,7 +207,16 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
</label>
<input
type="file"
onChange={handleFileSelect}
name="editEventFiles"
onChange={(e) => {
if (e.target.files) {
const newFiles = new Map(selectedFiles);
Array.from(e.target.files).forEach(file => {
newFiles.set(file.name, file);
});
setSelectedFiles(newFiles);
}
}}
className="file-input file-input-bordered"
multiple
/>
@ -471,7 +274,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => handleUndoFileDelete(filename)}
onClick={() => {
const newFilesToDelete = new Set(filesToDelete);
newFilesToDelete.delete(filename);
setFilesToDelete(newFilesToDelete);
}}
>
Undo
</button>
@ -479,7 +286,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
<button
type="button"
className="btn btn-ghost btn-xs text-error"
onClick={() => handleFileDelete(filename)}
onClick={() => {
const newFilesToDelete = new Set(filesToDelete);
newFilesToDelete.add(filename);
setFilesToDelete(newFilesToDelete);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
@ -533,14 +344,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
</span>
</label>
</div>
</form>
</div>
<div className="modal-action">
{/* Action Buttons */}
<div className="modal-action mt-6">
<button
type="submit"
className={`btn btn-primary ${isSubmitting ? 'loading' : ''}`}
form="editEventForm"
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
@ -552,15 +361,322 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close();
}}
disabled={isSubmitting}
>
Cancel
</button>
</div>
</form>
</div>
);
});
export default function EventEditor({ onEventSaved }: EventEditorProps) {
// State for form data and UI
const [event, setEvent] = useState<Event | null>(null);
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);
// Memoize service instances
const services = useMemo(() => ({
get: Get.getInstance(),
auth: Authentication.getInstance(),
update: Update.getInstance(),
fileManager: FileManager.getInstance(),
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);
});
return newFiles;
});
}
}, []);
const handleFileDelete = useCallback((filename: string) => {
if (confirm("Are you sure you want to remove this file?")) {
setFilesToDelete(prev => new Set([...prev, filename]));
}
}, []);
const handleUndoFileDelete = useCallback((filename: string) => {
setFilesToDelete(prev => {
const newSet = new Set(prev);
newSet.delete(filename);
return newSet;
});
}, []);
const handlePreviewFile = useCallback((url: string, filename: string) => {
setPreviewUrl(url);
setPreviewFilename(filename);
setShowPreview(true);
}, []);
// Optimize form submission
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitting) return; // Early return if already submitting
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
const cancelButton = form.querySelector('button[type="button"]') as HTMLButtonElement;
setIsSubmitting(true);
if (submitButton) submitButton.disabled = true;
if (cancelButton) cancelButton.disabled = true;
try {
window.showLoading?.();
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 = services.auth.getPocketBase();
if (event?.id) {
// Optimize update by fetching files in parallel
const currentEvent = await pb.collection("events").getOne(event.id);
const currentFiles = currentEvent.files || [];
const remainingFiles = currentFiles.filter(
(filename: string) => !filesToDelete.has(filename)
);
const updateFormData = new FormData();
Object.entries(eventData).forEach(([key, value]) => {
updateFormData.append(key, String(value));
});
// Fetch all remaining files in parallel
const filePromises = remainingFiles.map(async (filename: string) => {
try {
const response = await fetch(
services.fileManager.getFileUrl("events", event.id, 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;
}
});
const existingFiles = (await Promise.all(filePromises)).filter(
(file): file is File => file !== null
);
[...existingFiles, ...Array.from(selectedFiles.values())].forEach(
(file: File) => {
updateFormData.append("files", file);
}
);
await Promise.all([
pb.collection("events").update(event.id, updateFormData),
services.sendLog.send(
"update",
"event",
`Updated event: ${eventData.event_name}`
),
...Array.from(filesToDelete).map(filename =>
services.sendLog.send(
"delete",
"event_file",
`Deleted file ${filename} from event ${eventData.event_name}`
)
)
]);
} else {
const createFormData = new FormData();
Object.entries(eventData).forEach(([key, value]) => {
createFormData.append(key, String(value));
});
createFormData.append("attendees", JSON.stringify([]));
Array.from(selectedFiles.values()).forEach((file: File) => {
createFormData.append("files", file);
});
await Promise.all([
pb.collection("events").create(createFormData),
services.sendLog.send(
"create",
"event",
`Created event: ${eventData.event_name}`
)
]);
}
// Show success state briefly
if (submitButton) {
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));
}
// Reset form and close modal
setEvent(null);
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
form.reset();
// Call onEventSaved before closing modal
onEventSaved?.();
// Close modal last
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) {
modal.addEventListener('close', () => {
// Do nothing on close event
}, { once: true });
modal.close();
}
} catch (error) {
console.error("Failed to save event:", error);
if (submitButton) {
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 {
setIsSubmitting(false);
if (submitButton) {
submitButton.disabled = false;
submitButton.classList.remove("btn-disabled", "btn-success", "btn-error");
submitButton.innerHTML = 'Save Changes';
}
if (cancelButton) cancelButton.disabled = false;
window.hideLoading?.();
}
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]);
// Method to initialize form with event data
const initializeEventData = async (eventId: string) => {
try {
const eventData = await services.get.getOne<Event>("events", eventId);
setEvent(eventData);
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
} catch (error) {
console.error("Failed to fetch event data:", error);
alert("Failed to load event data. Please try again.");
}
};
// Expose initializeEventData to window
useEffect(() => {
(window as any).openEditModal = async (event?: Event) => {
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (!modal) return;
if (event?.id) {
await initializeEventData(event.id);
} else {
setEvent(null);
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
}
modal.showModal();
};
return () => {
delete (window as any).openEditModal;
};
}, []);
return (
<dialog id="editEventModal" className="modal" onClose={(e) => {
// Prevent any default close behavior
if (isSubmitting) {
e.preventDefault();
}
}}>
{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
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
</div>
</div>
) : (
<div className="modal-box max-w-2xl">
<EventForm
event={event}
setEvent={setEvent}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
filesToDelete={filesToDelete}
setFilesToDelete={setFilesToDelete}
handlePreviewFile={handlePreviewFile}
isSubmitting={isSubmitting}
fileManager={services.fileManager}
onSubmit={handleSubmit}
/>
</div>
)}
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
<div className="modal-backdrop" onClick={(e) => {
e.preventDefault();
if (!isSubmitting) {
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) {
modal.addEventListener('close', () => {
// Do nothing on close event
}, { once: true });
modal.close();
}
}
}}>
<button onClick={(e) => e.preventDefault()}>close</button>
</div>
</dialog>
);
}