import { html } from "./dom.js"; class MediaInput extends HTMLElement { connectedCallback() { requestAnimationFrame(() => this.#setup()); } #setup() { const endpoint = this.getAttribute("endpoint"); const fileInput = this.querySelector("input[type=file]"); const progressEl = this.querySelector("progress"); const field = this.querySelector("input[type=hidden]"); const gallery = this; function itemTemplate(cid, mimeType, size, previewUrl, alt, url = "") { const what = url ? "video" : "image"; const preview = url ? html`` : html``; const item = html`
${preview}
`.firstElementChild; return item; } // The id of the YouTube video a link is to, as a watch, short, embed, // live, or youtu.be link. function youtubeId(text) { let url; try { url = new URL(text.trim()); } catch { return null; } if (!["http:", "https:"].includes(url.protocol)) return null; const host = url.hostname.replace(/^(www|m)\./, ""); const [, first, second] = url.pathname.split("/"); let id = null; if (host === "youtu.be") id = first; else if (host === "youtube.com" || host === "youtube-nocookie.com") { if (["embed", "shorts", "live", "v"].includes(first)) id = second; else if (first === "watch") id = url.searchParams.get("v"); } return /^[A-Za-z0-9_-]{11}$/.test(id ?? "") ? id : null; } const youtubeInput = this.querySelector(".youtube-url"); function addYoutube() { const id = youtubeId(youtubeInput.value); if (!id) { youtubeInput.setCustomValidity("Enter a link to a YouTube video"); youtubeInput.reportValidity(); return; } gallery.insertBefore( itemTemplate("", "", 0, "", "", `https://www.youtube.com/watch?v=${id}`), fileInput.closest("label"), ); youtubeInput.value = ""; } youtubeInput.addEventListener("input", () => youtubeInput.setCustomValidity("")); // Enter here would otherwise save the whole form. youtubeInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); addYoutube(); }); this.querySelector(".youtube-add button").addEventListener("click", addYoutube); async function upload(file) { const response = await fetch(endpoint, { method: "POST", headers: { "content-type": file.type }, body: file, }); if (!response.ok) { throw new Error( (await response.text()) || "The selected file could not be uploaded - try again", ); } return response.json(); } async function addFiles(files) { const images = Array.from(files).filter((f) => f.type.startsWith("image/")); if (!images.length) return; progressEl.hidden = false; progressEl.max = images.length; progressEl.value = 0; for (const file of images) { try { const blob = await upload(file); gallery.insertBefore( itemTemplate( blob.cid, blob.mimeType, blob.size, URL.createObjectURL(file), "", ), fileInput.closest("label"), ); } catch (failure) { alert(failure.message); } progressEl.value += 1; } progressEl.hidden = true; fileInput.value = ""; } fileInput.addEventListener("change", () => addFiles(fileInput.files)); // On the images the page came with as well as those added since. gallery.addEventListener("click", (event) => { event.target.closest(".remove")?.closest(".item")?.remove(); }); let dragging = null; gallery.addEventListener("dragstart", (event) => { const item = event.target.closest(".item"); if (!item) return; dragging = item; item.classList.add("dragging"); }); gallery.addEventListener("dragend", () => { if (dragging) dragging.classList.remove("dragging"); dragging = null; }); gallery.addEventListener("dragover", (event) => { if (!dragging) return; event.preventDefault(); const target = event.target.closest(".item"); if (!target || target === dragging) return; const rect = target.getBoundingClientRect(); const before = event.clientY - rect.top < rect.height / 2; gallery.insertBefore(dragging, before ? target : target.nextSibling); }); function sync() { const items = Array.from(gallery.querySelectorAll(".item")).map((li) => { const alt = li.querySelector(".alt").value.trim(); if (li.dataset.url) return { url: li.dataset.url, alt }; return { cid: li.dataset.cid, mimeType: li.dataset.mimeType, size: Number(li.dataset.size), alt, }; }); field.value = JSON.stringify(items); } const form = gallery.closest("form"); if (form) form.addEventListener("submit", sync); gallery.addEventListener("input", sync); gallery.addEventListener("click", sync); new MutationObserver(sync).observe(gallery, { childList: true }); sync(); } } customElements.define("media-input", MediaInput);