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