mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #1614 from PR0M3TH3AN/codex/add-embed-video-modal-component
Add Embed Video modal and controller, wire into ModalManager
This commit is contained in:
3 files changed
+491
No files matched your search
@@ -0,0 +1,122 @@
|
||||
<!-- components/embed-video-modal.html -->
|
||||
<div
|
||||
id="embedVideoModal"
|
||||
class="bv-modal hidden items-start justify-center md:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="embedVideoModalTitle"
|
||||
aria-describedby="embedVideoModalDescription"
|
||||
data-ds="new"
|
||||
>
|
||||
<div class="bv-modal-backdrop" data-dismiss></div>
|
||||
|
||||
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
|
||||
<div class="modal-header">
|
||||
<h2 id="embedVideoModalTitle" class="text-xl font-bold text-text">Embed Video</h2>
|
||||
<button
|
||||
id="closeEmbedVideoModal"
|
||||
class="modal-close"
|
||||
type="button"
|
||||
aria-label="Close embed modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body space-y-6 overflow-y-auto flex-1 min-h-0 custom-scrollbar p-6">
|
||||
<p id="embedVideoModalDescription" class="text-sm text-muted">
|
||||
Generate an embed snippet to share this video on other sites.
|
||||
</p>
|
||||
|
||||
<fieldset class="space-y-3">
|
||||
<legend class="text-sm font-semibold text-text">Playback source</legend>
|
||||
<div class="form-control" role="radiogroup" aria-labelledby="embedVideoModalTitle">
|
||||
<div class="menu sm:flex-row sm:flex-wrap sm:py-0" role="presentation">
|
||||
<label class="menu__item gap-3 px-0 py-0" for="embedVideoSourceCdn">
|
||||
<input
|
||||
type="radio"
|
||||
name="embedVideoSource"
|
||||
id="embedVideoSourceCdn"
|
||||
value="url"
|
||||
class="h-4 w-4 border-border bg-panel text-accent focus:ring-accent"
|
||||
/>
|
||||
<span class="text-sm text-text">CDN (URL first)</span>
|
||||
</label>
|
||||
<label class="menu__item gap-3 px-0 py-0" for="embedVideoSourceP2p">
|
||||
<input
|
||||
type="radio"
|
||||
name="embedVideoSource"
|
||||
id="embedVideoSourceP2p"
|
||||
value="torrent"
|
||||
class="h-4 w-4 border-border bg-panel text-accent focus:ring-accent"
|
||||
/>
|
||||
<span class="text-sm text-text">P2P (Magnet)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label for="embedVideoWidth" class="block text-sm font-medium text-text">Width</label>
|
||||
<input
|
||||
id="embedVideoWidth"
|
||||
type="number"
|
||||
class="input"
|
||||
min="200"
|
||||
step="1"
|
||||
value="640"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="embedVideoHeight" class="block text-sm font-medium text-text">Height</label>
|
||||
<input
|
||||
id="embedVideoHeight"
|
||||
type="number"
|
||||
class="input"
|
||||
min="200"
|
||||
step="1"
|
||||
value="360"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="embedVideoSnippet" class="block text-sm font-medium text-text">
|
||||
Embed snippet
|
||||
</label>
|
||||
<textarea
|
||||
id="embedVideoSnippet"
|
||||
rows="5"
|
||||
class="textarea"
|
||||
readonly
|
||||
></textarea>
|
||||
<p class="text-xs text-muted">
|
||||
Paste this HTML into your page. Adjust the size to fit your layout.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer flex flex-col gap-3 border-t border-border bg-panel/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button id="cancelEmbedVideo" type="button" class="btn-ghost">
|
||||
Close
|
||||
</button>
|
||||
<button id="copyEmbedVideo" type="button" class="btn-primary">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@ import { showLoginRequiredToZapNotification } from "../payments/zapNotifications
|
||||
import { VideoModal } from "./components/VideoModal.js";
|
||||
import { RevertModal } from "./components/RevertModal.js";
|
||||
import { ShareNostrModal } from "./components/ShareNostrModal.js";
|
||||
import { EmbedVideoModal } from "./components/EmbedVideoModal.js";
|
||||
import initUploadModal from "./initUploadModal.js";
|
||||
import initEditModal from "./initEditModal.js";
|
||||
import initDeleteModal from "./initDeleteModal.js";
|
||||
@@ -51,6 +52,7 @@ export default class ModalManager {
|
||||
|
||||
this.eventDetailsModal = null;
|
||||
this.shareNostrModal = null;
|
||||
this.embedVideoModal = null;
|
||||
|
||||
this.zapController = null;
|
||||
}
|
||||
@@ -181,6 +183,18 @@ export default class ModalManager {
|
||||
});
|
||||
app.shareNostrModal = this.shareNostrModal;
|
||||
|
||||
this.embedVideoModal = new EmbedVideoModal({
|
||||
removeTrackingScripts,
|
||||
container: modalContainer,
|
||||
document: doc,
|
||||
getShareUrl: (eventId) => app.buildShareUrlFromEventId(eventId),
|
||||
callbacks: {
|
||||
showSuccess: (message) => app.showSuccess(message),
|
||||
showError: (message) => app.showError(message),
|
||||
},
|
||||
});
|
||||
app.embedVideoModal = this.embedVideoModal;
|
||||
|
||||
this.videoModal =
|
||||
(typeof this.ui.videoModal === "function"
|
||||
? this.ui.videoModal({ app })
|
||||
@@ -269,6 +283,31 @@ export default class ModalManager {
|
||||
this.videoModalHandlers.share,
|
||||
);
|
||||
|
||||
this.videoModalHandlers.embed = (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const targetVideo =
|
||||
detail && typeof detail.video === "object"
|
||||
? detail.video
|
||||
: app.currentVideo || null;
|
||||
if (!targetVideo) {
|
||||
app.showError("No video is available to embed.");
|
||||
return;
|
||||
}
|
||||
Promise.resolve(
|
||||
this.embedVideoModal?.open({
|
||||
video: targetVideo,
|
||||
triggerElement: event?.target || null,
|
||||
}),
|
||||
).catch((error) => {
|
||||
devLogger.error("[ModalManager] Failed to open embed modal:", error);
|
||||
app.showError("Unable to open the embed modal.");
|
||||
});
|
||||
};
|
||||
this.videoModal.addEventListener(
|
||||
"video:embed",
|
||||
this.videoModalHandlers.embed,
|
||||
);
|
||||
|
||||
this.videoModalHandlers.moderationOverride = (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const targetVideo =
|
||||
@@ -721,6 +760,18 @@ export default class ModalManager {
|
||||
app.shareNostrModal = null;
|
||||
}
|
||||
|
||||
if (this.embedVideoModal && typeof this.embedVideoModal.close === "function") {
|
||||
try {
|
||||
this.embedVideoModal.close();
|
||||
} catch (error) {
|
||||
devLogger.warn("[ModalManager] Failed to close embed modal:", error);
|
||||
}
|
||||
}
|
||||
this.embedVideoModal = null;
|
||||
if (app.embedVideoModal) {
|
||||
app.embedVideoModal = null;
|
||||
}
|
||||
|
||||
if (this.revertModal && this.revertConfirmHandler) {
|
||||
try {
|
||||
this.revertModal.removeEventListener(
|
||||
@@ -793,6 +844,9 @@ export default class ModalManager {
|
||||
case "share":
|
||||
eventName = "video:share";
|
||||
break;
|
||||
case "embed":
|
||||
eventName = "video:embed";
|
||||
break;
|
||||
case "moderationOverride":
|
||||
eventName = "video:moderation-override";
|
||||
break;
|
||||
@@ -886,6 +940,7 @@ export default class ModalManager {
|
||||
app.videoModal = null;
|
||||
app.eventDetailsModal = null;
|
||||
app.zapController = null;
|
||||
app.embedVideoModal = null;
|
||||
|
||||
this.uploadModal = null;
|
||||
this.uploadModalEvents = null;
|
||||
@@ -903,5 +958,6 @@ export default class ModalManager {
|
||||
this.videoModalHandlers = {};
|
||||
this.eventDetailsModal = null;
|
||||
this.zapController = null;
|
||||
this.embedVideoModal = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
prepareStaticModal,
|
||||
openStaticModal,
|
||||
closeStaticModal,
|
||||
} from "./staticModalAccessibility.js";
|
||||
import logger from "../../utils/logger.js";
|
||||
|
||||
const DEFAULT_WIDTH = 640;
|
||||
const DEFAULT_HEIGHT = 360;
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function escapeAttribute(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function resolveDimension(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export class EmbedVideoModal {
|
||||
constructor({
|
||||
removeTrackingScripts,
|
||||
container,
|
||||
document: doc,
|
||||
getShareUrl,
|
||||
callbacks = {},
|
||||
} = {}) {
|
||||
this.document = doc || document;
|
||||
this.container = container || this.document.getElementById("modalContainer") || this.document.body;
|
||||
this.removeTrackingScripts =
|
||||
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
|
||||
this.getShareUrl = typeof getShareUrl === "function" ? getShareUrl : null;
|
||||
this.callbacks = {
|
||||
showSuccess: callbacks.showSuccess || (() => {}),
|
||||
showError: callbacks.showError || (() => {}),
|
||||
};
|
||||
|
||||
this.modal = null;
|
||||
this.overlay = null;
|
||||
this.panel = null;
|
||||
this.closeButton = null;
|
||||
this.cancelButton = null;
|
||||
this.copyButton = null;
|
||||
this.sourceCdn = null;
|
||||
this.sourceP2p = null;
|
||||
this.widthInput = null;
|
||||
this.heightInput = null;
|
||||
this.snippetTextarea = null;
|
||||
|
||||
this.activeVideo = null;
|
||||
this.bound = false;
|
||||
|
||||
this.boundHandlers = {
|
||||
close: () => this.close(),
|
||||
copy: () => this.handleCopy(),
|
||||
update: () => this.updateSnippet(),
|
||||
};
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.modal) {
|
||||
return this.modal;
|
||||
}
|
||||
|
||||
if (!this.container) {
|
||||
throw new Error("Modal container element not found.");
|
||||
}
|
||||
|
||||
let modal = this.container.querySelector("#embedVideoModal");
|
||||
if (!modal) {
|
||||
const response = await fetch("components/embed-video-modal.html");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const wrapper = this.document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
this.removeTrackingScripts(wrapper);
|
||||
this.container.appendChild(wrapper);
|
||||
modal = wrapper.querySelector("#embedVideoModal");
|
||||
}
|
||||
|
||||
if (!modal) {
|
||||
throw new Error("Embed video modal markup missing after load.");
|
||||
}
|
||||
|
||||
this.cacheElements(modal);
|
||||
prepareStaticModal({ root: modal, document: this.document });
|
||||
this.bindEvents();
|
||||
|
||||
return this.modal;
|
||||
}
|
||||
|
||||
cacheElements(modal) {
|
||||
this.modal = modal;
|
||||
this.overlay = modal.querySelector(".bv-modal-backdrop");
|
||||
this.panel = modal.querySelector(".modal-sheet") || modal;
|
||||
this.closeButton = modal.querySelector("#closeEmbedVideoModal");
|
||||
this.cancelButton = modal.querySelector("#cancelEmbedVideo");
|
||||
this.copyButton = modal.querySelector("#copyEmbedVideo");
|
||||
this.sourceCdn = modal.querySelector("#embedVideoSourceCdn");
|
||||
this.sourceP2p = modal.querySelector("#embedVideoSourceP2p");
|
||||
this.widthInput = modal.querySelector("#embedVideoWidth");
|
||||
this.heightInput = modal.querySelector("#embedVideoHeight");
|
||||
this.snippetTextarea = modal.querySelector("#embedVideoSnippet");
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.bound || !this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeTargets = [this.closeButton, this.cancelButton, this.overlay];
|
||||
closeTargets.forEach((target) => {
|
||||
if (target) {
|
||||
target.addEventListener("click", this.boundHandlers.close);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.copyButton) {
|
||||
this.copyButton.addEventListener("click", this.boundHandlers.copy);
|
||||
}
|
||||
|
||||
const updateTargets = [this.sourceCdn, this.sourceP2p];
|
||||
updateTargets.forEach((target) => {
|
||||
if (target) {
|
||||
target.addEventListener("change", this.boundHandlers.update);
|
||||
}
|
||||
});
|
||||
|
||||
const dimensionTargets = [this.widthInput, this.heightInput];
|
||||
dimensionTargets.forEach((target) => {
|
||||
if (target) {
|
||||
target.addEventListener("input", this.boundHandlers.update);
|
||||
}
|
||||
});
|
||||
|
||||
this.bound = true;
|
||||
}
|
||||
|
||||
setVideo(video) {
|
||||
this.activeVideo = video && typeof video === "object" ? video : null;
|
||||
if (!this.activeVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUrl = isNonEmptyString(this.activeVideo.url);
|
||||
const hasMagnet = isNonEmptyString(this.activeVideo.magnet);
|
||||
|
||||
if (this.sourceCdn) {
|
||||
this.sourceCdn.disabled = !hasUrl;
|
||||
}
|
||||
if (this.sourceP2p) {
|
||||
this.sourceP2p.disabled = !hasMagnet;
|
||||
}
|
||||
|
||||
const shouldPreferCdn = hasUrl || !hasMagnet;
|
||||
if (this.sourceCdn && this.sourceP2p) {
|
||||
if (shouldPreferCdn) {
|
||||
this.sourceCdn.checked = true;
|
||||
this.sourceP2p.checked = false;
|
||||
} else {
|
||||
this.sourceCdn.checked = false;
|
||||
this.sourceP2p.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedSource() {
|
||||
if (this.sourceP2p?.checked) {
|
||||
return "torrent";
|
||||
}
|
||||
if (this.sourceCdn?.checked) {
|
||||
return "url";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
buildEmbedUrl(source) {
|
||||
if (!this.activeVideo) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rawShareUrl =
|
||||
(isNonEmptyString(this.activeVideo.shareUrl) && this.activeVideo.shareUrl.trim()) ||
|
||||
(this.getShareUrl && this.getShareUrl(this.activeVideo.id)) ||
|
||||
"";
|
||||
|
||||
if (!rawShareUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(rawShareUrl, this.document?.location?.origin || window.location.origin);
|
||||
if (isNonEmptyString(source)) {
|
||||
url.searchParams.set("source", source);
|
||||
}
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
logger.user.warn("Embed modal could not build embed URL.", error);
|
||||
return rawShareUrl;
|
||||
}
|
||||
}
|
||||
|
||||
buildEmbedSnippet() {
|
||||
if (!this.activeVideo) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const source = this.getSelectedSource();
|
||||
const embedUrl = this.buildEmbedUrl(source);
|
||||
if (!embedUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const width = resolveDimension(this.widthInput?.value, DEFAULT_WIDTH);
|
||||
const height = resolveDimension(this.heightInput?.value, DEFAULT_HEIGHT);
|
||||
const title =
|
||||
(isNonEmptyString(this.activeVideo.title) && this.activeVideo.title.trim()) ||
|
||||
"Bitvid video";
|
||||
|
||||
return `<iframe src="${escapeAttribute(embedUrl)}" width="${width}" height="${height}" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen title="${escapeAttribute(title)}"></iframe>`;
|
||||
}
|
||||
|
||||
updateSnippet() {
|
||||
if (!this.snippetTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snippet = this.buildEmbedSnippet();
|
||||
this.snippetTextarea.value = snippet;
|
||||
if (this.copyButton) {
|
||||
this.copyButton.disabled = !snippet;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCopy() {
|
||||
if (!this.snippetTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snippet = this.snippetTextarea.value;
|
||||
if (!snippet) {
|
||||
this.callbacks.showError("No embed snippet to copy.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
this.callbacks.showSuccess("Embed code copied to clipboard!");
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.user.warn("Embed modal clipboard write failed.", error);
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = this.snippetTextarea;
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
let succeeded = false;
|
||||
try {
|
||||
succeeded = this.document.execCommand("copy");
|
||||
} catch (error) {
|
||||
logger.user.warn("Embed modal copy fallback failed.", error);
|
||||
succeeded = false;
|
||||
}
|
||||
|
||||
if (succeeded) {
|
||||
this.callbacks.showSuccess("Embed code copied to clipboard!");
|
||||
} else {
|
||||
this.callbacks.showError("Unable to copy embed code. Please copy it manually.");
|
||||
}
|
||||
}
|
||||
|
||||
async open({ video, triggerElement } = {}) {
|
||||
const modal = await this.load();
|
||||
if (!video || typeof video !== "object") {
|
||||
this.callbacks.showError("No video available to embed.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setVideo(video);
|
||||
this.updateSnippet();
|
||||
|
||||
openStaticModal(modal, { triggerElement, document: this.document });
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.modal) {
|
||||
closeStaticModal(this.modal, { document: this.document });
|
||||
}
|
||||
this.activeVideo = null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user