From c8a63c57c6689cffb1744fd9a7e79f23f9635502 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:09:07 -0400 Subject: [PATCH] feat(edit): thumbnail + video file upload in Edit modal (steps 2-3/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Edit modal to upload parity with the Upload modal: re-upload a thumbnail or video file instead of only pasting URLs. - Wire storage services (r2/s3/storageService) + pubkey/npub accessors into EditModal via ModalManager/initEditModal; EditModal constructs a shared MediaUploader. - New editModalUpload.js holds all the upload glue (file-picker wiring, progress, storage-unlock checks, field write-back) to keep EditModal under its file-size budget. EditModal calls initEditModalUpload(this, context) in one line. - edit-video-modal.html: "Replace video file…" (under the URL field) and "Replace thumbnail file…" (under the thumbnail field), each with a hidden file input + status line. - On thumbnail upload: writes the new URL into the thumbnail field. On video upload: writes url + magnet (+ ws/xs hints). All written via setFieldUnlocked, which marks the field edited so EditModal.collect actually persists it (gated behind storage being configured + unlocked). setFieldUnlocked is unit-tested (mutation-verified): without the unlock, the re-upload would silently not save. Build + full lint + unit suite green. NOTE: the upload/storage flow itself can't be unit-tested (browser + R2/S3); pending manual verification of editing a video's thumbnail/file. Co-Authored-By: Claude Opus 4.8 --- components/edit-video-modal.html | 36 ++++++ js/ui/ModalManager.js | 8 ++ js/ui/components/EditModal.js | 27 +++++ js/ui/components/editModalUpload.js | 155 +++++++++++++++++++++++++ js/ui/initEditModal.js | 12 ++ tests/edit-modal-upload-field.test.mjs | 49 ++++++++ 6 files changed, 287 insertions(+) create mode 100644 js/ui/components/editModalUpload.js create mode 100644 tests/edit-modal-upload-field.test.mjs diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html index 1a5c5ffb..74fa9e5e 100644 --- a/components/edit-video-modal.html +++ b/components/edit-video-modal.html @@ -672,6 +672,26 @@ Edit field +
+ + +

+
+

+ Uploading a new file replaces the URL, magnet, and torrent hints. + Requires unlocked storage. +

@@ -760,6 +780,22 @@ Edit field
+
+ + +

+
diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index 61733177..cdf0c13b 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -109,6 +109,11 @@ export default class ModalManager { magnet: (value) => (typeof value === "string" ? value.trim() : ""), checkbox: (value) => !!value, }, + // Storage services so the Edit modal can re-upload thumbnail/video files + // (same wiring as the Upload modal). + r2Service: app.r2Service, + s3Service: app.s3Service, + storageService: this.services.storageService, }, utilities: { removeTrackingScripts, @@ -117,6 +122,9 @@ export default class ModalManager { }, callbacks: { showError: (message) => app.showError(message), + showSuccess: (message) => app.showSuccess(message), + getCurrentPubkey: () => app.pubkey, + safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), onSubmit: (event) => app.handleEditModalSubmit(event), onCancel: () => app.showError(""), }, diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index d8fa7f55..098a62f1 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -7,6 +7,9 @@ import { import { normalizeAndAugmentMagnet } from "../../magnetUtils.js"; import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; +import { MediaUploader } from "./mediaUploader.js"; +import { initEditModalUpload } from "./editModalUpload.js"; +import { devLogger } from "../../utils/logger.js"; export class EditModal { constructor({ @@ -15,7 +18,13 @@ export class EditModal { sanitizers = {}, escapeHtml, showError, + showSuccess, getMode, + r2Service = null, + s3Service = null, + storageService = null, + getCurrentPubkey = null, + safeEncodeNpub = null, eventTarget, container, } = {}) { @@ -25,6 +34,20 @@ export class EditModal { typeof setGlobalModalState === "function" ? setGlobalModalState : () => {}; this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`; this.showError = typeof showError === "function" ? showError : () => {}; + this.showSuccess = typeof showSuccess === "function" ? showSuccess : () => {}; + // Storage upload wiring (mirrors UploadModal) so Edit can re-upload files. + this.storageService = storageService || null; + this.getCurrentPubkey = + typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; + this.mediaUploader = new MediaUploader({ + r2Service, + s3Service, + storageService, + getCurrentPubkey: () => + this.getCurrentPubkey ? this.getCurrentPubkey() : null, + safeEncodeNpub: + typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p, + }); this.getMode = typeof getMode === "function" ? getMode @@ -196,6 +219,10 @@ export class EditModal { const nip71Context = this.form || context; this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context); + + // Wire optional "Replace file" upload controls (thumbnail + video). All the + // glue lives in editModalUpload.js to keep this module within budget. + initEditModalUpload(this, context); } setupModalAccessibility() { diff --git a/js/ui/components/editModalUpload.js b/js/ui/components/editModalUpload.js new file mode 100644 index 00000000..5317e731 --- /dev/null +++ b/js/ui/components/editModalUpload.js @@ -0,0 +1,155 @@ +// js/ui/components/editModalUpload.js +// +// "Replace file" upload glue for the Edit modal: wires the optional thumbnail +// and video file-pickers to the shared MediaUploader and writes the resulting +// URLs/magnet back into the (unlocked) edit fields. Kept out of EditModal.js to +// stay within its file-size budget. Operates on the EditModal instance (`modal`) +// and its rendered root (`context`). + +import { devLogger, userLogger } from "../../utils/logger.js"; + +// Set a field's value AND mark it edited/unlocked so EditModal.collect treats it +// as a user change (isEditing() reads readOnly===false || dataset.isEditing). +function setFieldUnlocked(input, value) { + if (!input) return; + input.value = value; + input.readOnly = false; + input.removeAttribute("readonly"); + input.dataset.isEditing = "true"; +} + +function setStatus(el, text) { + if (el) el.textContent = typeof text === "string" ? text : ""; +} + +// Resolve the active storage connection and surface a user-facing reason when +// upload isn't possible. Returns the connection on success, null otherwise. +async function ensureUploadable(modal) { + if (!modal.mediaUploader) { + modal.showError("Uploads are unavailable in this view."); + return null; + } + let conn = null; + try { + conn = await modal.mediaUploader.resolveActiveConnection(); + } catch (error) { + devLogger.warn("[editModalUpload] Failed to resolve storage connection:", error); + } + if (!conn || !conn.configured) { + modal.showError("Configure a storage provider in your profile before uploading."); + return null; + } + if (!conn.unlocked || !conn.credentials) { + modal.showError("Unlock your storage in the profile modal, then try again."); + return null; + } + return conn; +} + +export function initEditModalUpload(modal, context) { + if (!context || typeof context.querySelector !== "function") { + return; + } + + const els = { + thumbBtn: context.querySelector("#editThumbnailFileBtn") || null, + thumbInput: context.querySelector("#editThumbnailFile") || null, + thumbStatus: context.querySelector("#editThumbnailUploadStatus") || null, + videoBtn: context.querySelector("#editVideoFileBtn") || null, + videoInput: context.querySelector("#editVideoFile") || null, + videoStatus: context.querySelector("#editVideoUploadStatus") || null, + }; + + // Monotonic guard so a stale upload (modal reused for another video) can't + // overwrite fresh fields. + modal._editUploadId = modal._editUploadId || 0; + + if (els.thumbBtn instanceof HTMLElement && els.thumbInput instanceof HTMLElement) { + els.thumbBtn.addEventListener("click", () => els.thumbInput.click()); + els.thumbInput.addEventListener("change", async () => { + const file = els.thumbInput.files?.[0]; + els.thumbInput.value = ""; + if (!file) return; + + const conn = await ensureUploadable(modal); + if (!conn) return; + + const uploadId = ++modal._editUploadId; + setStatus(els.thumbStatus, "Uploading thumbnail…"); + try { + const { url } = await modal.mediaUploader.uploadThumbnail(file, { + provider: conn.provider, + credentials: conn.credentials, + onProgress: (fraction) => { + if (modal._editUploadId === uploadId && Number.isFinite(fraction)) { + setStatus(els.thumbStatus, `Uploading thumbnail… ${Math.round(fraction * 100)}%`); + } + }, + }); + if (modal._editUploadId !== uploadId) return; + setFieldUnlocked(modal.fields?.thumbnail, url); + setStatus(els.thumbStatus, "Thumbnail uploaded."); + modal.showSuccess?.("Thumbnail uploaded. Save changes to publish it."); + } catch (error) { + if (modal._editUploadId !== uploadId) return; + userLogger.error("[editModalUpload] Thumbnail upload failed:", error); + setStatus(els.thumbStatus, "Thumbnail upload failed."); + modal.showError("Thumbnail upload failed."); + } + }); + } + + if (els.videoBtn instanceof HTMLElement && els.videoInput instanceof HTMLElement) { + els.videoBtn.addEventListener("click", () => els.videoInput.click()); + els.videoInput.addEventListener("change", async () => { + const file = els.videoInput.files?.[0]; + els.videoInput.value = ""; + if (!file) return; + + const conn = await ensureUploadable(modal); + if (!conn) return; + + const uploadId = ++modal._editUploadId; + setStatus(els.videoStatus, "Uploading video…"); + try { + const result = await modal.mediaUploader.uploadVideo(file, { + provider: conn.provider, + credentials: conn.credentials, + onProgress: ({ fraction, label }) => { + if (modal._editUploadId !== uploadId) return; + if (label) setStatus(els.videoStatus, label); + else if (Number.isFinite(fraction)) { + setStatus(els.videoStatus, `Uploading video… ${Math.round(fraction * 100)}%`); + } + }, + }); + if (modal._editUploadId !== uploadId) return; + + // Write the new source into the (unlocked) fields. The magnet carries + // the new infohash + ws/xs; EditModal.collect re-derives hints from it. + setFieldUnlocked(modal.fields?.url, result.url); + if (result.magnet) { + setFieldUnlocked(modal.fields?.magnet, result.magnet); + if (modal.fields?.ws) setFieldUnlocked(modal.fields.ws, result.url); + if (modal.fields?.xs && result.torrentUrl) { + setFieldUnlocked(modal.fields.xs, result.torrentUrl); + } + } + setStatus( + els.videoStatus, + result.hasValidInfoHash + ? "Video uploaded. Save changes to publish it." + : "Video uploaded (no torrent). Save changes to publish it.", + ); + modal.showSuccess?.("Video uploaded. Save changes to publish it."); + } catch (error) { + if (modal._editUploadId !== uploadId) return; + userLogger.error("[editModalUpload] Video upload failed:", error); + setStatus(els.videoStatus, "Video upload failed."); + modal.showError("Video upload failed."); + } + }); + } +} + +export { setFieldUnlocked }; diff --git a/js/ui/initEditModal.js b/js/ui/initEditModal.js index 6e63103e..2f4cc9bb 100644 --- a/js/ui/initEditModal.js +++ b/js/ui/initEditModal.js @@ -14,6 +14,9 @@ export function initEditModal({ const { getMode = null, sanitizers = {}, + r2Service = null, + s3Service = null, + storageService = null, } = services; const { @@ -24,6 +27,9 @@ export function initEditModal({ const { showError = null, + showSuccess = null, + getCurrentPubkey = null, + safeEncodeNpub = null, onSubmit = null, onCancel = null, } = callbacks; @@ -36,9 +42,15 @@ export function initEditModal({ removeTrackingScripts: removeTrackingScriptsFn, setGlobalModalState: setGlobalModalStateFn, showError, + showSuccess, getMode, sanitizers, escapeHtml, + r2Service, + s3Service, + storageService, + getCurrentPubkey, + safeEncodeNpub, eventTarget: events, container, }); diff --git a/tests/edit-modal-upload-field.test.mjs b/tests/edit-modal-upload-field.test.mjs new file mode 100644 index 00000000..dc344b0d --- /dev/null +++ b/tests/edit-modal-upload-field.test.mjs @@ -0,0 +1,49 @@ +// When the Edit modal re-uploads a thumbnail/video file, the new URL/magnet must +// be written into the field AND the field marked "edited" — otherwise +// EditModal.collect (which gates each field on isEditing: readOnly===false || +// dataset.isEditing==="true") would keep the ORIGINAL value and the re-upload +// would silently not save. setFieldUnlocked encodes that contract. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { setFieldUnlocked } from "../js/ui/components/editModalUpload.js"; + +// Minimal input-element stand-in matching what setFieldUnlocked touches. +function makeInput() { + return { + value: "original", + readOnly: true, + dataset: {}, + removeAttribute(name) { + this._removed = this._removed || []; + this._removed.push(name); + }, + }; +} + +// Mirror of EditModal.collect's isEditing() gate. +function isEditing(input) { + if (!input) return true; + if (input.dataset?.isEditing === "true") return true; + return input.readOnly === false; +} + +test("setFieldUnlocked sets the value and marks the field edited so it persists", () => { + const input = makeInput(); + setFieldUnlocked(input, "https://cdn.example.com/new.mp4"); + + assert.equal(input.value, "https://cdn.example.com/new.mp4", "value updated"); + assert.equal(input.readOnly, false, "field unlocked"); + assert.equal(input.dataset.isEditing, "true", "marked as user-edited"); + assert.equal( + isEditing(input), + true, + "EditModal.collect would treat it as edited (so the upload persists)", + ); +}); + +test("setFieldUnlocked is a no-op on a missing field", () => { + assert.doesNotThrow(() => setFieldUnlocked(null, "x")); + assert.doesNotThrow(() => setFieldUnlocked(undefined, "x")); +});