[Ai] feat: 花魁短剧 Next.js 站点初始提交

- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { Data } from "@/server/services/model";
import { navIsActive } from "@/lib/view-helpers";
export function MobileTabbar({ data }: { data: Data }) {
const path = data.requestPath;
if (!path) return null;
const active = (url: string) => navIsActive(url, path),
home = active("/"),
short = active("/dramas/"),
series = active("/series/");
if (!(home || short || series)) return null;
return (
<nav className="hk-mobile-tabbar" aria-label="移动端主导航">
{[
["/", "首页", "home", home, "home"],
["/dramas/", "短剧", "film", short, "short"],
["/series/", "剧集", "tv", series, "series"],
].map(([href, label, icon, isActive, key]) => (
<a
key={String(key)}
className={"hk-mobile-tabbar__item" + (isActive ? " hk-is-active" : "")}
href={String(href)}
aria-current={isActive ? "page" : undefined}
data-navigation-key={"mobile_" + key}
data-navigation-name={String(label)}
>
<img
fetchPriority="low"
src={
"/static/images/mobile-tabbar/" +
icon +
"-" +
(isActive ? "active" : "muted") +
".svg"
}
width="22"
height="22"
alt=""
aria-hidden="true"
/>
<span>{label}</span>
</a>
))}
</nav>
);
}
@@ -0,0 +1,26 @@
"use client";
import { useLayoutEffect } from "react";
import { presentations } from "@/server/react/presentation";
const managedBodyClasses = [...new Set(
Object.values(presentations).flatMap(({ bodyClass }) => bodyClass.split(/\s+/).filter(Boolean)),
)];
/** Root layouts persist across client navigation; only page-owned presentation state changes here. */
export function PagePresentation({ view }: { view: string }) {
const presentation = presentations[view] || presentations["404"];
const styleTokens = presentation.styleTokens.join(" ");
const bodyClass = presentation.bodyClass;
useLayoutEffect(() => {
document.documentElement.setAttribute("data-hk-page-styles", styleTokens);
const activeClasses = new Set(bodyClass.split(/\s+/).filter(Boolean));
for (const className of managedBodyClasses)
document.body.classList.toggle(className, activeClasses.has(className));
// No cleanup: an outgoing page must not erase an incoming page's state.
// PWA/age-gate HTML state, temporary body classes and scroll locks have other owners.
}, [view, styleTokens, bodyClass]);
return null;
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import type { Data } from "@/server/services/model";
import { Icon } from "@/components/ui/Icon";
import { QRCode } from "@/components/ui/QRCode";
import { appDownloadURL } from "@/lib/app-download";
import { useSite } from "@/components/providers/SiteProvider";
import { NavigationLink } from "./SiteHeader";
export function SiteFooter({ data }: { data: Data }) {
const site = useSite(),
config = data.config || {},
qrValue = appDownloadURL(data.appConfig) || appDownloadURL(config),
appAddr = qrValue || "/app/download/";
const download = (event: React.MouseEvent) => {
event.preventDefault();
site.openDownload();
};
const nav = (label: string, items: [string, string, string][]) => (
<nav className="hk-footer-col" aria-label={label}>
<h3>{label}</h3>
{items.map(([href, title, key]) => (
<a
key={key}
href={href}
{...(/^https?:/.test(href)
? { target: "_blank", rel: "sponsored nofollow noopener" }
: {})}
data-navigation-key={"footer_" + key}
data-navigation-name={title}
>
{title}
</a>
))}
</nav>
);
const social = [
["brand-x", config.twitterAddr, "X"],
[
"brand-telegram",
config.tgGroup || config.telegram || config.telegramAdd,
"Telegram",
],
].filter(([, url]) => url);
return (
<footer className="hk-site-footer">
<div className="hk-site-footer__inner">
<div className="hk-site-footer__top">
<div className="hk-site-footer__brand">
<div className="hk-brand">
<picture>
<source
media="(max-width:768px)"
srcSet="/static/brand/header.svg"
/>
<img
className="hk-brand__logo hk-brand__logo--lg"
src="/static/brand/footer.svg"
width="124"
height="44"
alt="花魁短剧"
/>
</picture>
</div>
<p className="hk-content-section__subtitle">每一帧都值得细看</p>
<div className="hk-social">
{social.map(([icon, url, label]) => (
<a
className="hk-social__item"
href={url}
key={icon}
target="_blank"
rel="sponsored nofollow noopener"
aria-label={label}
data-navigation-key={"footer_" + label.toLowerCase()}
data-navigation-name={label}
>
<Icon name={icon} />
</a>
))}
</div>
</div>
<div className="hk-site-footer__links">
<nav className="hk-footer-col" aria-label="浏览">
<h3>浏览</h3>
{(data.categoryList || []).map((item: Data, index: number) => (
<NavigationLink key={index} item={item} data={data} />
))}
</nav>
{nav("帮助", [
["/about/", "关于我们", "about"],
["/help/", "常见问题", "help"],
["/return-home/", "回家的路", "home_road"],
["/feedback/", "意见反馈", "feedback"],
[
config.customerServiceHost || "/feedback/",
"联系我们",
"contact",
],
])}
{nav("条款", [
["/terms/", "用户条款", "terms"],
["/privacy/", "隐私政策", "privacy"],
["/2257/", "2257 合规声明", "2257"],
["/dmca/", "DMCA 版权声明", "dmca"],
])}
<a
className="hk-footer-download-mobile"
data-download-open=""
href={appAddr}
onClick={download}
data-navigation-key="footer_download_mobile"
data-navigation-name="下载 App"
>
下载 App
</a>
<div className="hk-footer-download">
<div className="hk-app-download-qr" aria-label="扫码下载">
<QRCode value={qrValue} />
</div>
<div className="hk-footer-download__copy">
<strong>扫码下载 App</strong>
<p>iOS / Android 海量短剧每日更新</p>
<a
className="hk-button hk-button--primary hk-button--sm"
data-download-open=""
href={appAddr}
onClick={download}
data-navigation-key="footer_download"
data-navigation-name="下载 APP"
>
<Icon name="download" className="hk-icon hk-icon--sm" />
下载 App
</a>
</div>
</div>
</div>
</div>
<hr className="hk-site-footer__rule" />
<div className="hk-site-footer__legal">
<p>
© 2026 花魁短剧 HUAKUI 
<span className="hk-site-footer__rights">All rights reserved.</span>
<span className="hk-site-footer__mobile-adult">
仅限 18 岁以上成年人
</span>
</p>
<p>
<span className="hk-site-footer__adult">仅限 18 岁以上成年人 </span>
本网站所呈现之所有人员,于拍摄当时均已年满 18 岁,符合 18 U.S.C. 第
2257 条规定。
</p>
</div>
</div>
</footer>
);
}
+815
View File
@@ -0,0 +1,815 @@
"use client";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type FormEvent,
type MouseEvent,
} from "react";
import type { Data } from "@/server/services/model";
import {
navHref,
navIsActive,
navHasMegaMenu,
navIsDownload,
navDrawerIcon,
listThemeSegment,
listUpdateStatus,
allListURL,
moreListURL,
searchResultsURL,
} from "@/lib/view-helpers";
import {
useSearchHistory,
useSearchSuggestions,
} from "@/features/catalog/search";
import { useSite } from "@/components/providers/SiteProvider";
import { EncryptedImage } from "@/components/media/EncryptedImage";
import { Icon } from "@/components/ui/Icon";
import { useDialog } from "@/components/ui/Dialog";
function external(href: string) {
return /^(https?:\/\/|\/\/)/i.test(href)
? { target: "_blank", rel: "sponsored nofollow noopener" }
: {};
}
export function NavigationLink({
item,
data,
className,
children,
onNavigate,
}: {
item: Data;
data: Data;
className?: string;
children?: React.ReactNode;
onNavigate?: () => void;
}) {
const site = useSite(),
download = navIsDownload(item.urlValue, item.name),
href = download
? data.appConfig?.appAddr || "/app/download/"
: navHref(item.urlValue);
return (
<a
className={className}
href={href}
{...external(href)}
onClick={
download || onNavigate
? (event) => {
if (download) event.preventDefault();
onNavigate?.();
if (download) site.openDownload();
}
: undefined
}
data-download-open={download ? "" : undefined}
data-navigation-key={item.urlValue}
data-navigation-name={item.name}
>
{children || item.name}
</a>
);
}
function navigationChildren(item: Data): Data[] {
return Array.isArray(item.translationList)
? item.translationList.filter(
(child: Data | null) =>
child && typeof child.urlValue === "string" && child.urlValue.trim(),
)
: [];
}
function NavigationSubmenu({
item,
items,
data,
mobile = false,
onNavigate,
}: {
item: Data;
items: Data[];
data: Data;
mobile?: boolean;
onNavigate?: () => void;
}) {
const [open, setOpen] = useState(false),
[popupRight, setPopupRight] = useState(0),
id = useId(),
ref = useRef<HTMLDivElement>(null),
popupRef = useRef<HTMLDivElement>(null),
toggleRef = useRef<HTMLButtonElement>(null),
openedByHover = useRef(false),
hasParentLink =
!!String(item.urlValue || "").trim() && navHref(item.urlValue) !== "#",
active =
(hasParentLink && navIsActive(item.urlValue, data.requestPath)) ||
items.some((child) => navIsActive(child.urlValue, data.requestPath)),
itemClass =
(mobile ? "hk-drawer__nav-item" : "hk-site-navigation__item") +
(active ? " hk-is-active" : ""),
toggleClass = mobile
? "hk-drawer__nav-toggle"
: "hk-site-navigation__submenu-toggle";
useEffect(() => {
if (!open || mobile) return;
const position = () => {
if (!ref.current || !popupRef.current) return;
const right = ref.current.getBoundingClientRect().right,
width = popupRef.current.getBoundingClientRect().width,
left = Math.min(Math.max(right - width, 12), innerWidth - width - 12);
setPopupRight(right - left - width);
};
const outside = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
toggleRef.current?.focus({ preventScroll: true });
setOpen(false);
};
position();
window.addEventListener("resize", position);
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
window.removeEventListener("resize", position);
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [open, mobile]);
const navigate = () => {
if (!mobile) toggleRef.current?.focus({ preventScroll: true });
setOpen(false);
onNavigate?.();
};
const hover = (event: React.PointerEvent<HTMLElement>) => {
if (!mobile && event.pointerType === "mouse" && !open) {
openedByHover.current = true;
setOpen(true);
}
};
const label = (
<>
{mobile && <Icon name={navDrawerIcon(item.urlValue)} />}
<span>{item.name}</span>
</>
);
const toggle = (
<button
ref={toggleRef}
type="button"
className={
(hasParentLink && mobile ? "" : itemClass + " ") +
toggleClass +
(hasParentLink ? " " + toggleClass + "--icon" : "")
}
aria-label={hasParentLink ? item.name + "子导航" : undefined}
aria-expanded={open}
aria-controls={id}
onPointerEnter={hover}
onClick={() => {
if (!mobile && openedByHover.current) {
openedByHover.current = false;
setOpen(true);
} else setOpen((value) => !value);
}}
>
{!hasParentLink && label}
<Icon
name="chevron-right"
className="hk-icon hk-site-navigation__submenu-chevron"
/>
</button>
);
return (
<div
ref={ref}
className={
mobile
? "hk-drawer__nav-group"
: "hk-site-navigation__item-wrap hk-site-navigation__item-wrap--submenu"
}
data-navigation-submenu={mobile ? "mobile" : "desktop"}
data-open={open}
style={
mobile
? undefined
: ({ "--hk-submenu-right": popupRight + "px" } as CSSProperties)
}
onPointerEnter={hover}
onPointerLeave={(event) => {
openedByHover.current = false;
if (
!mobile &&
event.pointerType === "mouse" &&
!event.currentTarget.querySelector(":focus-visible")
) {
setOpen(false);
}
}}
onFocus={(event) => {
if (!mobile && event.target.matches(":focus-visible")) setOpen(true);
}}
onBlur={(event) => {
if (!mobile && !event.currentTarget.contains(event.relatedTarget))
setOpen(false);
}}
>
{hasParentLink ? (
<div
className={
mobile ? "hk-drawer__nav-row" : "hk-site-navigation__submenu-trigger"
}
onPointerEnter={hover}
>
<NavigationLink
item={item}
data={data}
className={itemClass}
onNavigate={navigate}
>
{label}
</NavigationLink>
{toggle}
</div>
) : toggle}
<div
ref={popupRef}
id={id}
className={mobile ? "hk-drawer__submenu" : "hk-site-navigation__submenu"}
style={mobile ? undefined : { right: popupRight }}
role="group"
aria-label={item.name + "子导航"}
aria-hidden={!open}
inert={!open}
hidden={mobile && !open}
>
{items.map((child, index) => (
<NavigationLink
key={child.id || index}
item={child}
data={data}
className={
(mobile
? "hk-drawer__submenu-link"
: "hk-site-navigation__submenu-link") +
(navIsActive(child.urlValue, data.requestPath) ? " hk-is-active" : "")
}
onNavigate={navigate}
/>
))}
</div>
</div>
);
}
function HeaderSearch({ data }: { data: Data }) {
const [query, setQuery] = useState(String(data.query || "")),
[open, setOpen] = useState(false),
[selected, setSelected] = useState(-1);
const { history, remember, clear } = useSearchHistory(),
{ suggestions, loading } = useSearchSuggestions(query),
ref = useRef<HTMLFormElement>(null),
input = useRef<HTMLInputElement>(null);
useEffect(() => {
const close = (event: globalThis.MouseEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, []);
const submit = (event: FormEvent) => {
event.preventDefault();
const value = query.trim();
if (value) {
remember(value);
location.assign(searchResultsURL(value));
}
};
const options = suggestions?.items || [];
return (
<form
ref={ref}
className="hk-search hk-desktop-only"
action="/search/"
role="search"
data-path-search=""
data-header-search=""
onSubmit={submit}
>
<Icon name="search" />
<input
ref={input}
className="hk-search__input"
type="search"
placeholder="搜索剧名 / 演员 / 标签"
autoComplete="off"
data-header-search-input=""
value={query}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
setSelected(-1);
}}
onFocus={() => setOpen(true)}
aria-autocomplete="list"
aria-expanded={open}
aria-controls="header-search-dropdown"
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setSelected((old) =>
Math.max(
-1,
Math.min(
options.length - 1,
old + (event.key === "ArrowDown" ? 1 : -1),
),
),
);
}
if (event.key === "Enter" && selected >= 0 && options[selected]) {
event.preventDefault();
remember(options[selected].query || query);
location.assign(options[selected].url);
}
}}
/>
<button
className="hk-search__clear"
type="button"
aria-label="清除搜索词"
data-header-search-clear=""
hidden={!query}
onClick={() => {
setQuery("");
input.current?.focus();
}}
>
<Icon name="close" />
</button>
<div
className="hk-search-dropdown"
id="header-search-dropdown"
data-header-search-dropdown=""
hidden={!open}
>
<div
className="hk-search-dropdown__idle"
data-header-search-idle=""
hidden={!!query.trim()}
>
{history.length > 0 && (
<section
className="hk-search-dropdown__history"
aria-label="搜索历史"
data-header-search-history=""
>
<div className="hk-search-dropdown__head">
<h2 className="hk-search-dropdown__label">搜索历史</h2>
<button
className="hk-search-dropdown__clear"
type="button"
aria-label="清除搜索历史"
data-header-history-clear=""
onClick={clear}
>
<Icon name="close" />
</button>
</div>
<div
className="hk-search-dropdown__chips"
data-header-history-chips=""
>
{history.map((value) => (
<a
className="hk-search-dropdown__chip"
key={value}
href={searchResultsURL(value)}
onClick={() => remember(value)}
>
{value}
</a>
))}
</div>
</section>
)}
{data.searchTrends?.length > 0 && (
<section className="hk-search-dropdown__hot" aria-label="热门搜索">
<h2 className="hk-search-dropdown__label">热门搜索</h2>
<ol className="hk-search-dropdown__list">
{data.searchTrends.map((item: Data) => (
<li className="hk-search-dropdown__item" key={item.Title}>
<a
className="hk-search-dropdown__link"
href={searchResultsURL(item.Title)}
onClick={() => remember(item.Title)}
>
<span
className={
"hk-search-dropdown__rank" +
(item.Rank <= 3 ? " hk-is-top" : "")
}
>
{item.Rank}
</span>
<span className="hk-search-dropdown__name">
{item.Title}
</span>
{item.Badge && (
<span
className={
"hk-search-dropdown__badge hk-search-dropdown__badge--" +
(item.Badge === "新" ? "new" : "hot")
}
>
{item.Badge}
</span>
)}
<span className="hk-search-dropdown__heat">{item.Heat}</span>
</a>
</li>
))}
</ol>
</section>
)}
</div>
<div
className="hk-search-dropdown__suggest"
data-header-search-suggest=""
hidden={!query.trim()}
aria-label="搜索联想"
>
{loading ? (
<p className="hk-search-dropdown__loading">搜索中…</p>
) : (
<>
{suggestions?.feature && (
<a
className="hk-search-dropdown__feature"
href={suggestions.feature.url}
onClick={() => remember(query)}
>
<EncryptedImage
className="hk-search-dropdown__feature-cover"
src={suggestions.feature.cover}
alt={suggestions.feature.title}
/>
<span className="hk-search-dropdown__feature-copy">
<strong>{suggestions.feature.title}</strong>
<small>{suggestions.feature.meta}</small>
</span>
</a>
)}
<ul className="hk-search-dropdown__list">
{options.map((item, index) => (
<li
className={
"hk-search-dropdown__item" +
(selected === index ? " hk-is-active" : "")
}
key={item.url + index}
>
<a
className="hk-search-dropdown__link"
href={item.url}
onClick={() => remember(item.query || query)}
>
<Icon name="search" className="hk-icon hk-icon--sm" />
<span className="hk-search-dropdown__name">
{item.title}
</span>
{item.kind && (
<span className="hk-search-dropdown__kind">
{item.kind}
</span>
)}
</a>
</li>
))}
</ul>
</>
)}
</div>
</div>
</form>
);
}
function MegaMenu({ data }: { data: Data }) {
const theme = listThemeSegment(data.requestPath),
status = listUpdateStatus(data.requestPath);
return (
<div className="hk-site-navigation__mega" role="menu" aria-label="短剧快捷筛选">
<div className="hk-site-navigation__mega-col hk-site-navigation__mega-col--themes">
<p className="hk-site-navigation__mega-label">题材</p>
<div className="hk-site-navigation__mega-chips">
{(data.themeList || []).map((item: Data) => (
<a
key={item.id}
className={
"hk-site-navigation__mega-chip" +
([String(item.id), item.slug].includes(theme)
? " hk-is-active"
: "")
}
href={allListURL(item.slug, item.id, 0, 1, 1)}
role="menuitem"
data-navigation-key={"mega_theme_" + item.id}
data-navigation-name={"题材:" + item.name}
>
{item.name}
</a>
))}
</div>
</div>
<div className="hk-site-navigation__mega-col hk-site-navigation__mega-col--status">
<p className="hk-site-navigation__mega-label">状态</p>
<div className="hk-site-navigation__mega-chips">
{["全部", "连载中", "已完结"].map((label, index) => (
<a
key={label}
className={
"hk-site-navigation__mega-chip" + (status === index ? " hk-is-active" : "")
}
href={allListURL("", 0, index, 1, 1)}
role="menuitem"
data-navigation-key={"mega_status_" + index}
data-navigation-name={"状态:" + label}
>
{label}
</a>
))}
</div>
</div>
<div className="hk-site-navigation__mega-col hk-site-navigation__mega-col--links">
<p className="hk-site-navigation__mega-label">快捷入口</p>
{(data.homeConfigPlaylets || []).map((item: Data, index: number) => (
<a
className="hk-site-navigation__mega-link"
key={item.id}
href={moreListURL(item.slug, item.id, 0, "", 0, 1)}
role="menuitem"
data-navigation-key={"mega_section_" + item.id}
data-navigation-name={"快捷入口:" + item.name}
>
<Icon
name={
item.name === "预告片"
? "film2"
: ["nav_trend", "nav_zap", "nav_star", "nav_grid"][index % 4]
}
className="hk-icon hk-icon--sm"
/>
{item.name}
</a>
))}
</div>
</div>
);
}
function Drawer({ data, onClose }: { data: Data; onClose: () => void }) {
const ref = useDialog(onClose),
site = useSite();
useEffect(() => {
document.body.classList.add("hk-drawer-open");
return () => document.body.classList.remove("hk-drawer-open");
}, []);
const open = (action: () => void) => () => {
onClose();
action();
};
return (
<div ref={ref} className="hk-drawer hk-is-open" data-drawer="">
<div className="hk-drawer__mask" data-drawer-close="" onClick={onClose} />
<aside
className="hk-drawer__panel"
id="site-drawer"
role="dialog"
aria-modal="true"
aria-label="侧边导航"
>
<div className="hk-drawer__head">
<div className="hk-drawer__brand-row">
<a className="hk-brand" href="/">
<img
fetchPriority="low"
className="hk-brand__logo"
src="/static/brand/mobile.svg"
width="88"
height="28"
alt="花魁短剧"
/>
</a>
<button
className="hk-drawer__close"
type="button"
aria-label="关闭"
data-drawer-close=""
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
</div>
<hr className="hk-drawer__rule" />
<nav className="hk-drawer__nav" aria-label="页面导航">
{(data.categoryList || []).map((item: Data, index: number) => {
const items = navigationChildren(item);
return items.length ? (
<NavigationSubmenu
key={index}
item={item}
items={items}
data={data}
mobile
onNavigate={onClose}
/>
) : (
<NavigationLink
item={item}
data={data}
key={index}
className={
"hk-drawer__nav-item" +
(navIsActive(item.urlValue, data.requestPath)
? " hk-is-active"
: "")
}
>
<Icon name={navDrawerIcon(item.urlValue)} />
{item.name}
</NavigationLink>
);
})}
</nav>
<hr className="hk-drawer__rule" />
<div className="hk-drawer__tags">
<p className="hk-drawer__tags-label">题材</p>
<div className="hk-drawer__tag-list">
{(data.themeList || []).map((item: Data) => (
<a
className="hk-drawer__tag"
key={item.id}
href={allListURL(item.slug, item.id, 0, 1, 1)}
data-navigation-key={"drawer_theme_" + item.id}
data-navigation-name={"题材:" + item.name}
>
{item.name}
</a>
))}
</div>
</div>
<div className="hk-drawer__links">
<button
className="hk-drawer__link"
type="button"
data-download-open=""
onClick={open(site.openDownload)}
>
<Icon name="download" />
下载 App
</button>
</div>
</aside>
</div>
);
}
export function SiteHeader({ data }: { data: Data }) {
const site = useSite(),
[drawer, setDrawer] = useState(false),
[scrolled, setScrolled] = useState(false),
ref = useRef<HTMLElement>(null);
useEffect(() => {
const scroll = () => setScrolled(scrollY > 48),
measure = () =>
document.documentElement.style.setProperty(
"--site-header-height",
(ref.current?.getBoundingClientRect().height || 72) + "px",
);
scroll();
measure();
const observer = new ResizeObserver(measure);
if (ref.current) observer.observe(ref.current);
window.addEventListener("scroll", scroll, { passive: true });
return () => {
observer.disconnect();
window.removeEventListener("scroll", scroll);
};
}, []);
return (
<>
<header
ref={ref}
className={"hk-site-header" + (scrolled ? " hk-is-scrolled" : "")}
>
<div className="hk-site-header__left">
<button
className="hk-site-header__icon-btn hk-mobile-only"
type="button"
aria-label="菜单"
data-drawer-open=""
aria-expanded={drawer}
aria-controls="site-drawer"
onClick={() => setDrawer(true)}
>
<Icon name="menu" />
</button>
<a
className="hk-brand"
href="/"
data-navigation-key="home"
data-navigation-name="首页"
>
<picture>
<source
media="(max-width:768px)"
srcSet="/static/brand/mobile.svg"
/>
<img
className="hk-brand__logo"
src="/static/brand/header.svg"
width="104"
height="36"
alt="花魁短剧"
/>
</picture>
</a>
<nav className="hk-site-navigation hk-desktop-only" aria-label="主导航">
{(data.categoryList || []).map((item: Data, index: number) => {
const items = navigationChildren(item);
return items.length ? (
<NavigationSubmenu
key={index}
item={item}
items={items}
data={data}
/>
) : navHasMegaMenu(item.urlValue) ? (
<div className="hk-site-navigation__item-wrap" key={index}>
<a
className={
"hk-site-navigation__item" +
(navIsActive(item.urlValue, data.requestPath)
? " hk-is-active"
: "")
}
href={navHref(item.urlValue)}
aria-haspopup="true"
data-navigation-key={item.urlValue}
data-navigation-name={item.name}
>
{item.name}
</a>
<MegaMenu data={data} />
</div>
) : (
<NavigationLink
item={item}
data={data}
key={index}
className={
"hk-site-navigation__item" +
(navIsActive(item.urlValue, data.requestPath)
? " hk-is-active"
: "")
}
/>
);
})}
</nav>
</div>
<div className="hk-site-header__right">
<HeaderSearch data={data} />
<a
className="hk-site-header__icon-btn hk-mobile-only"
href="/discover/search/"
aria-label="搜索"
data-navigation-key="header_search"
data-navigation-name="搜索"
>
<Icon name="search_h5" />
</a>
<a
className="hk-button hk-button--ghost hk-site-header__download"
data-download-open=""
href={data.appConfig?.appAddr || "/app/download/"}
onClick={(event) => {
event.preventDefault();
site.openDownload();
}}
data-navigation-key="header_download"
data-navigation-name="下载 APP"
>
<Icon name="download" />
<span>下载 App</span>
</a>
</div>
</header>
{drawer && <Drawer data={data} onClose={() => setDrawer(false)} />}
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import type { Data } from "@/server/services/model";
import { SiteProvider } from "@/components/providers/SiteProvider";
import { TrackerProvider } from "@/components/analytics/TrackerProvider";
import { presentations } from "@/server/react/presentation";
import { SiteHeader } from "./SiteHeader";
import { SiteFooter } from "./SiteFooter";
import { MobileTabbar } from "./MobileTabbar";
import { PagePresentation } from "./PagePresentation";
export function SiteShell({
view,
data,
children,
}: {
view: string;
data: Data;
children: ReactNode;
}) {
const presentation = presentations[view] || presentations["404"];
return (
<SiteProvider data={data} dialogs={presentation.header}>
<PagePresentation view={view} />
<TrackerProvider data={data}>
{presentation.header && <SiteHeader data={data} />} {children}
{presentation.footer && <SiteFooter data={data} />}{" "}
{presentation.header && <MobileTabbar data={data} />}
</TrackerProvider>
<noscript>
<div>
<img
src={
"https://mc.yandex.ru/watch/" +
(data.isNavPage ? "112952789" : "112951899")
}
style={{ position: "absolute", left: -9999 }}
alt=""
/>
</div>
</noscript>
</SiteProvider>
);
}