feat(ui): densify home and ranking video cards

Drop nested home panels, overlay rank and duration on covers, and keep
metrics fully visible by moving dates onto the author row.
This commit is contained in:
kairee committed 2026-11-02 12:19:30 +08:00
1 parent 2641ba6f87
commit 374b138041
3 files changed
+165 -86

No files matched your search

+33 -15
View File
@@ -13,10 +13,10 @@ export function cover(item, className = "thumb") {
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
export function rankBadge(rank) {
export function rankBadge(rank, className = "rank-badge") {
if (!rank) return "";
const top = rank <= 3 ? ` rank-${rank}` : "";
return `<span class="rank-badge${top}">${rank}</span>`;
return `<span class="${className}${top}">${rank}</span>`;
}
export function avatar(item, className = "avatar") {
@@ -51,19 +51,25 @@ export function videoCard(item, {
const upLink = upMid ? `<span class="up-name" data-up-mid="${upMid}">${up}</span>` : up;
const status = showStatus && item.status
? `<span class="badge status-${escapeAttr(item.status)}">${escapeHtml(statusLabel(item.status))}</span>` : "";
const duration = formatDuration(item.duration);
const durationBadge = duration === "—"
? ""
: `<span class="cover-duration">${escapeHtml(duration)}</span>`;
return `
<a class="video-card${rank ? "" : " no-rank"}" href="/video/${item.aid}" data-aid="${item.aid}">
<a class="video-card" href="/video/${item.aid}" data-aid="${item.aid}">
${rankBadge(rank)}
${cover(item, "video-thumb")}
<div class="video-cover">
${cover(item, "video-thumb")}
${durationBadge}
</div>
<div class="video-card-body">
<h3 class="video-title">${title}</h3>
<div class="video-meta">
<span>${upLink}</span>
<span>${escapeHtml(formatDate(item.pubdate))}</span>
<span>${escapeHtml(formatDuration(item.duration))}</span>
${status}
<div class="video-byline">
<div class="video-up">${upLink}</div>
<span class="video-date">${escapeHtml(formatDate(item.pubdate))}</span>
</div>
<div class="video-stats">
${status}
<span class="primary-stat">
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
</span>
@@ -88,7 +94,9 @@ export function upCard(item, {
return `
<a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)}
${avatar(item, "up-avatar")}
<div class="up-cover">
${avatar(item, "up-avatar")}
</div>
<div class="up-card-body">
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
<div class="video-meta">
@@ -120,12 +128,22 @@ export function metricGrid(item) {
</div>`;
}
export function mediaCard(item, { rank = null, meta = "", highlight = "" } = {}) {
export function mediaCard(item, { rank = null, highlight = "" } = {}) {
const duration = formatDuration(item.duration);
const durationBadge = duration === "—"
? ""
: `<span class="cover-duration">${escapeHtml(duration)}</span>`;
return `<a class="media-card" href="/video/${item.aid}" data-aid="${item.aid}">
${rank === null || rank === undefined ? "" : `<span class="media-rank">${escapeHtml(String(rank))}</span>`}
${cover(item, "media-thumb")}
<strong class="media-title">${escapeHtml(item.title || "(无标题)")}</strong>
${meta ? `<span class="media-meta">${meta}</span>` : ""}
${rankBadge(rank)}
<div class="media-cover">
${cover(item, "media-thumb")}
${durationBadge}
</div>
<span class="media-title">${escapeHtml(item.title || "(无标题)")}</span>
<span class="media-byline">
<span class="media-up">${escapeHtml(item.up_name || "未知 UP")}</span>
<span class="media-date">${escapeHtml(formatDate(item.pubdate))}</span>
</span>
${highlight ? `<span class="media-highlight">${highlight}</span>` : ""}
</a>`;
}
+121 -58
View File
@@ -297,6 +297,7 @@ h1, h2, h3, strong, b { font-weight: 600; }
margin-bottom: 8px;
}
.section-head h2 { font-size: 16px; font-weight: 600; }
.home-block { min-width: 0; }
.section-note { color: var(--muted); font-size: 12px; margin: 0 0 12px; }
.warning-text { color: var(--warning); }
.danger { color: var(--danger); }
@@ -343,8 +344,7 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
.video-card, .up-card {
position: relative;
display: grid;
grid-template-columns: 24px 96px minmax(0, 1fr);
align-items: start;
align-items: stretch;
gap: 12px;
padding: 10px;
border: 1px solid var(--border);
@@ -353,50 +353,75 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
transition: border-color 0.15s cubic-bezier(0.22, 1, 0.36, 1),
background-color 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
.video-card { grid-template-columns: 128px minmax(0, 1fr); }
.up-card { grid-template-columns: 64px minmax(0, 1fr); align-items: center; }
.video-card:hover, .up-card:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 5%, var(--surface));
}
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 96px minmax(0, 1fr); }
.video-card.no-rank .video-thumb, .up-card.no-rank .video-thumb { grid-column: 1; }
.video-card.no-rank .video-card-body, .up-card.no-rank .up-card-body { grid-column: 2; }
.video-cover, .media-cover, .up-cover { position: relative; overflow: hidden; }
.video-cover, .media-cover { border-radius: 6px; }
.up-cover { width: 64px; height: 64px; }
.rank-badge {
grid-column: 1;
grid-row: 1;
align-self: start;
position: absolute;
z-index: 1;
top: 0;
left: 0;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: transparent;
color: var(--muted);
min-width: 28px;
height: 22px;
padding: 0 7px;
border-radius: inherit;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
background: rgba(0, 0, 0, 0.54);
color: #fff;
font-size: 13px;
font-weight: 600;
line-height: 1;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
}
.rank-badge.rank-1 {
background: color-mix(in srgb, var(--accent) 70%, transparent);
color: #fff;
}
.cover-duration {
position: absolute;
z-index: 1;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 11px;
font-weight: 400;
line-height: 1;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
}
.rank-badge.rank-1 { background: var(--accent); color: var(--accent-contrast); }
.rank-badge.rank-2, .rank-badge.rank-3 { background: var(--surface-2); color: var(--text); }
.video-thumb, .hero-cover, .media-thumb, .candidate-thumb {
position: relative;
overflow: hidden;
border-radius: 6px;
background: var(--surface-2);
}
.video-thumb {
grid-column: 2;
grid-row: 1;
width: 96px;
aspect-ratio: 16 / 9;
}
.video-thumb { width: 100%; aspect-ratio: 16 / 9; }
.video-thumb img, .hero-cover img, .media-thumb img, .candidate-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-placeholder { display: grid; place-items: center; color: var(--muted); font-size: 11px; }
.cover-placeholder img.image-error { opacity: 0; }
.video-card-body, .up-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
.video-card-body, .up-card-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.up-avatar {
grid-column: 2;
grid-row: 1;
justify-self: center;
align-self: center;
width: 64px;
height: 64px;
border-radius: 50%;
@@ -409,7 +434,6 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
font-weight: 600;
}
.up-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.up-card.no-rank > .up-avatar { grid-column: 1; }
.avatar-placeholder { text-transform: uppercase; }
.video-title {
display: -webkit-box;
@@ -420,8 +444,26 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
line-height: 1.35;
font-weight: 600;
}
/* 标题固定占两行高度(line-height 1.35 × 2),让单行与两行标题的行高一致。 */
.video-card .video-title, .media-title { min-height: 2.7em; }
/* 标题固定占两行高度,让单行与两行标题的卡片高度一致。 */
.video-card .video-title { min-height: 2.7em; }
.media-title { min-height: 2.8em; }
.video-byline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
min-width: 0;
}
.video-up {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 12px;
}
.video-meta {
display: flex;
flex-wrap: wrap;
@@ -434,12 +476,24 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
gap: 4px 8px;
margin-top: 6px;
color: var(--muted);
font-size: 11px;
min-width: 0;
}
.primary-stat { display: inline-flex; align-items: baseline; gap: 4px; color: var(--text); }
.video-card .video-stats {
flex-wrap: nowrap;
margin-top: auto;
padding-top: 8px;
}
.video-date {
flex: 0 0 auto;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.primary-stat { display: inline-flex; flex: 0 0 auto; align-items: baseline; gap: 4px; color: var(--text); white-space: nowrap; }
.primary-stat b { font-size: 15px; font-weight: 600; }
.primary-stat small { color: var(--muted); font-size: 11px; }
.secondary-stat { white-space: nowrap; }
@@ -451,12 +505,13 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
/* 统一的响应式媒体网格:移动端 2 列(图片自动缩放),PC 端 3 列。 */
.media-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@media (min-width: 900px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
/* 统一的竖排媒体卡:图 + 两行截断标题 + 作者/数据行 + 高亮数据行。 */
/* 竖排媒体卡:封面(时长)+ 两行标题 + 单行 UP + 日期/指标底栏。 */
.media-card {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
height: 100%;
gap: 8px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 16px;
@@ -468,33 +523,45 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 5%, var(--surface));
}
.media-rank {
position: absolute;
top: 10px;
left: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.media-thumb { width: 100%; aspect-ratio: 16 / 9; }
.media-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
line-height: 1.35;
font-size: 12px;
line-height: 1.4;
font-weight: 400;
}
.media-byline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.media-up {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 11px;
}
.media-date {
flex: 0 0 auto;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.media-highlight {
margin-top: auto;
color: var(--accent-strong);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.media-meta { color: var(--muted); font-size: 11px; }
.media-highlight { color: var(--accent-strong); font-size: 11px; font-weight: 600; }
.status-grid {
display: grid;
@@ -928,9 +995,7 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
.up-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.detail-hero { flex-direction: row; align-items: flex-start; }
.hero-cover { width: 300px; flex: 0 0 300px; }
.video-thumb { width: 112px; }
.video-card, .up-card { grid-template-columns: 24px 112px minmax(0, 1fr); }
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 112px minmax(0, 1fr); }
.video-card { grid-template-columns: 144px minmax(0, 1fr); }
.secondary-stat.optional { display: inline; }
}
@@ -983,9 +1048,7 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
}
@media (max-width: 380px) {
.video-card, .up-card { grid-template-columns: 20px 80px minmax(0, 1fr); gap: 8px; }
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 80px minmax(0, 1fr); }
.video-thumb { width: 80px; }
.video-card { grid-template-columns: 104px minmax(0, 1fr); gap: 8px; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.candidate-card { grid-template-columns: 80px minmax(0, 1fr); }
.candidate-thumb { width: 80px; }
+11 -13
View File
@@ -1,8 +1,8 @@
import { api } from "../api.js";
import { emptyState, mediaCard } from "../components.js";
import {
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
formatDuration, formatPercent, formatScore,
escapeAttr, escapeHtml, formatCount, formatDelta,
formatPercent, formatScore,
} from "../format.js";
import { canAccessProtected } from "../session.js";
@@ -19,7 +19,6 @@ function heatGrid(items) {
return `<div class="media-grid">
${items.map((item, index) => mediaCard(item, {
rank: index + 1,
meta: `${escapeHtml(item.up_name || "未知 UP")} · ${formatCount(item.metrics?.view)} 播放`,
highlight: `热度 ${formatScore(item.heat)} · 前 ${Math.max(0, 100 - (item.heat_percentile || 0)).toFixed(0)}%`,
})).join("")}
</div>`;
@@ -27,19 +26,18 @@ function heatGrid(items) {
function publishedCard(item) {
return mediaCard(item, {
meta: `${escapeHtml(item.up_name || "未知 UP")} · ${escapeHtml(formatDate(item.pubdate))} · ${escapeHtml(formatDuration(item.duration))}`,
highlight: `${formatCount(item.metrics?.view)} 播放 · 互动 ${formatPercent(item.interaction_rate)}`,
});
}
function growthCard(item, index) {
const delta = item.growth?.delta;
const deltaText = delta === null || delta === undefined ? "" : ` · ${formatDelta(delta)}`;
return mediaCard(item, {
rank: index + 1,
meta: `${escapeHtml(item.up_name || "未知 UP")} · ${escapeHtml(formatDate(item.pubdate))} · ${escapeHtml(formatDuration(item.duration))}`,
highlight: `${formatCount(item.metrics?.view)} 播放${deltaText}`,
});
let highlight = `${formatCount(item.metrics?.view)} 播放`;
if (delta !== null && delta !== undefined) {
const tone = Number(delta) >= 0 ? "up" : "down";
highlight += ` <span class="${tone}">${formatDelta(delta)}</span>`;
}
return mediaCard(item, { rank: index + 1, highlight });
}
export async function renderHome(root) {
@@ -96,7 +94,7 @@ export async function renderHome(root) {
<span>请在 Telegram 中处理 →</span>
</a>` : ""}
<section class="panel">
<section class="home-block">
<div class="section-head">
<h2>最近发布</h2>
<a class="text-link" href="/videos?sort=published&within_days=30">查看全部</a>
@@ -108,7 +106,7 @@ export async function renderHome(root) {
</div>
</section>
<section class="panel">
<section class="home-block">
<div class="section-head">
<h2>增长动态 · ${escapeHtml(growthLabel)}</h2>
<a class="text-link" href="/videos?sort=growth&growth_days=${growthDays}">增长榜</a>
@@ -118,7 +116,7 @@ export async function renderHome(root) {
: emptyState(growthEmptyTitle, growthEmptyHint)}
</section>
<section class="panel">
<section class="home-block">
<div class="section-head">
<h2>热度速览</h2>
<a class="text-link" href="/videos?sort=heat">完整榜单</a>