From b52be2ab4262cf08f43e4ed41a7745502d76f45b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:37:34 -0400 Subject: [PATCH 01/82] Add source toggle to upload modal --- components/upload-modal.html | 39 ++++++++++++++++++++++-- js/app.js | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/components/upload-modal.html b/components/upload-modal.html index d361a352..77414892 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -50,8 +50,34 @@ -
-
+
+
+
+ + +
+
+ +
+

Share a title plus either a hosted video URL or a magnet link. Providing both helps Bitvid fall back gracefully.

@@ -166,6 +192,15 @@ Publish +
+ +
diff --git a/js/app.js b/js/app.js index be73a127..e6289f56 100644 --- a/js/app.js +++ b/js/app.js @@ -455,6 +455,10 @@ class bitvidApp { this.closeUploadModalBtn = document.getElementById("closeUploadModal") || null; this.uploadForm = document.getElementById("uploadForm") || null; + this.uploadModeToggleButtons = []; + this.customUploadSection = null; + this.cloudflareUploadSection = null; + this.activeUploadMode = "custom"; // Optional small inline player stats this.status = document.getElementById("status") || null; @@ -1041,6 +1045,13 @@ class bitvidApp { this.closeUploadModalBtn = document.getElementById("closeUploadModal") || null; this.uploadForm = document.getElementById("uploadForm") || null; + this.uploadModeToggleButtons = Array.from( + document.querySelectorAll(".upload-mode-toggle[data-upload-mode]") + ); + this.customUploadSection = + document.getElementById("customUploadSection") || null; + this.cloudflareUploadSection = + document.getElementById("cloudflareUploadSection") || null; // Optional: if close button found, wire up if (this.closeUploadModalBtn) { @@ -1058,6 +1069,17 @@ class bitvidApp { }); } + if (this.uploadModeToggleButtons.length > 0) { + this.uploadModeToggleButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const mode = btn.dataset.uploadMode || "custom"; + this.setUploadMode(mode); + }); + }); + } + + this.setUploadMode(this.activeUploadMode); + console.log("Upload modal initialization successful"); return true; } catch (error) { @@ -1067,6 +1089,42 @@ class bitvidApp { } } + setUploadMode(mode) { + const normalized = mode === "cloudflare" ? "cloudflare" : "custom"; + this.activeUploadMode = normalized; + + if (this.customUploadSection) { + if (normalized === "custom") { + this.customUploadSection.classList.remove("hidden"); + } else { + this.customUploadSection.classList.add("hidden"); + } + } + + if (this.cloudflareUploadSection) { + if (normalized === "cloudflare") { + this.cloudflareUploadSection.classList.remove("hidden"); + } else { + this.cloudflareUploadSection.classList.add("hidden"); + } + } + + if (Array.isArray(this.uploadModeToggleButtons)) { + this.uploadModeToggleButtons.forEach((btn) => { + if (!btn || !btn.dataset) { + return; + } + + const isActive = btn.dataset.uploadMode === normalized; + btn.classList.toggle("bg-blue-500", isActive); + btn.classList.toggle("text-white", isActive); + btn.classList.toggle("shadow", isActive); + btn.classList.toggle("text-gray-300", !isActive); + btn.setAttribute("aria-pressed", isActive ? "true" : "false"); + }); + } + } + /** * (Optional) Initialize a separate profile modal (profile-modal.html). */ From ea300b47bf1051edec6cd6b374864f88c0016f2e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:00:26 -0400 Subject: [PATCH 02/82] Add Cloudflare R2 upload workflow --- components/upload-modal.html | 260 +++++++++++- js/app.js | 793 ++++++++++++++++++++++++++++++++--- js/r2.js | 416 ++++++++++++++++++ 3 files changed, 1414 insertions(+), 55 deletions(-) create mode 100644 js/r2.js diff --git a/components/upload-modal.html b/components/upload-modal.html index 77414892..caa12232 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -195,10 +195,262 @@ diff --git a/js/app.js b/js/app.js index e6289f56..4dc4cae4 100644 --- a/js/app.js +++ b/js/app.js @@ -16,6 +16,16 @@ import { initialBlacklist, initialEventBlacklist, } from "./lists.js"; +import { + loadR2Settings, + saveR2Settings, + clearR2Settings, + bucketForNpub, + buildR2Key, + buildPublicUrl, + createR2Client, + uploadToR2, +} from "./r2.js"; /** * Simple "decryption" placeholder for private videos. @@ -459,6 +469,30 @@ class bitvidApp { this.customUploadSection = null; this.cloudflareUploadSection = null; this.activeUploadMode = "custom"; + this.cloudflareSettings = null; + this.cloudflareSettingsForm = null; + this.cloudflareClearSettingsButton = null; + this.cloudflareSettingsStatus = null; + this.cloudflareBucketPreview = null; + this.cloudflareBucketModeInputs = []; + this.cloudflareManualBucketGroup = null; + this.cloudflareManualBucketInput = null; + this.cloudflareUploadForm = null; + this.cloudflareFileInput = null; + this.cloudflareUploadButton = null; + this.cloudflareUploadStatus = null; + this.cloudflareProgressBar = null; + this.cloudflareProgressFill = null; + this.cloudflareTitleInput = null; + this.cloudflareDescriptionInput = null; + this.cloudflareThumbnailInput = null; + this.cloudflareMagnetInput = null; + this.cloudflareWsInput = null; + this.cloudflareXsInput = null; + this.r2AccountIdInput = null; + this.r2AccessKeyIdInput = null; + this.r2SecretAccessKeyInput = null; + this.r2PublicBaseUrlInput = null; // Optional small inline player stats this.status = document.getElementById("status") || null; @@ -1052,6 +1086,53 @@ class bitvidApp { document.getElementById("customUploadSection") || null; this.cloudflareUploadSection = document.getElementById("cloudflareUploadSection") || null; + this.cloudflareSettingsForm = + document.getElementById("cloudflareSettingsForm") || null; + this.cloudflareClearSettingsButton = + document.getElementById("cloudflareClearSettings") || null; + this.cloudflareSettingsStatus = + document.getElementById("cloudflareSettingsStatus") || null; + this.cloudflareBucketPreview = + document.getElementById("cloudflareBucketPreview") || null; + this.cloudflareBucketModeInputs = Array.from( + document.querySelectorAll("input[name='r2BucketMode']") + ); + this.cloudflareManualBucketGroup = + document.getElementById("r2ManualBucketGroup") || null; + this.cloudflareManualBucketInput = + document.getElementById("r2ManualBucket") || null; + this.cloudflareUploadForm = + document.getElementById("cloudflareUploadForm") || null; + this.cloudflareFileInput = + document.getElementById("cloudflareFile") || null; + this.cloudflareUploadButton = + document.getElementById("cloudflareUploadButton") || null; + this.cloudflareUploadStatus = + document.getElementById("cloudflareUploadStatus") || null; + this.cloudflareProgressBar = + document.getElementById("cloudflareProgressBar") || null; + this.cloudflareProgressFill = + document.getElementById("cloudflareProgressFill") || null; + this.cloudflareTitleInput = + document.getElementById("cloudflareTitle") || null; + this.cloudflareDescriptionInput = + document.getElementById("cloudflareDescription") || null; + this.cloudflareThumbnailInput = + document.getElementById("cloudflareThumbnail") || null; + this.cloudflareMagnetInput = + document.getElementById("cloudflareMagnet") || null; + this.cloudflareWsInput = + document.getElementById("cloudflareWs") || null; + this.cloudflareXsInput = + document.getElementById("cloudflareXs") || null; + this.r2AccountIdInput = + document.getElementById("r2AccountId") || null; + this.r2AccessKeyIdInput = + document.getElementById("r2AccessKeyId") || null; + this.r2SecretAccessKeyInput = + document.getElementById("r2SecretAccessKey") || null; + this.r2PublicBaseUrlInput = + document.getElementById("r2PublicBaseUrlTemplate") || null; // Optional: if close button found, wire up if (this.closeUploadModalBtn) { @@ -1069,6 +1150,40 @@ class bitvidApp { }); } + if (this.cloudflareSettingsForm) { + this.cloudflareSettingsForm.addEventListener("submit", (e) => { + e.preventDefault(); + this.handleCloudflareSettingsSubmit(); + }); + } + + if (this.cloudflareClearSettingsButton) { + this.cloudflareClearSettingsButton.addEventListener("click", () => { + this.handleCloudflareClearSettings(); + }); + } + + if (this.cloudflareBucketModeInputs.length > 0) { + this.cloudflareBucketModeInputs.forEach((input) => { + input.addEventListener("change", () => { + this.handleCloudflareBucketModeChange(); + }); + }); + } + + if (this.cloudflareManualBucketInput) { + this.cloudflareManualBucketInput.addEventListener("input", () => { + this.handleManualBucketInputChange(); + }); + } + + if (this.cloudflareUploadForm) { + this.cloudflareUploadForm.addEventListener("submit", (e) => { + e.preventDefault(); + this.handleCloudflareUploadSubmit(); + }); + } + if (this.uploadModeToggleButtons.length > 0) { this.uploadModeToggleButtons.forEach((btn) => { btn.addEventListener("click", () => { @@ -1078,6 +1193,9 @@ class bitvidApp { }); } + await this.loadCloudflareSettingsFromStorage(); + await this.updateCloudflareBucketPreview(); + this.setUploadMode(this.activeUploadMode); console.log("Upload modal initialization successful"); @@ -1123,6 +1241,552 @@ class bitvidApp { btn.setAttribute("aria-pressed", isActive ? "true" : "false"); }); } + + if (normalized === "cloudflare") { + this.updateCloudflareBucketPreview(); + } + } + + getCloudflareBucketMode() { + if (this.cloudflareBucketModeInputs.length === 0) { + return "auto"; + } + + const active = this.cloudflareBucketModeInputs.find( + (input) => input && input.checked + ); + return active && active.value === "manual" ? "manual" : "auto"; + } + + sanitizeBucketName(name) { + const raw = String(name || "") + .toLowerCase() + .replace(/[^a-z0-9-]/g, ""); + const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, ""); + return trimmed; + } + + setCloudflareSettingsStatus(message = "", variant = "info") { + if (!this.cloudflareSettingsStatus) { + return; + } + + const el = this.cloudflareSettingsStatus; + el.textContent = message || ""; + el.classList.remove( + "text-green-400", + "text-red-400", + "text-yellow-400", + "text-gray-400" + ); + if (!message) { + el.classList.add("text-gray-400"); + return; + } + + let cls = "text-gray-400"; + if (variant === "success") { + cls = "text-green-400"; + } else if (variant === "error") { + cls = "text-red-400"; + } else if (variant === "warning") { + cls = "text-yellow-400"; + } + el.classList.add(cls); + } + + setCloudflareUploadStatus(message = "", variant = "info") { + if (!this.cloudflareUploadStatus) { + return; + } + + const el = this.cloudflareUploadStatus; + el.textContent = message || ""; + el.classList.remove( + "text-green-400", + "text-red-400", + "text-yellow-400", + "text-gray-400" + ); + if (!message) { + el.classList.add("text-gray-400"); + return; + } + + let cls = "text-gray-400"; + if (variant === "success") { + cls = "text-green-400"; + } else if (variant === "error") { + cls = "text-red-400"; + } else if (variant === "warning") { + cls = "text-yellow-400"; + } + el.classList.add(cls); + } + + setCloudflareUploading(isUploading) { + if (this.cloudflareUploadButton) { + this.cloudflareUploadButton.disabled = Boolean(isUploading); + this.cloudflareUploadButton.textContent = isUploading + ? "Uploading…" + : "Upload to R2 & publish"; + } + + if (this.cloudflareFileInput) { + this.cloudflareFileInput.disabled = Boolean(isUploading); + } + } + + updateCloudflareProgress(fraction) { + if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) { + return; + } + + if (typeof fraction !== "number" || Number.isNaN(fraction)) { + this.cloudflareProgressBar.classList.add("hidden"); + this.cloudflareProgressFill.style.width = "0%"; + return; + } + + const clamped = Math.max(0, Math.min(1, fraction)); + this.cloudflareProgressBar.classList.remove("hidden"); + this.cloudflareProgressFill.style.width = `${(clamped * 100).toFixed(1)}%`; + } + + resetCloudflareUploadForm() { + if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = ""; + if (this.cloudflareDescriptionInput) + this.cloudflareDescriptionInput.value = ""; + if (this.cloudflareThumbnailInput) + this.cloudflareThumbnailInput.value = ""; + if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = ""; + if (this.cloudflareWsInput) this.cloudflareWsInput.value = ""; + if (this.cloudflareXsInput) this.cloudflareXsInput.value = ""; + if (this.cloudflareFileInput) this.cloudflareFileInput.value = ""; + this.updateCloudflareProgress(Number.NaN); + } + + async loadCloudflareSettingsFromStorage() { + try { + const settings = await loadR2Settings(); + this.cloudflareSettings = settings; + this.populateCloudflareSettingsInputs(settings); + } catch (err) { + console.error("Failed to load Cloudflare settings:", err); + this.setCloudflareSettingsStatus( + "Failed to load saved settings.", + "error" + ); + this.cloudflareSettings = { + accountId: "", + accessKeyId: "", + secretAccessKey: "", + publicBaseUrlTemplate: "", + bucketMode: "auto", + manualBucket: "", + autoBuckets: {}, + }; + this.populateCloudflareSettingsInputs(this.cloudflareSettings); + } + } + + populateCloudflareSettingsInputs(settings) { + const data = settings || { + accountId: "", + accessKeyId: "", + secretAccessKey: "", + publicBaseUrlTemplate: "", + bucketMode: "auto", + manualBucket: "", + }; + + if (this.r2AccountIdInput) { + this.r2AccountIdInput.value = data.accountId || ""; + } + if (this.r2AccessKeyIdInput) { + this.r2AccessKeyIdInput.value = data.accessKeyId || ""; + } + if (this.r2SecretAccessKeyInput) { + this.r2SecretAccessKeyInput.value = data.secretAccessKey || ""; + } + if (this.r2PublicBaseUrlInput) { + this.r2PublicBaseUrlInput.value = data.publicBaseUrlTemplate || ""; + } + + const mode = data.bucketMode === "manual" ? "manual" : "auto"; + if (this.cloudflareBucketModeInputs.length > 0) { + this.cloudflareBucketModeInputs.forEach((input) => { + if (input) { + input.checked = input.value === mode; + } + }); + } + + if (this.cloudflareManualBucketGroup) { + if (mode === "manual") { + this.cloudflareManualBucketGroup.classList.remove("hidden"); + } else { + this.cloudflareManualBucketGroup.classList.add("hidden"); + } + } + + if (this.cloudflareManualBucketInput) { + this.cloudflareManualBucketInput.value = data.manualBucket || ""; + } + + this.setCloudflareSettingsStatus(""); + } + + async handleCloudflareSettingsSubmit({ quiet = false } = {}) { + const accountId = (this.r2AccountIdInput?.value || "").trim(); + const accessKeyId = (this.r2AccessKeyIdInput?.value || "").trim(); + const secretAccessKey = (this.r2SecretAccessKeyInput?.value || "").trim(); + const publicBaseUrlTemplate = + (this.r2PublicBaseUrlInput?.value || "").trim(); + const bucketMode = this.getCloudflareBucketMode(); + let manualBucket = this.cloudflareManualBucketInput + ? this.cloudflareManualBucketInput.value + : ""; + manualBucket = this.sanitizeBucketName(manualBucket); + + if (!accountId || !accessKeyId || !secretAccessKey) { + if (!quiet) { + this.setCloudflareSettingsStatus( + "Account ID, Access Key ID, and Secret are required.", + "error" + ); + } + return false; + } + + if (bucketMode === "manual" && manualBucket.length < 3) { + if (!quiet) { + this.setCloudflareSettingsStatus( + "Manual bucket names must be at least 3 characters.", + "error" + ); + } + return false; + } + + if (this.cloudflareManualBucketInput) { + this.cloudflareManualBucketInput.value = manualBucket; + } + + const autoBuckets = { + ...(this.cloudflareSettings?.autoBuckets || {}), + }; + + const updatedSettings = { + accountId, + accessKeyId, + secretAccessKey, + publicBaseUrlTemplate, + bucketMode, + manualBucket, + autoBuckets, + }; + + try { + this.cloudflareSettings = await saveR2Settings(updatedSettings); + this.populateCloudflareSettingsInputs(this.cloudflareSettings); + if (!quiet) { + this.setCloudflareSettingsStatus("Settings saved locally.", "success"); + } + await this.updateCloudflareBucketPreview(); + return true; + } catch (err) { + console.error("Failed to save Cloudflare settings:", err); + if (!quiet) { + this.setCloudflareSettingsStatus( + "Failed to save settings. Check console for details.", + "error" + ); + } + } + + return false; + } + + async handleCloudflareClearSettings() { + try { + await clearR2Settings(); + this.cloudflareSettings = await loadR2Settings(); + this.populateCloudflareSettingsInputs(this.cloudflareSettings); + this.setCloudflareSettingsStatus("Settings cleared.", "success"); + await this.updateCloudflareBucketPreview(); + } catch (err) { + console.error("Failed to clear Cloudflare settings:", err); + this.setCloudflareSettingsStatus( + "Failed to clear settings.", + "error" + ); + } + } + + handleCloudflareBucketModeChange() { + const mode = this.getCloudflareBucketMode(); + if (this.cloudflareManualBucketGroup) { + if (mode === "manual") { + this.cloudflareManualBucketGroup.classList.remove("hidden"); + } else { + this.cloudflareManualBucketGroup.classList.add("hidden"); + } + } + if (this.cloudflareSettings) { + this.cloudflareSettings.bucketMode = mode; + } + this.updateCloudflareBucketPreview(); + } + + handleManualBucketInputChange() { + this.updateCloudflareBucketPreview(); + } + + async ensureAutoBucketForCurrentUser() { + if (!this.pubkey || !this.cloudflareSettings) { + return null; + } + + const mode = this.cloudflareSettings.bucketMode === "manual" ? "manual" : "auto"; + if (mode !== "auto") { + return null; + } + + const npub = this.safeEncodeNpub(this.pubkey); + if (!npub) { + return null; + } + + const existing = this.cloudflareSettings.autoBuckets?.[npub]; + if (existing) { + return existing; + } + + const bucket = bucketForNpub(npub); + const updatedSettings = { + ...this.cloudflareSettings, + autoBuckets: { + ...(this.cloudflareSettings.autoBuckets || {}), + [npub]: bucket, + }, + }; + + try { + this.cloudflareSettings = await saveR2Settings(updatedSettings); + } catch (err) { + console.warn("Failed to persist auto bucket name:", err); + this.cloudflareSettings = updatedSettings; + } + + return bucket; + } + + async updateCloudflareBucketPreview() { + if (!this.cloudflareBucketPreview) { + return; + } + + const el = this.cloudflareBucketPreview; + const mode = this.getCloudflareBucketMode(); + const template = (this.r2PublicBaseUrlInput?.value || "").trim(); + + if (mode === "manual") { + const manualBucket = this.sanitizeBucketName( + this.cloudflareManualBucketInput?.value || + this.cloudflareSettings?.manualBucket || + "" + ); + if (!manualBucket) { + el.textContent = "Enter a manual bucket name."; + return; + } + + const sampleNpub = this.safeEncodeNpub(this.pubkey) || "anon"; + const sampleKey = buildR2Key(sampleNpub, { + name: "sample.mp4", + }); + const publicUrl = buildPublicUrl(manualBucket, sampleKey, template); + el.textContent = `${manualBucket} • ${publicUrl}`; + return; + } + + if (!this.pubkey) { + el.textContent = "Login to preview your auto bucket."; + return; + } + + if (!this.cloudflareSettings) { + el.textContent = "Save your credentials to generate a bucket."; + return; + } + + const npub = this.safeEncodeNpub(this.pubkey); + if (!npub) { + el.textContent = "Unable to encode npub."; + return; + } + + let bucket = this.cloudflareSettings.autoBuckets?.[npub]; + if (!bucket) { + bucket = await this.ensureAutoBucketForCurrentUser(); + } + + if (!bucket) { + el.textContent = "Save your credentials to generate a bucket."; + return; + } + + const sampleKey = buildR2Key(npub, { name: "sample.mp4" }); + const publicUrl = buildPublicUrl(bucket, sampleKey, template); + el.textContent = `${bucket} • ${publicUrl}`; + } + + async handleCloudflareUploadSubmit() { + if (!this.pubkey) { + this.showError("Please login to post a video."); + return; + } + + const saved = await this.handleCloudflareSettingsSubmit({ quiet: true }); + if (!saved) { + this.setCloudflareUploadStatus( + "Fix your R2 settings before uploading.", + "error" + ); + return; + } + + const title = (this.cloudflareTitleInput?.value || "").trim(); + if (!title) { + this.setCloudflareUploadStatus("Title is required.", "error"); + return; + } + + const file = this.cloudflareFileInput?.files?.[0] || null; + if (!file) { + this.setCloudflareUploadStatus( + "Select a video or HLS file to upload.", + "error" + ); + return; + } + + const description = (this.cloudflareDescriptionInput?.value || "").trim(); + const thumbnail = (this.cloudflareThumbnailInput?.value || "").trim(); + const magnet = (this.cloudflareMagnetInput?.value || "").trim(); + const ws = (this.cloudflareWsInput?.value || "").trim(); + const xs = (this.cloudflareXsInput?.value || "").trim(); + + const accountId = this.cloudflareSettings?.accountId || ""; + const accessKeyId = this.cloudflareSettings?.accessKeyId || ""; + const secretAccessKey = this.cloudflareSettings?.secretAccessKey || ""; + const publicBaseUrlTemplate = + this.cloudflareSettings?.publicBaseUrlTemplate || ""; + + if (!accountId || !accessKeyId || !secretAccessKey) { + this.setCloudflareUploadStatus( + "Missing R2 credentials. Save them before uploading.", + "error" + ); + return; + } + + const npub = this.safeEncodeNpub(this.pubkey); + if (!npub) { + this.setCloudflareUploadStatus("Unable to encode npub.", "error"); + return; + } + + let bucketName = ""; + const mode = this.cloudflareSettings?.bucketMode === "manual" ? "manual" : "auto"; + if (mode === "manual") { + bucketName = this.sanitizeBucketName( + this.cloudflareSettings?.manualBucket || + this.cloudflareManualBucketInput?.value || + "" + ); + if (!bucketName) { + this.setCloudflareUploadStatus( + "Enter a valid manual bucket name.", + "error" + ); + return; + } + } else { + bucketName = this.cloudflareSettings?.autoBuckets?.[npub] || + (await this.ensureAutoBucketForCurrentUser()); + if (!bucketName) { + this.setCloudflareUploadStatus( + "Failed to compute an auto bucket name.", + "error" + ); + return; + } + } + + const key = buildR2Key(npub, file); + const publicUrl = buildPublicUrl( + bucketName, + key, + publicBaseUrlTemplate + ); + + this.setCloudflareUploadStatus("Uploading…", "info"); + this.updateCloudflareProgress(0); + this.setCloudflareUploading(true); + + try { + const s3 = await createR2Client({ + accountId, + accessKeyId, + secretAccessKey, + }); + + await uploadToR2({ + s3, + bucket: bucketName, + key, + file, + contentType: file.type, + onProgress: (fraction) => { + this.updateCloudflareProgress(fraction); + }, + }); + + const payload = { + title, + url: publicUrl, + magnet, + thumbnail, + description, + ws, + xs, + }; + + const published = await this.publishVideoNote(payload, { + onSuccess: () => { + this.resetCloudflareUploadForm(); + }, + }); + + if (published) { + this.setCloudflareUploadStatus( + `Published ${publicUrl}`, + "success" + ); + } + } catch (err) { + console.error("Cloudflare upload failed:", err); + this.setCloudflareUploadStatus( + err?.message ? `Upload failed: ${err.message}` : "Upload failed.", + "error" + ); + } finally { + this.setCloudflareUploading(false); + this.updateCloudflareProgress(Number.NaN); + await this.updateCloudflareBucketPreview(); + } } /** @@ -1496,15 +2160,69 @@ class bitvidApp { }); } + async publishVideoNote(payload, { onSuccess, suppressModalClose } = {}) { + if (!this.pubkey) { + this.showError("Please login to post a video."); + return false; + } + + const title = (payload?.title || "").trim(); + const url = (payload?.url || "").trim(); + const magnet = (payload?.magnet || "").trim(); + const thumbnail = (payload?.thumbnail || "").trim(); + const description = (payload?.description || "").trim(); + const ws = (payload?.ws || "").trim(); + const xs = (payload?.xs || "").trim(); + + const formData = { + version: 3, + title, + url, + magnet, + thumbnail, + description, + mode: isDevMode ? "dev" : "live", + }; + + if (!formData.title || (!formData.url && !formData.magnet)) { + this.showError("Title and at least one of URL or Magnet is required."); + return false; + } + + if (formData.url && !/^https:\/\//i.test(formData.url)) { + this.showError("Hosted video URLs must use HTTPS."); + return false; + } + + if (formData.magnet) { + formData.magnet = normalizeAndAugmentMagnet(formData.magnet, { + ws, + xs, + }); + } + + try { + await nostrClient.publishVideo(formData, this.pubkey); + if (typeof onSuccess === "function") { + await onSuccess(); + } + if (suppressModalClose !== true && this.uploadModal) { + this.uploadModal.classList.add("hidden"); + } + await this.loadVideos(); + this.showSuccess("Video shared successfully!"); + return true; + } catch (err) { + console.error("Failed to publish video:", err); + this.showError("Failed to share video. Please try again later."); + return false; + } + } + /** * Actually handle the upload form submission. */ async handleUploadSubmit() { - if (!this.pubkey) { - this.showError("Please login to post a video."); - return; - } - const titleEl = document.getElementById("uploadTitle"); const urlEl = document.getElementById("uploadUrl"); const magnetEl = document.getElementById("uploadMagnet"); @@ -1522,59 +2240,28 @@ class bitvidApp { const thumbnail = thumbEl?.value.trim() || ""; const description = descEl?.value.trim() || ""; - const formData = { - version: 3, + const payload = { title, url, magnet, thumbnail, description, - mode: isDevMode ? "dev" : "live", - // isPrivate: privEl?.checked || false, + ws, + xs, }; - if (!formData.title || (!formData.url && !formData.magnet)) { - this.showError("Title and at least one of URL or Magnet is required."); - return; - } - - if (formData.url && !/^https:\/\//i.test(formData.url)) { - this.showError("Hosted video URLs must use HTTPS."); - return; - } - - if (formData.magnet) { - formData.magnet = normalizeAndAugmentMagnet(formData.magnet, { - ws, - xs, - }); - } - - try { - await nostrClient.publishVideo(formData, this.pubkey); - - // Clear fields - if (titleEl) titleEl.value = ""; - if (urlEl) urlEl.value = ""; - if (magnetEl) magnetEl.value = ""; - if (wsEl) wsEl.value = ""; - if (xsEl) xsEl.value = ""; - if (thumbEl) thumbEl.value = ""; - if (descEl) descEl.value = ""; - if (privEl) privEl.checked = false; - - // Hide the modal - if (this.uploadModal) { - this.uploadModal.classList.add("hidden"); - } - - // *** Refresh to show the newly uploaded video in the grid *** - await this.loadVideos(); - this.showSuccess("Video shared successfully!"); - } catch (err) { - console.error("Failed to publish video:", err); - this.showError("Failed to share video. Please try again later."); - } + await this.publishVideoNote(payload, { + onSuccess: () => { + if (titleEl) titleEl.value = ""; + if (urlEl) urlEl.value = ""; + if (magnetEl) magnetEl.value = ""; + if (wsEl) wsEl.value = ""; + if (xsEl) xsEl.value = ""; + if (thumbEl) thumbEl.value = ""; + if (descEl) descEl.value = ""; + if (privEl) privEl.checked = false; + }, + }); } /** @@ -1623,6 +2310,8 @@ class bitvidApp { // Force a fresh fetch of all profile pictures/names this.forceRefreshAllProfiles(); + + await this.updateCloudflareBucketPreview(); } /** @@ -1669,6 +2358,8 @@ class bitvidApp { // Force a fresh fetch of all profile pictures/names (public ones in this case) this.forceRefreshAllProfiles(); + + await this.updateCloudflareBucketPreview(); } /** diff --git a/js/r2.js b/js/r2.js new file mode 100644 index 00000000..dc356111 --- /dev/null +++ b/js/r2.js @@ -0,0 +1,416 @@ +const S3_MODULE_URL = + "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle"; + +const DB_NAME = "bitvidSettings"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; +const SETTINGS_KEY = "r2Settings"; +const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings"; + +let s3ModulePromise = null; + +function loadS3Module() { + if (!s3ModulePromise) { + s3ModulePromise = import(S3_MODULE_URL); + } + return s3ModulePromise; +} + +function isIndexedDbAvailable() { + try { + return typeof indexedDB !== "undefined"; + } catch (err) { + return false; + } +} + +function openSettingsDb() { + return new Promise((resolve, reject) => { + if (!isIndexedDbAvailable()) { + resolve(null); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Failed to open settings DB")); + }; + }); +} + +function normalizeSettings(raw) { + if (!raw || typeof raw !== "object") { + return { + accountId: "", + accessKeyId: "", + secretAccessKey: "", + publicBaseUrlTemplate: "", + bucketMode: "auto", + manualBucket: "", + autoBuckets: {}, + }; + } + + return { + accountId: String(raw.accountId || ""), + accessKeyId: String(raw.accessKeyId || ""), + secretAccessKey: String(raw.secretAccessKey || ""), + publicBaseUrlTemplate: String(raw.publicBaseUrlTemplate || ""), + bucketMode: raw.bucketMode === "manual" ? "manual" : "auto", + manualBucket: String(raw.manualBucket || ""), + autoBuckets: + raw.autoBuckets && typeof raw.autoBuckets === "object" + ? { ...raw.autoBuckets } + : {}, + }; +} + +export async function loadR2Settings() { + try { + const db = await openSettingsDb(); + if (db) { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const req = store.get(SETTINGS_KEY); + req.onsuccess = () => { + resolve(normalizeSettings(req.result)); + }; + req.onerror = () => { + reject(req.error || new Error("Failed to load settings")); + }; + }); + } + } catch (err) { + console.warn("Failed to open IndexedDB for settings, falling back:", err); + } + + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY); + if (raw) { + return normalizeSettings(JSON.parse(raw)); + } + } + } catch (err) { + console.warn("Failed to read fallback settings:", err); + } + + return normalizeSettings(null); +} + +export async function saveR2Settings(settings) { + const normalized = normalizeSettings(settings); + + let db = null; + try { + db = await openSettingsDb(); + } catch (err) { + console.warn("Unable to open IndexedDB, continuing with fallback:", err); + } + + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.put(normalized, SETTINGS_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => + reject(req.error || new Error("Failed to save R2 settings")); + }); + } else if (typeof localStorage !== "undefined") { + localStorage.setItem( + LOCALSTORAGE_FALLBACK_KEY, + JSON.stringify(normalized) + ); + } + + return normalized; +} + +export async function clearR2Settings() { + let cleared = false; + try { + const db = await openSettingsDb(); + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(SETTINGS_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error || new Error("Failed to clear")); + }); + cleared = true; + } + } catch (err) { + console.warn("Failed to clear IndexedDB settings:", err); + } + + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY); + cleared = true; + } + } catch (err) { + console.warn("Failed to clear fallback settings:", err); + } + + return cleared; +} + +export function bucketForNpub( + npub, + { prefix = "bitvid", suffix } = {} +) { + const suffixValue = suffix || Math.random().toString(36).slice(2, 8); + const npubPart = String(npub || "").toLowerCase(); + const raw = `${prefix}-${npubPart}-${suffixValue}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, ""); + const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, ""); + return trimmed || `${prefix}-${Date.now()}`; +} + +function ensureHeadersObject(req) { + if (!req) { + return; + } + + if (req.headers && typeof req.headers.set === "function") { + req.headers.set("cf-create-bucket-if-missing", "true"); + return; + } + + if (req.headers && typeof req.headers === "object") { + req.headers["cf-create-bucket-if-missing"] = "true"; + return; + } + + if (typeof req.headers === "undefined") { + req.headers = { "cf-create-bucket-if-missing": "true" }; + } +} + +export async function createR2Client({ + accountId, + accessKeyId, + secretAccessKey, +}) { + if (!accountId || !accessKeyId || !secretAccessKey) { + throw new Error("Missing required R2 credentials"); + } + + const { S3Client } = await loadS3Module(); + const client = new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId, secretAccessKey }, + }); + + client.middlewareStack.add( + (next) => async (args) => { + ensureHeadersObject(args.request); + return next(args); + }, + { step: "build", name: "r2AutoCreateBucket" } + ); + + return client; +} + +function guessExtension(file) { + if (!file) { + return "mp4"; + } + + const name = typeof file.name === "string" ? file.name : ""; + const dotIndex = name.lastIndexOf("."); + if (dotIndex > -1 && dotIndex < name.length - 1) { + return name.slice(dotIndex + 1).toLowerCase(); + } + + const type = typeof file.type === "string" ? file.type : ""; + switch (type) { + case "video/webm": + return "webm"; + case "application/vnd.apple.mpegurl": + return "m3u8"; + case "video/mp2t": + return "ts"; + case "video/quicktime": + return "mov"; + case "video/x-matroska": + return "mkv"; + default: + return "mp4"; + } +} + +export function buildR2Key(npub, file) { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + const safeNpub = String(npub || "") + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + const baseName = typeof file?.name === "string" ? file.name : "video"; + const withoutExt = baseName.replace(/\.[^/.]+$/, ""); + const slug = withoutExt + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const safeSlug = slug || "video"; + const ext = guessExtension(file); + return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`; +} + +export async function uploadToR2({ + s3, + bucket, + key, + file, + contentType, + onProgress, + concurrency = 4, +}) { + if (!s3) { + throw new Error("S3 client is required"); + } + if (!bucket) { + throw new Error("Bucket is required"); + } + if (!key) { + throw new Error("Object key is required"); + } + if (!file) { + throw new Error("File is required"); + } + + const module = await loadS3Module(); + const { + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, + } = module; + + const resolvedContentType = + contentType || file.type || "video/mp4"; + + const createCommand = new CreateMultipartUploadCommand({ + Bucket: bucket, + Key: key, + ContentType: resolvedContentType, + CacheControl: resolvedContentType.includes("mpegurl") + ? "public, max-age=30" + : "public, max-age=31536000, immutable", + }); + + const { UploadId } = await s3.send(createCommand); + if (!UploadId) { + throw new Error("Failed to initiate multipart upload"); + } + + const PART_SIZE = 8 * 1024 * 1024; + const total = file.size; + const totalParts = Math.ceil(total / PART_SIZE); + const parts = []; + let assignedPart = 0; + let uploadedBytes = 0; + const uploadErrors = []; + + const workers = Array.from({ length: Math.max(1, concurrency) }, () => + (async () => { + try { + for (;;) { + if (assignedPart >= totalParts) { + break; + } + + const currentIndex = assignedPart; + assignedPart += 1; + + const partNumber = currentIndex + 1; + const start = currentIndex * PART_SIZE; + const end = Math.min(start + PART_SIZE, total); + const Body = file.slice(start, end); + + const command = new UploadPartCommand({ + Bucket: bucket, + Key: key, + UploadId, + PartNumber: partNumber, + Body, + }); + + const { ETag } = await s3.send(command); + parts.push({ ETag, PartNumber: partNumber }); + + uploadedBytes += end - start; + if (typeof onProgress === "function") { + onProgress(Math.min(1, uploadedBytes / total)); + } + } + } catch (err) { + uploadErrors.push(err); + assignedPart = totalParts; + } + })() + ); + + try { + await Promise.all(workers); + + if (uploadErrors.length > 0) { + throw uploadErrors[0]; + } + + parts.sort((a, b) => a.PartNumber - b.PartNumber); + + const completeCommand = new CompleteMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId, + MultipartUpload: { Parts: parts }, + }); + + await s3.send(completeCommand); + } catch (err) { + try { + const abortCommand = new AbortMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId, + }); + await s3.send(abortCommand); + } catch (abortErr) { + console.warn("Failed to abort multipart upload:", abortErr); + } + throw err; + } +} + +export function buildPublicUrl(bucket, key, template) { + const baseTemplate = (template || `https://${bucket}.r2.dev`).replace( + /\{bucket\}/g, + bucket + ); + const sanitizedBase = baseTemplate.replace(/\/$/, ""); + const encodedKey = key + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `${sanitizedBase}/${encodedKey}`; +} From 7d73a8c5948b231451de876431a2a3a7254bc38d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:09:49 -0400 Subject: [PATCH 03/82] Enable scrolling inside upload modal --- components/upload-modal.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/upload-modal.html b/components/upload-modal.html index caa12232..9522a92f 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -19,7 +19,7 @@ class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20" >
S3 Access Key ID
S3 Secret Access Key
- Cloudflare API Token (optional)

- Use {bucket} - where the bucket name should appear. Defaults to - https://{bucket}.r2.dev - when blank. + Used to create buckets, configure CORS, and attach domains automatically.

-
- - Bucket mode - -
- - -
-
+
+ + +

+ We'll attach npub.<baseDomain>. + Leave blank to use the managed *.r2.dev domain. +

+
+
diff --git a/js/app.js b/js/app.js index 4dc4cae4..f1a459ac 100644 --- a/js/app.js +++ b/js/app.js @@ -20,12 +20,19 @@ import { loadR2Settings, saveR2Settings, clearR2Settings, - bucketForNpub, buildR2Key, buildPublicUrl, - createR2Client, - uploadToR2, + mergeBucketEntry, + sanitizeBaseDomain, } from "./r2.js"; +import { + sanitizeBucketName, + ensureBucket, + putCors, + attachCustomDomain, + enableManagedDomain, +} from "./storage/r2-mgmt.js"; +import { makeR2Client, multipartUpload } from "./storage/r2-s3.js"; /** * Simple "decryption" placeholder for private videos. @@ -474,9 +481,6 @@ class bitvidApp { this.cloudflareClearSettingsButton = null; this.cloudflareSettingsStatus = null; this.cloudflareBucketPreview = null; - this.cloudflareBucketModeInputs = []; - this.cloudflareManualBucketGroup = null; - this.cloudflareManualBucketInput = null; this.cloudflareUploadForm = null; this.cloudflareFileInput = null; this.cloudflareUploadButton = null; @@ -492,7 +496,9 @@ class bitvidApp { this.r2AccountIdInput = null; this.r2AccessKeyIdInput = null; this.r2SecretAccessKeyInput = null; - this.r2PublicBaseUrlInput = null; + this.r2ApiTokenInput = null; + this.r2ZoneIdInput = null; + this.r2BaseDomainInput = null; // Optional small inline player stats this.status = document.getElementById("status") || null; @@ -1094,13 +1100,6 @@ class bitvidApp { document.getElementById("cloudflareSettingsStatus") || null; this.cloudflareBucketPreview = document.getElementById("cloudflareBucketPreview") || null; - this.cloudflareBucketModeInputs = Array.from( - document.querySelectorAll("input[name='r2BucketMode']") - ); - this.cloudflareManualBucketGroup = - document.getElementById("r2ManualBucketGroup") || null; - this.cloudflareManualBucketInput = - document.getElementById("r2ManualBucket") || null; this.cloudflareUploadForm = document.getElementById("cloudflareUploadForm") || null; this.cloudflareFileInput = @@ -1131,8 +1130,11 @@ class bitvidApp { document.getElementById("r2AccessKeyId") || null; this.r2SecretAccessKeyInput = document.getElementById("r2SecretAccessKey") || null; - this.r2PublicBaseUrlInput = - document.getElementById("r2PublicBaseUrlTemplate") || null; + this.r2ApiTokenInput = + document.getElementById("r2ApiToken") || null; + this.r2ZoneIdInput = document.getElementById("r2ZoneId") || null; + this.r2BaseDomainInput = + document.getElementById("r2BaseDomain") || null; // Optional: if close button found, wire up if (this.closeUploadModalBtn) { @@ -1163,20 +1165,6 @@ class bitvidApp { }); } - if (this.cloudflareBucketModeInputs.length > 0) { - this.cloudflareBucketModeInputs.forEach((input) => { - input.addEventListener("change", () => { - this.handleCloudflareBucketModeChange(); - }); - }); - } - - if (this.cloudflareManualBucketInput) { - this.cloudflareManualBucketInput.addEventListener("input", () => { - this.handleManualBucketInputChange(); - }); - } - if (this.cloudflareUploadForm) { this.cloudflareUploadForm.addEventListener("submit", (e) => { e.preventDefault(); @@ -1247,25 +1235,6 @@ class bitvidApp { } } - getCloudflareBucketMode() { - if (this.cloudflareBucketModeInputs.length === 0) { - return "auto"; - } - - const active = this.cloudflareBucketModeInputs.find( - (input) => input && input.checked - ); - return active && active.value === "manual" ? "manual" : "auto"; - } - - sanitizeBucketName(name) { - const raw = String(name || "") - .toLowerCase() - .replace(/[^a-z0-9-]/g, ""); - const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, ""); - return trimmed; - } - setCloudflareSettingsStatus(message = "", variant = "info") { if (!this.cloudflareSettingsStatus) { return; @@ -1381,24 +1350,25 @@ class bitvidApp { accountId: "", accessKeyId: "", secretAccessKey: "", - publicBaseUrlTemplate: "", - bucketMode: "auto", - manualBucket: "", - autoBuckets: {}, + apiToken: "", + zoneId: "", + baseDomain: "", + buckets: {}, }; this.populateCloudflareSettingsInputs(this.cloudflareSettings); } } populateCloudflareSettingsInputs(settings) { - const data = settings || { - accountId: "", - accessKeyId: "", - secretAccessKey: "", - publicBaseUrlTemplate: "", - bucketMode: "auto", - manualBucket: "", - }; + const data = + settings || { + accountId: "", + accessKeyId: "", + secretAccessKey: "", + apiToken: "", + zoneId: "", + baseDomain: "", + }; if (this.r2AccountIdInput) { this.r2AccountIdInput.value = data.accountId || ""; @@ -1409,29 +1379,14 @@ class bitvidApp { if (this.r2SecretAccessKeyInput) { this.r2SecretAccessKeyInput.value = data.secretAccessKey || ""; } - if (this.r2PublicBaseUrlInput) { - this.r2PublicBaseUrlInput.value = data.publicBaseUrlTemplate || ""; + if (this.r2ApiTokenInput) { + this.r2ApiTokenInput.value = data.apiToken || ""; } - - const mode = data.bucketMode === "manual" ? "manual" : "auto"; - if (this.cloudflareBucketModeInputs.length > 0) { - this.cloudflareBucketModeInputs.forEach((input) => { - if (input) { - input.checked = input.value === mode; - } - }); + if (this.r2ZoneIdInput) { + this.r2ZoneIdInput.value = data.zoneId || ""; } - - if (this.cloudflareManualBucketGroup) { - if (mode === "manual") { - this.cloudflareManualBucketGroup.classList.remove("hidden"); - } else { - this.cloudflareManualBucketGroup.classList.add("hidden"); - } - } - - if (this.cloudflareManualBucketInput) { - this.cloudflareManualBucketInput.value = data.manualBucket || ""; + if (this.r2BaseDomainInput) { + this.r2BaseDomainInput.value = data.baseDomain || ""; } this.setCloudflareSettingsStatus(""); @@ -1441,13 +1396,11 @@ class bitvidApp { const accountId = (this.r2AccountIdInput?.value || "").trim(); const accessKeyId = (this.r2AccessKeyIdInput?.value || "").trim(); const secretAccessKey = (this.r2SecretAccessKeyInput?.value || "").trim(); - const publicBaseUrlTemplate = - (this.r2PublicBaseUrlInput?.value || "").trim(); - const bucketMode = this.getCloudflareBucketMode(); - let manualBucket = this.cloudflareManualBucketInput - ? this.cloudflareManualBucketInput.value - : ""; - manualBucket = this.sanitizeBucketName(manualBucket); + const apiToken = (this.r2ApiTokenInput?.value || "").trim(); + const zoneId = (this.r2ZoneIdInput?.value || "").trim(); + const baseDomain = sanitizeBaseDomain( + this.r2BaseDomainInput?.value || "" + ); if (!accountId || !accessKeyId || !secretAccessKey) { if (!quiet) { @@ -1459,32 +1412,26 @@ class bitvidApp { return false; } - if (bucketMode === "manual" && manualBucket.length < 3) { - if (!quiet) { - this.setCloudflareSettingsStatus( - "Manual bucket names must be at least 3 characters.", - "error" - ); - } - return false; + let buckets = { ...(this.cloudflareSettings?.buckets || {}) }; + const previousAccount = this.cloudflareSettings?.accountId || ""; + const previousBaseDomain = this.cloudflareSettings?.baseDomain || ""; + const previousZoneId = this.cloudflareSettings?.zoneId || ""; + if ( + previousAccount !== accountId || + previousBaseDomain !== baseDomain || + previousZoneId !== zoneId + ) { + buckets = {}; } - if (this.cloudflareManualBucketInput) { - this.cloudflareManualBucketInput.value = manualBucket; - } - - const autoBuckets = { - ...(this.cloudflareSettings?.autoBuckets || {}), - }; - const updatedSettings = { accountId, accessKeyId, secretAccessKey, - publicBaseUrlTemplate, - bucketMode, - manualBucket, - autoBuckets, + apiToken, + zoneId, + baseDomain, + buckets, }; try { @@ -1524,62 +1471,133 @@ class bitvidApp { } } - handleCloudflareBucketModeChange() { - const mode = this.getCloudflareBucketMode(); - if (this.cloudflareManualBucketGroup) { - if (mode === "manual") { - this.cloudflareManualBucketGroup.classList.remove("hidden"); - } else { - this.cloudflareManualBucketGroup.classList.add("hidden"); + getCorsOrigins() { + const origins = new Set(); + if (typeof window !== "undefined" && window.location) { + const origin = window.location.origin; + if (origin && origin !== "null") { + origins.add(origin); + } + if (origin && origin.startsWith("http://localhost")) { + origins.add(origin.replace("http://", "https://")); } } - if (this.cloudflareSettings) { - this.cloudflareSettings.bucketMode = mode; - } - this.updateCloudflareBucketPreview(); + return Array.from(origins); } - handleManualBucketInputChange() { - this.updateCloudflareBucketPreview(); + deriveSubdomainForNpub(npub) { + const base = String(npub || "user") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+|[-]+$/g, ""); + return base.slice(0, 48) || "user"; } - async ensureAutoBucketForCurrentUser() { - if (!this.pubkey || !this.cloudflareSettings) { + async ensureBucketConfigForNpub(npub) { + if (!npub || !this.cloudflareSettings) { return null; } - const mode = this.cloudflareSettings.bucketMode === "manual" ? "manual" : "auto"; - if (mode !== "auto") { - return null; + const accountId = (this.cloudflareSettings.accountId || "").trim(); + const apiToken = (this.cloudflareSettings.apiToken || "").trim(); + const zoneId = (this.cloudflareSettings.zoneId || "").trim(); + const baseDomain = this.cloudflareSettings.baseDomain || ""; + + if (!accountId) { + throw new Error("Cloudflare account ID is missing."); } - const npub = this.safeEncodeNpub(this.pubkey); - if (!npub) { - return null; + let entry = this.cloudflareSettings.buckets?.[npub] || null; + + if (entry && entry.publicBaseUrl) { + if (apiToken) { + try { + await ensureBucket({ + accountId, + bucket: entry.bucket, + token: apiToken, + }); + await putCors({ + accountId, + bucket: entry.bucket, + token: apiToken, + origins: this.getCorsOrigins(), + }); + } catch (err) { + console.warn("Failed to refresh bucket configuration:", err); + } + } + return { entry, usedManagedFallback: entry.domainType !== "custom" }; } - const existing = this.cloudflareSettings.autoBuckets?.[npub]; - if (existing) { - return existing; + if (!apiToken) { + throw new Error( + "Provide a Cloudflare API token to auto-create the bucket and domain." + ); } - const bucket = bucketForNpub(npub); - const updatedSettings = { - ...this.cloudflareSettings, - autoBuckets: { - ...(this.cloudflareSettings.autoBuckets || {}), - [npub]: bucket, - }, - }; + const bucketName = entry?.bucket || sanitizeBucketName(npub); + + await ensureBucket({ accountId, bucket: bucketName, token: apiToken }); try { - this.cloudflareSettings = await saveR2Settings(updatedSettings); + await putCors({ + accountId, + bucket: bucketName, + token: apiToken, + origins: this.getCorsOrigins(), + }); } catch (err) { - console.warn("Failed to persist auto bucket name:", err); - this.cloudflareSettings = updatedSettings; + console.warn("Failed to apply R2 CORS rules:", err); } - return bucket; + let publicBaseUrl = entry?.publicBaseUrl || ""; + let domainType = entry?.domainType || "managed"; + let usedManagedFallback = false; + + if (baseDomain && zoneId) { + const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`; + try { + publicBaseUrl = await attachCustomDomain({ + accountId, + bucket: bucketName, + token: apiToken, + zoneId, + domain, + }); + domainType = "custom"; + } catch (err) { + if (/already exists/i.test(err.message || "")) { + publicBaseUrl = `https://${domain}`; + domainType = "custom"; + } else { + console.warn("Failed to attach custom domain, falling back:", err); + usedManagedFallback = true; + } + } + } + + if (!publicBaseUrl) { + publicBaseUrl = await enableManagedDomain({ + accountId, + bucket: bucketName, + token: apiToken, + }); + domainType = "managed"; + usedManagedFallback = true; + } + + const mergedEntry = { + bucket: bucketName, + publicBaseUrl, + domainType, + lastUpdated: Date.now(), + }; + const updatedSettings = await saveR2Settings( + mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry) + ); + this.cloudflareSettings = updatedSettings; + return { entry: mergedEntry, usedManagedFallback }; } async updateCloudflareBucketPreview() { @@ -1588,36 +1606,13 @@ class bitvidApp { } const el = this.cloudflareBucketPreview; - const mode = this.getCloudflareBucketMode(); - const template = (this.r2PublicBaseUrlInput?.value || "").trim(); - - if (mode === "manual") { - const manualBucket = this.sanitizeBucketName( - this.cloudflareManualBucketInput?.value || - this.cloudflareSettings?.manualBucket || - "" - ); - if (!manualBucket) { - el.textContent = "Enter a manual bucket name."; - return; - } - - const sampleNpub = this.safeEncodeNpub(this.pubkey) || "anon"; - const sampleKey = buildR2Key(sampleNpub, { - name: "sample.mp4", - }); - const publicUrl = buildPublicUrl(manualBucket, sampleKey, template); - el.textContent = `${manualBucket} • ${publicUrl}`; + if (!this.cloudflareSettings) { + el.textContent = "Save your credentials to configure R2."; return; } if (!this.pubkey) { - el.textContent = "Login to preview your auto bucket."; - return; - } - - if (!this.cloudflareSettings) { - el.textContent = "Save your credentials to generate a bucket."; + el.textContent = "Login to preview your R2 bucket."; return; } @@ -1627,19 +1622,15 @@ class bitvidApp { return; } - let bucket = this.cloudflareSettings.autoBuckets?.[npub]; - if (!bucket) { - bucket = await this.ensureAutoBucketForCurrentUser(); - } - - if (!bucket) { - el.textContent = "Save your credentials to generate a bucket."; + const entry = this.cloudflareSettings.buckets?.[npub]; + if (!entry || !entry.publicBaseUrl) { + el.textContent = "Bucket will be auto-created on your next upload."; return; } const sampleKey = buildR2Key(npub, { name: "sample.mp4" }); - const publicUrl = buildPublicUrl(bucket, sampleKey, template); - el.textContent = `${bucket} • ${publicUrl}`; + const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey); + el.textContent = `${entry.bucket} • ${publicUrl}`; } async handleCloudflareUploadSubmit() { @@ -1678,11 +1669,11 @@ class bitvidApp { const ws = (this.cloudflareWsInput?.value || "").trim(); const xs = (this.cloudflareXsInput?.value || "").trim(); - const accountId = this.cloudflareSettings?.accountId || ""; - const accessKeyId = this.cloudflareSettings?.accessKeyId || ""; - const secretAccessKey = this.cloudflareSettings?.secretAccessKey || ""; - const publicBaseUrlTemplate = - this.cloudflareSettings?.publicBaseUrlTemplate || ""; + const accountId = (this.cloudflareSettings?.accountId || "").trim(); + const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim(); + const secretAccessKey = ( + this.cloudflareSettings?.secretAccessKey || "" + ).trim(); if (!accountId || !accessKeyId || !secretAccessKey) { this.setCloudflareUploadStatus( @@ -1698,54 +1689,56 @@ class bitvidApp { return; } - let bucketName = ""; - const mode = this.cloudflareSettings?.bucketMode === "manual" ? "manual" : "auto"; - if (mode === "manual") { - bucketName = this.sanitizeBucketName( - this.cloudflareSettings?.manualBucket || - this.cloudflareManualBucketInput?.value || - "" - ); - if (!bucketName) { - this.setCloudflareUploadStatus( - "Enter a valid manual bucket name.", - "error" - ); - return; - } - } else { - bucketName = this.cloudflareSettings?.autoBuckets?.[npub] || - (await this.ensureAutoBucketForCurrentUser()); - if (!bucketName) { - this.setCloudflareUploadStatus( - "Failed to compute an auto bucket name.", - "error" - ); - return; - } - } - - const key = buildR2Key(npub, file); - const publicUrl = buildPublicUrl( - bucketName, - key, - publicBaseUrlTemplate - ); - - this.setCloudflareUploadStatus("Uploading…", "info"); + this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info"); this.updateCloudflareProgress(0); this.setCloudflareUploading(true); + let bucketResult = null; try { - const s3 = await createR2Client({ - accountId, - accessKeyId, - secretAccessKey, - }); + bucketResult = await this.ensureBucketConfigForNpub(npub); + } catch (err) { + console.error("Failed to prepare R2 bucket:", err); + this.setCloudflareUploadStatus( + err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.", + "error" + ); + this.setCloudflareUploading(false); + this.updateCloudflareProgress(Number.NaN); + return; + } - await uploadToR2({ + const bucketEntry = + bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub]; + + if (!bucketEntry || !bucketEntry.publicBaseUrl) { + this.setCloudflareUploadStatus( + "Bucket is missing a public domain. Check your Cloudflare settings.", + "error" + ); + this.setCloudflareUploading(false); + this.updateCloudflareProgress(Number.NaN); + return; + } + + const statusMessage = bucketResult?.usedManagedFallback && + (this.cloudflareSettings?.baseDomain || "") + ? `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone. Uploading…` + : `Uploading to ${bucketEntry.bucket}…`; + + this.setCloudflareUploadStatus( + statusMessage, + bucketResult?.usedManagedFallback ? "warning" : "info" + ); + + const key = buildR2Key(npub, file); + const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key); + + try { + const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey }); + + await multipartUpload({ s3, - bucket: bucketName, + bucket: bucketEntry.bucket, key, file, contentType: file.type, diff --git a/js/r2.js b/js/r2.js index dc356111..d3b73429 100644 --- a/js/r2.js +++ b/js/r2.js @@ -1,19 +1,56 @@ -const S3_MODULE_URL = - "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle"; - const DB_NAME = "bitvidSettings"; const DB_VERSION = 1; const STORE_NAME = "kv"; const SETTINGS_KEY = "r2Settings"; const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings"; -let s3ModulePromise = null; - -function loadS3Module() { - if (!s3ModulePromise) { - s3ModulePromise = import(S3_MODULE_URL); +function sanitizeBaseDomain(domain) { + if (!domain) { + return ""; } - return s3ModulePromise; + let value = String(domain).trim().toLowerCase(); + value = value.replace(/^https?:\/\//, ""); + value = value.replace(/\/.*$/, ""); + return value; +} + +function normalizeBucketEntry(entry) { + if (!entry || typeof entry !== "object") { + return null; + } + const bucket = String(entry.bucket || "").toLowerCase(); + if (!bucket) { + return null; + } + const publicBaseUrl = String(entry.publicBaseUrl || ""); + const domainType = entry.domainType === "custom" ? "custom" : "managed"; + const lastUpdated = Number.isFinite(entry.lastUpdated) + ? entry.lastUpdated + : Date.now(); + return { bucket, publicBaseUrl, domainType, lastUpdated }; +} + +function normalizeSettings(raw) { + const base = raw && typeof raw === "object" ? raw : {}; + const buckets = {}; + if (base.buckets && typeof base.buckets === "object") { + for (const [npub, value] of Object.entries(base.buckets)) { + const normalizedEntry = normalizeBucketEntry(value); + if (normalizedEntry) { + buckets[String(npub)] = normalizedEntry; + } + } + } + + return { + accountId: String(base.accountId || ""), + accessKeyId: String(base.accessKeyId || ""), + secretAccessKey: String(base.secretAccessKey || ""), + apiToken: String(base.apiToken || ""), + zoneId: String(base.zoneId || ""), + baseDomain: sanitizeBaseDomain(base.baseDomain || ""), + buckets, + }; } function isIndexedDbAvailable() { @@ -50,33 +87,6 @@ function openSettingsDb() { }); } -function normalizeSettings(raw) { - if (!raw || typeof raw !== "object") { - return { - accountId: "", - accessKeyId: "", - secretAccessKey: "", - publicBaseUrlTemplate: "", - bucketMode: "auto", - manualBucket: "", - autoBuckets: {}, - }; - } - - return { - accountId: String(raw.accountId || ""), - accessKeyId: String(raw.accessKeyId || ""), - secretAccessKey: String(raw.secretAccessKey || ""), - publicBaseUrlTemplate: String(raw.publicBaseUrlTemplate || ""), - bucketMode: raw.bucketMode === "manual" ? "manual" : "auto", - manualBucket: String(raw.manualBucket || ""), - autoBuckets: - raw.autoBuckets && typeof raw.autoBuckets === "object" - ? { ...raw.autoBuckets } - : {}, - }; -} - export async function loadR2Settings() { try { const db = await openSettingsDb(); @@ -170,64 +180,21 @@ export async function clearR2Settings() { return cleared; } -export function bucketForNpub( - npub, - { prefix = "bitvid", suffix } = {} -) { - const suffixValue = suffix || Math.random().toString(36).slice(2, 8); - const npubPart = String(npub || "").toLowerCase(); - const raw = `${prefix}-${npubPart}-${suffixValue}` - .toLowerCase() - .replace(/[^a-z0-9-]/g, ""); - const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, ""); - return trimmed || `${prefix}-${Date.now()}`; -} - -function ensureHeadersObject(req) { - if (!req) { - return; +export function mergeBucketEntry(settings, npub, entry) { + if (!settings || typeof settings !== "object") { + return settings; } - - if (req.headers && typeof req.headers.set === "function") { - req.headers.set("cf-create-bucket-if-missing", "true"); - return; + const normalizedEntry = normalizeBucketEntry(entry); + if (!normalizedEntry) { + return settings; } - - if (req.headers && typeof req.headers === "object") { - req.headers["cf-create-bucket-if-missing"] = "true"; - return; - } - - if (typeof req.headers === "undefined") { - req.headers = { "cf-create-bucket-if-missing": "true" }; - } -} - -export async function createR2Client({ - accountId, - accessKeyId, - secretAccessKey, -}) { - if (!accountId || !accessKeyId || !secretAccessKey) { - throw new Error("Missing required R2 credentials"); - } - - const { S3Client } = await loadS3Module(); - const client = new S3Client({ - region: "auto", - endpoint: `https://${accountId}.r2.cloudflarestorage.com`, - credentials: { accessKeyId, secretAccessKey }, - }); - - client.middlewareStack.add( - (next) => async (args) => { - ensureHeadersObject(args.request); - return next(args); + return { + ...settings, + buckets: { + ...(settings.buckets || {}), + [npub]: normalizedEntry, }, - { step: "build", name: "r2AutoCreateBucket" } - ); - - return client; + }; } function guessExtension(file) { @@ -276,141 +243,16 @@ export function buildR2Key(npub, file) { return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`; } -export async function uploadToR2({ - s3, - bucket, - key, - file, - contentType, - onProgress, - concurrency = 4, -}) { - if (!s3) { - throw new Error("S3 client is required"); +export function buildPublicUrl(baseUrl, key) { + if (!baseUrl) { + return ""; } - if (!bucket) { - throw new Error("Bucket is required"); - } - if (!key) { - throw new Error("Object key is required"); - } - if (!file) { - throw new Error("File is required"); - } - - const module = await loadS3Module(); - const { - CreateMultipartUploadCommand, - UploadPartCommand, - CompleteMultipartUploadCommand, - AbortMultipartUploadCommand, - } = module; - - const resolvedContentType = - contentType || file.type || "video/mp4"; - - const createCommand = new CreateMultipartUploadCommand({ - Bucket: bucket, - Key: key, - ContentType: resolvedContentType, - CacheControl: resolvedContentType.includes("mpegurl") - ? "public, max-age=30" - : "public, max-age=31536000, immutable", - }); - - const { UploadId } = await s3.send(createCommand); - if (!UploadId) { - throw new Error("Failed to initiate multipart upload"); - } - - const PART_SIZE = 8 * 1024 * 1024; - const total = file.size; - const totalParts = Math.ceil(total / PART_SIZE); - const parts = []; - let assignedPart = 0; - let uploadedBytes = 0; - const uploadErrors = []; - - const workers = Array.from({ length: Math.max(1, concurrency) }, () => - (async () => { - try { - for (;;) { - if (assignedPart >= totalParts) { - break; - } - - const currentIndex = assignedPart; - assignedPart += 1; - - const partNumber = currentIndex + 1; - const start = currentIndex * PART_SIZE; - const end = Math.min(start + PART_SIZE, total); - const Body = file.slice(start, end); - - const command = new UploadPartCommand({ - Bucket: bucket, - Key: key, - UploadId, - PartNumber: partNumber, - Body, - }); - - const { ETag } = await s3.send(command); - parts.push({ ETag, PartNumber: partNumber }); - - uploadedBytes += end - start; - if (typeof onProgress === "function") { - onProgress(Math.min(1, uploadedBytes / total)); - } - } - } catch (err) { - uploadErrors.push(err); - assignedPart = totalParts; - } - })() - ); - - try { - await Promise.all(workers); - - if (uploadErrors.length > 0) { - throw uploadErrors[0]; - } - - parts.sort((a, b) => a.PartNumber - b.PartNumber); - - const completeCommand = new CompleteMultipartUploadCommand({ - Bucket: bucket, - Key: key, - UploadId, - MultipartUpload: { Parts: parts }, - }); - - await s3.send(completeCommand); - } catch (err) { - try { - const abortCommand = new AbortMultipartUploadCommand({ - Bucket: bucket, - Key: key, - UploadId, - }); - await s3.send(abortCommand); - } catch (abortErr) { - console.warn("Failed to abort multipart upload:", abortErr); - } - throw err; - } -} - -export function buildPublicUrl(bucket, key, template) { - const baseTemplate = (template || `https://${bucket}.r2.dev`).replace( - /\{bucket\}/g, - bucket - ); - const sanitizedBase = baseTemplate.replace(/\/$/, ""); + const sanitizedBase = String(baseUrl).replace(/\/$/, ""); const encodedKey = key .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); return `${sanitizedBase}/${encodedKey}`; } + +export { sanitizeBaseDomain }; diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js new file mode 100644 index 00000000..58c8e58a --- /dev/null +++ b/js/storage/r2-mgmt.js @@ -0,0 +1,131 @@ +// js/storage/r2-mgmt.js +export function sanitizeBucketName(npub) { + const base = (npub || "user") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+|[-]+$/g, ""); + const suffix = Date.now().toString(36); + const name = `bv-${base || "u"}-${suffix}`.slice(0, 63); + return name.length < 3 ? `bv-u-${suffix}` : name; +} + +function buildError(error, fallbackMessage) { + if (error instanceof Error) { + return error; + } + const err = new Error(fallbackMessage || "Cloudflare request failed"); + err.original = error; + return err; +} + +// --- Cloudflare API helpers (Bearer token) --- +async function cfFetch(path, { token, method = "GET", body, headers = {} }) { + if (!token) { + throw new Error("Cloudflare API token is required"); + } + const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await res.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (parseErr) { + if (!res.ok) { + const err = new Error(text || `Cloudflare API error ${res.status}`); + err.status = res.status; + throw err; + } + throw parseErr; + } + } + + if (!res.ok || data?.success === false) { + const errorMessage = + data?.errors?.[0]?.message || + data?.message || + text || + `Cloudflare API error ${res.status}`; + const err = new Error(errorMessage); + err.status = res.status; + err.response = data; + throw err; + } + + return data; +} + +// 1) Create bucket (idempotent-ish: treat 409 as success) +export async function ensureBucket({ accountId, bucket, token }) { + try { + await cfFetch(`/accounts/${accountId}/r2/buckets`, { + token, + method: "POST", + body: { name: bucket }, + }); + } catch (error) { + const err = buildError(error); + if (err.status === 409 || /already exists/i.test(err.message || "")) { + return; + } + throw err; + } +} + +// 2) Set CORS so browser PUT/GETs work from your app origins +export async function putCors({ accountId, bucket, token, origins }) { + const filteredOrigins = (origins || []).filter(Boolean); + if (filteredOrigins.length === 0) { + return; + } + const rules = [ + { + AllowedOrigins: filteredOrigins, + AllowedMethods: ["GET", "HEAD", "PUT", "POST"], + AllowedHeaders: ["*"], + ExposeHeaders: ["ETag", "Content-Length", "Content-Range"], + MaxAgeSeconds: 3600, + }, + ]; + await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, { + token, + method: "PUT", + body: { rules }, + }); +} + +// 3) Attach custom domain to bucket (auto-provisions on Cloudflare) +export async function attachCustomDomain({ + accountId, + bucket, + token, + zoneId, + domain, +}) { + const { result } = await cfFetch( + `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, + { + token, + method: "POST", + body: { domain, zoneId, enabled: true, minTLS: "1.2" }, + } + ); + return `https://${result?.domain || domain}`; +} + +// 4) Or enable r2.dev managed domain as fallback +export async function enableManagedDomain({ accountId, bucket, token }) { + const { result } = await cfFetch( + `/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`, + { token, method: "PUT", body: { enabled: true } } + ); + return `https://${result?.domain}`; +} diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js new file mode 100644 index 00000000..7912541b --- /dev/null +++ b/js/storage/r2-s3.js @@ -0,0 +1,138 @@ +// js/storage/r2-s3.js +import { + S3Client, + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, +} from "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle"; + +export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) { + if (!accountId || !accessKeyId || !secretAccessKey) { + throw new Error("Missing Cloudflare R2 credentials"); + } + + return new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: true, + }); +} + +export async function multipartUpload({ + s3, + bucket, + key, + file, + contentType, + onProgress, + concurrency = 4, +}) { + if (!s3) { + throw new Error("S3 client is required"); + } + if (!bucket) { + throw new Error("Bucket is required"); + } + if (!key) { + throw new Error("Object key is required"); + } + if (!file) { + throw new Error("File is required"); + } + + const { UploadId } = await s3.send( + new CreateMultipartUploadCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType || file.type || "video/mp4", + CacheControl: "public, max-age=31536000, immutable", + }) + ); + + if (!UploadId) { + throw new Error("Failed to start multipart upload"); + } + + const PART = 8 * 1024 * 1024; + const total = file.size; + const parts = []; + let sent = 0; + let partNumber = 1; + const totalParts = Math.ceil(total / PART); + const errors = []; + + const uploadPart = async () => { + const start = sent; + if (start >= total) { + return null; + } + const end = Math.min(start + PART, total); + const body = file.slice(start, end); + const currentPart = partNumber++; + sent = end; + + return s3 + .send( + new UploadPartCommand({ + Bucket: bucket, + Key: key, + UploadId, + PartNumber: currentPart, + Body: body, + }) + ) + .then(({ ETag }) => { + parts.push({ ETag, PartNumber: currentPart }); + if (typeof onProgress === "function") { + onProgress(end / total); + } + }); + }; + + const workers = Array.from({ length: Math.max(1, concurrency) }, () => + (async () => { + try { + while (parts.length < totalParts) { + const task = uploadPart(); + if (!task) { + break; + } + await task; + } + } catch (error) { + errors.push(error); + } + })() + ); + + try { + await Promise.all(workers); + if (errors.length > 0) { + throw errors[0]; + } + + parts.sort((a, b) => a.PartNumber - b.PartNumber); + + await s3.send( + new CompleteMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId, + MultipartUpload: { Parts: parts }, + }) + ); + } catch (error) { + await s3 + .send( + new AbortMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId, + }) + ) + .catch(() => {}); + throw error; + } +} From 7f702708897b82110c7289b417c626f8dc0cb357 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:25:20 -0400 Subject: [PATCH 05/82] Hide advanced Cloudflare settings behind toggle --- components/upload-modal.html | 115 +++++++++++++++++++++-------------- js/app.js | 65 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 44 deletions(-) diff --git a/components/upload-modal.html b/components/upload-modal.html index 678bf679..484372f3 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -250,52 +250,79 @@ required />
-
- +
-
-
- - -

- Required when you want us to attach a custom domain on your behalf. -

-
-
- - -

- We'll attach npub.<baseDomain>. - Leave blank to use the managed *.r2.dev domain. -

+ + + Show advanced options + +
diff --git a/js/app.js b/js/app.js index f1a459ac..dc644229 100644 --- a/js/app.js +++ b/js/app.js @@ -493,6 +493,11 @@ class bitvidApp { this.cloudflareMagnetInput = null; this.cloudflareWsInput = null; this.cloudflareXsInput = null; + this.cloudflareAdvancedToggle = null; + this.cloudflareAdvancedToggleLabel = null; + this.cloudflareAdvancedToggleIcon = null; + this.cloudflareAdvancedFields = null; + this.cloudflareAdvancedVisible = false; this.r2AccountIdInput = null; this.r2AccessKeyIdInput = null; this.r2SecretAccessKeyInput = null; @@ -1124,6 +1129,14 @@ class bitvidApp { document.getElementById("cloudflareWs") || null; this.cloudflareXsInput = document.getElementById("cloudflareXs") || null; + this.cloudflareAdvancedToggle = + document.getElementById("cloudflareAdvancedToggle") || null; + this.cloudflareAdvancedToggleLabel = + document.getElementById("cloudflareAdvancedToggleLabel") || null; + this.cloudflareAdvancedToggleIcon = + document.getElementById("cloudflareAdvancedToggleIcon") || null; + this.cloudflareAdvancedFields = + document.getElementById("cloudflareAdvancedFields") || null; this.r2AccountIdInput = document.getElementById("r2AccountId") || null; this.r2AccessKeyIdInput = @@ -1181,6 +1194,16 @@ class bitvidApp { }); } + if (this.cloudflareAdvancedToggle) { + this.cloudflareAdvancedToggle.addEventListener("click", () => { + this.setCloudflareAdvancedVisibility( + !this.cloudflareAdvancedVisible + ); + }); + } + + this.setCloudflareAdvancedVisibility(this.cloudflareAdvancedVisible); + await this.loadCloudflareSettingsFromStorage(); await this.updateCloudflareBucketPreview(); @@ -1235,6 +1258,36 @@ class bitvidApp { } } + setCloudflareAdvancedVisibility(visible) { + const isVisible = Boolean(visible); + this.cloudflareAdvancedVisible = isVisible; + + if (this.cloudflareAdvancedFields) { + if (isVisible) { + this.cloudflareAdvancedFields.classList.remove("hidden"); + } else { + this.cloudflareAdvancedFields.classList.add("hidden"); + } + } + + if (this.cloudflareAdvancedToggle) { + this.cloudflareAdvancedToggle.setAttribute( + "aria-expanded", + isVisible ? "true" : "false" + ); + } + + if (this.cloudflareAdvancedToggleLabel) { + this.cloudflareAdvancedToggleLabel.textContent = isVisible + ? "Hide advanced options" + : "Show advanced options"; + } + + if (this.cloudflareAdvancedToggleIcon) { + this.cloudflareAdvancedToggleIcon.classList.toggle("rotate-90", isVisible); + } + } + setCloudflareSettingsStatus(message = "", variant = "info") { if (!this.cloudflareSettingsStatus) { return; @@ -1389,6 +1442,17 @@ class bitvidApp { this.r2BaseDomainInput.value = data.baseDomain || ""; } + const hasAdvancedValues = Boolean( + (data.apiToken && data.apiToken.length > 0) || + (data.zoneId && data.zoneId.length > 0) || + (data.baseDomain && data.baseDomain.length > 0) + ); + if (hasAdvancedValues) { + this.setCloudflareAdvancedVisibility(true); + } else if (!this.cloudflareAdvancedVisible) { + this.setCloudflareAdvancedVisibility(false); + } + this.setCloudflareSettingsStatus(""); } @@ -1460,6 +1524,7 @@ class bitvidApp { await clearR2Settings(); this.cloudflareSettings = await loadR2Settings(); this.populateCloudflareSettingsInputs(this.cloudflareSettings); + this.setCloudflareAdvancedVisibility(false); this.setCloudflareSettingsStatus("Settings cleared.", "success"); await this.updateCloudflareBucketPreview(); } catch (err) { From 9da4e40ec9381423f00f639719e17389c1a47e6a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:51:22 -0400 Subject: [PATCH 06/82] Improve R2 auto-setup and multipart upload reliability --- js/app.js | 68 +++++++++++++++---- js/storage/r2-mgmt.js | 147 ++++++++++++++++++++++++++++++++++++------ js/storage/r2-s3.js | 118 +++++++++++++++++---------------- 3 files changed, 246 insertions(+), 87 deletions(-) diff --git a/js/app.js b/js/app.js index dc644229..cb222031 100644 --- a/js/app.js +++ b/js/app.js @@ -29,8 +29,8 @@ import { sanitizeBucketName, ensureBucket, putCors, - attachCustomDomain, - enableManagedDomain, + attachCustomDomainAndWait, + setManagedDomain, } from "./storage/r2-mgmt.js"; import { makeR2Client, multipartUpload } from "./storage/r2-s3.js"; @@ -1592,7 +1592,11 @@ class bitvidApp { console.warn("Failed to refresh bucket configuration:", err); } } - return { entry, usedManagedFallback: entry.domainType !== "custom" }; + return { + entry, + usedManagedFallback: entry.domainType !== "custom", + customDomainStatus: entry.domainType === "custom" ? "active" : "skipped", + }; } if (!apiToken) { @@ -1619,37 +1623,71 @@ class bitvidApp { let publicBaseUrl = entry?.publicBaseUrl || ""; let domainType = entry?.domainType || "managed"; let usedManagedFallback = false; + let customDomainStatus = "skipped"; if (baseDomain && zoneId) { const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`; try { - publicBaseUrl = await attachCustomDomain({ + const custom = await attachCustomDomainAndWait({ accountId, bucket: bucketName, token: apiToken, zoneId, domain, + pollInterval: 2500, + timeoutMs: 120000, }); - domainType = "custom"; + customDomainStatus = custom?.status || "unknown"; + if (custom?.active && custom?.url) { + publicBaseUrl = custom.url; + domainType = "custom"; + try { + await setManagedDomain({ + accountId, + bucket: bucketName, + token: apiToken, + enabled: false, + }); + } catch (disableErr) { + console.warn("Failed to disable managed domain:", disableErr); + } + } else { + usedManagedFallback = true; + } } catch (err) { if (/already exists/i.test(err.message || "")) { publicBaseUrl = `https://${domain}`; domainType = "custom"; + customDomainStatus = "active"; + try { + await setManagedDomain({ + accountId, + bucket: bucketName, + token: apiToken, + enabled: false, + }); + } catch (disableErr) { + console.warn("Failed to disable managed domain:", disableErr); + } } else { console.warn("Failed to attach custom domain, falling back:", err); usedManagedFallback = true; + customDomainStatus = "error"; } } } if (!publicBaseUrl) { - publicBaseUrl = await enableManagedDomain({ + const managed = await setManagedDomain({ accountId, bucket: bucketName, token: apiToken, + enabled: true, }); + publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`; domainType = "managed"; usedManagedFallback = true; + customDomainStatus = customDomainStatus === "skipped" ? "managed" : customDomainStatus; } const mergedEntry = { @@ -1662,7 +1700,7 @@ class bitvidApp { mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry) ); this.cloudflareSettings = updatedSettings; - return { entry: mergedEntry, usedManagedFallback }; + return { entry: mergedEntry, usedManagedFallback, customDomainStatus }; } async updateCloudflareBucketPreview() { @@ -1785,10 +1823,18 @@ class bitvidApp { return; } - const statusMessage = bucketResult?.usedManagedFallback && - (this.cloudflareSettings?.baseDomain || "") - ? `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone. Uploading…` - : `Uploading to ${bucketEntry.bucket}…`; + let statusMessage = `Uploading to ${bucketEntry.bucket}…`; + if (bucketResult?.usedManagedFallback) { + const baseDomain = this.cloudflareSettings?.baseDomain || ""; + if (baseDomain) { + const customStatus = bucketResult?.customDomainStatus + ? ` (custom domain status: ${bucketResult.customDomainStatus})` + : ""; + statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`; + } else { + statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`; + } + } this.setCloudflareUploadStatus( statusMessage, diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js index 58c8e58a..20434737 100644 --- a/js/storage/r2-mgmt.js +++ b/js/storage/r2-mgmt.js @@ -1,12 +1,32 @@ // js/storage/r2-mgmt.js export function sanitizeBucketName(npub) { - const base = (npub || "user") + let value = String(npub || "") .toLowerCase() - .replace(/[^a-z0-9-]/g, "") + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") .replace(/^-+|[-]+$/g, ""); - const suffix = Date.now().toString(36); - const name = `bv-${base || "u"}-${suffix}`.slice(0, 63); - return name.length < 3 ? `bv-u-${suffix}` : name; + + if (!value) { + value = "bitvid-user"; + } + + if (!/^[a-z0-9]/.test(value)) { + value = `bv-${value}`; + } + + if (!/[a-z0-9]$/.test(value)) { + value = `${value}0`; + } + + if (value.length > 63) { + value = value.slice(0, 63).replace(/-+$/g, ""); + } + + while (value.length < 3) { + value += "0"; + } + + return value; } function buildError(error, fallbackMessage) { @@ -88,11 +108,14 @@ export async function putCors({ accountId, bucket, token, origins }) { } const rules = [ { - AllowedOrigins: filteredOrigins, - AllowedMethods: ["GET", "HEAD", "PUT", "POST"], - AllowedHeaders: ["*"], - ExposeHeaders: ["ETag", "Content-Length", "Content-Range"], - MaxAgeSeconds: 3600, + id: "bitvid-default", + allowed: { + origins: filteredOrigins, + methods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"], + headers: ["*"], + }, + expose_headers: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"], + max_age_seconds: 3600, }, ]; await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, { @@ -102,30 +125,112 @@ export async function putCors({ accountId, bucket, token, origins }) { }); } +function extractStatus(payload) { + return ( + payload?.result?.status || + payload?.result?.result?.status || + payload?.status || + payload?.result?.statusText || + null + ); +} + +async function pollCustomDomain({ + accountId, + bucket, + token, + domain, + pollInterval = 2500, + timeoutMs = 120000, +}) { + const path = `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent( + domain + )}`; + const deadline = Date.now() + timeoutMs; + let lastPayload = null; + + while (Date.now() < deadline) { + lastPayload = await cfFetch(path, { token, method: "GET" }).catch((err) => { + if (err?.status === 404) { + return null; + } + throw err; + }); + + if (!lastPayload) { + break; + } + + const status = extractStatus(lastPayload); + if (status === "active") { + return { status, payload: lastPayload }; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + return { + status: extractStatus(lastPayload) || "unknown", + payload: lastPayload, + }; +} + // 3) Attach custom domain to bucket (auto-provisions on Cloudflare) -export async function attachCustomDomain({ +export async function attachCustomDomainAndWait({ accountId, bucket, token, zoneId, domain, + pollInterval, + timeoutMs, }) { - const { result } = await cfFetch( - `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, - { + let shouldPoll = true; + try { + await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, { token, method: "POST", - body: { domain, zoneId, enabled: true, minTLS: "1.2" }, + body: { domain, zoneId, enabled: true }, + }); + } catch (error) { + const err = buildError(error); + if (err.status === 409 || /already exists/i.test(err.message || "")) { + shouldPoll = true; + } else { + throw err; } - ); - return `https://${result?.domain || domain}`; + } + + let status = "unknown"; + if (shouldPoll) { + const pollResult = await pollCustomDomain({ + accountId, + bucket, + token, + domain, + pollInterval, + timeoutMs, + }); + status = pollResult.status; + } + + const url = `https://${domain}`; + return { + url, + status, + active: status === "active", + }; } -// 4) Or enable r2.dev managed domain as fallback -export async function enableManagedDomain({ accountId, bucket, token }) { +// 4) Toggle managed r2.dev domain for the bucket +export async function setManagedDomain({ accountId, bucket, token, enabled }) { const { result } = await cfFetch( `/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`, - { token, method: "PUT", body: { enabled: true } } + { token, method: "PUT", body: { enabled: Boolean(enabled) } } ); - return `https://${result?.domain}`; + const domain = result?.domain || result?.result?.domain || ""; + return { + enabled: Boolean(result?.enabled ?? enabled), + url: domain ? `https://${domain}` : "", + }; } diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index 7912541b..e71f948b 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -5,7 +5,28 @@ import { UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, -} from "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle"; +} from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle"; + +function computeCacheControl(key) { + if (!key) { + return "public, max-age=3600"; + } + + const lower = String(key).toLowerCase(); + if (/(?:\.)(m3u8|mpd)$/.test(lower)) { + return "public, max-age=60, must-revalidate"; + } + + if ( + /\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test( + lower + ) + ) { + return "public, max-age=31536000, immutable"; + } + + return "public, max-age=3600"; +} export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) { if (!accountId || !accessKeyId || !secretAccessKey) { @@ -27,7 +48,6 @@ export async function multipartUpload({ file, contentType, onProgress, - concurrency = 4, }) { if (!s3) { throw new Error("S3 client is required"); @@ -42,79 +62,67 @@ export async function multipartUpload({ throw new Error("File is required"); } - const { UploadId } = await s3.send( - new CreateMultipartUploadCommand({ - Bucket: bucket, - Key: key, - ContentType: contentType || file.type || "video/mp4", - CacheControl: "public, max-age=31536000, immutable", - }) + const createCommand = new CreateMultipartUploadCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType || file.type || "application/octet-stream", + CacheControl: computeCacheControl(key), + }); + + createCommand.middlewareStack.add( + (next) => async (args) => { + if (args?.request?.headers) { + args.request.headers["cf-create-bucket-if-missing"] = "true"; + } + return next(args); + }, + { step: "build" } ); + const { UploadId } = await s3.send(createCommand); + if (!UploadId) { throw new Error("Failed to start multipart upload"); } - const PART = 8 * 1024 * 1024; + const MIN_PART = 5 * 1024 * 1024; + const maxParts = 10000; + const calculated = Math.ceil(file.size / maxParts); + const partSize = Math.max(MIN_PART, calculated); const total = file.size; const parts = []; - let sent = 0; + let uploadedBytes = 0; let partNumber = 1; - const totalParts = Math.ceil(total / PART); - const errors = []; - const uploadPart = async () => { - const start = sent; - if (start >= total) { - return null; - } - const end = Math.min(start + PART, total); - const body = file.slice(start, end); - const currentPart = partNumber++; - sent = end; + if (typeof onProgress === "function") { + onProgress(0); + } - return s3 - .send( + try { + while (uploadedBytes < total) { + const start = uploadedBytes; + const end = Math.min(start + partSize, total); + const chunk = file.slice(start, end); + + const { ETag } = await s3.send( new UploadPartCommand({ Bucket: bucket, Key: key, UploadId, - PartNumber: currentPart, - Body: body, + PartNumber: partNumber, + Body: chunk, }) - ) - .then(({ ETag }) => { - parts.push({ ETag, PartNumber: currentPart }); - if (typeof onProgress === "function") { - onProgress(end / total); - } - }); - }; + ); - const workers = Array.from({ length: Math.max(1, concurrency) }, () => - (async () => { - try { - while (parts.length < totalParts) { - const task = uploadPart(); - if (!task) { - break; - } - await task; - } - } catch (error) { - errors.push(error); + parts.push({ ETag, PartNumber: partNumber }); + uploadedBytes = end; + partNumber += 1; + + if (typeof onProgress === "function") { + onProgress(uploadedBytes / total); } - })() - ); - - try { - await Promise.all(workers); - if (errors.length > 0) { - throw errors[0]; } - parts.sort((a, b) => a.PartNumber - b.PartNumber); - await s3.send( new CompleteMultipartUploadCommand({ Bucket: bucket, From 7890a949497dd83132b3dc9aafc356088b00b30b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:24:01 -0400 Subject: [PATCH 07/82] Add quick Cloudflare R2 upload helper to custom modal --- components/upload-modal.html | 99 +++++++++ js/app.js | 2 + js/r2-quick.js | 411 +++++++++++++++++++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 js/r2-quick.js diff --git a/components/upload-modal.html b/components/upload-modal.html index 484372f3..991c1340 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -148,6 +148,105 @@

+
+ + Optional: Upload directly to Cloudflare R2 + +
+

+ Supply your Cloudflare credentials to upload in-browser. Keys + stay on this device unless you opt in to remember them. +

+
+ + + + + + +
+
+ + +
+
+ + +
+

+
+
+
+

Some future profile details or settings could go here.

diff --git a/js/app.js b/js/app.js index 69f9c1c7..99bb18df 100644 --- a/js/app.js +++ b/js/app.js @@ -498,6 +498,7 @@ class bitvidApp { this.profileLogoutBtn = null; this.profileModalAvatar = null; this.profileModalName = null; + this.profileChannelLink = null; // Upload modal elements this.uploadButton = document.getElementById("uploadButton") || null; @@ -758,6 +759,32 @@ class bitvidApp { return entry; } + prepareForViewLoad() { + if (this._videoListElement && this._videoListClickHandler) { + try { + this._videoListElement.removeEventListener( + "click", + this._videoListClickHandler + ); + } catch (err) { + console.warn("[prepareForViewLoad] Failed to detach video list handler:", err); + } + this._videoListElement = null; + this._videoListClickHandler = null; + } + + this.videoList = null; + this._lastRenderedVideoListElement = null; + this.lastRenderedVideoSignature = null; + if (this.renderedVideoIds) { + this.renderedVideoIds.clear(); + } + + if (this.mediaLoader && typeof this.mediaLoader.disconnect === "function") { + this.mediaLoader.disconnect(); + } + } + forceRefreshAllProfiles() { // 1) Grab the newest set of videos from nostrClient const activeVideos = nostrClient.getActiveVideos(); @@ -2066,6 +2093,8 @@ class bitvidApp { document.getElementById("profileModalAvatar") || null; this.profileModalName = document.getElementById("profileModalName") || null; + this.profileChannelLink = + document.getElementById("profileChannelLink") || null; // Wire up if (this.closeProfileModal) { @@ -2081,6 +2110,20 @@ class bitvidApp { }); } + if (this.profileChannelLink) { + this.profileChannelLink.addEventListener("click", (event) => { + event.preventDefault(); + const targetNpub = this.profileChannelLink?.dataset?.targetNpub; + if (!targetNpub) { + return; + } + if (this.profileModal) { + this.profileModal.classList.add("hidden"); + } + window.location.hash = `#view=channel-profile&npub=${targetNpub}`; + }); + } + console.log("Profile modal initialization successful"); return true; } catch (error) { @@ -2288,6 +2331,18 @@ class bitvidApp { if (this.profileModalAvatar) { this.profileModalAvatar.src = picture; } + if (this.profileChannelLink) { + const targetNpub = this.safeEncodeNpub(pubkey); + if (targetNpub) { + this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`; + this.profileChannelLink.dataset.targetNpub = targetNpub; + this.profileChannelLink.classList.remove("hidden"); + } else { + this.profileChannelLink.removeAttribute("href"); + delete this.profileChannelLink.dataset.targetNpub; + this.profileChannelLink.classList.add("hidden"); + } + } } catch (error) { console.error("loadOwnProfile error:", error); } @@ -2589,6 +2644,11 @@ class bitvidApp { if (this.profileButton) { this.profileButton.classList.add("hidden"); } + if (this.profileChannelLink) { + this.profileChannelLink.classList.add("hidden"); + this.profileChannelLink.removeAttribute("href"); + delete this.profileChannelLink.dataset.targetNpub; + } // Hide the Subscriptions link if (this.subscriptionsLink) { diff --git a/js/viewManager.js b/js/viewManager.js index f22746c7..741cd0a2 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -9,6 +9,10 @@ const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; */ export async function loadView(viewUrl) { try { + if (window.app && typeof window.app.prepareForViewLoad === "function") { + window.app.prepareForViewLoad(); + } + const res = await fetch(viewUrl); if (!res.ok) { throw new Error(`Failed to load view: ${res.status}`); From 1b4bf696b77d7da2a6485785dcb6b9abf3c2a4d4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 27 Sep 2025 21:28:20 -0400 Subject: [PATCH 32/82] Add modal-driven video edit flow --- components/edit-video-modal.html | 180 ++++++++++++++ css/style.css | 31 +++ js/app.js | 402 +++++++++++++++++++++++++------ 3 files changed, 534 insertions(+), 79 deletions(-) create mode 100644 components/edit-video-modal.html diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html new file mode 100644 index 00000000..5275bd60 --- /dev/null +++ b/components/edit-video-modal.html @@ -0,0 +1,180 @@ + + diff --git a/css/style.css b/css/style.css index b523fd42..cde5e9ea 100644 --- a/css/style.css +++ b/css/style.css @@ -705,3 +705,34 @@ footer a:hover { align-items: center; display: flex; } +.edit-field-button { + align-items: center; + background-color: #1f2937; + border-radius: 0.5rem; + color: #e2e8f0; + display: inline-flex; + font-size: 0.75rem; + font-weight: 600; + justify-content: center; + padding: 0.5rem 0.85rem; + transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.edit-field-button:hover, +.edit-field-button:focus { + background-color: #334155; + color: #f8fafc; + transform: translateY(-1px); +} + +.locked-input { + background-color: rgba(31, 41, 55, 0.75) !important; + border-color: rgba(55, 65, 81, 0.9) !important; + color: #94a3b8 !important; + cursor: default; +} + +.locked-input:focus { + outline: none; + box-shadow: none; +} diff --git a/js/app.js b/js/app.js index 99bb18df..0b7d3990 100644 --- a/js/app.js +++ b/js/app.js @@ -539,6 +539,16 @@ class bitvidApp { this.r2ZoneIdInput = null; this.r2BaseDomainInput = null; + // Edit video modal elements + this.editVideoModal = null; + this.editVideoOverlay = null; + this.editVideoForm = null; + this.closeEditVideoModalBtn = null; + this.cancelEditVideoBtn = null; + this.editVideoSubmitBtn = null; + this.editVideoFieldButtons = []; + this.activeEditVideo = null; + // Optional small inline player stats this.status = document.getElementById("status") || null; this.progressBar = document.getElementById("progress") || null; @@ -817,6 +827,9 @@ class bitvidApp { await this.initUploadModal(); initQuickR2Upload(this); + // 2.5 Initialize the edit modal (components/edit-video-modal.html) + await this.initEditVideoModal(); + // 3. (Optional) Initialize the profile modal (components/profile-modal.html) await this.initProfileModal(); @@ -1280,6 +1293,241 @@ class bitvidApp { } } + async initEditVideoModal() { + try { + let modal = document.getElementById("editVideoModal"); + if (!modal) { + const resp = await fetch("components/edit-video-modal.html"); + if (!resp.ok) { + throw new Error(`HTTP error! status: ${resp.status}`); + } + const html = await resp.text(); + const modalContainer = document.getElementById("modalContainer"); + if (!modalContainer) { + throw new Error("Modal container element not found!"); + } + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html; + removeTrackingScripts(wrapper); + modalContainer.appendChild(wrapper); + + modal = wrapper.querySelector("#editVideoModal"); + } + + if (!modal) { + throw new Error("Edit video modal markup missing after load."); + } + + this.editVideoModal = modal; + this.editVideoOverlay = + modal.querySelector("#editVideoModalOverlay") || null; + this.editVideoForm = modal.querySelector("#editVideoForm") || null; + this.closeEditVideoModalBtn = + modal.querySelector("#closeEditVideoModal") || null; + this.cancelEditVideoBtn = + modal.querySelector("#cancelEditVideo") || null; + this.editVideoSubmitBtn = + modal.querySelector("#submitEditVideo") || null; + this.editVideoFieldButtons = Array.from( + modal.querySelectorAll("[data-edit-target]") + ); + + if (this.editVideoForm) { + this.editVideoForm.addEventListener("submit", (event) => { + event.preventDefault(); + this.handleEditVideoSubmit(); + }); + } + + if (this.closeEditVideoModalBtn) { + this.closeEditVideoModalBtn.addEventListener("click", () => { + this.hideEditVideoModal(); + }); + } + + if (this.cancelEditVideoBtn) { + this.cancelEditVideoBtn.addEventListener("click", () => { + this.hideEditVideoModal(); + }); + } + + if (this.editVideoOverlay) { + this.editVideoOverlay.addEventListener("click", () => { + this.hideEditVideoModal(); + }); + } + + if (Array.isArray(this.editVideoFieldButtons)) { + this.editVideoFieldButtons.forEach((btn) => { + btn.addEventListener("click", (event) => { + this.handleEditFieldToggle(event); + }); + }); + } + + this.resetEditVideoForm(); + + return true; + } catch (error) { + console.error("initEditVideoModal failed:", error); + this.showError(`Failed to initialize edit modal: ${error.message}`); + return false; + } + } + + resetEditVideoForm() { + if (!this.editVideoModal) { + this.activeEditVideo = null; + return; + } + + const fieldIds = [ + "editVideoTitle", + "editVideoUrl", + "editVideoMagnet", + "editVideoThumbnail", + "editVideoDescription", + ]; + + fieldIds.forEach((id) => { + const input = this.editVideoModal.querySelector(`#${id}`); + if (input) { + input.value = ""; + input.readOnly = false; + input.classList.remove("locked-input"); + delete input.dataset.originalValue; + } + const button = this.editVideoModal.querySelector( + `[data-edit-target="${id}"]` + ); + if (button) { + button.classList.add("hidden"); + button.dataset.mode = "locked"; + button.textContent = "Edit field"; + } + }); + + this.activeEditVideo = null; + } + + showEditVideoModal() { + if (this.editVideoModal) { + this.editVideoModal.classList.remove("hidden"); + } + } + + hideEditVideoModal() { + if (this.editVideoModal) { + this.editVideoModal.classList.add("hidden"); + } + this.resetEditVideoForm(); + } + + populateEditVideoForm(video) { + if (!video || !this.editVideoModal) { + return; + } + + this.resetEditVideoForm(); + + const fieldMap = { + editVideoTitle: video.title || "", + editVideoUrl: video.url || "", + editVideoMagnet: video.magnet || "", + editVideoThumbnail: video.thumbnail || "", + editVideoDescription: video.description || "", + }; + + Object.entries(fieldMap).forEach(([id, rawValue]) => { + const input = this.editVideoModal.querySelector(`#${id}`); + const button = this.editVideoModal.querySelector( + `[data-edit-target="${id}"]` + ); + const value = typeof rawValue === "string" ? rawValue : ""; + const hasValue = value.trim().length > 0; + + if (input) { + input.value = value; + input.dataset.originalValue = value; + if (hasValue) { + input.readOnly = true; + input.classList.add("locked-input"); + } else { + input.readOnly = false; + input.classList.remove("locked-input"); + } + } + + if (button) { + if (hasValue) { + button.classList.remove("hidden"); + button.dataset.mode = "locked"; + button.textContent = "Edit field"; + } else { + button.classList.add("hidden"); + button.dataset.mode = "locked"; + button.textContent = "Edit field"; + } + } + }); + + this.activeEditVideo = video; + } + + handleEditFieldToggle(event) { + const button = event?.currentTarget; + if (!button || !this.editVideoModal) { + return; + } + + const targetId = button.dataset?.editTarget; + if (!targetId) { + return; + } + + const input = this.editVideoModal.querySelector(`#${targetId}`); + if (!input) { + return; + } + + const mode = button.dataset.mode || "locked"; + if (mode === "locked") { + input.readOnly = false; + input.classList.remove("locked-input"); + button.dataset.mode = "editing"; + button.textContent = "Restore original"; + if (typeof input.focus === "function") { + input.focus(); + if (typeof input.setSelectionRange === "function") { + const length = input.value.length; + try { + input.setSelectionRange(length, length); + } catch (err) { + // ignore selection errors (e.g. for input types that do not support it) + } + } + } + return; + } + + const originalValue = input.dataset?.originalValue || ""; + input.value = originalValue; + + if (originalValue) { + input.readOnly = true; + input.classList.add("locked-input"); + button.dataset.mode = "locked"; + button.textContent = "Edit field"; + } else { + input.readOnly = false; + input.classList.remove("locked-input"); + button.classList.add("hidden"); + button.dataset.mode = "locked"; + button.textContent = "Edit field"; + } + } + setUploadMode(mode) { const normalized = mode === "cloudflare" ? "cloudflare" : "custom"; this.activeUploadMode = normalized; @@ -2519,6 +2767,73 @@ class bitvidApp { } } + async handleEditVideoSubmit() { + if (!this.activeEditVideo || !this.editVideoModal) { + this.showError("No video selected for editing."); + return; + } + + const fieldValue = (id) => { + const el = this.editVideoModal.querySelector(`#${id}`); + if (!el || typeof el.value !== "string") { + return ""; + } + return el.value.trim(); + }; + + const original = this.activeEditVideo; + + const newTitle = fieldValue("editVideoTitle"); + const newUrl = fieldValue("editVideoUrl"); + const newMagnet = fieldValue("editVideoMagnet"); + const newThumbnail = fieldValue("editVideoThumbnail"); + const newDescription = fieldValue("editVideoDescription"); + + const finalTitle = newTitle || original.title || ""; + const finalUrl = newUrl || original.url || ""; + const finalMagnet = newMagnet || original.magnet || ""; + const finalThumbnail = newThumbnail || original.thumbnail || ""; + const finalDescription = newDescription || original.description || ""; + + if (!finalTitle || (!finalUrl && !finalMagnet)) { + this.showError("Title and at least one of URL or Magnet is required."); + return; + } + + if (finalUrl && !/^https:\/\//i.test(finalUrl)) { + this.showError("Hosted video URLs must use HTTPS."); + return; + } + + const updatedData = { + version: original.version || 2, + title: finalTitle, + magnet: finalMagnet, + url: finalUrl, + thumbnail: finalThumbnail, + description: finalDescription, + mode: isDevMode ? "dev" : "live", + }; + + const originalEvent = { + id: original.id, + pubkey: original.pubkey, + videoRootId: original.videoRootId, + }; + + try { + await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); + await this.loadVideos(); + this.videosMap.clear(); + this.showSuccess("Video updated successfully!"); + this.hideEditVideoModal(); + this.forceRefreshAllProfiles(); + } catch (err) { + console.error("Failed to edit video:", err); + this.showError("Failed to edit video. Please try again."); + } + } + /** * Actually handle the upload form submission. */ @@ -4339,88 +4654,17 @@ class bitvidApp { return; } - // 3) Prompt the user for updated fields - const newTitle = prompt("New Title? (blank=keep existing)", video.title); - if (newTitle === null) { + if (!this.editVideoModal) { + await this.initEditVideoModal(); + } + + if (!this.editVideoModal) { + this.showError("Edit modal is not available right now."); return; } - const newMagnet = prompt( - "New Magnet? (blank=keep existing)", - video.magnet - ); - if (newMagnet === null) { - return; - } - - const newUrl = prompt( - "New URL? (blank=keep existing)", - video.url || "" - ); - if (newUrl === null) { - return; - } - - const newThumb = prompt( - "New Thumbnail? (blank=keep existing)", - video.thumbnail - ); - if (newThumb === null) { - return; - } - - const newDesc = prompt( - "New Description? (blank=keep existing)", - video.description - ); - if (newDesc === null) { - return; - } - // const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); - - // 4) Build final updated fields (or fallback to existing) - const title = - !newTitle || !newTitle.trim() ? video.title : newTitle.trim(); - const magnet = - !newMagnet || !newMagnet.trim() ? video.magnet : newMagnet.trim(); - const url = !newUrl || !newUrl.trim() ? video.url : newUrl.trim(); - const thumbnail = - !newThumb || !newThumb.trim() ? video.thumbnail : newThumb.trim(); - const description = - !newDesc || !newDesc.trim() ? video.description : newDesc.trim(); - - // 5) Create an object with the new data - const updatedData = { - version: video.version || 2, - // isPrivate: wantPrivate, - title, - magnet, - url, - thumbnail, - description, - mode: isDevMode ? "dev" : "live", - }; - - // 6) Build the originalEvent stub, now including videoRootId to avoid extra fetch - const originalEvent = { - id: video.id, - pubkey: video.pubkey, - videoRootId: video.videoRootId, // <-- pass this if it exists - }; - - // 7) Call the editVideo method - await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); - - // 8) Refresh local UI - await this.loadVideos(); - - // 8.1) Purge the outdated cache - this.videosMap.clear(); - - this.showSuccess("Video updated successfully!"); - - // 9) Also refresh all profile caches so any new name/pic changes are reflected - this.forceRefreshAllProfiles(); + this.populateEditVideoForm(video); + this.showEditVideoModal(); } catch (err) { console.error("Failed to edit video:", err); this.showError("Failed to edit video. Please try again."); From c773a07cdc657682ab5be5e247877744860b5310 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 27 Sep 2025 21:36:21 -0400 Subject: [PATCH 33/82] Add experimental warning to Cloudflare upload mode --- components/upload-modal.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/upload-modal.html b/components/upload-modal.html index 991c1340..329845ab 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -295,6 +295,9 @@ +
+ +
+
+ + + Allow replies once comment threads are available. + +
+ +
+
+
+ +
+ +
+ +

+ Keep this on to let viewers discuss the video once comment threads launch. +

+
+
- BitVid Logo + + BitVid Logo + diff --git a/torrent/beacon.html b/torrent/beacon.html index 8c6b6052..c43d3672 100644 --- a/torrent/beacon.html +++ b/torrent/beacon.html @@ -44,11 +44,13 @@
- + + +
From 5365fad888c40c3c5624a54fa9fdce9ecf163611 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 27 Sep 2025 23:55:18 -0400 Subject: [PATCH 43/82] Add profile modal scaffolding and video overflow menus --- assets/svg/ellipsis.svg | 5 + components/profile-modal.html | 152 ++++++-- components/video-modal.html | 49 +++ js/app.js | 637 +++++++++++++++++++++++++++++++++- js/channelProfile.js | 46 ++- js/subscriptions.js | 46 ++- 6 files changed, 897 insertions(+), 38 deletions(-) create mode 100644 assets/svg/ellipsis.svg diff --git a/assets/svg/ellipsis.svg b/assets/svg/ellipsis.svg new file mode 100644 index 00000000..18556f8c --- /dev/null +++ b/assets/svg/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/components/profile-modal.html b/components/profile-modal.html index d4377e4d..5a3f0f85 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -2,6 +2,10 @@
diff --git a/components/video-modal.html b/components/video-modal.html index 56df5588..81f5c635 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -31,6 +31,55 @@ +
+ + +
diff --git a/js/app.js b/js/app.js index 17c7a6f6..75a261ac 100644 --- a/js/app.js +++ b/js/app.js @@ -499,6 +499,26 @@ class bitvidApp { this.profileModalAvatar = null; this.profileModalName = null; this.profileChannelLink = null; + this.profileNavButtons = { + account: null, + relays: null, + blocked: null, + }; + this.profilePaneElements = { + account: null, + relays: null, + blocked: null, + }; + this.profileRelayList = null; + this.profileBlockedList = null; + this.profileBlockedEmpty = null; + this.profileRelayInput = null; + this.profileAddRelayBtn = null; + this.profileRestoreRelaysBtn = null; + this.lastFocusedBeforeProfileModal = null; + this.boundProfileModalKeydown = null; + this.boundProfileModalFocusIn = null; + this.profileModalFocusables = []; // Upload modal elements this.uploadButton = document.getElementById("uploadButton") || null; @@ -597,6 +617,8 @@ class bitvidApp { this.copyMagnetBtn = null; this.shareBtn = null; this.modalZapBtn = null; + this.modalMoreBtn = null; + this.modalMoreMenu = null; this.modalPosterCleanup = null; this.cleanupPromise = null; @@ -618,6 +640,9 @@ class bitvidApp { // Videos stored as a Map (key=event.id) this.videosMap = new Map(); + this.moreMenuGlobalHandlerBound = false; + this.boundMoreMenuDocumentClick = null; + this.boundMoreMenuDocumentKeydown = null; // Simple cache for user profiles this.profileCache = new Map(); this.lastRenderedVideoSignature = null; @@ -993,6 +1018,8 @@ class bitvidApp { this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null; this.shareBtn = document.getElementById("shareBtn") || null; this.modalZapBtn = document.getElementById("modalZapBtn") || null; + this.modalMoreBtn = document.getElementById("modalMoreBtn") || null; + this.modalMoreMenu = document.getElementById("moreDropdown-modal") || null; this.setModalZapVisibility(false); // Attach existing event listeners for copy/share @@ -1037,6 +1064,10 @@ class bitvidApp { this.openCreatorChannel(); }); } + + if (this.playerModal) { + this.attachMoreMenuHandlers(this.playerModal); + } } goToProfile(pubkey) { @@ -3223,35 +3254,85 @@ class bitvidApp { document.getElementById("profileModalName") || null; this.profileChannelLink = document.getElementById("profileChannelLink") || null; + this.profileNavButtons.account = + document.getElementById("profileNavAccount") || null; + this.profileNavButtons.relays = + document.getElementById("profileNavRelays") || null; + this.profileNavButtons.blocked = + document.getElementById("profileNavBlocked") || null; + this.profilePaneElements.account = + document.getElementById("profilePaneAccount") || null; + this.profilePaneElements.relays = + document.getElementById("profilePaneRelays") || null; + this.profilePaneElements.blocked = + document.getElementById("profilePaneBlocked") || null; + this.profileRelayList = document.getElementById("relayList") || null; + this.profileBlockedList = document.getElementById("blockedList") || null; + this.profileBlockedEmpty = + document.getElementById("blockedEmpty") || null; + this.profileRelayInput = document.getElementById("relayInput") || null; + this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null; + this.profileRestoreRelaysBtn = + document.getElementById("restoreRelaysBtn") || null; // Wire up - if (this.closeProfileModal) { + if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) { + this.closeProfileModal.dataset.bound = "true"; this.closeProfileModal.addEventListener("click", () => { - this.profileModal.classList.add("hidden"); + this.hideProfileModal(); }); } - if (this.profileLogoutBtn) { + if (this.profileLogoutBtn && !this.profileLogoutBtn.dataset.bound) { + this.profileLogoutBtn.dataset.bound = "true"; this.profileLogoutBtn.addEventListener("click", () => { - // On "Logout" inside the profile modal this.logout(); - this.profileModal.classList.add("hidden"); + this.hideProfileModal(); }); } - if (this.profileChannelLink) { + if (this.profileChannelLink && !this.profileChannelLink.dataset.bound) { + this.profileChannelLink.dataset.bound = "true"; this.profileChannelLink.addEventListener("click", (event) => { event.preventDefault(); const targetNpub = this.profileChannelLink?.dataset?.targetNpub; if (!targetNpub) { return; } - if (this.profileModal) { - this.profileModal.classList.add("hidden"); - } + this.hideProfileModal(); window.location.hash = `#view=channel-profile&npub=${targetNpub}`; }); } + Object.entries(this.profileNavButtons).forEach(([name, button]) => { + if (!button || button.dataset.navBound === "true") { + return; + } + button.dataset.navBound = "true"; + button.addEventListener("click", () => { + this.selectProfilePane(name); + }); + }); + + if (this.profileAddRelayBtn && !this.profileAddRelayBtn.dataset.bound) { + this.profileAddRelayBtn.dataset.bound = "true"; + this.profileAddRelayBtn.addEventListener("click", () => { + this.showSuccess("Relay management coming soon."); + }); + } + if ( + this.profileRestoreRelaysBtn && + this.profileRestoreRelaysBtn.dataset.bound !== "true" + ) { + this.profileRestoreRelaysBtn.dataset.bound = "true"; + this.profileRestoreRelaysBtn.addEventListener("click", () => { + this.showSuccess("Relay management coming soon."); + }); + } + + this.selectProfilePane("account"); + this.populateProfileRelays(); + this.populateBlockedList(); + console.log("Profile modal initialization successful"); return true; } catch (error) { @@ -3261,6 +3342,332 @@ class bitvidApp { } } + selectProfilePane(name = "account") { + const normalized = typeof name === "string" ? name.toLowerCase() : "account"; + const target = ["account", "relays", "blocked"].includes(normalized) + ? normalized + : "account"; + + Object.entries(this.profilePaneElements).forEach(([key, pane]) => { + if (!pane) { + return; + } + const isActive = key === target; + pane.classList.toggle("hidden", !isActive); + pane.setAttribute("aria-hidden", (!isActive).toString()); + }); + + Object.entries(this.profileNavButtons).forEach(([key, button]) => { + if (!button) { + return; + } + const isActive = key === target; + button.setAttribute("aria-selected", isActive ? "true" : "false"); + button.classList.toggle("bg-gray-800", isActive); + button.classList.toggle("text-white", isActive); + button.classList.toggle("text-gray-400", !isActive); + }); + + this.updateProfileModalFocusables(); + } + + updateProfileModalFocusables() { + if (!this.profileModal) { + this.profileModalFocusables = []; + return; + } + + const focusableSelectors = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; + const candidates = Array.from( + this.profileModal.querySelectorAll(focusableSelectors) + ); + + this.profileModalFocusables = candidates.filter((el) => { + if (!(el instanceof HTMLElement)) { + return false; + } + if (el.hasAttribute("disabled")) { + return false; + } + if (el.getAttribute("aria-hidden") === "true") { + return false; + } + if (el.offsetParent === null && el !== document.activeElement) { + return false; + } + return true; + }); + } + + openProfileModal() { + if (!this.profileModal) { + return; + } + + this.selectProfilePane("account"); + this.populateProfileRelays(); + this.populateBlockedList(); + + this.profileModal.classList.remove("hidden"); + this.profileModal.setAttribute("aria-hidden", "false"); + + this.lastFocusedBeforeProfileModal = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + + this.updateProfileModalFocusables(); + + const initialTarget = + this.profileNavButtons.account instanceof HTMLElement + ? this.profileNavButtons.account + : this.profileModal; + + window.requestAnimationFrame(() => { + if (initialTarget && typeof initialTarget.focus === "function") { + initialTarget.focus(); + } + }); + + if (!this.boundProfileModalKeydown) { + this.boundProfileModalKeydown = (event) => { + if (!this.profileModal || this.profileModal.classList.contains("hidden")) { + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + this.hideProfileModal(); + return; + } + + if (event.key !== "Tab") { + return; + } + + this.updateProfileModalFocusables(); + if (!this.profileModalFocusables.length) { + event.preventDefault(); + if (typeof this.profileModal.focus === "function") { + this.profileModal.focus(); + } + return; + } + + const first = this.profileModalFocusables[0]; + const last = this.profileModalFocusables[this.profileModalFocusables.length - 1]; + const active = document.activeElement; + + if (event.shiftKey) { + if (active === first || !this.profileModal.contains(active)) { + event.preventDefault(); + if (last && typeof last.focus === "function") { + last.focus(); + } + } + return; + } + + if (active === last) { + event.preventDefault(); + if (first && typeof first.focus === "function") { + first.focus(); + } + } + }; + } + + if (!this.boundProfileModalFocusIn) { + this.boundProfileModalFocusIn = (event) => { + if ( + !this.profileModal || + this.profileModal.classList.contains("hidden") || + this.profileModal.contains(event.target) + ) { + return; + } + + this.updateProfileModalFocusables(); + const fallback = this.profileModalFocusables[0] || this.profileModal; + if (fallback && typeof fallback.focus === "function") { + fallback.focus(); + } + }; + } + + this.profileModal.addEventListener("keydown", this.boundProfileModalKeydown); + document.addEventListener("focusin", this.boundProfileModalFocusIn); + } + + hideProfileModal() { + if (!this.profileModal) { + return; + } + + if (!this.profileModal.classList.contains("hidden")) { + this.profileModal.classList.add("hidden"); + } + this.profileModal.setAttribute("aria-hidden", "true"); + + if (this.boundProfileModalKeydown) { + this.profileModal.removeEventListener( + "keydown", + this.boundProfileModalKeydown + ); + } + if (this.boundProfileModalFocusIn) { + document.removeEventListener("focusin", this.boundProfileModalFocusIn); + } + + this.closeAllMoreMenus(); + + if ( + this.lastFocusedBeforeProfileModal && + typeof this.lastFocusedBeforeProfileModal.focus === "function" + ) { + this.lastFocusedBeforeProfileModal.focus(); + } + this.lastFocusedBeforeProfileModal = null; + } + + populateProfileRelays(relayUrls) { + if (!this.profileRelayList) { + return; + } + + const rawRelays = + Array.isArray(relayUrls) + ? relayUrls + : Array.isArray(nostrClient.relays) + ? nostrClient.relays + : Array.from(nostrClient.relays || []); + + const relays = rawRelays + .map((relay) => (typeof relay === "string" ? relay.trim() : "")) + .filter((relay) => relay.length > 0); + + this.profileRelayList.innerHTML = ""; + + if (!relays.length) { + const emptyState = document.createElement("li"); + emptyState.className = + "rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"; + emptyState.textContent = "No relays configured."; + this.profileRelayList.appendChild(emptyState); + return; + } + + relays.forEach((relayUrl) => { + const item = document.createElement("li"); + item.className = + "flex items-start justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3"; + + const info = document.createElement("div"); + info.className = "flex-1 min-w-0"; + + const urlEl = document.createElement("p"); + urlEl.className = "text-sm font-medium text-gray-100 break-all"; + urlEl.textContent = relayUrl; + + const statusEl = document.createElement("p"); + statusEl.className = "mt-1 text-xs text-gray-400"; + statusEl.textContent = "Active"; + + info.appendChild(urlEl); + info.appendChild(statusEl); + + const actions = document.createElement("div"); + actions.className = "flex items-center gap-2"; + + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.className = + "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; + editBtn.textContent = "Edit"; + editBtn.addEventListener("click", () => { + this.showSuccess("Relay management coming soon."); + }); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = + "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => { + this.showSuccess("Relay management coming soon."); + }); + + actions.appendChild(editBtn); + actions.appendChild(removeBtn); + + item.appendChild(info); + item.appendChild(actions); + + this.profileRelayList.appendChild(item); + }); + } + + populateBlockedList(blocked = []) { + if (!this.profileBlockedList || !this.profileBlockedEmpty) { + return; + } + + const entries = Array.isArray(blocked) ? blocked : []; + this.profileBlockedList.innerHTML = ""; + + if (!entries.length) { + this.profileBlockedEmpty.classList.remove("hidden"); + this.profileBlockedList.classList.add("hidden"); + return; + } + + this.profileBlockedEmpty.classList.add("hidden"); + this.profileBlockedList.classList.remove("hidden"); + + entries.forEach((entry) => { + const identifier = + typeof entry === "string" + ? entry + : typeof entry?.npub === "string" && entry.npub + ? entry.npub + : typeof entry?.pubkey === "string" + ? entry.pubkey + : ""; + + if (!identifier) { + return; + } + + const item = document.createElement("li"); + item.className = + "flex items-center justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3"; + + const label = document.createElement("div"); + label.className = "min-w-0"; + + const title = document.createElement("p"); + title.className = "text-sm font-medium text-gray-100 break-all"; + title.textContent = identifier; + + label.appendChild(title); + + const actionBtn = document.createElement("button"); + actionBtn.type = "button"; + actionBtn.className = + "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; + actionBtn.textContent = "Remove"; + actionBtn.addEventListener("click", () => { + this.showSuccess("Block management coming soon."); + }); + + item.appendChild(label); + item.appendChild(actionBtn); + + this.profileBlockedList.appendChild(item); + }); + } + /** * Setup general event listeners for logout, modals, etc. */ @@ -3275,9 +3682,7 @@ class bitvidApp { // 2) Profile button if (this.profileButton) { this.profileButton.addEventListener("click", () => { - if (this.profileModal) { - this.profileModal.classList.remove("hidden"); - } + this.openProfileModal(); }); } @@ -4934,6 +5339,48 @@ class bitvidApp { ` : ""; + const moreMenu = ` +
+ + +
+ `; + + const cardControls = ` +
+ ${moreMenu}${gearMenu} +
+ `; + const trimmedUrl = typeof video.url === "string" ? video.url.trim() : ""; const trimmedMagnet = typeof video.magnet === "string" ? video.magnet.trim() : ""; @@ -5057,7 +5504,7 @@ class bitvidApp { - ${gearMenu} + ${cardControls} ${connectionBadgesHtml} ${torrentWarningHtml} @@ -5265,6 +5712,7 @@ class bitvidApp { // with HTTP 200 + empty body). We set up the listeners before kicking off // any lazy-loading observers so cached failures are covered as well. this.bindThumbnailFallbacks(this.videoList); + this.attachMoreMenuHandlers(this.videoList); // Lazy-load images const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); @@ -5445,6 +5893,169 @@ class bitvidApp { }); } + ensureGlobalMoreMenuHandlers() { + if (this.moreMenuGlobalHandlerBound) { + return; + } + + this.moreMenuGlobalHandlerBound = true; + + this.boundMoreMenuDocumentClick = (event) => { + const target = event.target; + if ( + target instanceof HTMLElement && + (target.closest("[data-more-menu-wrapper]") || + target.closest("[data-more-menu]")) + ) { + return; + } + this.closeAllMoreMenus(); + }; + + this.boundMoreMenuDocumentKeydown = (event) => { + if (event.key === "Escape") { + this.closeAllMoreMenus(); + } + }; + + document.addEventListener("click", this.boundMoreMenuDocumentClick); + document.addEventListener("keydown", this.boundMoreMenuDocumentKeydown); + } + + closeAllMoreMenus() { + const menus = document.querySelectorAll("[data-more-menu]"); + menus.forEach((menu) => { + if (menu instanceof HTMLElement) { + menu.classList.add("hidden"); + } + }); + + const buttons = document.querySelectorAll("[data-more-dropdown]"); + buttons.forEach((btn) => { + if (btn instanceof HTMLElement) { + btn.setAttribute("aria-expanded", "false"); + } + }); + } + + attachMoreMenuHandlers(container) { + if (!container || typeof container.querySelectorAll !== "function") { + return; + } + + const buttons = container.querySelectorAll("[data-more-dropdown]"); + if (!buttons.length) { + return; + } + + this.ensureGlobalMoreMenuHandlers(); + + buttons.forEach((button) => { + if (!(button instanceof HTMLElement)) { + return; + } + if (button.dataset.moreMenuToggleBound === "true") { + return; + } + button.dataset.moreMenuToggleBound = "true"; + button.setAttribute("aria-expanded", "false"); + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + const key = button.getAttribute("data-more-dropdown") || ""; + const dropdown = document.getElementById(`moreDropdown-${key}`); + if (!(dropdown instanceof HTMLElement)) { + return; + } + + const willOpen = dropdown.classList.contains("hidden"); + this.closeAllMoreMenus(); + + if (willOpen) { + dropdown.classList.remove("hidden"); + button.setAttribute("aria-expanded", "true"); + } + }); + }); + + const actionButtons = container.querySelectorAll( + "[data-more-menu] button[data-action]" + ); + actionButtons.forEach((button) => { + if (!(button instanceof HTMLElement)) { + return; + } + if (button.dataset.moreMenuActionBound === "true") { + return; + } + button.dataset.moreMenuActionBound = "true"; + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const { action } = button.dataset; + this.handleMoreMenuAction(action, button.dataset); + this.closeAllMoreMenus(); + }); + }); + } + + handleMoreMenuAction(action, dataset = {}) { + const normalized = typeof action === "string" ? action.trim() : ""; + + switch (normalized) { + case "open-channel": { + const author = dataset.author || ""; + if (author) { + this.goToProfile(author); + } else { + this.showError("No creator info available."); + } + break; + } + case "modal-open-channel": { + this.openCreatorChannel(); + break; + } + case "copy-link": + case "modal-copy-link": { + const eventId = + dataset.eventId || + (normalized === "modal-copy-link" && this.currentVideo + ? this.currentVideo.id + : ""); + if (!eventId) { + this.showError("Could not generate link."); + break; + } + const shareUrl = this.buildShareUrlFromEventId(eventId); + if (!shareUrl) { + this.showError("Could not generate link."); + break; + } + navigator.clipboard + .writeText(shareUrl) + .then(() => this.showSuccess("Video link copied to clipboard!")) + .catch(() => this.showError("Failed to copy the link.")); + break; + } + case "block-author": + case "modal-block-author": { + this.showSuccess("Block management coming soon."); + break; + } + case "report": + case "modal-report": { + this.showSuccess("Reporting coming soon."); + break; + } + default: + break; + } + } + /** * Updates the modal to reflect current torrent stats. * We remove the unused torrent.status references, diff --git a/js/channelProfile.js b/js/channelProfile.js index 9e23492f..6f5105a6 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -387,6 +387,48 @@ async function loadUserVideos(pubkey) { `; } + const moreMenu = ` +
+ + +
+ `; + + const cardControls = ` +
+ ${moreMenu}${gearMenu} +
+ `; + // Fallback thumbnail const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg"; const safeThumb = video.thumbnail || fallbackThumb; @@ -470,7 +512,7 @@ async function loadUserVideos(pubkey) { ${new Date(video.created_at * 1000).toLocaleString()}

- ${gearMenu} + ${cardControls} ${connectionBadgesHtml} @@ -576,6 +618,8 @@ async function loadUserVideos(pubkey) { const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => app.mediaLoader.observe(el)); + app.attachMoreMenuHandlers(container); + // Gear menu toggles const gearButtons = container.querySelectorAll("[data-settings-dropdown]"); gearButtons.forEach((btn) => { diff --git a/js/subscriptions.js b/js/subscriptions.js index 34e129e0..c79ef25b 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -377,6 +377,48 @@ class SubscriptionsManager { ` : ""; + const moreMenu = ` +
+ + +
+ `; + + const cardControls = ` +
+ ${moreMenu}${gearMenu} +
+ `; + const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; const playbackUrl = @@ -457,7 +499,7 @@ class SubscriptionsManager { - ${gearMenu} + ${cardControls} ${connectionBadgesHtml} @@ -566,6 +608,8 @@ class SubscriptionsManager { const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); + window.app?.attachMoreMenuHandlers?.(container); + // Gear menus const gearButtons = container.querySelectorAll("[data-settings-dropdown]"); gearButtons.forEach((btn) => { From de2d6c409f97093967419002407a5bb0adf312fb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 00:02:57 -0400 Subject: [PATCH 44/82] Make modal overflow menu reuse card actions --- assets/svg/ellipsis.svg | 14 ++++++--- components/video-modal.html | 12 ++++--- js/app.js | 62 ++++++++++++++++++++++++++++--------- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/assets/svg/ellipsis.svg b/assets/svg/ellipsis.svg index 18556f8c..ca2af607 100644 --- a/assets/svg/ellipsis.svg +++ b/assets/svg/ellipsis.svg @@ -1,5 +1,11 @@ - - - - + + + + diff --git a/components/video-modal.html b/components/video-modal.html index 81f5c635..cccfa219 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -55,25 +55,29 @@
diff --git a/js/app.js b/js/app.js index 75a261ac..7509f987 100644 --- a/js/app.js +++ b/js/app.js @@ -1068,6 +1068,8 @@ class bitvidApp { if (this.playerModal) { this.attachMoreMenuHandlers(this.playerModal); } + + this.syncModalMoreMenuData(); } goToProfile(pubkey) { @@ -6002,12 +6004,49 @@ class bitvidApp { }); } + syncModalMoreMenuData() { + if (!this.modalMoreMenu) { + return; + } + + const buttons = this.modalMoreMenu.querySelectorAll("button[data-action]"); + buttons.forEach((button) => { + if (!(button instanceof HTMLElement)) { + return; + } + + const action = button.dataset.action || ""; + if (action === "open-channel" || action === "block-author") { + if (this.currentVideo && this.currentVideo.pubkey) { + button.dataset.author = this.currentVideo.pubkey; + } else { + delete button.dataset.author; + } + } + + if (action === "copy-link" || action === "report") { + if (this.currentVideo && this.currentVideo.id) { + button.dataset.eventId = this.currentVideo.id; + } else { + delete button.dataset.eventId; + } + } + }); + } + handleMoreMenuAction(action, dataset = {}) { const normalized = typeof action === "string" ? action.trim() : ""; + const context = dataset.context || ""; switch (normalized) { case "open-channel": { - const author = dataset.author || ""; + if (context === "modal") { + this.openCreatorChannel(); + break; + } + + const author = + dataset.author || (this.currentVideo ? this.currentVideo.pubkey : ""); if (author) { this.goToProfile(author); } else { @@ -6015,17 +6054,10 @@ class bitvidApp { } break; } - case "modal-open-channel": { - this.openCreatorChannel(); - break; - } - case "copy-link": - case "modal-copy-link": { + case "copy-link": { const eventId = dataset.eventId || - (normalized === "modal-copy-link" && this.currentVideo - ? this.currentVideo.id - : ""); + (context === "modal" && this.currentVideo ? this.currentVideo.id : ""); if (!eventId) { this.showError("Could not generate link."); break; @@ -6041,13 +6073,11 @@ class bitvidApp { .catch(() => this.showError("Failed to copy the link.")); break; } - case "block-author": - case "modal-block-author": { + case "block-author": { this.showSuccess("Block management coming soon."); break; } - case "report": - case "modal-report": { + case "report": { this.showSuccess("Reporting coming soon."); break; } @@ -7136,6 +7166,8 @@ class bitvidApp { lightningAddress: null, }; + this.syncModalMoreMenuData(); + this.currentMagnetUri = sanitizedMagnet || null; this.setCopyMagnetState(!!sanitizedMagnet); @@ -7251,6 +7283,8 @@ class bitvidApp { lightningAddress: null, }; + this.syncModalMoreMenuData(); + this.currentMagnetUri = sanitizedMagnet || null; this.setCopyMagnetState(!!sanitizedMagnet); From e5f05f362c0b8c47b128c3fbcb061f4b286c808f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 10:18:43 -0400 Subject: [PATCH 45/82] Fix modal overflow to show options menu --- components/video-modal.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/video-modal.html b/components/video-modal.html index cccfa219..7a31a724 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -4,7 +4,7 @@ class="modal-container h-full w-full flex items-start justify-center overflow-y-auto" >