mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
playlists(#37): restyle playlist cards (stacked cover + themed scrim) + back button
Design polish from feedback: - Playlist cards are now a "stack of clips" cover: the first video's thumbnail fills a 16:9 tile with two offset/rotated layers peeking behind it (::before/ ::after). A themed gradient scrim (accent-strong→accent→clear, via color-mix) darkens the base so the title sits BOLD and WHITE over the cover instead of small grey text underneath. Video count moved to a pill in the top-right. channelPlaylists.js resolves the cover from the app's active videos (buildVideoAddressPointer → video.thumbnail), falling back to playlist.image. - Back button on the playlist view: was a bare "← Back to channel" text link, now a proper .btn-ghost (sm) pill with a chevron icon — matches the site's buttons and is theme-aware. - tokens.css: added --size-4px/--size-5px for the stack offsets (token lint). Scrim/title use --color-white (always light — the scrim is always dark regardless of theme). Lint + build clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
a2e26b00b9
commit
f46b1ce7f3
4 files changed
+139
-31
No files matched your search
+73
-23
@@ -2951,34 +2951,62 @@
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
/* #37: channel playlists section — a responsive row of playlist cards. */
|
||||
/* #37: channel playlists — each card is a stacked "pile of clips" cover with a
|
||||
themed gradient scrim and the title set bold over it. */
|
||||
.playlist-grid {
|
||||
@apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4;
|
||||
gap: var(--space-md);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease-out;
|
||||
/* leave room for the offset stack layers to peek out */
|
||||
margin-block-start: var(--space-2xs);
|
||||
margin-inline-end: var(--space-xs);
|
||||
transition: transform 0.14s ease-out;
|
||||
}
|
||||
|
||||
/* Two offset layers behind the cover read as a stack of videos. */
|
||||
.playlist-card::before,
|
||||
.playlist-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-page-alt);
|
||||
border: var(--border-width-hairline) solid var(--color-overlay-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.playlist-card::before {
|
||||
transform: translate(var(--size-5px), calc(-1 * var(--size-5px)))
|
||||
rotate(2.5deg);
|
||||
}
|
||||
|
||||
.playlist-card::after {
|
||||
transform: translate(calc(-1 * var(--size-4px)), calc(-1 * var(--size-3px)))
|
||||
rotate(-2deg);
|
||||
}
|
||||
|
||||
.playlist-card__thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-page-alt);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.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:hover .playlist-card__thumb {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.playlist-card__image {
|
||||
@@ -2989,23 +3017,45 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Themed scrim: accent-tinted dark at the base fading to clear, so the bold
|
||||
title reads over any cover (always dark, so the title is always white). */
|
||||
.playlist-card__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--color-accent-strong) 62%, black) 0%,
|
||||
color-mix(in srgb, var(--color-accent) 26%, transparent) 45%,
|
||||
transparent 74%
|
||||
);
|
||||
}
|
||||
|
||||
.playlist-card__count {
|
||||
position: relative;
|
||||
margin: var(--space-2xs);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-block-start: var(--space-2xs);
|
||||
inset-inline-end: 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);
|
||||
font-weight: 600;
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-overlay-strong);
|
||||
}
|
||||
|
||||
.playlist-card__title {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-inline: 0;
|
||||
inset-block-end: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-white);
|
||||
text-shadow: 0 var(--size-2px) var(--size-3px) var(--color-overlay-black);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
|
||||
@@ -301,6 +301,8 @@
|
||||
/* Measurement primitives */
|
||||
--size-2px: 2px;
|
||||
--size-3px: 3px;
|
||||
--size-4px: 4px;
|
||||
--size-5px: 5px;
|
||||
--size-12px: 12px;
|
||||
--size-24px: 24px;
|
||||
--size-30px: 30px;
|
||||
|
||||
+48
-6
@@ -11,6 +11,8 @@
|
||||
// playlist, instead of needing a manual page reload.
|
||||
|
||||
import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js";
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import { buildVideoAddressPointer } from "./utils/videoPointer.js";
|
||||
import { FEATURE_PLAYLISTS } from "./constants.js";
|
||||
import { devLogger } from "./utils/logger.js";
|
||||
|
||||
@@ -35,7 +37,38 @@ function playlistHref(playlist) {
|
||||
return `#${params.toString()}`;
|
||||
}
|
||||
|
||||
function renderPlaylistCard(doc, playlist) {
|
||||
// coord -> thumbnail URL, from the app's active videos, so a playlist can show
|
||||
// its first video's thumbnail as the cover.
|
||||
function buildThumbnailMap() {
|
||||
const map = new Map();
|
||||
let active = [];
|
||||
try {
|
||||
active = nostrClient.getActiveVideos() || [];
|
||||
} catch (error) {
|
||||
active = [];
|
||||
}
|
||||
for (const video of active) {
|
||||
const coordinate = buildVideoAddressPointer(video);
|
||||
if (coordinate && typeof video.thumbnail === "string" && video.thumbnail) {
|
||||
map.set(coordinate, video.thumbnail);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function coverFor(playlist, thumbnails) {
|
||||
for (const item of playlist.items) {
|
||||
if (item.type === "a") {
|
||||
const thumb = thumbnails.get(item.value);
|
||||
if (thumb) {
|
||||
return thumb;
|
||||
}
|
||||
}
|
||||
}
|
||||
return playlist.image || "";
|
||||
}
|
||||
|
||||
function renderPlaylistCard(doc, playlist, coverUrl) {
|
||||
const card = doc.createElement("a");
|
||||
card.href = playlistHref(playlist);
|
||||
card.className = "playlist-card";
|
||||
@@ -46,27 +79,33 @@ function renderPlaylistCard(doc, playlist) {
|
||||
|
||||
const thumb = doc.createElement("div");
|
||||
thumb.className = "playlist-card__thumb";
|
||||
if (playlist.image) {
|
||||
|
||||
if (coverUrl) {
|
||||
const img = doc.createElement("img");
|
||||
img.src = playlist.image;
|
||||
img.src = coverUrl;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
img.className = "playlist-card__image";
|
||||
thumb.appendChild(img);
|
||||
}
|
||||
|
||||
const scrim = doc.createElement("div");
|
||||
scrim.className = "playlist-card__scrim";
|
||||
thumb.appendChild(scrim);
|
||||
|
||||
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);
|
||||
thumb.appendChild(title);
|
||||
|
||||
card.appendChild(thumb);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -111,9 +150,12 @@ async function refresh() {
|
||||
}
|
||||
|
||||
clearChildren(grid);
|
||||
const thumbnails = buildThumbnailMap();
|
||||
const frag = doc.createDocumentFragment();
|
||||
for (const playlist of playlists) {
|
||||
frag.appendChild(renderPlaylistCard(doc, playlist));
|
||||
frag.appendChild(
|
||||
renderPlaylistCard(doc, playlist, coverFor(playlist, thumbnails)),
|
||||
);
|
||||
}
|
||||
grid.appendChild(frag);
|
||||
section.classList.remove("hidden");
|
||||
|
||||
+16
-2
@@ -5,9 +5,23 @@
|
||||
<a
|
||||
href="#view=most-recent-videos"
|
||||
id="playlistBackLink"
|
||||
class="text-sm text-info-strong hover:underline"
|
||||
>← Back to channel</a
|
||||
class="btn-ghost"
|
||||
data-size="sm"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
Back to channel
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h2 id="playlistTitle" class="text-xl font-semibold text-text">Playlist</h2>
|
||||
|
||||
Reference in new issue
Block a user