96 lines
8.2 KiB
TypeScript
96 lines
8.2 KiB
TypeScript
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;
|
||
}
|