Files
bitvid/js/ui/components/SimilarContentCard.js
T
google-labs-jules[bot] fb15ff3eb0 Update Similar Content Card to match Video Card layout.
- Remove view count and separator from the metadata row in `SimilarContentCard.js`.
- Add new `buildEngagementSection` method to render view count and discussion count in a dedicated footer.
- Use Eye and Message SVG icons for engagement metrics.
- Ensure visual consistency with the main Video Card component.
- Fix import and definition of `SVG_NAMESPACE` to support icon creation.
2026-01-12 21:21:11 +00:00

841 lines
24 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";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
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 };
this.root = null;
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.build();
}
set onPlay(fn) {
this.callbacks.onPlay = 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;
}
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.applyPointerDatasets();
this.bindEvents();
}
buildMediaSection() {
const anchor = this.document.createElement("a");
anchor.classList.add("player-modal__similar-card-media");
anchor.href = this.shareUrl;
anchor.setAttribute("data-primary-action", "play");
const thumbnail = this.buildThumbnail();
if (thumbnail) {
anchor.appendChild(thumbnail);
}
this.mediaLinkEl = anchor;
return anchor;
}
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";
}
const handleLoad = () => {
const currentSrc = img.currentSrc || img.src || "";
if (!currentSrc) {
return;
}
const fallbackAttr =
(typeof img.dataset.fallbackSrc === "string"
? img.dataset.fallbackSrc.trim()
: "") || fallbackSrc || "";
const isFallback =
!!fallbackAttr &&
(currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr));
if (!isFallback) {
this.setCardBackdropImage(currentSrc);
} else if (fallbackAttr) {
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 (fallbackAttr) {
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");
content.style.minWidth = "0";
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";
titleLink.style.minWidth = "0";
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");
wrapper.style.minWidth = "0";
const textWrapper = this.document.createElement("span");
textWrapper.classList.add("player-modal__similar-card-author-meta");
textWrapper.style.minWidth = "0";
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");
row.style.minWidth = "0";
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.root.style) {
return;
}
const style = this.root.style;
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");
style.setProperty(
"--similar-card-thumb-url",
`url("${escaped}")`
);
} else {
style.removeProperty("--similar-card-thumb-url");
}
}
}