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);