Add video picker and Discord video attachments.
CI / test (pull_request) Successful in 6m29s

The picker accepts one MP4 or WebM, the thread lightbox zooms photos, and Discord gets the video as a file on a follow-up message so photo embeds still render.
This commit is contained in:
2026-08-31 02:00:56 -07:00
parent 84dea8ea5d
commit f96df3222d
22 changed files with 1018 additions and 144 deletions
+123
View File
@@ -854,6 +854,55 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
font-size: 0.72rem;
}
.video-picker-slot:not(:empty) {
margin-bottom: 10px;
}
.video-preview {
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
border: 1px solid var(--line);
border-radius: 3px;
overflow: hidden;
background: #141516;
}
.video-preview[hidden] { display: none; }
.video-preview-media {
position: relative;
background: var(--bg);
}
.video-preview-media video,
.post-video video {
display: block;
width: 100%;
max-height: 20rem;
background: #000;
}
.post-video {
margin: 16px 0 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 3px;
background: #141516;
}
.post-video video { max-height: 32rem; }
.post-video figcaption {
padding: 8px 10px;
border-top: 1px solid var(--line);
color: var(--muted);
font-family: var(--mono);
font-size: 0.7rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.image-preview-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -962,6 +1011,18 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
background: #141516;
}
.post-image-zoom {
display: block;
color: inherit;
text-decoration: none;
cursor: zoom-in;
}
.post-image-zoom:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.post-image img {
width: 100%;
height: 100%;
@@ -976,6 +1037,68 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
object-fit: contain;
}
.image-zoom {
width: min(100vw - 24px, 1100px);
max-width: none;
height: min(100vh - 24px, 100dvh);
max-height: none;
margin: auto;
padding: 12px;
border: 1px solid var(--line);
border-radius: 4px;
background: #101113;
color: var(--ink);
}
.image-zoom::backdrop {
background: rgba(10, 11, 12, 0.88);
}
.image-zoom-bar {
display: flex;
justify-content: flex-end;
margin: 0 0 8px;
}
.image-zoom-close {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 3px;
background: var(--panel);
color: var(--ink);
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.image-zoom-close:hover { border-color: var(--zinc); }
.image-zoom-close:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.image-zoom img {
display: block;
width: 100%;
height: calc(100% - 4.5rem);
max-height: calc(100dvh - 8rem);
object-fit: contain;
background: #000;
}
.image-zoom-caption {
margin: 8px 0 0;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.post-image figcaption {
padding: 8px 10px;
border-top: 1px solid var(--line);
+176 -36
View File
@@ -2,7 +2,9 @@
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() {
@@ -29,12 +31,20 @@
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 count = existingImageCount(picker) + state.entries.length;
const photos = existingImageCount(picker) + state.entries.length;
const videos = existingVideoCount(picker) + (state.video ? 1 : 0);
const status = picker.querySelector("[data-image-count]");
if (status) {
status.textContent = `${count} of ${state.max}`;
if (!status) {
return;
}
status.textContent = videos
? `${photos} of ${state.max} · 1 video`
: `${photos} of ${state.max}`;
}
function showImageError(picker, message) {
@@ -56,6 +66,16 @@
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 &&
@@ -64,6 +84,9 @@
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;
}
@@ -81,54 +104,108 @@
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.entries.some((entry) => sameImageFile(entry.file, file)) &&
!(state.video && sameImageFile(state.video.file, file))
);
const available = state.max - existingImageCount(picker) - state.entries.length;
if (uniqueFiles.length > available) {
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,
available > 0
? `You can add ${available} more ${available === 1 ? "image" : "images"}.`
availablePhotos > 0
? `You can add ${availablePhotos} more ${availablePhotos === 1 ? "image" : "images"}.`
: "You already have 4 images selected."
);
syncImageInput(state);
return;
}
for (const file of uniqueFiles) {
if (!imageFileAllowed(file)) {
showImageError(picker, "Images must be JPEG, PNG, or WebP.");
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;
}
}
uniqueFiles.forEach((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;
for (const file of videos) {
if (file.size > maxVideoBytes) {
showImageError(picker, `${file.name} is larger than 25 MB.`);
syncImageInput(state);
return;
}
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);
});
}
videos.forEach((file) => addNewVideo(picker, state, file));
images.forEach((file) => addNewImage(picker, state, file));
syncImageInput(state);
updateImageCount(picker, state);
}
@@ -143,8 +220,13 @@
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]").forEach((card) => {
picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => {
card.hidden = false;
card.querySelectorAll("input").forEach((input) => {
input.disabled = false;
@@ -166,15 +248,21 @@
const dropzone = picker.querySelector("[data-image-dropzone]");
const list = picker.querySelector("[data-image-list]");
const template = picker.querySelector("[data-image-template]");
if (!input || !dropzone || !list || !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);
@@ -182,7 +270,7 @@
input.addEventListener("change", () => {
addImageFiles(picker, state, Array.from(input.files));
});
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
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) => {
@@ -242,6 +330,9 @@
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;
@@ -249,4 +340,53 @@
});
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;
});
})();