mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(edit): thumbnail + video file upload in Edit modal (steps 2-3/3)
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 <[email protected]>
This commit is contained in:
1 parent
7bbf0f08b1
commit
c8a63c57c6
6 files changed
+287
No files matched your search
@@ -672,6 +672,26 @@
|
|||||||
Edit field
|
Edit field
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<input type="file" id="editVideoFile" accept="video/*" class="hidden" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="editVideoFileBtn"
|
||||||
|
class="btn-ghost focus-ring text-xs"
|
||||||
|
>
|
||||||
|
Replace video file…
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
id="editVideoUploadStatus"
|
||||||
|
class="text-2xs text-muted empty:hidden"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
Uploading a new file replaces the URL, magnet, and torrent hints.
|
||||||
|
Requires unlocked storage.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@@ -760,6 +780,22 @@
|
|||||||
Edit field
|
Edit field
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<input type="file" id="editThumbnailFile" accept="image/*" class="hidden" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="editThumbnailFileBtn"
|
||||||
|
class="btn-ghost focus-ring text-xs"
|
||||||
|
>
|
||||||
|
Replace thumbnail file…
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
id="editThumbnailUploadStatus"
|
||||||
|
class="text-2xs text-muted empty:hidden"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export default class ModalManager {
|
|||||||
magnet: (value) => (typeof value === "string" ? value.trim() : ""),
|
magnet: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||||
checkbox: (value) => !!value,
|
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: {
|
utilities: {
|
||||||
removeTrackingScripts,
|
removeTrackingScripts,
|
||||||
@@ -117,6 +122,9 @@ export default class ModalManager {
|
|||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
showError: (message) => app.showError(message),
|
showError: (message) => app.showError(message),
|
||||||
|
showSuccess: (message) => app.showSuccess(message),
|
||||||
|
getCurrentPubkey: () => app.pubkey,
|
||||||
|
safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
|
||||||
onSubmit: (event) => app.handleEditModalSubmit(event),
|
onSubmit: (event) => app.handleEditModalSubmit(event),
|
||||||
onCancel: () => app.showError(""),
|
onCancel: () => app.showError(""),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
import { normalizeAndAugmentMagnet } from "../../magnetUtils.js";
|
import { normalizeAndAugmentMagnet } from "../../magnetUtils.js";
|
||||||
import { createModalAccessibility } from "./modalAccessibility.js";
|
import { createModalAccessibility } from "./modalAccessibility.js";
|
||||||
import { Nip71FormManager } from "./nip71FormManager.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 {
|
export class EditModal {
|
||||||
constructor({
|
constructor({
|
||||||
@@ -15,7 +18,13 @@ export class EditModal {
|
|||||||
sanitizers = {},
|
sanitizers = {},
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
showError,
|
showError,
|
||||||
|
showSuccess,
|
||||||
getMode,
|
getMode,
|
||||||
|
r2Service = null,
|
||||||
|
s3Service = null,
|
||||||
|
storageService = null,
|
||||||
|
getCurrentPubkey = null,
|
||||||
|
safeEncodeNpub = null,
|
||||||
eventTarget,
|
eventTarget,
|
||||||
container,
|
container,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -25,6 +34,20 @@ export class EditModal {
|
|||||||
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
||||||
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
|
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
|
||||||
this.showError = typeof showError === "function" ? showError : () => {};
|
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 =
|
this.getMode =
|
||||||
typeof getMode === "function"
|
typeof getMode === "function"
|
||||||
? getMode
|
? getMode
|
||||||
@@ -196,6 +219,10 @@ export class EditModal {
|
|||||||
|
|
||||||
const nip71Context = this.form || context;
|
const nip71Context = this.form || context;
|
||||||
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
|
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() {
|
setupModalAccessibility() {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -14,6 +14,9 @@ export function initEditModal({
|
|||||||
const {
|
const {
|
||||||
getMode = null,
|
getMode = null,
|
||||||
sanitizers = {},
|
sanitizers = {},
|
||||||
|
r2Service = null,
|
||||||
|
s3Service = null,
|
||||||
|
storageService = null,
|
||||||
} = services;
|
} = services;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -24,6 +27,9 @@ export function initEditModal({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
showError = null,
|
showError = null,
|
||||||
|
showSuccess = null,
|
||||||
|
getCurrentPubkey = null,
|
||||||
|
safeEncodeNpub = null,
|
||||||
onSubmit = null,
|
onSubmit = null,
|
||||||
onCancel = null,
|
onCancel = null,
|
||||||
} = callbacks;
|
} = callbacks;
|
||||||
@@ -36,9 +42,15 @@ export function initEditModal({
|
|||||||
removeTrackingScripts: removeTrackingScriptsFn,
|
removeTrackingScripts: removeTrackingScriptsFn,
|
||||||
setGlobalModalState: setGlobalModalStateFn,
|
setGlobalModalState: setGlobalModalStateFn,
|
||||||
showError,
|
showError,
|
||||||
|
showSuccess,
|
||||||
getMode,
|
getMode,
|
||||||
sanitizers,
|
sanitizers,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
r2Service,
|
||||||
|
s3Service,
|
||||||
|
storageService,
|
||||||
|
getCurrentPubkey,
|
||||||
|
safeEncodeNpub,
|
||||||
eventTarget: events,
|
eventTarget: events,
|
||||||
container,
|
container,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user