Revamp watch history cards

This commit is contained in:
thePR0M3TH3AN committed 2025-09-30 21:11:02 -04:00
1 parent fdbb24049d
commit 7af3a536c3
2 files changed
+538 -142

No files matched your search

+257 -11
View File
@@ -104,31 +104,28 @@ header img {
padding: 1.5rem 0;
}
/* Dedicated watch-history grids mirror the home feed layout */
/* Dedicated watch-history layout stacks cards vertically */
.watch-history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
padding: 1.5rem 0;
}
/* Profile modal gets a tighter column and removes outer padding */
/* Profile modal uses the same vertical stack but without outer padding */
.watch-history-grid.watch-history-grid--modal {
grid-template-columns: repeat(auto-fill, minmax(17.5rem, 1fr));
gap: 1.5rem;
padding: 0;
gap: 1.25rem;
}
@media (max-width: 640px) {
.watch-history-grid {
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
gap: 1.5rem;
gap: 1.25rem;
}
.watch-history-grid.watch-history-grid--modal {
grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr));
gap: 1.25rem;
gap: 1.125rem;
}
}
@@ -159,6 +156,255 @@ header img {
animation: video-card-fade-in 220ms ease-out forwards;
}
/* Watch history cards */
.watch-history-card {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: stretch;
background-color: var(--color-card);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 0.0625rem solid transparent;
box-shadow: var(--shadow-md);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.watch-history-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.watch-history-card--private {
border-color: rgba(234, 179, 8, 0.75);
}
.watch-history-card__primary {
display: flex;
gap: 1rem;
flex: 1 1 18rem;
min-width: 0;
}
.watch-history-card__thumbnail {
display: block;
flex: 0 0 14rem;
max-width: 14rem;
text-decoration: none;
}
.watch-history-card__thumbnailInner {
width: 100%;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
background-color: #0f172a;
}
.watch-history-card__thumbnailInner img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.watch-history-card__details {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
min-width: 0;
}
.watch-history-card__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #ffffff;
line-height: 1.4;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.watch-history-card__title:hover,
.watch-history-card__title:focus {
color: #60a5fa;
}
.watch-history-card__created {
margin: 0;
font-size: 0.875rem;
color: #9ca3af;
}
.watch-history-card__meta {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1rem;
flex: 0 0 16rem;
min-width: 14rem;
}
.watch-history-card__watched {
margin: 0;
font-size: 0.875rem;
color: #d1d5db;
}
.watch-history-card__creator {
display: flex;
align-items: center;
gap: 0.75rem;
}
.watch-history-card__creatorAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #1f2937;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__creatorAvatar:hover,
.watch-history-card__creatorAvatar:focus {
box-shadow: 0 0 0 0.125rem rgba(59, 130, 246, 0.5);
outline: none;
}
.watch-history-card__creatorAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 9999px;
}
.watch-history-card__creatorName {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #e5e7eb;
text-align: left;
cursor: pointer;
}
.watch-history-card__creatorName:hover,
.watch-history-card__creatorName:focus {
color: #60a5fa;
outline: none;
}
.watch-history-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.watch-history-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 0.0625rem solid transparent;
background-color: #1f2937;
color: #e5e7eb;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease,
border-color 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__action:hover,
.watch-history-card__action:focus {
background-color: #111827;
color: #ffffff;
border-color: #374151;
outline: none;
}
.watch-history-card__action--primary {
background-color: #2563eb;
color: #ffffff;
}
.watch-history-card__action--primary:hover,
.watch-history-card__action--primary:focus {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
.watch-history-card__action--danger {
background-color: #7f1d1d;
color: #ffffff;
}
.watch-history-card__action--danger:hover,
.watch-history-card__action--danger:focus {
background-color: #991b1b;
border-color: #991b1b;
}
.watch-history-card__menus {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 1024px) {
.watch-history-card__meta {
flex: 1 1 18rem;
}
}
@media (max-width: 768px) {
.watch-history-card {
gap: 1.25rem;
padding: 1rem;
}
.watch-history-card__thumbnail {
flex-basis: 11rem;
max-width: 11rem;
}
}
@media (max-width: 640px) {
.watch-history-card {
flex-direction: column;
}
.watch-history-card__primary,
.watch-history-card__meta {
flex: 1 1 auto;
min-width: 0;
}
.watch-history-card__thumbnail {
width: 100%;
max-width: 100%;
}
.watch-history-card__meta {
gap: 0.75rem;
}
}
@keyframes video-card-fade-in {
from {
opacity: 0;
+281 -131
View File
@@ -3,8 +3,6 @@
import { nostrClient } from "./nostr.js";
import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js";
import { subscriptions } from "./subscriptions.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
const DEFAULT_BATCH_SIZE = 20;
const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1;
@@ -103,12 +101,31 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
const canEdit = window.app?.pubkey === video.pubkey;
const highlightClass =
video.isPrivate && canEdit ? "border-2 border-yellow-500" : "border-none";
video.isPrivate && canEdit ? "watch-history-card--private" : "";
const timeAgo = window.app?.formatTimeAgo
? window.app.formatTimeAgo(video.created_at)
: new Date(video.created_at * 1000).toLocaleString();
const watchedAtTimestamp = toNumber(
video?.watchHistory?.watchedAt,
null
);
let watchedAtLabel = "Watch date unavailable";
if (Number.isFinite(watchedAtTimestamp) && watchedAtTimestamp > 0) {
const watchedDate = new Date(watchedAtTimestamp);
if (!Number.isNaN(watchedDate.getTime())) {
try {
watchedAtLabel = `Watched on ${watchedDate.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})}`;
} catch (err) {
watchedAtLabel = `Watched on ${watchedDate.toLocaleString()}`;
}
}
}
let hasOlder = false;
if (canEdit && video.videoRootId && window.app?.hasOlderVersion) {
hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray);
@@ -203,7 +220,7 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
`;
const cardControls = `
<div class="flex items-center">
<div class="watch-history-card__menus">
${moreMenu}${gearMenu}
</div>
`;
@@ -219,77 +236,98 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
const magnetCandidate = trimmedMagnet || legacyInfoHash;
const playbackMagnet = magnetCandidate;
const magnetProvided = magnetCandidate.length > 0;
const magnetSupported =
window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false;
const urlBadgeHtml = trimmedUrl
? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ?? ""
: "";
const torrentHealthBadgeHtml =
magnetProvided && magnetSupported
? window.app?.getTorrentHealthBadgeMarkup?.({
includeMargin: false,
}) ?? ""
: "";
const connectionBadgesHtml = urlBadgeHtml || torrentHealthBadgeHtml
? `
<div class="mt-3 flex flex-wrap items-center gap-2">
${urlBadgeHtml}${torrentHealthBadgeHtml}
</div>
`
: "";
const watchHistoryKey = video?.watchHistory?.key || "";
const cardHtml = `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<a
href="${shareUrl}"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
class="block cursor-pointer relative group"
>
<div class="ratio-16-9">
<img
src="assets/jpg/video-thumbnail-fallback.jpg"
data-lazy="${safeThumb}"
alt="${safeTitle}"
/>
</div>
</a>
<div class="p-4">
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
<article
class="watch-history-card ${highlightClass}"
data-watch-history-key="${watchHistoryKey}"
>
<div class="watch-history-card__primary">
<a
href="${shareUrl}"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
class="watch-history-card__thumbnail"
>
${safeTitle}
</h3>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center">
<img
class="author-pic"
data-pubkey="${video.pubkey}"
src="assets/svg/default-profile.svg"
alt="Placeholder"
/>
</div>
<div class="min-w-0">
<p
class="text-sm text-gray-400 author-name"
data-pubkey="${video.pubkey}"
>
Loading name...
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
</div>
</div>
<div class="watch-history-card__thumbnailInner ratio-16-9">
<img
src="assets/jpg/video-thumbnail-fallback.jpg"
data-lazy="${safeThumb}"
alt="${safeTitle}"
/>
</div>
</a>
<div class="watch-history-card__details">
<h3
class="watch-history-card__title"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
>
${safeTitle}
</h3>
<p class="watch-history-card__created">${timeAgo}</p>
</div>
</div>
<div class="watch-history-card__meta">
<p class="watch-history-card__watched" data-watched-at="${
watchedAtTimestamp || ""
}">${watchedAtLabel}</p>
<div class="watch-history-card__creator">
<button
type="button"
class="watch-history-card__creatorAvatar"
data-pubkey="${video.pubkey}"
aria-label="View creator profile"
>
<img
class="author-pic"
data-pubkey="${video.pubkey}"
src="assets/svg/default-profile.svg"
alt="Creator avatar"
/>
</button>
<button
type="button"
class="watch-history-card__creatorName author-name"
data-pubkey="${video.pubkey}"
>
Loading name...
</button>
</div>
<div class="watch-history-card__actions">
<a
href="${shareUrl}"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
class="watch-history-card__action watch-history-card__action--primary"
>
View
</a>
<button
type="button"
class="watch-history-card__action"
data-history-share="true"
data-share-url="${shareUrl}"
data-share-title="${safeTitle}"
data-event-id="${video.id}"
>
Share
</button>
<button
type="button"
class="watch-history-card__action watch-history-card__action--danger"
data-history-remove-key="${watchHistoryKey}"
data-history-remove-event-id="${video.id}"
>
Remove
</button>
${cardControls}
</div>
${connectionBadgesHtml}
</div>
</div>
</article>
`;
const t = document.createElement("template");
@@ -303,35 +341,6 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
delete cardEl.dataset.ownerPubkey;
}
if (trimmedUrl) {
cardEl.dataset.urlHealthState = "checking";
if (cardEl.dataset.urlHealthReason) {
delete cardEl.dataset.urlHealthReason;
}
cardEl.dataset.urlHealthEventId = video.id || "";
cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
cardEl.dataset.urlHealthState = "offline";
cardEl.dataset.urlHealthReason = "missing-source";
if (cardEl.dataset.urlHealthEventId) {
delete cardEl.dataset.urlHealthEventId;
}
if (cardEl.dataset.urlHealthUrl) {
delete cardEl.dataset.urlHealthUrl;
}
}
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
if (cardEl.dataset.streamHealthReason) {
delete cardEl.dataset.streamHealthReason;
}
} else {
cardEl.dataset.streamHealthState = "unhealthy";
cardEl.dataset.streamHealthReason = magnetProvided
? "unsupported"
: "missing-source";
}
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
interactiveEls.forEach((el) => {
if (!el.dataset) return;
@@ -351,34 +360,12 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
delete cardEl.dataset.magnet;
}
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
if (trimmedUrl) {
badgeEl.dataset.urlHealthEventId = video.id || "";
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
if (badgeEl.dataset.urlHealthEventId) {
delete badgeEl.dataset.urlHealthEventId;
}
if (badgeEl.dataset.urlHealthUrl) {
delete badgeEl.dataset.urlHealthUrl;
}
}
}
}
fragment.appendChild(cardEl);
});
container.innerHTML = "";
container.appendChild(fragment);
attachHealthBadges(container);
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
if (!window.app?.handleUrlHealthBadge) {
return;
}
const video = window.app.videosMap?.get?.(eventId) || { id: eventId };
window.app.handleUrlHealthBadge({ video, url, badgeEl });
});
window.app?.attachVideoListHandler?.();
@@ -447,6 +434,9 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
const authorPics = container.querySelectorAll(".author-pic");
const authorNames = container.querySelectorAll(".author-name");
const creatorButtons = container.querySelectorAll(
".watch-history-card__creatorAvatar"
);
authorPics.forEach((pic) => {
localAuthorSet.add(pic.getAttribute("data-pubkey"));
@@ -454,29 +444,50 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
authorNames.forEach((nameEl) => {
localAuthorSet.add(nameEl.getAttribute("data-pubkey"));
});
creatorButtons.forEach((btn) => {
localAuthorSet.add(btn.getAttribute("data-pubkey"));
});
if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) {
window.app.batchFetchProfiles(localAuthorSet);
}
authorPics.forEach((pic) => {
pic.style.cursor = "pointer";
pic.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = pic.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
if (!pic.dataset.watchHistoryClickAttached) {
pic.dataset.watchHistoryClickAttached = "true";
pic.style.cursor = "pointer";
pic.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = pic.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
}
});
creatorButtons.forEach((btn) => {
if (!btn.dataset.watchHistoryClickAttached) {
btn.dataset.watchHistoryClickAttached = "true";
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = btn.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
}
});
authorNames.forEach((nameEl) => {
nameEl.style.cursor = "pointer";
nameEl.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = nameEl.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
if (!nameEl.dataset.watchHistoryClickAttached) {
nameEl.dataset.watchHistoryClickAttached = "true";
nameEl.style.cursor = "pointer";
nameEl.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = nameEl.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
}
});
}
@@ -618,6 +629,7 @@ export function createWatchHistoryRenderer(config = {}) {
batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)),
actor: null,
snapshotFingerprint: null,
gridEventHandler: null,
};
const query = (selector) => {
@@ -717,6 +729,17 @@ export function createWatchHistoryRenderer(config = {}) {
}
};
const cleanupGridHandler = () => {
if (!state.gridEventHandler) {
return;
}
const { grid } = getElements();
if (grid) {
grid.removeEventListener("click", state.gridEventHandler);
}
state.gridEventHandler = null;
};
const showEmptyState = (message = state.emptyCopy) => {
const { grid, emptyEl } = getElements();
if (grid) {
@@ -754,6 +777,130 @@ export function createWatchHistoryRenderer(config = {}) {
state.resolvedVideos = Array.from(dedupeMap.values());
};
const ensureGridEventHandlers = () => {
const { grid } = getElements();
if (!grid || state.gridEventHandler) {
return;
}
const handler = async (event) => {
const removeTrigger = event.target.closest(
"[data-history-remove-key]"
);
if (removeTrigger) {
event.preventDefault();
event.stopPropagation();
if (removeTrigger.disabled) {
return;
}
const targetKey = removeTrigger
.getAttribute("data-history-remove-key")
?.trim();
const targetEventId = removeTrigger
.getAttribute("data-history-remove-event-id")
?.trim();
if (!targetKey) {
window.app?.showError?.(
"Unable to remove this video from watch history."
);
return;
}
removeTrigger.disabled = true;
removeTrigger.setAttribute("aria-busy", "true");
try {
const result = await nostrClient.removeWatchHistoryItem(targetKey);
if (!result?.ok) {
const errorMessage =
typeof result?.error === "string" && result.error
? result.error
: "remove-failed";
throw new Error(errorMessage);
}
state.resolvedVideos = state.resolvedVideos.filter((video) => {
const key = video?.watchHistory?.key || "";
if (key) {
return key !== targetKey;
}
if (targetEventId) {
return video?.id !== targetEventId;
}
return true;
});
setLoadingVisible(false);
renderResolvedVideos();
window.app?.showSuccess?.("Removed from watch history.");
} catch (error) {
console.error(
"[historyView] Failed to remove watch history item:",
error
);
removeTrigger.disabled = false;
window.app?.showError?.(
"Failed to remove this video from watch history."
);
} finally {
removeTrigger.removeAttribute("aria-busy");
}
return;
}
const shareTrigger = event.target.closest("[data-history-share]");
if (shareTrigger) {
event.preventDefault();
event.stopPropagation();
const shareUrl = shareTrigger.getAttribute("data-share-url") || "";
const shareTitle = shareTrigger.getAttribute("data-share-title") || "";
const eventId = shareTrigger.getAttribute("data-event-id") || "";
const normalizedTitle = shareTitle || "Watch on Bitvid";
try {
if (shareUrl && navigator.share) {
await navigator.share({ title: normalizedTitle, url: shareUrl });
return;
}
if (window.app?.handleMoreMenuAction) {
await window.app.handleMoreMenuAction("copy-link", { eventId });
return;
}
if (shareUrl && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(shareUrl);
window.app?.showSuccess?.("Video link copied to clipboard!");
return;
}
if (shareUrl) {
window.open(shareUrl, "_blank", "noopener,noreferrer");
return;
}
window.app?.showError?.("Unable to share this video right now.");
} catch (error) {
if (error?.name === "AbortError") {
return;
}
console.error(
"[historyView] Failed to share watch history item:",
error
);
window.app?.showError?.("Unable to share this video right now.");
}
}
};
grid.addEventListener("click", handler);
state.gridEventHandler = handler;
};
const renderResolvedVideos = () => {
const { grid, emptyEl } = getElements();
if (!grid) {
@@ -775,6 +922,8 @@ export function createWatchHistoryRenderer(config = {}) {
renderWatchHistoryGrid(state.resolvedVideos, grid);
}
ensureGridEventHandlers();
grid.classList.remove("hidden");
if (emptyEl) {
emptyEl.classList.add("hidden");
@@ -1097,6 +1246,7 @@ export function createWatchHistoryRenderer(config = {}) {
destroy() {
debugLog("destroy called");
cleanupObservers();
cleanupGridHandler();
state.initialized = false;
state.resolvedVideos = [];
state.hasMore = true;