Files
web_hgcg_h5/src/lib/client/detail.ts
T
2026-10-27 17:39:27 +07:00

96 lines
8.2 KiB
TypeScript
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 { contentPath, contentSegment } from '../routing';
import type { Chapter, DramaDetail } from "../api/types";
import { formatCount } from "../api/protocol";
import { escape, imageAttributes, title } from "./render";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_ORIGIN, SITE_URL } from '../site';
// Original HTML action icons, kept as SVG paths so active colors follow the stylesheet.
const actionPaths = {
"save": "M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z",
"like": "M855.738182 198.283636A252.043636 252.043636 0 0 0 670.254545 116.363636a225.28 225.28 0 0 0-160.814545 63.069091 248.32 248.32 0 0 0-152.901818-53.294545A251.578182 251.578182 0 0 0 171.287273 209.454545 287.883636 287.883636 0 0 0 162.909091 588.567273l256 275.083636a122.181818 122.181818 0 0 0 90.065454 39.563636 121.716364 121.716364 0 0 0 89.832728-39.563636l256-272.989091 2.792727-3.723636a290.443636 290.443636 0 0 0-1.861818-388.654546z",
"share": "M910.661818 117.992727a114.734545 114.734545 0 0 0-113.803636-16.756363L134.050909 363.752727a138.472727 138.472727 0 0 0-15.592727 250.181818l97.978182 54.225455 1.861818 162.909091a87.272727 87.272727 0 0 0 137.309091 69.818182L482.210909 814.545455l180.363636 99.374545a138.472727 138.472727 0 0 0 203.869091-101.236364L954.181818 224.814545a113.803636 113.803636 0 0 0-43.52-106.821818z",
"phone": "M756.363636 69.818182h-488.727272A128.232727 128.232727 0 0 0 139.636364 197.818182v628.363636a128.232727 128.232727 0 0 0 128 128h488.727272a128 128 0 0 0 128-128v-628.363636A128 128 0 0 0 756.363636 69.818182z m-139.636363 698.181818h-209.454546a34.909091 34.909091 0 0 1 0-69.818182h209.454546a34.909091 34.909091 0 0 1 0 69.818182z"
} as const;
export function detailActionIcon(kind: keyof typeof actionPaths): string {
return `<svg class="play-action-icon" viewBox="0 0 1024 1024" aria-hidden="true"><path d="${actionPaths[kind]}"/></svg>`;
}
/** Genre and tag names as internal links when the API supplies their identities. */
export function genreLinks(drama: DramaDetail): string {
const useTags = Array.isArray(drama.tagNames);
const names = (useTags ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []) || [];
return names.map((name, i) => {
if (typeof name !== 'string' || !name.trim()) return '';
const identity = {id:(useTags ? drama.tagIds : drama.themeIds)?.[i], slug:(useTags ? drama.tagSlugs : drama.themeSlugs)?.[i]};
return contentSegment(identity) ? `<a href="${contentPath(useTags ? 'tag' : 'topics', identity)}">${escape(name)}</a>` : `<span>${escape(name)}</span>`;
}).filter(Boolean).join('');
}
/** Labelled facts in a description list so the episode count, genres and engagement are explicit page content. */
export function detailFacts(drama: DramaDetail, episodes: number, counts: {countCollect?: number; countLike?: number} = drama): string {
const genres = genreLinks(drama);
return `<dl class="hg-facts">${genres ? `<div><dt>类型</dt><dd class="hg-facts-links">${genres}</dd></div>` : ''}<div><dt>集数</dt><dd>全${episodes}集</dd></div><div><dt>热度</dt><dd>${formatCount(drama.heatValue)}</dd></div><div><dt>收藏</dt><dd data-engage-count="collect" aria-live="polite">${formatCount(counts.countCollect)}收藏</dd></div><div><dt>点赞</dt><dd data-engage-count="like" aria-live="polite">${formatCount(counts.countLike)}点赞</dd></div></dl>`;
}
export function synopsisMarkup(drama: DramaDetail, heading = '剧情简介'): string {
const text = drama.desc || drama.description;
return `<section class="hg-synopsis" aria-labelledby="hg-synopsis-title"><h2 id="hg-synopsis-title">${escape(heading)}</h2><div class="series-description-wrap"><p class="series-description" id="series-description-text">${text ? escape(text) : '暂无简介'}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div></section>`;
}
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string, episodes = drama.totalEpisode || 0): string {
const name = title(drama);
const badge = drama.isFeatured ? '<span class="hg-detail-badge hg-detail-badge--hot">爆剧</span>' : drama.isNew ? '<span class="hg-detail-badge hg-detail-badge--new">新剧</span>' : '';
return `<div class="hg-detail">
<div class="hg-detail-poster"><img class="series-poster" ${imageAttributes(drama.cover || drama.coverImg)} width="640" height="914" alt="《${escape(name)}》海报">${badge}</div>
<div class="hg-detail-head">
<h1 id="play-detail-title">${escape(name)}</h1>
<p class="hg-detail-lead">《${escape(name)}》全${episodes}集在线观看</p>
${detailFacts(drama, episodes)}
<div class="hg-detail-actions">
${first ? `<a class="primary detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>播放第1集</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
<a class="series-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><svg class="series-download-phone" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App</span></a>
${actions}
</div>
</div>
</div>
${synopsisMarkup(drama, `《${name}》剧情简介`)}`;
}
/** Fixed site address and contact; configured mirrors and social links are not shown. */
export function officialLinks(): string {
const external = (url: string, label = url) => `<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)}</a>`;
return `<section class="play-official" aria-label="官方链接">
<div class="play-official-line play-official-inline"><span>官方地址:</span>${external(SITE_URL,SITE_ORIGIN)}</div>
<div class="play-official-line play-official-inline"><span>官方TG:</span>${external(OFFICIAL_TG_URL,OFFICIAL_TG_HANDLE)}</div>
</section>`;
}
export function bindSeriesDescription(root: HTMLElement, signal: AbortSignal): () => void {
const text = root.querySelector<HTMLElement>('#series-description-text');
const toggle = root.querySelector<HTMLButtonElement>('.series-description-toggle');
if (!text || !toggle) return () => {};
const update = () => {
if (signal.aborted || !text.clientWidth) return;
const lineHeight = Number.parseFloat(getComputedStyle(text).lineHeight) || 26.6;
const overflow = text.scrollHeight > lineHeight*3 + 1;
toggle.hidden = !overflow;
if (!overflow) {
text.classList.remove('is-expanded');
toggle.setAttribute('aria-expanded','false');toggle.textContent = '展开';
}
};
toggle.addEventListener('click',() => {
const expanded = toggle.getAttribute('aria-expanded') !== 'true';
text.classList.toggle('is-expanded',expanded);
toggle.setAttribute('aria-expanded',String(expanded));toggle.textContent = expanded ? '收起' : '展开';
},{signal});
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
observer?.observe(text);
if (!observer) window.addEventListener('resize',update,{signal});
const dispose = () => observer?.disconnect();
signal.addEventListener('abort',dispose,{once:true});
update();
return dispose;
}