diff --git a/css/style.css b/css/style.css index 3d23f26b..cdabeb25 100644 --- a/css/style.css +++ b/css/style.css @@ -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; diff --git a/js/historyView.js b/js/historyView.js index 1c422cb8..a55f3a73 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -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 = ` -
+
${moreMenu}${gearMenu}
`; @@ -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 - ? ` -
- ${urlBadgeHtml}${torrentHealthBadgeHtml} -
- ` - : ""; + const watchHistoryKey = video?.watchHistory?.key || ""; const cardHtml = ` -
- -
- ${safeTitle} -
-
-
-

+

-
-
-
- Placeholder -
-
-

- Loading name... -

-
- ${timeAgo} -
-
+
+ ${safeTitle}
+
+
+

+ ${safeTitle} +

+

${timeAgo}

+
+
+
+

${watchedAtLabel}

+
+ + +
+
+ + View + + + ${cardControls}
- ${connectionBadgesHtml}
-
+ `; 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;