Files
bitvid/js/ui/components/SimilarContentCard.js
T
google-labs-jules[bot] 43bbc40c57 chore(ai): apply formatting/lint autofixes
- Fixed inline style violations in `js/ui/components/EventDetailsModal.js` and `js/ui/components/SimilarContentCard.js` by converting to Tailwind classes.
- Fixed Tailwind token violations in `components/login-modal.html`.
- Fixed Tailwind color violations in `js/ui/components/EventDetailsModal.js`.
- Formatted `README.md`.
- Updated CSS formatting in `css/tailwind.source.css`.
2026-01-18 20:58:09 +00:00

1294 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { normalizeDesignSystemContext } from "../../designSystem.js";
import { formatShortNpub } from "../../utils/formatters.js";
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
import {
getModerationOverrideActionLabels,
normalizeVideoModerationContext,
} from "../moderationUiHelpers.js";
import { buildModerationBadgeText } from "../moderationCopy.js";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
let similarCardIdCounter = 0;
const similarCardStyleRegistry = new WeakMap();
function getSimilarCardStyleState(doc) {
if (!doc || typeof doc.createElement !== "function") {
return null;
}
const existing = similarCardStyleRegistry.get(doc);
if (existing) {
return existing;
}
let styleNode = doc.getElementById?.("similarContentCardStyles") || null;
if (!(styleNode instanceof HTMLStyleElement)) {
styleNode = doc.createElement("style");
styleNode.id = "similarContentCardStyles";
doc.head?.appendChild(styleNode);
}
const state = {
styleNode,
rules: new Map(),
};
similarCardStyleRegistry.set(doc, state);
return state;
}
function updateSimilarCardBackdrop(doc, cardId, url) {
const state = getSimilarCardStyleState(doc);
if (!state || !cardId) {
return;
}
if (url) {
state.rules.set(
cardId,
`[data-similar-card-id="${cardId}"] { --similar-card-thumb-url: url("${url}"); }`,
);
} else {
state.rules.delete(cardId);
}
state.styleNode.textContent = [...state.rules.values()].join("\n");
}
export class SimilarContentCard {
constructor({
document: doc,
video,
index = 0,
shareUrl = "#",
pointerInfo = null,
timeAgo = "",
postedAt = null,
identity = null,
nsfwContext = null,
designSystem = null,
thumbnailCache = null,
fallbackThumbnailSrc = "",
} = {}) {
if (!doc) {
throw new Error("SimilarContentCard requires a document reference.");
}
if (!video || typeof video !== "object" || !video.id || !video.title) {
throw new Error("SimilarContentCard requires a video with id and title.");
}
this.document = doc;
this.window = doc.defaultView || globalThis;
this.video = video;
this.index = Number.isFinite(index) ? Number(index) : 0;
this.shareUrl =
typeof shareUrl === "string" && shareUrl.trim() ? shareUrl.trim() : "#";
this.pointerInfo = this.normalizePointerInfo(pointerInfo);
this.timeAgo = typeof timeAgo === "string" ? timeAgo : "";
this.postedAt = this.normalizeTimestamp(postedAt);
this.identity = this.normalizeIdentity(identity);
this.nsfwContext = {
isNsfw: Boolean(nsfwContext?.isNsfw),
allowNsfw: nsfwContext?.allowNsfw !== false,
viewerIsOwner: nsfwContext?.viewerIsOwner === true,
};
this.shouldMaskNsfwForOwner =
this.nsfwContext.isNsfw &&
!this.nsfwContext.allowNsfw &&
this.nsfwContext.viewerIsOwner;
this.designSystem = normalizeDesignSystemContext(designSystem);
this.thumbnailCache =
thumbnailCache instanceof Map ? thumbnailCache : null;
this.fallbackThumbnailSrc =
typeof fallbackThumbnailSrc === "string"
? fallbackThumbnailSrc.trim()
: "";
this.callbacks = {
onPlay: null,
onModerationOverride: null,
onModerationBlock: null,
onModerationHide: null,
};
this.root = null;
this.cardStyleId = "";
this.mediaLinkEl = null;
this.thumbnailEl = null;
this.contentEl = null;
this.titleEl = null;
this.avatarEl = null;
this.authorNameEl = null;
this.authorNpubEl = null;
this.timeEl = null;
this.viewCountEl = null;
this.discussionCountEl = null;
this.moderationBadgeEl = null;
this.moderationBadgeLabelEl = null;
this.moderationBadgeTextEl = null;
this.moderationActionsContainer = null;
this.moderationActionButton = null;
this.moderationActionButtonMode = "";
this.moderationBlockButton = null;
this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event);
this.boundModerationHideHandler = (event) =>
this.handleModerationHideClick(event);
this.boundModerationBlockHandler = (event) =>
this.handleModerationBlockClick(event);
this.build();
}
set onPlay(fn) {
this.callbacks.onPlay = typeof fn === "function" ? fn : null;
}
set onModerationOverride(fn) {
this.callbacks.onModerationOverride = typeof fn === "function" ? fn : null;
}
set onModerationBlock(fn) {
this.callbacks.onModerationBlock = typeof fn === "function" ? fn : null;
}
set onModerationHide(fn) {
this.callbacks.onModerationHide = typeof fn === "function" ? fn : null;
}
getRoot() {
return this.root;
}
getViewCountElement() {
return this.viewCountEl;
}
getDiscussionCountElement() {
return this.discussionCountEl;
}
closeMoreMenu() {}
closeSettingsMenu() {}
updateIdentity(nextIdentity = {}) {
this.identity = this.normalizeIdentity(nextIdentity, this.identity);
this.applyIdentityToElements();
}
normalizePointerInfo(info) {
if (!info || typeof info !== "object") {
return null;
}
const key =
typeof info.key === "string" && info.key.trim() ? info.key.trim() : "";
const pointer = Array.isArray(info.pointer) ? info.pointer.slice(0, 3) : null;
if (!key && !pointer) {
return null;
}
return { key, pointer };
}
normalizeTimestamp(candidate) {
if (!Number.isFinite(candidate)) {
return null;
}
const value = Number(candidate);
const seconds = value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
return seconds;
}
normalizeIdentity(nextIdentity = {}, fallback = null) {
const baseline = fallback && typeof fallback === "object" ? fallback : {};
const candidate =
nextIdentity && typeof nextIdentity === "object" ? nextIdentity : {};
const pubkey = (() => {
if (typeof candidate.pubkey === "string" && candidate.pubkey.trim()) {
return candidate.pubkey.trim();
}
if (typeof baseline.pubkey === "string" && baseline.pubkey.trim()) {
return baseline.pubkey.trim();
}
if (typeof this.video?.pubkey === "string" && this.video.pubkey.trim()) {
return this.video.pubkey.trim();
}
return "";
})();
const nameCandidates = [
candidate.name,
candidate.displayName,
candidate.username,
baseline.name,
baseline.displayName,
baseline.username,
];
let name = "";
for (const entry of nameCandidates) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (trimmed) {
name = trimmed;
break;
}
}
const npub = (() => {
const entries = [candidate.npub, baseline.npub];
for (const entry of entries) {
if (typeof entry === "string" && entry.trim()) {
return entry.trim();
}
}
return "";
})();
const shortNpub = (() => {
const entries = [candidate.shortNpub, baseline.shortNpub];
let resolved = "";
for (const entry of entries) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (trimmed) {
resolved = trimmed;
break;
}
}
if (npub) {
const formatted = formatShortNpub(npub);
if (formatted) {
resolved = formatted;
}
}
return resolved;
})();
if (!name) {
name = shortNpub || npub || "";
}
const picture = (() => {
const entries = [
candidate.picture,
candidate.image,
candidate.photo,
baseline.picture,
baseline.image,
baseline.photo,
this.video?.author?.picture,
this.video?.creator?.picture,
this.video?.profile?.picture,
this.video?.authorPicture,
this.video?.creatorPicture,
];
for (const entry of entries) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const sanitized = sanitizeProfileMediaUrl(trimmed);
if (sanitized) {
return sanitized;
}
}
const fallback =
typeof baseline.picture === "string" && baseline.picture.trim()
? sanitizeProfileMediaUrl(baseline.picture) || baseline.picture.trim()
: "";
return fallback;
})();
const resolvedPicture = picture || DEFAULT_PROFILE_AVATAR;
return { name, npub, shortNpub, pubkey, picture: resolvedPicture };
}
applyIdentityToElements() {
const nameLabel =
this.identity.name ||
this.identity.shortNpub ||
this.identity.npub ||
"";
const npubLabel = this.identity.shortNpub || this.identity.npub || "";
if (this.authorNameEl) {
this.authorNameEl.textContent = nameLabel;
if (this.identity.pubkey) {
this.authorNameEl.dataset.pubkey = this.identity.pubkey;
} else if (this.authorNameEl.dataset?.pubkey) {
delete this.authorNameEl.dataset.pubkey;
}
}
if (this.authorNpubEl) {
this.authorNpubEl.textContent = npubLabel;
const hasNpub = Boolean(npubLabel);
const normalizedName = nameLabel.trim().toLowerCase();
const normalizedNpub = npubLabel.trim().toLowerCase();
const isDuplicate =
hasNpub && normalizedName && normalizedName === normalizedNpub;
this.authorNpubEl.hidden = !hasNpub || isDuplicate;
this.authorNpubEl.setAttribute(
"aria-hidden",
!hasNpub || isDuplicate ? "true" : "false",
);
if (this.identity.npub && !isDuplicate) {
this.authorNpubEl.setAttribute("title", this.identity.npub);
} else {
this.authorNpubEl.removeAttribute("title");
}
if (this.identity.npub) {
this.authorNpubEl.dataset.npub = this.identity.npub;
} else if (this.authorNpubEl.dataset?.npub) {
delete this.authorNpubEl.dataset.npub;
}
const resolvedPubkey =
this.identity.pubkey ||
(typeof this.video?.pubkey === "string" ? this.video.pubkey.trim() : "");
if (resolvedPubkey) {
this.authorNpubEl.dataset.pubkey = resolvedPubkey;
} else if (this.authorNpubEl.dataset?.pubkey) {
delete this.authorNpubEl.dataset.pubkey;
}
}
if (this.avatarEl) {
const picture = this.identity.picture || DEFAULT_PROFILE_AVATAR;
if (this.avatarEl.getAttribute("src") !== picture) {
this.avatarEl.src = picture;
}
const altLabel =
nameLabel || this.identity.shortNpub || this.identity.npub || "Channel";
this.avatarEl.alt = `${altLabel}'s avatar`;
if (this.identity.pubkey) {
this.avatarEl.dataset.pubkey = this.identity.pubkey;
} else if (this.avatarEl.dataset?.pubkey) {
delete this.avatarEl.dataset.pubkey;
}
}
}
build() {
// Modified: Ensure 'group' class for hover effects
const root = this.document.createElement("article");
root.classList.add("player-modal__similar-card", "card", "group");
root.dataset.component = "similar-content-card";
root.dataset.index = String(this.index);
if (this.video.id) {
root.dataset.videoId = this.video.id;
}
similarCardIdCounter += 1;
this.cardStyleId = `similar-card-${similarCardIdCounter}`;
root.dataset.similarCardId = this.cardStyleId;
const dsMode = this.designSystem?.getMode?.();
if (dsMode) {
root.setAttribute("data-ds", dsMode);
}
this.root = root;
this.root.__bitvidSimilarContentCard = this;
const media = this.buildMediaSection();
const content = this.buildContentSection();
if (media) {
root.appendChild(media);
}
if (content) {
root.appendChild(content);
}
this.refreshModerationUi();
this.applyPointerDatasets();
this.bindEvents();
}
buildMediaSection() {
const anchor = this.document.createElement("a");
anchor.classList.add(
"player-modal__similar-card-media",
"block",
"relative",
"overflow-hidden",
"rounded"
);
anchor.href = this.shareUrl;
anchor.setAttribute("data-primary-action", "play");
const thumbnail = this.buildThumbnail();
if (thumbnail) {
anchor.appendChild(thumbnail);
}
this.mediaLinkEl = anchor;
return anchor;
}
getModerationContext() {
return normalizeVideoModerationContext(this.video?.moderation);
}
shouldShowModerationBlockButton(context = this.getModerationContext()) {
if (!context || !context.trustedMuted) {
return false;
}
if (context.activeHidden && !context.overrideActive) {
return false;
}
return true;
}
createModerationOverrideButton() {
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: false,
});
const button = this.document.createElement("button");
button.classList.add(
"moderation-badge__action",
"flex-shrink-0",
"text-xs",
"py-1",
"px-2"
);
button.type = "button";
button.dataset.moderationAction = "override";
button.setAttribute("aria-pressed", "false");
button.setAttribute("aria-label", ariaLabel);
button.textContent = "Show";
button.addEventListener("click", this.boundShowAnywayHandler);
// Explicit pointer events since container is none
button.classList.add("pointer-events-auto");
return button;
}
createModerationHideButton() {
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: true,
});
const button = this.document.createElement("button");
button.classList.add(
"moderation-badge__action",
"flex-shrink-0",
"text-xs",
"py-1",
"px-2"
);
button.type = "button";
button.dataset.moderationAction = "hide";
button.setAttribute("aria-label", ariaLabel);
button.textContent = "Hide";
button.addEventListener("click", this.boundModerationHideHandler);
button.classList.add("pointer-events-auto");
return button;
}
createModerationBlockButton() {
const button = this.document.createElement("button");
button.classList.add(
"moderation-badge__action",
"flex-shrink-0",
"text-xs",
"py-1",
"px-2"
);
button.type = "button";
button.dataset.moderationAction = "block";
button.textContent = "Block";
button.addEventListener("click", this.boundModerationBlockHandler);
button.classList.add("pointer-events-auto");
return button;
}
handleShowAnywayClick(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.moderationActionButton) {
this.moderationActionButton.disabled = true;
this.moderationActionButton.setAttribute("aria-busy", "true");
}
if (!this.callbacks.onModerationOverride) {
if (this.moderationActionButton) {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
return;
}
let result;
try {
result = this.callbacks.onModerationOverride({
event,
video: this.video,
card: this,
});
} catch (error) {
if (this.moderationActionButton) {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
return;
}
Promise.resolve(result).then((handled) => {
if (handled !== false) {
this.refreshModerationUi();
} else if (this.moderationActionButton) {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
});
}
handleModerationHideClick(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.moderationActionButton) {
this.moderationActionButton.disabled = true;
this.moderationActionButton.setAttribute("aria-busy", "true");
}
if (!this.callbacks.onModerationHide) {
if (this.moderationActionButton) {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
return;
}
let result;
try {
result = this.callbacks.onModerationHide({
event,
video: this.video,
card: this,
});
} catch (error) {
if (this.moderationActionButton) {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
return;
}
Promise.resolve(result).then((handled) => {
if (handled !== false) {
this.refreshModerationUi();
} else if (this.moderationActionButton) {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
});
}
handleModerationBlockClick(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.moderationBlockButton) {
this.moderationBlockButton.disabled = true;
this.moderationBlockButton.setAttribute("aria-busy", "true");
}
if (!this.callbacks.onModerationBlock) {
if (this.moderationBlockButton) {
this.moderationBlockButton.disabled = false;
this.moderationBlockButton.removeAttribute("aria-busy");
}
return;
}
let result;
try {
result = this.callbacks.onModerationBlock({
event,
video: this.video,
card: this,
});
} catch (error) {
if (this.moderationBlockButton) {
this.moderationBlockButton.disabled = false;
this.moderationBlockButton.removeAttribute("aria-busy");
}
return;
}
Promise.resolve(result).then((handled) => {
if (handled !== false) {
this.refreshModerationUi();
} else if (this.moderationBlockButton) {
this.moderationBlockButton.disabled = false;
this.moderationBlockButton.removeAttribute("aria-busy");
}
});
}
buildModerationBadge(context = this.getModerationContext()) {
if (!context.shouldShow || !context.trustedMuted) {
this.moderationBadgeEl = null;
this.moderationActionsContainer = null;
this.moderationActionButton = null;
this.moderationActionButtonMode = "";
this.moderationBlockButton = null;
return null;
}
const badge = this.document.createElement("div");
badge.className = "moderation-badge moderation-badge--interactive opacity-95";
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
badge.dataset.moderationBadge = "true";
badge.dataset.moderationState = context.overrideActive
? "override"
: "trusted-mute";
// Allow interaction with children
badge.classList.add("pointer-events-auto");
const label = this.document.createElement("span");
label.className = "moderation-badge__label inline-flex items-center gap-xs";
const iconWrapper = this.document.createElement("span");
iconWrapper.className = "moderation-badge__icon";
iconWrapper.setAttribute("aria-hidden", "true");
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 20 20");
svg.setAttribute("focusable", "false");
svg.classList.add("moderation-badge__icon-mark");
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("fill", "currentColor");
if (context.overrideActive) {
path.setAttribute("d", "M10 18a8 8 0 100-16 8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L9 11.94l-1.72-1.72a.75.75 0 10-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l3.25-3.25z");
} else {
path.setAttribute("d", "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z");
}
path.setAttribute("fill-rule", "evenodd");
path.setAttribute("clip-rule", "evenodd");
svg.appendChild(path);
iconWrapper.appendChild(svg);
label.appendChild(iconWrapper);
const text = this.document.createElement("span");
text.className = "moderation-badge__text text-xs";
text.textContent = "Blocked by trusted";
label.appendChild(text);
badge.appendChild(label);
const actions = this.document.createElement("div");
actions.className = "moderation-badge__actions flex items-center gap-1 mt-1";
if (context.allowOverride) {
if (context.overrideActive) {
const hideButton = this.createModerationHideButton();
actions.appendChild(hideButton);
this.moderationActionButton = hideButton;
this.moderationActionButtonMode = "hide";
} else {
const showButton = this.createModerationOverrideButton();
actions.appendChild(showButton);
this.moderationActionButton = showButton;
this.moderationActionButtonMode = "override";
}
}
if (this.shouldShowModerationBlockButton(context)) {
const blockButton = this.createModerationBlockButton();
actions.appendChild(blockButton);
this.moderationBlockButton = blockButton;
}
if (actions.childElementCount > 0) {
badge.appendChild(actions);
this.moderationActionsContainer = actions;
}
this.moderationBadgeEl = badge;
return badge;
}
refreshModerationUi() {
const context = this.getModerationContext();
if (this.moderationBadgeEl) {
this.moderationBadgeEl.remove();
this.moderationBadgeEl = null;
}
this.moderationActionsContainer = null;
this.moderationActionButton = null;
this.moderationActionButtonMode = "";
this.moderationBlockButton = null;
// Apply blurring class if needed
if (this.thumbnailEl) {
const shouldBlur =
(this.shouldMaskNsfwForOwner ||
this.video?.moderation?.blurThumbnail) &&
!context.overrideActive;
if (shouldBlur) {
this.thumbnailEl.dataset.thumbnailState = "blurred";
this.thumbnailEl.classList.add("blur-xl");
// Force blur style in case class utility is missing or overridden,
// and clear the backdrop so the sharp background doesn't show through.
// Scale up to hide feathered edges of the blur.
this.thumbnailEl.classList.add("scale-[1.2]");
this.setCardBackdropImage("");
} else {
delete this.thumbnailEl.dataset.thumbnailState;
this.thumbnailEl.classList.remove("blur-xl");
this.thumbnailEl.classList.remove("scale-[1.2]");
// Restore backdrop if src is available
if (this.thumbnailEl.src) {
this.setCardBackdropImage(this.thumbnailEl.src);
}
}
}
// Check specifically for muted/blurred state where we want the overlay
if (context.shouldShow && context.trustedMuted) {
if (!this.mediaLinkEl) return;
const badge = this.buildModerationBadge(context);
if (badge) {
const container = this.document.createElement("div");
container.className =
"absolute inset-0 flex flex-col items-center justify-center p-2 z-10 pointer-events-none";
container.appendChild(badge);
this.mediaLinkEl.appendChild(container);
this.moderationBadgeEl = container;
}
}
}
buildThumbnail() {
const img = this.document.createElement("img");
img.decoding = "async";
img.loading = "lazy";
img.alt = this.video.title || "";
img.dataset.videoThumbnail = "true";
// Modified: Add transition classes for hover zoom
img.classList.add(
"transition-transform",
"duration-300",
"ease-out",
"group-hover:scale-105"
);
const rawThumbnail =
typeof this.video.thumbnail === "string"
? this.video.thumbnail.trim()
: "";
const thumbnailUrl = rawThumbnail;
const fallbackSrc = this.fallbackThumbnailSrc;
const cachedValue = this.thumbnailCache?.get(this.video.id) || "";
const shouldLazyLoad = !!thumbnailUrl && cachedValue !== thumbnailUrl;
if (shouldLazyLoad) {
if (fallbackSrc) {
img.src = fallbackSrc;
img.dataset.fallbackSrc = fallbackSrc;
this.setCardBackdropImage(fallbackSrc);
} else {
this.setCardBackdropImage("");
}
img.dataset.lazy = thumbnailUrl;
} else {
const initialSrc = thumbnailUrl || fallbackSrc;
if (initialSrc) {
img.src = initialSrc;
if (fallbackSrc) {
img.dataset.fallbackSrc = fallbackSrc;
}
this.setCardBackdropImage(initialSrc);
} else {
this.setCardBackdropImage("");
}
}
if (this.shouldMaskNsfwForOwner || this.video?.moderation?.blurThumbnail) {
img.dataset.thumbnailState = "blurred";
img.classList.add("blur-xl");
// Pre-apply style to avoid flash and crop edges
img.classList.add("scale-[1.2]");
}
const handleLoad = () => {
const currentSrc = img.currentSrc || img.src || "";
if (!currentSrc) {
return;
}
// If currently blurred, do not restore the backdrop to avoid transparency bleed-through.
const isBlurred = img.dataset.thumbnailState === "blurred";
const fallbackAttr =
(typeof img.dataset.fallbackSrc === "string"
? img.dataset.fallbackSrc.trim()
: "") ||
fallbackSrc ||
"";
const isFallback =
!!fallbackAttr &&
(currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr));
if (!isFallback) {
if (isBlurred) {
this.setCardBackdropImage("");
} else {
this.setCardBackdropImage(currentSrc);
}
} else if (fallbackAttr) {
if (isBlurred) {
this.setCardBackdropImage("");
} else {
this.setCardBackdropImage(fallbackAttr);
}
}
if (thumbnailUrl && !isFallback && this.thumbnailCache) {
this.thumbnailCache.set(this.video.id, thumbnailUrl);
}
};
const handleError = () => {
const fallbackAttr =
(typeof img.dataset.fallbackSrc === "string"
? img.dataset.fallbackSrc.trim()
: "") ||
fallbackSrc ||
"";
// If currently blurred, do not set the backdrop.
const isBlurred = img.dataset.thumbnailState === "blurred";
if (fallbackAttr) {
if (isBlurred) {
this.setCardBackdropImage("");
} else {
this.setCardBackdropImage(fallbackAttr);
}
if (!img.src || img.src === thumbnailUrl) {
img.src = fallbackAttr;
}
} else {
this.setCardBackdropImage("");
}
if (thumbnailUrl && this.thumbnailCache) {
const cached = this.thumbnailCache.get(this.video.id);
if (cached === thumbnailUrl) {
this.thumbnailCache.delete(this.video.id);
}
}
};
img.addEventListener("load", handleLoad);
img.addEventListener("error", handleError, { once: true });
if (img.complete) {
handleLoad();
}
this.thumbnailEl = img;
return img;
}
buildContentSection() {
const content = this.document.createElement("div");
content.classList.add("player-modal__similar-card-content");
const titleLink = this.document.createElement("a");
titleLink.classList.add("player-modal__similar-card-title");
titleLink.href = this.shareUrl;
titleLink.textContent = this.video.title || "Untitled";
titleLink.title = this.video.title || "Untitled";
const authorStack = this.buildAuthorStack();
const metaRow = this.buildMetaRow();
const engagement = this.buildEngagementSection();
content.appendChild(titleLink);
if (authorStack) {
content.appendChild(authorStack);
}
if (metaRow) {
content.appendChild(metaRow);
}
if (engagement) {
content.appendChild(engagement);
}
this.contentEl = content;
this.titleEl = titleLink;
return content;
}
buildAuthorStack() {
const wrapper = this.document.createElement("div");
wrapper.classList.add("player-modal__similar-card-author");
const textWrapper = this.document.createElement("span");
textWrapper.classList.add("player-modal__similar-card-author-meta");
const nameEl = this.document.createElement("span");
nameEl.classList.add("author-name", "player-modal__similar-card-author-name");
textWrapper.appendChild(nameEl);
// Removed npubEl appending here to simplify visual noise in compact card
// but keeping element reference if needed for logic
const npubEl = this.document.createElement("span");
npubEl.classList.add("author-npub", "player-modal__similar-card-author-npub");
// textWrapper.appendChild(npubEl);
// Reordered: Content -> Meta (Avatar logic removed from stack to clean up UI,
// or kept minimal if desired. For now, we keep structure but can style via CSS order if needed)
// Actually, keeping the avatar structure but CSS will handle sizing/layout
const avatarWrapper = this.document.createElement("span");
avatarWrapper.classList.add("player-modal__similar-card-avatar");
const avatarImg = this.document.createElement("img");
avatarImg.classList.add("player-modal__similar-card-avatar-img");
avatarImg.decoding = "async";
avatarImg.loading = "lazy";
avatarImg.alt = "";
avatarWrapper.appendChild(avatarImg);
// Append avatar then text
wrapper.appendChild(textWrapper);
// Note: To match modern style, we might want text first or no avatar.
// But let's keep avatar for now but maybe hide it via CSS if needed,
// or just show name.
// Let's re-append avatar if we want it visible
// wrapper.appendChild(avatarWrapper);
// Let's stick to the previous logical structure but rely on CSS flex order or just
// simplified HTML order:
// [Name]
// [Meta]
// Cleaning up: We want Title -> Name -> Meta
// This wrapper is for the "Author" line.
// Let's clear wrapper and re-append in clean order
while (wrapper.firstChild) { wrapper.removeChild(wrapper.firstChild); }
wrapper.appendChild(textWrapper);
// We preserve refs
this.avatarEl = avatarImg;
this.authorNameEl = nameEl;
this.authorNpubEl = npubEl;
this.applyIdentityToElements();
return wrapper;
}
buildMetaRow() {
const row = this.document.createElement("div");
row.classList.add("player-modal__similar-card-meta");
const timeEl = this.document.createElement("time");
timeEl.classList.add("player-modal__similar-card-timestamp");
if (this.postedAt !== null) {
try {
const iso = new Date(this.postedAt * 1000).toISOString();
timeEl.setAttribute("datetime", iso);
timeEl.title = new Date(this.postedAt * 1000).toLocaleString();
} catch {
/* noop */
}
}
timeEl.textContent = this.timeAgo || "";
row.appendChild(timeEl);
this.timeEl = timeEl;
return row;
}
createEyeIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("d", "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z");
svg.appendChild(path);
const circle = this.document.createElementNS(SVG_NAMESPACE, "circle");
circle.setAttribute("cx", "12");
circle.setAttribute("cy", "12");
circle.setAttribute("r", "3");
svg.appendChild(circle);
return svg;
}
createMessageIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute(
"d",
"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
);
svg.appendChild(path);
return svg;
}
buildEngagementSection() {
const hasPointer = this.pointerInfo && this.pointerInfo.key;
const hasDiscussion =
this.video.enableComments !== false &&
(typeof this.video.discussionCount === "number" ||
(typeof this.video.discussionCount === "string" &&
this.video.discussionCount.trim()));
if (!hasPointer && !hasDiscussion) {
return null;
}
const container = this.document.createElement("div");
container.classList.add(
"flex",
"items-center",
"gap-4",
"text-xs",
"text-muted-strong",
"mt-1.5",
"video-card__engagement" // Reuse this class for consistent styling if global, otherwise it's just a marker
);
// Add specific class for scoping if needed, but Tailwind classes usually suffice.
// Views
if (hasPointer) {
const wrapper = this.document.createElement("div");
wrapper.classList.add("flex", "items-center", "gap-1.5");
wrapper.setAttribute("title", "Views");
const icon = this.createEyeIcon(["w-3.5", "h-3.5"]);
wrapper.appendChild(icon);
const view = this.document.createElement("span");
view.classList.add("view-count-text");
view.textContent = "–";
view.dataset.viewCount = "";
view.dataset.viewPointer = this.pointerInfo.key;
wrapper.appendChild(view);
container.appendChild(wrapper);
this.viewCountEl = view;
}
// Discussion
if (hasDiscussion) {
let initialCount = 0;
if (typeof this.video.discussionCount === "number") {
initialCount = this.video.discussionCount;
} else if (typeof this.video.discussionCount === "string") {
const parsed = Number.parseInt(this.video.discussionCount.trim(), 10);
if (Number.isFinite(parsed)) {
initialCount = parsed;
}
}
if (Number.isFinite(initialCount) && initialCount >= 0) {
const wrapper = this.document.createElement("div");
wrapper.classList.add("flex", "items-center", "gap-1.5");
wrapper.dataset.discussionCount = this.video.id;
wrapper.dataset.countState = "ready";
wrapper.setAttribute("title", "Comments");
const icon = this.createMessageIcon(["w-3.5", "h-3.5"]);
wrapper.appendChild(icon);
const displayValue = initialCount.toLocaleString();
const valueEl = this.document.createElement("span");
valueEl.textContent = displayValue;
valueEl.dataset.discussionCountValue = "";
wrapper.appendChild(valueEl);
container.appendChild(wrapper);
this.discussionCountEl = wrapper;
}
}
return container;
}
applyPointerDatasets() {
if (!this.root || !this.pointerInfo) {
return;
}
const { key, pointer } = this.pointerInfo;
if (key) {
this.root.dataset.pointerKey = key;
}
if (Array.isArray(pointer)) {
const [type, value, relay] = pointer;
if (typeof type === "string" && type) {
this.root.dataset.pointerType = type;
}
if (typeof value === "string" && value) {
this.root.dataset.pointerValue = value;
}
if (typeof relay === "string" && relay) {
this.root.dataset.pointerRelay = relay;
}
}
}
bindEvents() {
const MouseEventCtor = this.window?.MouseEvent || globalThis.MouseEvent;
const handler = (event) => {
if (!this.callbacks.onPlay) {
return;
}
const isMouseEvent =
typeof MouseEventCtor !== "undefined" && event instanceof MouseEventCtor;
if (isMouseEvent) {
const isPrimaryClick =
typeof event.button !== "number" || event.button === 0;
const hasModifier =
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
if (!isPrimaryClick || hasModifier) {
return;
}
}
event.preventDefault?.();
event.stopPropagation?.();
this.callbacks.onPlay({ event, video: this.video, card: this });
};
[this.mediaLinkEl, this.titleEl].forEach((el) => {
if (!el) {
return;
}
el.addEventListener("click", handler);
});
}
setCardBackdropImage(src) {
if (!this.root || !this.cardStyleId) {
return;
}
const normalizeSource = (raw) => {
if (typeof raw !== "string") {
return "";
}
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (/^javascript:/i.test(trimmed) || /^vbscript:/i.test(trimmed)) {
return "";
}
if (/^(?:https?:|data:|blob:)/i.test(trimmed)) {
return trimmed;
}
if (
trimmed.startsWith("/") ||
trimmed.startsWith("./") ||
trimmed.startsWith("../") ||
trimmed.startsWith("assets/")
) {
return trimmed;
}
try {
const base =
typeof this.document?.baseURI === "string" && this.document.baseURI
? this.document.baseURI
: this.window?.location?.href || "";
if (!base) {
return "";
}
const resolved = new URL(trimmed, base);
if (/^(?:https?:|data:|blob:)/i.test(resolved.protocol)) {
return resolved.href;
}
} catch {
return "";
}
return "";
};
const sanitized = normalizeSource(src);
if (sanitized) {
const escaped = sanitized.replace(/(["\\])/g, "\\$1");
updateSimilarCardBackdrop(this.document, this.cardStyleId, escaped);
} else {
updateSimilarCardBackdrop(this.document, this.cardStyleId, "");
}
}
}