diff --git a/components/revert-video-modal.html b/components/revert-video-modal.html new file mode 100644 index 00000000..87f2e6d7 --- /dev/null +++ b/components/revert-video-modal.html @@ -0,0 +1,121 @@ + +
diff --git a/js/app.js b/js/app.js index 0b7d3990..e7c5bb9b 100644 --- a/js/app.js +++ b/js/app.js @@ -549,6 +549,27 @@ class bitvidApp { this.editVideoFieldButtons = []; this.activeEditVideo = null; + // Revert video modal elements + this.revertVideoModal = null; + this.revertVideoOverlay = null; + this.revertVersionsList = null; + this.revertVersionDetails = null; + this.revertVersionPlaceholder = null; + this.revertVersionDetailsDefaultHTML = ""; + this.revertHistoryCount = null; + this.revertSelectionStatus = null; + this.revertModalTitle = null; + this.revertModalSubtitle = null; + this.closeRevertVideoModalBtn = null; + this.cancelRevertVideoBtn = null; + this.confirmRevertVideoBtn = null; + this.activeRevertVideo = null; + this.revertHistory = []; + this.selectedRevertTarget = null; + this.revertModalBusy = false; + this.revertConfirmDefaultLabel = "Revert to selected version"; + this.pendingRevertEntries = []; + // Optional small inline player stats this.status = document.getElementById("status") || null; this.progressBar = document.getElementById("progress") || null; @@ -1424,6 +1445,779 @@ class bitvidApp { this.resetEditVideoForm(); } + async initRevertVideoModal() { + try { + let modal = document.getElementById("revertVideoModal"); + if (!modal) { + const response = await fetch("components/revert-video-modal.html"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const html = await response.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("#revertVideoModal"); + } + + if (!modal) { + throw new Error("Revert video modal markup missing after load."); + } + + this.revertVideoModal = modal; + this.revertVideoOverlay = + modal.querySelector("#revertVideoModalOverlay") || null; + this.revertVersionsList = + modal.querySelector("#revertVersionsList") || null; + this.revertVersionDetails = + modal.querySelector("#revertVersionDetails") || null; + this.revertVersionPlaceholder = + modal.querySelector("#revertVersionPlaceholder") || null; + if (this.revertVersionDetails && !this.revertVersionDetailsDefaultHTML) { + this.revertVersionDetailsDefaultHTML = this.revertVersionDetails.innerHTML; + } + this.revertHistoryCount = + modal.querySelector("#revertHistoryCount") || null; + this.revertSelectionStatus = + modal.querySelector("#revertSelectionStatus") || null; + this.revertModalTitle = + modal.querySelector("#revertModalTitle") || null; + this.revertModalSubtitle = + modal.querySelector("#revertModalSubtitle") || null; + this.closeRevertVideoModalBtn = + modal.querySelector("#closeRevertVideoModal") || null; + this.cancelRevertVideoBtn = + modal.querySelector("#cancelRevertVideo") || null; + this.confirmRevertVideoBtn = + modal.querySelector("#confirmRevertVideo") || null; + + if ( + this.revertVersionsList && + !this.revertVersionsList.dataset.listenerAttached + ) { + this.revertVersionsList.addEventListener("click", (event) => { + this.handleRevertVersionListClick(event); + }); + this.revertVersionsList.dataset.listenerAttached = "true"; + } + + if (this.closeRevertVideoModalBtn && !this.closeRevertVideoModalBtn.dataset.listenerAttached) { + this.closeRevertVideoModalBtn.addEventListener("click", () => { + if (!this.revertModalBusy) { + this.hideRevertVideoModal(); + } + }); + this.closeRevertVideoModalBtn.dataset.listenerAttached = "true"; + } + + if (this.cancelRevertVideoBtn && !this.cancelRevertVideoBtn.dataset.listenerAttached) { + this.cancelRevertVideoBtn.addEventListener("click", () => { + if (!this.revertModalBusy) { + this.hideRevertVideoModal(); + } + }); + this.cancelRevertVideoBtn.dataset.listenerAttached = "true"; + } + + if (this.revertVideoOverlay && !this.revertVideoOverlay.dataset.listenerAttached) { + this.revertVideoOverlay.addEventListener("click", () => { + if (!this.revertModalBusy) { + this.hideRevertVideoModal(); + } + }); + this.revertVideoOverlay.dataset.listenerAttached = "true"; + } + + if ( + this.confirmRevertVideoBtn && + !this.confirmRevertVideoBtn.dataset.listenerAttached + ) { + this.confirmRevertVideoBtn.addEventListener("click", () => { + this.handleConfirmRevertSelection(); + }); + this.confirmRevertVideoBtn.dataset.listenerAttached = "true"; + } + + this.resetRevertVideoModal(); + + return true; + } catch (error) { + console.error("initRevertVideoModal failed:", error); + this.showError(`Failed to initialize revert modal: ${error.message}`); + return false; + } + } + + resetRevertVideoModal() { + this.activeRevertVideo = null; + this.revertHistory = []; + this.selectedRevertTarget = null; + this.revertModalBusy = false; + this.pendingRevertEntries = []; + + if (this.revertHistoryCount) { + this.revertHistoryCount.textContent = ""; + } + + if (this.revertSelectionStatus) { + this.revertSelectionStatus.textContent = + "Select an older revision to inspect its metadata before reverting."; + } + + if (this.revertModalTitle) { + this.revertModalTitle.textContent = "Revert Video Note"; + } + + if (this.revertModalSubtitle) { + this.revertModalSubtitle.textContent = + "Review previous versions before restoring an older state."; + } + + if (this.revertVersionsList) { + this.revertVersionsList.innerHTML = ""; + } + + if (this.revertVersionDetails && this.revertVersionDetailsDefaultHTML) { + this.revertVersionDetails.innerHTML = this.revertVersionDetailsDefaultHTML; + this.revertVersionPlaceholder = + this.revertVersionDetails.querySelector("#revertVersionPlaceholder"); + } + + if (this.confirmRevertVideoBtn) { + this.confirmRevertVideoBtn.disabled = true; + this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel; + this.confirmRevertVideoBtn.classList.remove("cursor-wait"); + } + + if (this.cancelRevertVideoBtn) { + this.cancelRevertVideoBtn.disabled = false; + this.cancelRevertVideoBtn.classList.remove("opacity-60", "cursor-not-allowed"); + } + + if (this.closeRevertVideoModalBtn) { + this.closeRevertVideoModalBtn.disabled = false; + this.closeRevertVideoModalBtn.classList.remove("opacity-60", "cursor-not-allowed"); + } + } + + showRevertVideoModal() { + if (this.revertVideoModal) { + this.revertVideoModal.classList.remove("hidden"); + } + } + + hideRevertVideoModal() { + if (this.revertVideoModal) { + this.revertVideoModal.classList.add("hidden"); + } + this.resetRevertVideoModal(); + } + + populateRevertVideoModal(video, history = []) { + if (!this.revertVideoModal) { + return; + } + + this.resetRevertVideoModal(); + + if (!video || typeof video !== "object") { + if (this.revertSelectionStatus) { + this.revertSelectionStatus.textContent = + "Unable to load revision history for this note."; + } + return; + } + + this.activeRevertVideo = video; + + const merged = Array.isArray(history) ? history.slice() : []; + if (video.id && !merged.some((entry) => entry && entry.id === video.id)) { + merged.push(video); + } + + const deduped = new Map(); + for (const entry of merged) { + if (!entry || typeof entry !== "object" || !entry.id) { + continue; + } + deduped.set(entry.id, entry); + } + + this.revertHistory = Array.from(deduped.values()).sort( + (a, b) => b.created_at - a.created_at + ); + + this.selectedRevertTarget = null; + if (this.revertHistory.length > 1 && video.created_at) { + // Auto-select the newest non-deleted revision that predates the active + // event so the confirmation button immediately conveys its effect. + const firstOlder = this.revertHistory.find( + (entry) => + entry && + entry.id !== video.id && + entry.deleted !== true && + typeof entry.created_at === "number" && + entry.created_at < video.created_at + ); + if (firstOlder) { + this.selectedRevertTarget = firstOlder; + } + } + + if (this.revertHistoryCount) { + this.revertHistoryCount.textContent = `${this.revertHistory.length}`; + } + + if (this.revertModalTitle) { + this.revertModalTitle.textContent = video.title + ? `Revert “${video.title}”` + : "Revert Video Note"; + } + + if (this.revertModalSubtitle) { + const subtitleParts = []; + const dTagValue = this.extractDTagValue(video.tags); + if (dTagValue) { + subtitleParts.push(`d=${dTagValue}`); + } + if (video.videoRootId) { + subtitleParts.push(`root=${truncateMiddle(video.videoRootId, 40)}`); + } + this.revertModalSubtitle.textContent = subtitleParts.length + ? `History grouped by ${subtitleParts.join(" • ")}` + : "Review previous versions before restoring an older state."; + } + + this.renderRevertVersionsList(); + + if (this.selectedRevertTarget) { + this.renderRevertVersionDetails(this.selectedRevertTarget); + } + + if (this.revertHistory.length <= 1) { + if (this.revertSelectionStatus) { + this.revertSelectionStatus.textContent = + "No earlier revisions are available for this note."; + } + this.updateRevertConfirmationState(); + return; + } + + this.updateRevertConfirmationState(); + } + + renderRevertVersionsList() { + if (!this.revertVersionsList) { + return; + } + + const history = Array.isArray(this.revertHistory) + ? this.revertHistory + : []; + const selectedId = this.selectedRevertTarget?.id || ""; + const currentId = this.activeRevertVideo?.id || ""; + + this.revertVersionsList.innerHTML = ""; + + if (!history.length) { + this.revertVersionsList.innerHTML = + 'No revisions found.
'; + return; + } + + const fragment = document.createDocumentFragment(); + + history.forEach((entry) => { + if (!entry || !entry.id) { + return; + } + + const isCurrent = entry.id === currentId; + const isSelected = entry.id === selectedId; + const isDeleted = entry.deleted === true; + + const button = document.createElement("button"); + button.type = "button"; + button.dataset.revertVersionId = entry.id; + + const classes = [ + "w-full", + "text-left", + "rounded-md", + "border", + "px-3", + "py-3", + "text-sm", + "transition", + "duration-150", + "focus:outline-none", + "focus:ring-2", + "focus:ring-offset-2", + "focus:ring-offset-gray-900", + ]; + + if (isSelected) { + classes.push( + "border-blue-500", + "bg-blue-500/20", + "text-blue-100", + "focus:ring-blue-500" + ); + } else if (isCurrent) { + classes.push( + "border-green-500/60", + "bg-green-500/10", + "text-green-100", + "focus:ring-green-500/80" + ); + } else if (isDeleted) { + classes.push( + "border-red-800/70", + "bg-red-900/30", + "text-red-200/90", + "hover:bg-red-900/40" + ); + } else { + classes.push( + "border-gray-800", + "bg-gray-800/60", + "hover:bg-gray-700/70", + "text-gray-200" + ); + } + + button.className = classes.join(" "); + button.setAttribute("aria-pressed", isSelected ? "true" : "false"); + if (isCurrent) { + button.setAttribute("aria-current", "true"); + } + + const relative = this.formatTimeAgo(entry.created_at); + const absolute = this.formatAbsoluteTimestamp(entry.created_at); + const versionLabel = + entry.version !== undefined ? `v${entry.version}` : "v?"; + + const metaParts = []; + if (isCurrent) { + metaParts.push("Current version"); + } + if (entry.deleted) { + metaParts.push("Marked deleted"); + } + if (entry.isPrivate) { + metaParts.push("Private"); + } + const meta = metaParts.join(" • "); + + const metaClass = entry.deleted + ? "text-red-200/80" + : "text-gray-400"; + + button.innerHTML = ` +${this.escapeHTML( + entry.title || "Untitled" + )}
+${this.escapeHTML( + relative + )} • ${this.escapeHTML(absolute)}
+ ${ + meta + ? `` + : "" + } +${this.escapeHTML( + description + )}
` + : 'No description provided.
'; + + const rootId = + typeof version.videoRootId === "string" ? version.videoRootId : ""; + const rootDisplay = rootId + ? this.escapeHTML(truncateMiddle(rootId, 64)) + : ""; + const eventDisplay = version.id + ? this.escapeHTML(truncateMiddle(version.id, 64)) + : ""; + + this.revertVersionDetails.innerHTML = ` +${this.escapeHTML( + absolute + )} (${this.escapeHTML(relative)})
+${this.escapeHTML(
+ dTagValue
+ )}`
+ : 'Not provided'}
+ ${rootDisplay}`
+ : 'Not provided'}
+ ${eventDisplay}`
+ : 'Unknown'}
+