Files
bitvid/js/ui/components/UploadModal.js
T
google-labs-jules[bot] 2b446e8ddd Refactor upload modal for generic storage and read-only config
- Updates Upload Modal UI labels to be provider-agnostic (e.g., 'Storage Credentials', 'Endpoint').
- Implements a read-only 'Summary View' for configured storage connections, collapsing the editable form by default.
- Adds 'Manage Storage' button that navigates to the Profile Modal's storage pane.
- Updates `initUploadModal.js` to inject the navigation callback.
- Updates `r2Service.js` status messages to be generic.
- Fixes unlock flow to ensure UI updates after storage unlock.
2026-01-16 15:44:22 +00:00

1129 lines
39 KiB
JavaScript

// components/UploadModal.js
import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
import logger, { userLogger } from "../../utils/logger.js";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
VIDEO_NOTE_ERROR_CODES,
} from "../../services/videoNotePayload.js";
import { createTorrentMetadata } from "../../utils/torrentHash.js";
import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
import { buildR2Key, buildPublicUrl } from "../../r2.js";
import { PROVIDERS } from "../../services/storageService.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
function normalizeInfoHash(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isValidInfoHash(value) {
return INFO_HASH_PATTERN.test(value);
}
export class UploadModal {
constructor({
authService,
r2Service,
storageService,
publishVideoNote,
removeTrackingScripts,
setGlobalModalState,
showError,
showSuccess,
getCurrentPubkey,
safeEncodeNpub,
eventTarget,
container,
onRequestStorageSettings,
} = {}) {
this.authService = authService || null;
this.r2Service = r2Service || null;
this.storageService = storageService || null;
this.publishVideoNote =
typeof publishVideoNote === "function" ? publishVideoNote : null;
this.removeTrackingScripts =
typeof removeTrackingScripts === "function"
? removeTrackingScripts
: () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function"
? setGlobalModalState
: () => {};
this.showError = typeof showError === "function" ? showError : () => {};
this.showSuccess =
typeof showSuccess === "function" ? showSuccess : () => {};
this.getCurrentPubkey =
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
this.safeEncodeNpub =
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null;
this.root = null;
this.isVisible = false;
this.activeSource = "upload"; // 'upload' | 'external'
this.cloudflareSettings = this.r2Service?.getSettings?.() || null;
this.isStorageUnlocked = false;
this.activeConnectionId = null;
this.storageConfigured = false;
// UI References
this.form = null;
this.modeButtons = {};
this.sourceSections = {};
this.inputs = {};
this.toggles = {};
this.submitButton = null;
this.submitStatus = null;
this.summaryView = {};
// Logic/State
this.nip71FormManager = new Nip71FormManager();
this.r2Unsubscribes = [];
this.cleanupHandlers = [];
this.modalAccessibility = null;
this.loadPromise = null;
// Automation
this.summaryLocked = true;
this.isUploading = false;
}
// --- Core Lifecycle ---
async load({ container } = {}) {
if (this.root && this.root.isConnected) return this.root;
if (this.loadPromise) return this.loadPromise;
this.loadPromise = (async () => {
const targetContainer =
container || this.container || document.getElementById("modalContainer");
if (!targetContainer) throw new Error("Modal container not found!");
// Cleanup existing
targetContainer.querySelectorAll("#uploadModal").forEach(n => n.remove());
const response = await fetch("components/upload-modal.html");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const html = await response.text();
const doc = targetContainer.ownerDocument || document;
const wrapper = doc.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper.firstElementChild);
this.root = targetContainer.querySelector("#uploadModal");
this.container = targetContainer;
this.cacheElements();
this.bindEvents();
this.setupModalAccessibility();
this.registerR2Subscriptions();
// Initial State
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
// Even if locked, we want to know if there's *potentially* a connection saved to show correct UI state (locked vs empty)
await this.loadFromStorage();
} else {
await this.loadR2Settings();
}
this.updateLockUi();
this.setSourceMode("upload");
return this.root;
})();
try {
return await this.loadPromise;
} finally {
this.loadPromise = null;
}
}
cacheElements() {
const $ = (sel) => this.root.querySelector(sel);
this.form = $("#unifiedUploadForm");
this.submitButton = $("#btn-submit");
this.submitStatus = $("#submit-status");
this.closeButton = $("#closeUploadModal");
// Mode Switchers
this.modeButtons = {
upload: $("#btn-mode-upload"),
external: $("#btn-mode-external"),
};
// Sections
this.sourceSections = {
upload: $("#section-source-upload"),
external: $("#section-source-external"),
settings: $("#section-storage-settings"),
advanced: $("#section-advanced"),
progress: $("#upload-progress-container"),
};
this.storageViews = {
summary: $("#storage-summary-view"),
form: $("#storage-form-view"),
};
// Inputs (Common)
this.inputs = {
title: $("#input-title"),
description: $("#input-description"),
thumbnail: $("#input-thumbnail"),
thumbnailFile: $("#input-thumbnail-file"),
file: $("#input-file"),
url: $("#input-url"),
magnet: $("#input-magnet"),
// Settings
r2BucketName: $("#input-r2-bucket-name"),
r2Account: $("#input-r2-account"),
r2Key: $("#input-r2-key"),
r2Secret: $("#input-r2-secret"),
r2Domain: $("#input-r2-domain"), // Public URL
// Advanced (Manual or NIP71 managed)
ws: $("#input-ws"),
xs: $("#input-xs"),
summary: $("#input-summary"),
contentWarning: $("#input-content-warning"),
duration: $("#input-duration"),
// Progress
progress: $("#input-progress"),
};
// Wizard Containers
this.wizard = {
step1: $("#step-credentials"),
step2: $("#step-verification"),
nextBtn: $("#btn-next-step"),
backBtn: $("#btn-back-step"),
errorContainer: $("#container-verification-error"),
errorText: $("#text-verification-error"),
guideLink: $("#link-cloudflare-guide"),
};
// Toggles/Buttons
this.toggles = {
nsfw: $("#check-nsfw"),
kids: $("#check-kids"),
comments: $("#check-comments"),
summaryUnlock: $("#check-summary-unlock"),
advanced: $("#btn-advanced-toggle"),
storageSettings: $("#btn-storage-settings"),
saveSettings: $("#btn-save-settings"), // Verify & Save
browseThumbnail: $("#btn-thumbnail-file"),
r2HelpLink: $("#link-r2-help"),
copyBucket: $("#btn-copy-bucket"),
storageUnlock: $("#btn-storage-unlock"),
manageStorage: $("#btn-manage-storage"),
};
// Status text
this.statusText = {
storage: $("#storage-status"),
storageLock: $("#storage-lock-status"),
uploadMain: $("#upload-status-text"),
uploadPercent: $("#upload-percent-text"),
summaryProvider: $("#summary-provider"),
summaryBucket: $("#summary-bucket"),
};
this.nip71FormManager.registerSection("main", this.form);
}
bindEvents() {
// Mode Switching
this.modeButtons.upload.addEventListener("click", () => this.setSourceMode("upload"));
this.modeButtons.external.addEventListener("click", () => this.setSourceMode("external"));
// Toggles
this.setupAccordion(this.toggles.advanced, this.sourceSections.advanced);
this.setupAccordion(this.toggles.storageSettings, this.sourceSections.settings);
// Form Submission
this.form.addEventListener("submit", (e) => {
e.preventDefault();
this.handleSubmit();
});
// Wizard: Step 1 Next
if (this.wizard.nextBtn) {
this.wizard.nextBtn.addEventListener("click", () => this.goToVerificationStep());
}
// Wizard: Step 2 Back
if (this.wizard.backBtn) {
this.wizard.backBtn.addEventListener("click", () => this.goToCredentialsStep());
}
// Settings Save (Verify & Save)
this.toggles.saveSettings.addEventListener("click", async () => {
await this.handleVerifyAndSave();
});
// Help Link
if (this.toggles.r2HelpLink) {
this.toggles.r2HelpLink.addEventListener("click", () => {
this.close();
});
}
// Copy Bucket Name
if (this.toggles.copyBucket && this.inputs.r2BucketName) {
this.toggles.copyBucket.addEventListener("click", () => {
const val = this.inputs.r2BucketName.value;
if (val) {
navigator.clipboard.writeText(val).then(() => {
const original = this.toggles.copyBucket.textContent;
this.toggles.copyBucket.textContent = "Copied!";
setTimeout(() => this.toggles.copyBucket.textContent = original, 1500);
});
}
});
}
// Storage Unlock
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
}
// Manage Storage
if (this.toggles.manageStorage) {
this.toggles.manageStorage.addEventListener("click", () => {
if (this.onRequestStorageSettings) {
this.close();
this.onRequestStorageSettings();
}
});
}
// Automation
this.setupDescriptionMirror();
this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids);
this.setupNsfwToContentWarning();
this.setupThumbnailInput();
// Close
this.closeButton.addEventListener("click", () => this.close());
// NIP-71 Manager
this.nip71FormManager.bindSection("main");
}
// --- Logic & State ---
setSourceMode(mode) {
this.activeSource = mode;
// UI Updates
const isUpload = mode === "upload";
// Toggle Buttons
this.modeButtons.upload.setAttribute("aria-pressed", isUpload);
this.modeButtons.upload.classList.toggle("bg-surface", isUpload);
this.modeButtons.upload.classList.toggle("text-text", isUpload);
this.modeButtons.upload.classList.toggle("shadow-sm", isUpload);
this.modeButtons.upload.classList.toggle("text-muted", !isUpload);
this.modeButtons.external.setAttribute("aria-pressed", !isUpload);
this.modeButtons.external.classList.toggle("bg-surface", !isUpload);
this.modeButtons.external.classList.toggle("text-text", !isUpload);
this.modeButtons.external.classList.toggle("shadow-sm", !isUpload);
this.modeButtons.external.classList.toggle("text-muted", isUpload);
// Sections
if (isUpload) {
this.sourceSections.upload.classList.remove("hidden");
this.sourceSections.external.classList.add("hidden");
// Auto-show settings if missing or not configured
if (!this.storageConfigured && !this.hasValidR2Settings() && this.sourceSections.settings.classList.contains("hidden")) {
this.toggles.storageSettings.click(); // Expand
} else if (this.storageConfigured) {
// Keep collapsed if configured, per requirements
}
if (this.toggles.browseThumbnail) {
this.toggles.browseThumbnail.classList.remove("hidden");
}
// Populate derived bucket name
this.populateBucketName();
} else {
this.sourceSections.upload.classList.add("hidden");
this.sourceSections.external.classList.remove("hidden");
if (this.toggles.browseThumbnail) {
this.toggles.browseThumbnail.classList.add("hidden");
}
}
// Update Button Text
this.submitButton.textContent = isUpload ? "Upload & Publish" : "Publish Video";
}
setupAccordion(btn, section) {
if (!btn || !section) return;
btn.addEventListener("click", () => {
const isHidden = section.classList.contains("hidden");
if (isHidden) {
section.classList.remove("hidden");
btn.setAttribute("aria-expanded", "true");
// Rotate icon if exists
const icon = btn.querySelector("svg");
if (icon) icon.classList.add("rotate-90");
} else {
section.classList.add("hidden");
btn.setAttribute("aria-expanded", "false");
const icon = btn.querySelector("svg");
if (icon) icon.classList.remove("rotate-90");
}
});
}
// --- Automation Helpers ---
populateBucketName() {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (pubkey && this.safeEncodeNpub && this.inputs.r2BucketName) {
const npub = this.safeEncodeNpub(pubkey);
const bucketName = sanitizeBucketName(npub);
this.inputs.r2BucketName.value = bucketName;
}
}
setupDescriptionMirror() {
const { description, summary } = this.inputs;
const { summaryUnlock } = this.toggles;
if (!description || !summary) return;
description.addEventListener("input", () => {
if (this.summaryLocked) summary.value = description.value;
});
if (summaryUnlock) {
summaryUnlock.addEventListener("change", () => {
this.summaryLocked = !summaryUnlock.checked;
summary.readOnly = this.summaryLocked;
if (this.summaryLocked) {
summary.value = description.value;
summary.classList.add("text-muted");
} else {
summary.classList.remove("text-muted");
}
});
}
}
setupMutuallyExclusiveCheckboxes(a, b) {
if (!a || !b) return;
a.addEventListener("change", () => { if (a.checked) b.checked = false; });
b.addEventListener("change", () => { if (b.checked) a.checked = false; });
}
setupNsfwToContentWarning() {
const { nsfw } = this.toggles;
const { contentWarning } = this.inputs;
if (!nsfw || !contentWarning) return;
nsfw.addEventListener("change", () => {
if (nsfw.checked && !contentWarning.value) {
contentWarning.value = "NSFW";
} else if (!nsfw.checked && contentWarning.value === "NSFW") {
contentWarning.value = "";
}
});
}
setupThumbnailInput() {
const { thumbnailFile, thumbnail } = this.inputs;
const { browseThumbnail } = this.toggles;
if (!thumbnailFile || !browseThumbnail) return;
browseThumbnail.addEventListener("click", () => thumbnailFile.click());
thumbnailFile.addEventListener("change", () => {
const file = thumbnailFile.files?.[0];
if (file) {
thumbnail.value = ""; // Clear explicit URL
thumbnail.placeholder = `Selected: ${file.name}`;
thumbnail.disabled = true;
} else {
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
thumbnail.disabled = false;
}
});
}
// --- R2 Integration ---
async refreshState() {
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
this.updateLockUi();
await this.loadFromStorage();
} else if (this.r2Service && typeof this.r2Service.loadSettings === "function") {
await this.loadR2Settings();
}
}
// Legacy fallback loader (renamed for clarity if desired, but keeping signature safe)
async loadR2Settings() {
// Fallback legacy load if storage service not used/available
if (!this.r2Service?.loadSettings) return;
const settings = await this.r2Service.loadSettings();
this.cloudflareSettings = settings || {};
this.fillSettingsForm(this.cloudflareSettings);
this.updateStorageStatus(this.hasValidR2Settings());
// Legacy mode implies no storage service or not used
this.toggleStorageView("form");
return settings;
}
async loadFromStorage() {
if (!this.storageService) return;
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
this.cloudflareSettings = null;
this.activeConnectionId = null;
this.storageConfigured = false;
if (!pubkey) {
this.toggleStorageView("form");
return;
}
try {
const connections = await this.storageService.listConnections(pubkey);
// Prefer default connection, fallback to R2
const defaultConn = connections.find(c => c.meta?.defaultForUploads);
const targetConn = defaultConn || connections.find(c => c.provider === PROVIDERS.R2 || c.provider === "cloudflare_r2");
if (targetConn) {
this.storageConfigured = true;
this.toggleStorageView("summary");
// Populate Summary
const providerName = targetConn.meta?.provider === "cloudflare_r2" ? "Cloudflare R2" : (targetConn.meta?.label || "S3 Storage");
const bucketName = targetConn.meta?.bucket || "Unknown Bucket";
if (this.statusText.summaryProvider) this.statusText.summaryProvider.textContent = providerName;
if (this.statusText.summaryBucket) this.statusText.summaryBucket.textContent = bucketName;
if (this.isStorageUnlocked) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
// Map generic S3 or R2 details to settings
// R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain
this.cloudflareSettings = {
accountId: details.accountId, // Might be undefined for generic S3
accessKeyId: details.accessKeyId,
secretAccessKey: details.secretAccessKey,
baseDomain: details.meta?.baseDomain || "",
// Add generic endpoint for internal use if needed
endpoint: details.endpoint,
region: details.region,
bucket: details.bucket
};
this.fillSettingsForm(this.cloudflareSettings);
this.updateStorageStatus(true, providerName);
this.activeConnectionId = targetConn.id;
}
} else {
// Locked state, can't get details yet
this.updateStorageStatus(false);
}
} else {
// No connections configured
this.toggleStorageView("form");
}
} catch (err) {
console.error("Failed to load connection", err);
this.toggleStorageView("form");
}
}
toggleStorageView(viewName) {
if (viewName === "summary") {
this.storageViews.summary.classList.remove("hidden");
this.storageViews.form.classList.add("hidden");
} else {
this.storageViews.summary.classList.add("hidden");
this.storageViews.form.classList.remove("hidden");
}
}
async handleUnlock() {
if (!this.storageService) return;
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (!pubkey) return;
// We need a signer. Try window.nostr or authService
let signer = window.nostr;
if (!signer && this.authService?.signer) {
signer = this.authService.signer;
}
if (!signer) {
alert("No signer available to unlock storage.");
return;
}
try {
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlocking...";
this.toggles.storageUnlock.disabled = true;
}
await this.storageService.unlock(pubkey, { signer });
this.isStorageUnlocked = true;
this.updateLockUi();
await this.loadFromStorage();
} catch (err) {
console.error("Unlock failed", err);
alert("Failed to unlock storage: " + err.message);
} finally {
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlock";
this.toggles.storageUnlock.disabled = false;
}
}
}
updateLockUi() {
const locked = !this.isStorageUnlocked;
if (this.statusText.storageLock) {
this.statusText.storageLock.textContent = locked ? "Locked 🔒" : "Unlocked 🔓";
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
}
if (this.toggles.storageUnlock) {
// Show unlock button only if locked AND we have a configuration to unlock
if (locked && this.storageConfigured) {
this.toggles.storageUnlock.classList.remove("hidden");
} else {
this.toggles.storageUnlock.classList.add("hidden");
}
}
if (this.statusText.storage) {
if (locked && this.storageConfigured) {
this.statusText.storage.classList.add("hidden"); // Summary view has its own status area
} else if (locked) {
this.statusText.storage.classList.add("hidden");
this.statusText.storage.textContent = "";
} else {
this.statusText.storage.classList.remove("hidden");
}
}
}
hasValidR2Settings() {
const s = this.cloudflareSettings;
return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey && s?.baseDomain);
}
fillSettingsForm(s) {
if (this.inputs.r2Account) this.inputs.r2Account.value = s.accountId || "";
if (this.inputs.r2Key) this.inputs.r2Key.value = s.accessKeyId || "";
if (this.inputs.r2Secret) this.inputs.r2Secret.value = s.secretAccessKey || "";
if (this.inputs.r2Domain) this.inputs.r2Domain.value = s.baseDomain || "";
}
collectSettingsForm() {
return {
accountId: this.inputs.r2Account?.value?.trim() || "",
accessKeyId: this.inputs.r2Key?.value?.trim() || "",
secretAccessKey: this.inputs.r2Secret?.value?.trim() || "",
baseDomain: this.inputs.r2Domain?.value?.trim() || "", // Public URL
};
}
goToVerificationStep() {
// Validate Step 1
const accountId = this.inputs.r2Account?.value?.trim();
const keyId = this.inputs.r2Key?.value?.trim();
const secret = this.inputs.r2Secret?.value?.trim();
if (!accountId || !keyId || !secret) {
alert("Please fill in Account ID/Endpoint, Access Key ID, and Secret Access Key.");
return;
}
// Transition
this.wizard.step1.classList.add("hidden");
this.wizard.step2.classList.remove("hidden");
// Auto-focus URL if empty
if (!this.inputs.r2Domain.value) {
this.inputs.r2Domain.focus();
}
}
goToCredentialsStep() {
this.wizard.step2.classList.add("hidden");
this.wizard.step1.classList.remove("hidden");
this.wizard.errorContainer.classList.add("hidden");
}
async handleVerifyAndSave() {
const btn = this.toggles.saveSettings;
const originalText = btn.textContent;
// Clear previous error
this.wizard.errorContainer.classList.add("hidden");
const settings = this.collectSettingsForm();
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
const npub = pubkey ? this.safeEncodeNpub(pubkey) : null;
if (!settings.baseDomain) {
this.wizard.errorText.textContent = "Public URL is required.";
this.wizard.errorContainer.classList.remove("hidden");
return;
}
// 1. Verify
btn.disabled = true;
btn.textContent = "Verifying...";
try {
const verification = await this.r2Service.verifyPublicAccess({ settings, npub });
if (!verification.success) {
btn.disabled = false;
btn.textContent = originalText;
this.wizard.errorText.textContent = verification.error || "Verification failed.";
this.wizard.errorContainer.classList.remove("hidden");
// Dynamic Cloudflare Link
// https://dash.cloudflare.com/?to=/:account/r2/default/buckets/:bucket/settings
if (settings.accountId && npub && !settings.accountId.includes("://")) {
const bucketName = this.inputs.r2BucketName?.value || "";
const cfLink = `https://dash.cloudflare.com/?to=/${settings.accountId}/r2/default/buckets/${bucketName}/settings`;
this.wizard.guideLink.href = cfLink;
} else {
this.wizard.guideLink.href = "https://dash.cloudflare.com";
}
return;
}
// 2. Save on Success
btn.textContent = "Saving...";
if (this.storageService && this.isStorageUnlocked) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (pubkey) {
// Save to secure storage
const connectionId = this.activeConnectionId || `r2-${Date.now()}`;
// Heuristic for provider type
let provider = PROVIDERS.GENERIC;
let payload = {
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
};
// If account ID doesn't look like a URL, assume R2/S3-account-based
if (settings.accountId && !settings.accountId.includes("://")) {
provider = PROVIDERS.R2;
payload.accountId = settings.accountId;
} else {
payload.endpoint = settings.accountId;
}
await this.storageService.saveConnection(pubkey, connectionId, {
provider,
...payload,
}, {
provider,
label: provider === PROVIDERS.R2 ? "Default R2" : "Default S3",
baseDomain: settings.baseDomain,
defaultForUploads: true,
bucket: this.inputs.r2BucketName?.value || sanitizeBucketName(npub)
});
this.activeConnectionId = connectionId;
}
} else {
// Legacy fallback
await this.r2Service.saveSettings(settings);
}
this.cloudflareSettings = settings;
this.updateStorageStatus(true);
btn.disabled = false;
btn.textContent = "Saved & Ready!";
setTimeout(() => {
btn.textContent = originalText;
// Collapse
this.sourceSections.settings.classList.add("hidden");
this.toggles.storageSettings.setAttribute("aria-expanded", "false");
// Reset wizard to start for next edit
this.goToCredentialsStep();
// Reload state to potentially switch to summary view
this.loadFromStorage();
}, 1500);
} catch (err) {
userLogger.error("Verification crashed:", err);
btn.disabled = false;
btn.textContent = originalText;
this.wizard.errorText.textContent = "Unexpected error during verification.";
this.wizard.errorContainer.classList.remove("hidden");
}
}
updateStorageStatus(isValid, providerLabel) {
if (this.statusText.storage) {
const baseText = isValid ? "Ready" : "Missing Credentials";
const label = providerLabel ? ` (${providerLabel})` : "";
this.statusText.storage.textContent = baseText + label;
this.statusText.storage.className = isValid ? "text-xs text-accent" : "text-xs text-critical";
}
}
registerR2Subscriptions() {
if (!this.r2Service?.on) return;
// Clear old
this.r2Unsubscribes.forEach(u => u && u());
this.r2Unsubscribes = [];
const sub = (evt, fn) => {
const unsub = this.r2Service.on(evt, fn);
if (unsub) this.r2Unsubscribes.push(unsub);
};
sub("uploadProgress", ({ fraction }) => this.updateProgress(fraction));
sub("uploadStatus", ({ message, variant }) => this.updateUploadStatus(message, variant));
sub("uploadStateChange", ({ isUploading }) => {
this.isUploading = isUploading;
this.submitButton.disabled = isUploading;
});
}
updateProgress(fraction) {
const container = this.sourceSections.progress;
const bar = this.inputs.progress;
const txt = this.statusText.uploadPercent;
if (fraction === null || fraction < 0 || isNaN(fraction)) {
container.classList.add("hidden");
return;
}
container.classList.remove("hidden");
const pct = Math.round(fraction * 100);
bar.value = pct;
txt.textContent = `${pct}%`;
}
updateUploadStatus(msg, variant) {
if (this.statusText.uploadMain) {
this.statusText.uploadMain.textContent = msg;
// Could style based on variant (error/success)
}
}
// --- Submission ---
async handleSubmit() {
if (this.isUploading) return;
const audienceFlags = {
isNsfw: this.toggles.nsfw?.checked || false,
isForKids: this.toggles.kids?.checked || false,
};
// Base Metadata
const metadata = {
title: this.inputs.title?.value?.trim() || "",
description: this.inputs.description?.value?.trim() || "",
thumbnail: this.inputs.thumbnail?.value?.trim() || "",
enableComments: this.toggles.comments?.checked || true,
...audienceFlags,
// These might be empty depending on mode, filled below
url: "",
magnet: "",
ws: this.inputs.ws?.value?.trim() || "",
xs: this.inputs.xs?.value?.trim() || "",
};
// NIP-71 Advanced Data
const nip71 = this.nip71FormManager.collectSection("main");
if (nip71) {
metadata.nip71 = nip71;
}
try {
if (this.activeSource === "upload") {
await this.handleUploadFlow(metadata);
} else {
await this.handleExternalFlow(metadata);
}
} catch (err) {
userLogger.error("Upload failed", err);
this.showError(err.message || "An unexpected error occurred.");
}
}
async handleUploadFlow(metadata) {
const file = this.inputs.file?.files?.[0];
if (!file) throw new Error("Please select a video file to upload.");
const thumbnailFile = this.inputs.thumbnailFile?.files?.[0];
if (!this.hasValidR2Settings()) throw new Error("Please configure R2 storage credentials.");
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (!pubkey) throw new Error("Please login to publish.");
const npub = this.safeEncodeNpub(pubkey);
// Pre-calculate Keys & URLs to support WebSeeding
// We need the Public URL *before* we generate the torrent so we can embed it.
let videoKey = "";
let videoPublicUrl = "";
let torrentKey = "";
let torrentPublicUrl = "";
try {
// Temporarily grab current bucket settings to calculate URL
const bucketName = this.inputs.r2BucketName?.value || sanitizeBucketName(npub);
// Note: The actual upload might re-verify this, but we need a best-guess for the torrent.
// We rely on r2Service using the *same* logic or accepting our forced keys.
// Since we can't easily get the 'final' bucket config here without doing the 'ensureBucket' dance first,
// we'll do a lightweight check of the settings form.
const currentSettings = this.collectSettingsForm();
if (!currentSettings.baseDomain) {
throw new Error("Missing Public URL (Base Domain) in settings.");
}
videoKey = buildR2Key(npub, file);
// 'buildPublicUrl' logic: ${baseDomain}/${key}
videoPublicUrl = buildPublicUrl(currentSettings.baseDomain, videoKey);
// For the torrent file, we swap extension
const baseKey = videoKey.replace(/\.[^/.]+$/, "");
torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
torrentPublicUrl = buildPublicUrl(currentSettings.baseDomain, torrentKey);
} catch (prepErr) {
userLogger.warn("Failed to pre-calculate R2 keys:", prepErr);
// We continue, but WebSeed generation will fail or be skipped.
}
// 1. Calculate Torrent Info Hash (Client-side)
this.isUploading = true;
this.submitButton.disabled = true;
this.updateUploadStatus("Calculating Info Hash...", "info");
this.updateProgress(0); // Show bar at 0
let infoHash = "";
let torrentFile = null;
try {
// Pass the pre-calculated video URL as a WebSeed
const urlList = videoPublicUrl ? [videoPublicUrl] : [];
const torrentMetadata = await createTorrentMetadata(file, urlList);
infoHash = torrentMetadata?.infoHash || "";
if (torrentMetadata?.torrentFile) {
const baseName = file.name.replace(/\.[^/.]+$/, "") || file.name;
torrentFile = new File([torrentMetadata.torrentFile], `${baseName}.torrent`, {
type: "application/x-bittorrent",
});
}
} catch (hashErr) {
userLogger.warn("Failed to calculate info hash:", hashErr);
}
const normalizedInfoHash = normalizeInfoHash(infoHash);
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
if (!hasValidInfoHash) {
const proceed = confirm(
"We couldn't calculate a valid info hash. Publishing will continue with URL-first playback only, and WebTorrent fallback will be unavailable. Continue?"
);
if (!proceed) {
this.updateUploadStatus(
"Upload canceled. A valid info hash is required for WebTorrent fallback.",
"warning"
);
this.isUploading = false;
this.submitButton.disabled = false;
this.updateProgress(null);
return;
}
this.updateUploadStatus(
"Continuing without WebTorrent fallback (info hash unavailable).",
"warning"
);
}
// 2. Upload
let explicitCredentials = null;
if (this.storageService && this.isStorageUnlocked && this.hasValidR2Settings()) {
// If using secure storage, we pass the credentials explicitly so they aren't saved to legacy DB
explicitCredentials = this.collectSettingsForm();
}
await this.r2Service.uploadVideo({
npub,
file,
thumbnailFile,
torrentFile,
metadata,
infoHash: hasValidInfoHash ? normalizedInfoHash : "",
settingsInput: explicitCredentials ? null : this.collectSettingsForm(),
explicitCredentials,
publishVideoNote: this.publishVideoNote,
onReset: () => this.resetForm(),
// Pass forced keys/URLs to ensure what's in the torrent matches where we upload
forcedVideoKey: videoKey,
forcedVideoUrl: videoPublicUrl,
forcedTorrentKey: torrentKey,
forcedTorrentUrl: torrentPublicUrl,
});
}
async handleExternalFlow(metadata) {
metadata.url = this.inputs.url?.value?.trim() || "";
metadata.magnet = this.inputs.magnet?.value?.trim() || "";
const hasUrl = metadata.url.length > 0;
const hasMagnet = metadata.magnet.length > 0;
const hasImeta = metadata.nip71?.imeta?.some(v => v.url);
if (!hasUrl && !hasMagnet && !hasImeta) {
throw new Error("Please provide at least a Video URL or Magnet Link.");
}
if (!hasUrl && !hasImeta && hasMagnet) {
if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return;
}
// Normalize & Publish
const { payload, errors } = normalizeVideoNotePayload(metadata);
if (errors.length) {
throw new Error(getVideoNoteErrorMessage(errors[0]));
}
// Simulate async publish
this.submitButton.disabled = true;
this.submitButton.textContent = "Publishing...";
try {
if (this.publishVideoNote) {
await this.publishVideoNote(payload);
this.showSuccess("Video published successfully!");
this.close();
this.resetForm();
}
} finally {
this.submitButton.disabled = false;
this.submitButton.textContent = "Publish Video";
}
}
resetForm() {
this.form.reset();
this.nip71FormManager.resetSection("main");
// Restore defaults
this.setSourceMode("upload");
this.toggles.comments.checked = true;
this.toggles.nsfw.checked = false;
this.toggles.kids.checked = false;
this.updateProgress(null);
this.populateBucketName(); // Re-populate if user logged in
// Reset thumbnail UI
if (this.inputs.thumbnail) {
this.inputs.thumbnail.disabled = false;
this.inputs.thumbnail.placeholder = "https://example.com/thumbnail.jpg";
}
if (this.inputs.thumbnailFile) {
this.inputs.thumbnailFile.value = "";
}
}
// --- Modal Control ---
open({ triggerElement } = {}) {
if (!this.root) return;
this.root.classList.remove("hidden");
this.setGlobalModalState("upload", true);
this.isVisible = true;
this.populateBucketName(); // Ensure bucket name is fresh on open
// Refresh lock state on open
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
this.updateLockUi();
if (this.isStorageUnlocked) {
this.loadFromStorage();
}
}
this.modalAccessibility?.activate({ triggerElement });
}
close() {
if (!this.root) return;
this.root.classList.add("hidden");
this.setGlobalModalState("upload", false);
this.isVisible = false;
this.modalAccessibility?.deactivate();
}
addEventListener(type, listener) {
this.eventTarget.addEventListener(type, listener);
}
emit(type, detail) {
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
}
getRoot() {
return this.root;
}
setupModalAccessibility() {
if (!this.root) return;
if (this.modalAccessibility?.destroy) this.modalAccessibility.destroy();
this.modalAccessibility = createModalAccessibility({
root: this.root,
backdrop: this.root.querySelector(".bv-modal-backdrop") || this.root,
panel: this.root.querySelector(".modal-sheet") || this.root,
onRequestClose: () => this.close()
});
}
destroy() {
this.r2Unsubscribes.forEach(u => u && u());
if (this.modalAccessibility?.destroy) this.modalAccessibility.destroy();
this.root = null;
this.container = null;
}
}