diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 1cf66059..9549e477 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2951,6 +2951,68 @@ background-color: currentColor; } + /* #37: channel playlists section — a responsive row of playlist cards. */ + .playlist-grid { + @apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4; + gap: var(--space-md); + } + + .playlist-card { + display: flex; + flex-direction: column; + gap: var(--space-2xs); + text-decoration: none; + transition: transform 0.12s ease-out; + } + + .playlist-card:hover { + transform: translateY(-2px); + } + + .playlist-card__thumb { + position: relative; + display: flex; + align-items: flex-end; + justify-content: flex-end; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: var(--radius-sm); + background-color: var(--color-page-alt); + border: var(--border-width-hairline) solid var(--color-page-alt); + } + + .playlist-card__image { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + object-fit: cover; + } + + .playlist-card__count { + position: relative; + margin: var(--space-2xs); + border-radius: var(--radius-sm); + padding-block: var(--space-3xs); + padding-inline: var(--space-2xs); + font-size: var(--font-size-2xs); + font-weight: 500; + color: var(--color-text-strong); + background-color: var(--color-page); + } + + .playlist-card__title { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + /* #43: always-visible active-identity pill. Rendered after the header in flow and pulled up with a negative block-start margin so it sits half-on / half-off the header divider — every pane shows who you're acting as. */ diff --git a/js/channelPlaylists.js b/js/channelPlaylists.js new file mode 100644 index 00000000..971670fc --- /dev/null +++ b/js/channelPlaylists.js @@ -0,0 +1,128 @@ +// js/channelPlaylists.js +// +// Channel-page "Playlists" section (#37). Fetches the creator's playlists and +// renders a row of cards linking to the playlist view. Kept out of +// channelProfile.js (that file is at its size cap) and wired in via a one-line +// hook, mirroring channelZapTotal.js. Gated by FEATURE_PLAYLISTS — with the flag +// off (default) it renders nothing and does no fetch, so it's invisible until +// the whole playlist UI ships. + +import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js"; +import { FEATURE_PLAYLISTS } from "./constants.js"; +import { devLogger } from "./utils/logger.js"; + +let currentToken = 0; + +function clearChildren(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +function playlistHref(playlist) { + const params = new URLSearchParams({ + view: "playlist", + pubkey: playlist.pubkey, + id: playlist.id, + }); + return `#${params.toString()}`; +} + +function renderPlaylistCard(doc, playlist) { + const card = doc.createElement("a"); + card.href = playlistHref(playlist); + card.className = "playlist-card"; + card.setAttribute( + "aria-label", + `Playlist: ${playlist.title} (${playlist.items.length} videos)`, + ); + + const thumb = doc.createElement("div"); + thumb.className = "playlist-card__thumb"; + if (playlist.image) { + const img = doc.createElement("img"); + img.src = playlist.image; + img.alt = ""; + img.loading = "lazy"; + img.referrerPolicy = "no-referrer"; + img.className = "playlist-card__image"; + thumb.appendChild(img); + } + const count = doc.createElement("span"); + count.className = "playlist-card__count"; + const n = playlist.items.length; + count.textContent = `${n} ${n === 1 ? "video" : "videos"}`; + thumb.appendChild(count); + card.appendChild(thumb); + + const title = doc.createElement("p"); + title.className = "playlist-card__title"; + title.textContent = playlist.title; + card.appendChild(title); + + return card; +} + +export function teardownChannelPlaylists() { + currentToken += 1; +} + +/** + * Populate the channel "Playlists" section for a creator. Hidden when the flag + * is off, the container is missing, or the creator has no playlists. + * @param {string} pubkey the channel's hex pubkey + * @param {{ document?: Document, client?: any }} [opts] + */ +export async function wireChannelPlaylists( + pubkey, + { document: doc = globalThis.document, client } = {}, +) { + teardownChannelPlaylists(); + const token = currentToken; + + const section = + doc && typeof doc.getElementById === "function" + ? doc.getElementById("channelPlaylistsSection") + : null; + const grid = doc?.getElementById?.("channelPlaylistsGrid") || null; + if (!section || !grid) { + return; + } + + const hide = () => section.classList.add("hidden"); + const show = () => section.classList.remove("hidden"); + + const hex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!FEATURE_PLAYLISTS || !hex) { + hide(); + return; + } + + let playlists = []; + try { + playlists = await fetchCreatorPlaylists(hex, client ? { client } : {}); + } catch (error) { + devLogger.warn("[channelPlaylists] Failed to load playlists:", error); + playlists = []; + } + + // A newer navigation (channel switch / teardown) superseded this fetch. + if (token !== currentToken) { + return; + } + + if (!playlists.length) { + hide(); + return; + } + + clearChildren(grid); + const frag = doc.createDocumentFragment(); + for (const playlist of playlists) { + frag.appendChild(renderPlaylistCard(doc, playlist)); + } + grid.appendChild(frag); + show(); +} + +export default wireChannelPlaylists; diff --git a/js/channelProfile.js b/js/channelProfile.js index 5c476341..2da3d44a 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -3,6 +3,7 @@ import { nostrClient } from "./nostrClientFacade.js"; import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js"; import { wireChannelZapTotal } from "./channelZapTotal.js"; +import { wireChannelPlaylists } from "./channelPlaylists.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5231,6 +5232,9 @@ function applyChannelProfileMetadata({ // #47: total sats zapped to this creator (p: pointer in the store). wireChannelZapTotal(currentChannelHex); + + // #37: creator playlists section (no-op unless FEATURE_PLAYLISTS is on). + wireChannelPlaylists(currentChannelHex); } async function fetchChannelProfileFromRelays(pubkey) { diff --git a/js/constants.js b/js/constants.js index 13e7d0f4..e65dbb65 100644 --- a/js/constants.js +++ b/js/constants.js @@ -201,6 +201,7 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab. FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab. FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false. + FEATURE_PLAYLISTS: false, // Creator playlists (#37): channel section + playlist view + add-to-playlist. Default OFF until the UI is complete. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, @@ -226,6 +227,7 @@ const runtimeFlags = (() => { FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED, FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED, FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY, + FEATURE_PLAYLISTS: DEFAULT_FLAGS.FEATURE_PLAYLISTS, TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], @@ -281,6 +283,11 @@ export let FEATURE_ZAP_TALLY = coerceBoolean( DEFAULT_FLAGS.FEATURE_ZAP_TALLY ); +export let FEATURE_PLAYLISTS = coerceBoolean( + runtimeFlags.FEATURE_PLAYLISTS, + DEFAULT_FLAGS.FEATURE_PLAYLISTS +); + export let FEATURE_TRUST_SEEDS = coerceBoolean( runtimeFlags.FEATURE_TRUST_SEEDS, DEFAULT_FLAGS.FEATURE_TRUST_SEEDS diff --git a/views/channel-profile.html b/views/channel-profile.html index 76d68571..d73fcf3c 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -156,6 +156,15 @@
+ + +

Videos by This User