Files
bitvid/js/ui/components/SimilarContentCard.js
T
google-labs-jules[bot] 90adb7db21 Refactor "Similar Content" UI for modern layout and responsiveness
- Refactored `SimilarContentCard.js` to use Flexbox layout.
- Updated `css/tailwind.source.css` to remove grid layout and use standard flex utilities.
- Improved thumbnail sizing (16:9, fixed width) and responsiveness.
- Added hover effects (background color, thumbnail zoom).
- Cleaned up typography and metadata hierarchy.
2026-01-08 21:07:17 +00:00

710 lines
20 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 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.build();
}
set onPlay(fn) {
this.callbacks.onPlay = typeof fn === "function" ? fn : null;
}
getRoot() {
return this.root;
}
getViewCountElement() {
return this.viewCountEl;
}
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";
// Modified order: Title -> Author -> Meta
const authorStack = this.buildAuthorStack();
const metaRow = this.buildMetaRow();
content.appendChild(titleLink);
if (authorStack) {
content.appendChild(authorStack);
}
if (metaRow) {
content.appendChild(metaRow);
}
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";
// Reordered: Views -> Separator -> Time
const viewEl = this.document.createElement("span");
viewEl.classList.add("player-modal__similar-card-views", "view-count-text");
viewEl.dataset.viewCount = "";
viewEl.textContent = "– views";
if (this.pointerInfo?.key) {
viewEl.dataset.viewPointer = this.pointerInfo.key;
}
row.appendChild(viewEl);
this.viewCountEl = viewEl;
const separator = this.document.createElement("span");
separator.classList.add("player-modal__similar-card-separator");
separator.setAttribute("aria-hidden", "true");
separator.textContent = "•";
row.appendChild(separator);
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;
}
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");
}
}
}