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"));
+});