[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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user