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

177 lines
7.4 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 avatar(item, className = "avatar") {
const url = coverUrl(item?.up_face || item?.avatar);
const name = escapeHtml(item?.up_name || "UP");
if (!url) {
const initial = String(item?.up_name || "U").trim().slice(0, 1).toUpperCase() || "U";
return `<div class="${className} avatar-placeholder" aria-hidden="true">${escapeHtml(initial)}</div>`;
}
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${name}"
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
export function videoCard(item, {
rank = null, primary = "view", primaryLabel = "", primaryText = null,
showStatus = false,
} = {}) {
const metrics = item.metrics || {};
const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary);
const primaryValue = primaryText !== null
? primaryText
: (hasMetric ? metrics[primary] : item[primary]);
const label = primaryLabel || metricLabel(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>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
</span>
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</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, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null,
secondaryLabel = "总播放", secondaryText = null,
} = {}) {
const value = primaryText !== null ? primaryText : item[primary];
const secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views);
const hours = item.content_hours;
const follower = item.follower_latest;
const followerText = follower === null || follower === undefined
? "粉丝 —"
: `粉丝 ${formatCount(follower)}`;
return `
<a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)}
${avatar(item, "up-avatar")}
<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>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
<span>${followerText}</span>
</div>
<div class="video-stats">
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
<span class="secondary-stat">${escapeHtml(secondaryLabel)} ${escapeHtml(secondary)}</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 mediaCard(item, { rank = null, meta = "", highlight = "" } = {}) {
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>` : ""}
${highlight ? `<span class="media-highlight">${highlight}</span>` : ""}
</a>`;
}
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>`;
}