Files
re-xxm/reaction_board/web/static/components.js
T

147 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
formatDelta, formatDuration, formatPercent, metricLabel, statusLabel,
} from "./format.js";
export function cover(item, className = "thumb") {
const url = coverUrl(item.cover);
const title = escapeAttr(item.title || "视频封面");
if (!url) {
return `<div class="${className} cover-placeholder" aria-hidden="true">无封面</div>`;
}
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${title}"
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
export function rankBadge(rank) {
if (!rank) return "";
const top = rank <= 3 ? ` rank-${rank}` : "";
return `<span class="rank-badge${top}">${rank}</span>`;
}
export function videoCard(item, { rank = null, primary = "view", showStatus = false } = {}) {
const metrics = item.metrics || {};
const primaryValue = metrics[primary];
const growth = item.growth;
const delta = growth?.available ? growth.delta : null;
const title = escapeHtml(item.title || "(无标题)");
const up = escapeHtml(item.up_name || "未知 UP");
const upMid = item.up_mid;
// 卡片本身已经是 <a>,这里不能再嵌套链接,否则 HTML 解析会截断卡片。
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>` : "";
return `
<a class="video-card${rank ? "" : " no-rank"}" href="/video/${item.aid}" data-aid="${item.aid}">
${rankBadge(rank)}
${cover(item, "video-thumb")}
<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>
<div class="video-stats">
<span class="primary-stat">
<b>${formatCount(primaryValue)}</b><small>${escapeHtml(metricLabel(primary))}</small>
</span>
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</span>` : ""}
<span class="secondary-stat">赞 ${formatCount(metrics.like)}</span>
<span class="secondary-stat optional">币 ${formatCount(metrics.coin)}</span>
<span class="secondary-stat optional">藏 ${formatCount(metrics.favorite)}</span>
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
</div>
</div>
<span class="card-arrow" aria-hidden="true">›</span>
</a>`;
}
export function upCard(item, { rank = null } = {}) {
const best = item.best_video || {};
const displayCover = cover(
{ title: best.title || item.up_name, cover: best.cover },
"video-thumb up-thumb",
);
return `
<a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)}
${displayCover}
<div class="up-card-body">
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
<div class="video-meta">
<span>${formatNumber(item.video_count)} 条视频</span>
<span>均播 ${formatCount(item.avg_views)}</span>
</div>
<div class="video-stats">
<span class="primary-stat"><b>${formatCount(item.total_views)}</b><small>总播放</small></span>
<span class="secondary-stat">总互动 ${formatCount(item.total_interactions)}</span>
<span class="secondary-stat">均互动 ${formatPercent(item.avg_interaction)}</span>
</div>
</div>
<span class="card-arrow" aria-hidden="true">›</span>
</a>`;
}
export function metricGrid(item) {
const metrics = item.metrics || {};
const growth = item.growth;
return `<div class="metric-grid">
${["view", "like", "coin", "favorite", "share", "reply", "danmaku"].map((metric) => {
const extra = metric === "view" && growth?.available
? `<span class="metric-delta ${growth.delta >= 0 ? "up" : "down"}">${formatDelta(growth.delta)}</span>`
: "";
return `<div class="metric-cell">
<span class="metric-label">${escapeHtml(metricLabel(metric))}</span>
<strong>${formatCount(metrics[metric])}</strong>${extra}
</div>`;
}).join("")}
</div>`;
}
export function sectionHeader(title, extra = "") {
return `<div class="section-head"><h2>${escapeHtml(title)}</h2>${extra}</div>`;
}
export function emptyState(title, message = "", action = "") {
return `<div class="empty-state"><strong>${escapeHtml(title)}</strong>
${message ? `<p>${escapeHtml(message)}</p>` : ""}${action}</div>`;
}
export function badge(text, kind = "subtle") {
return `<span class="badge ${kind}">${escapeHtml(text)}</span>`;
}
export function chip(label, active, data, value = "") {
return `<button type="button" class="chip${active ? " active" : ""}" data-${data}="${escapeAttr(value)}">${escapeHtml(label)}</button>`;
}
export function formatNumber(value) {
return value === null || value === undefined ? "—" : Number(value).toLocaleString("zh-CN");
}
export function metricChips(metrics, active, dataName = "metric") {
return metrics.map((metric) => chip(
metricLabel(metric), metric === active, dataName, metric,
)).join("");
}
export function centerActiveChips(root) {
root.querySelectorAll(".chip-row.scroll-x").forEach((row) => {
const active = row.querySelector(".chip.active");
if (!active) return;
const target = active.offsetLeft - row.clientWidth / 2 + active.clientWidth / 2;
row.scrollLeft = Math.max(0, target);
});
}
export function pager(page, pages) {
if (!pages || pages <= 1) return "";
return `<div class="pager">
<button type="button" class="button ghost" data-page="${page - 1}" ${page <= 1 ? "disabled" : ""}>上一页</button>
<span>第 ${page} / ${pages} 页</span>
<button type="button" class="button ghost" data-page="${page + 1}" ${page >= pages ? "disabled" : ""}>下一页</button>
</div>`;
}