(() => { const formSelector = "form[data-submit-once]"; const pickerSelector = "[data-image-picker]"; const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]); const allowedVideoTypes = new Set(["video/mp4", "video/webm"]); const maxImageBytes = 5 * 1024 * 1024; const maxVideoBytes = 25 * 1024 * 1024; const pickerStates = new WeakMap(); function progressIndicator() { return document.getElementById("submit-progress"); } function resetForm(form) { form.removeAttribute("aria-busy"); delete form.dataset.submitting; const button = form.querySelector("[data-submit-button]"); if (!button) { return; } button.disabled = false; button.removeAttribute("aria-disabled"); if (button.dataset.idleLabel) { button.textContent = button.dataset.idleLabel; delete button.dataset.idleLabel; } } function existingImageCount(picker) { return picker.querySelectorAll("[data-existing-image]:not([hidden])").length; } function existingVideoCount(picker) { return picker.querySelectorAll("[data-existing-video]:not([hidden])").length; } function updateImageCount(picker, state) { const photos = existingImageCount(picker) + state.entries.length; const videos = existingVideoCount(picker) + (state.video ? 1 : 0); const status = picker.querySelector("[data-image-count]"); if (!status) { return; } status.textContent = videos ? `${photos} of ${state.max} · 1 video` : `${photos} of ${state.max}`; } function showImageError(picker, message) { const error = picker.querySelector("[data-image-error]"); if (!error) { return; } error.textContent = message; error.hidden = !message; } function imageFileAllowed(file) { if (allowedImageTypes.has(file.type)) { return true; } if (file.type) { return false; } return /\.(jpe?g|png|webp)$/i.test(file.name); } function videoFileAllowed(file) { if (allowedVideoTypes.has(file.type)) { return true; } if (file.type) { return false; } return /\.(mp4|webm)$/i.test(file.name); } function sameImageFile(left, right) { return left.name === right.name && left.size === right.size && left.lastModified === right.lastModified; } function syncImageInput(state) { const transfer = new DataTransfer(); if (state.video) { transfer.items.add(state.video.file); } state.entries.forEach((entry) => transfer.items.add(entry.file)); state.input.files = transfer.files; } function removeNewImage(picker, state, entry) { const index = state.entries.indexOf(entry); if (index === -1) { return; } state.entries.splice(index, 1); URL.revokeObjectURL(entry.previewURL); entry.card.remove(); syncImageInput(state); showImageError(picker, ""); updateImageCount(picker, state); } function removeNewVideo(picker, state) { if (!state.video) { return; } URL.revokeObjectURL(state.video.previewURL); state.video.card.remove(); state.video = null; syncImageInput(state); showImageError(picker, ""); updateImageCount(picker, state); } function addNewImage(picker, state, file) { const fragment = state.template.content.cloneNode(true); const card = fragment.querySelector("[data-new-image]"); const preview = fragment.querySelector("[data-image-preview]"); const name = fragment.querySelector("[data-image-name]"); const previewURL = URL.createObjectURL(file); preview.src = previewURL; if (name) { name.textContent = file.name; } const entry = { file, card, previewURL }; const removeButton = card.querySelector("[data-remove-image]"); removeButton.setAttribute("aria-label", `Remove selected image: ${file.name}`); removeButton.addEventListener("click", () => { removeNewImage(picker, state, entry); }); state.list.appendChild(fragment); state.entries.push(entry); } function addNewVideo(picker, state, file) { const fragment = state.videoTemplate.content.cloneNode(true); const card = fragment.querySelector("[data-new-video]"); const preview = fragment.querySelector("[data-video-preview]"); const name = fragment.querySelector("[data-image-name]"); const previewURL = URL.createObjectURL(file); preview.src = previewURL; preview.setAttribute("aria-label", file.name); if (name) { name.textContent = file.name; } const entry = { file, card, previewURL }; const removeButton = card.querySelector("[data-remove-image]"); removeButton.setAttribute("aria-label", `Remove selected video: ${file.name}`); removeButton.addEventListener("click", () => { removeNewVideo(picker, state); }); state.videoSlot.appendChild(fragment); state.video = entry; } function addImageFiles(picker, state, files) { showImageError(picker, ""); const uniqueFiles = files.filter((file) => !state.entries.some((entry) => sameImageFile(entry.file, file)) && !(state.video && sameImageFile(state.video.file, file)) ); const videos = uniqueFiles.filter(videoFileAllowed); const images = uniqueFiles.filter(imageFileAllowed); if (videos.length + images.length !== uniqueFiles.length) { showImageError(picker, "Use JPEG, PNG, or WebP photos and one MP4 or WebM video."); syncImageInput(state); return; } const availablePhotos = state.max - existingImageCount(picker) - state.entries.length; if (images.length > availablePhotos) { showImageError( picker, availablePhotos > 0 ? `You can add ${availablePhotos} more ${availablePhotos === 1 ? "image" : "images"}.` : "You already have 4 images selected." ); syncImageInput(state); return; } const availableVideos = state.maxVideos - existingVideoCount(picker) - (state.video ? 1 : 0); if (videos.length > availableVideos) { showImageError( picker, availableVideos > 0 ? "You can attach one video." : "You already have a video selected." ); syncImageInput(state); return; } for (const file of images) { if (file.size > maxImageBytes) { showImageError(picker, `${file.name} is larger than 5 MB.`); syncImageInput(state); return; } } for (const file of videos) { if (file.size > maxVideoBytes) { showImageError(picker, `${file.name} is larger than 25 MB.`); syncImageInput(state); return; } } videos.forEach((file) => addNewVideo(picker, state, file)); images.forEach((file) => addNewImage(picker, state, file)); syncImageInput(state); updateImageCount(picker, state); } function resetImagePicker(picker) { const state = pickerStates.get(picker); if (!state) { return; } state.entries.forEach((entry) => { URL.revokeObjectURL(entry.previewURL); entry.card.remove(); }); state.entries = []; if (state.video) { URL.revokeObjectURL(state.video.previewURL); state.video.card.remove(); state.video = null; } state.input.value = ""; picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => { card.hidden = false; card.querySelectorAll("input").forEach((input) => { input.disabled = false; }); }); showImageError(picker, ""); updateImageCount(picker, state); } function initializeImagePicker(picker) { if ( pickerStates.has(picker) || typeof DataTransfer === "undefined" || typeof URL.createObjectURL !== "function" ) { return; } const input = picker.querySelector("[data-image-input]"); const dropzone = picker.querySelector("[data-image-dropzone]"); const list = picker.querySelector("[data-image-list]"); const template = picker.querySelector("[data-image-template]"); const videoSlot = picker.querySelector("[data-video-slot]"); const videoTemplate = picker.querySelector("[data-video-template]"); if (!input || !dropzone || !list || !template || !videoSlot || !videoTemplate) { return; } const state = { input, list, template, videoSlot, videoTemplate, entries: [], video: null, max: Number.parseInt(picker.dataset.maxImages, 10) || 4, maxVideos: Number.parseInt(picker.dataset.maxVideos, 10) || 1, }; pickerStates.set(picker, state); updateImageCount(picker, state); input.addEventListener("change", () => { addImageFiles(picker, state, Array.from(input.files)); }); picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => { card.querySelector("[data-remove-image]").addEventListener("click", () => { card.hidden = true; card.querySelectorAll("input").forEach((existingInput) => { existingInput.disabled = true; }); showImageError(picker, ""); updateImageCount(picker, state); }); }); ["dragenter", "dragover"].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.add("is-dragging"); }); }); ["dragleave", "drop"].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.remove("is-dragging"); }); }); dropzone.addEventListener("drop", (event) => { addImageFiles(picker, state, Array.from(event.dataTransfer.files)); }); picker.closest("form")?.addEventListener("reset", () => { window.setTimeout(() => resetImagePicker(picker), 0); }); } document.addEventListener("submit", (event) => { const form = event.target.closest(formSelector); if (!form) { return; } if (form.dataset.submitting === "true") { event.preventDefault(); return; } form.dataset.submitting = "true"; form.setAttribute("aria-busy", "true"); const button = event.submitter || form.querySelector("[data-submit-button]"); if (button) { button.dataset.idleLabel = button.textContent; button.textContent = form.dataset.submittingLabel || "Posting…"; button.disabled = true; button.setAttribute("aria-disabled", "true"); } const progress = progressIndicator(); if (progress) { progress.hidden = false; } }); window.addEventListener("pageshow", () => { document.querySelectorAll(formSelector).forEach(resetForm); document.querySelectorAll(pickerSelector).forEach(resetImagePicker); if (zoomDialog?.open) { zoomDialog.close(); } const progress = progressIndicator(); if (progress) { progress.hidden = true; } }); document.querySelectorAll(pickerSelector).forEach(initializeImagePicker); const zoomDialog = document.querySelector("[data-image-zoom-dialog]"); const zoomImage = zoomDialog?.querySelector("[data-image-zoom-img]"); const zoomCaption = zoomDialog?.querySelector("[data-image-zoom-caption]"); let zoomTrigger = null; function openImageZoom(link) { if (!zoomDialog || !zoomImage || typeof zoomDialog.showModal !== "function") { return false; } const photo = link.querySelector("img"); if (!photo) { return false; } zoomTrigger = link; zoomImage.src = link.href; zoomImage.alt = photo.alt || ""; if (zoomCaption) { zoomCaption.textContent = photo.alt || ""; } if (!zoomDialog.open) { zoomDialog.showModal(); } return true; } document.addEventListener("click", (event) => { const link = event.target.closest("[data-image-zoom]"); if (!link) { return; } if (openImageZoom(link)) { event.preventDefault(); } }); zoomDialog?.addEventListener("click", (event) => { if (event.target === zoomDialog) { zoomDialog.close(); } }); zoomDialog?.addEventListener("close", () => { if (zoomImage) { zoomImage.removeAttribute("src"); } zoomTrigger?.focus(); zoomTrigger = null; }); })();