@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Suspense, useSyncExternalStore } from "react";
|
||||
|
||||
import { useAppDownloadHref } from "@/components/download/download-dialog";
|
||||
import type { TranslationCategory, TranslationInfo } from "@/lib/api/types";
|
||||
|
||||
import { resolveNavHref } from "./nav-href";
|
||||
import { categoryHref, isNavActive } from "./nav-utils";
|
||||
|
||||
const linkClass = (active: boolean) =>
|
||||
`relative inline-flex h-5 items-center text-sm font-semibold leading-5 transition-colors ${
|
||||
active ? "text-[#F12B9E]" : "text-[#948CA8] hover:text-white"
|
||||
}`;
|
||||
|
||||
function ActiveMark({ active }: { active: boolean }) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<img
|
||||
src="/images/nav_selected.png"
|
||||
alt=""
|
||||
className="pointer-events-none absolute top-full left-1/2 mt-1 h-1.5 w-auto -translate-x-1/2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
const getClientMounted = () => true;
|
||||
const getServerMounted = () => false;
|
||||
|
||||
/** SSR / 首屏先按未选中渲染,挂载后再按 pathname 高亮,避免 hydration 不一致 */
|
||||
function useNavActive(href: string) {
|
||||
const pathname = usePathname();
|
||||
const ready = useSyncExternalStore(subscribeNoop, getClientMounted, getServerMounted);
|
||||
return ready && isNavActive(pathname, href);
|
||||
}
|
||||
|
||||
function DesktopNavItemInner({
|
||||
category,
|
||||
items,
|
||||
}: {
|
||||
category: TranslationCategory;
|
||||
items: TranslationInfo[];
|
||||
}) {
|
||||
const categoryLink = categoryHref(category);
|
||||
const downloadHref = useAppDownloadHref();
|
||||
const isDownloadApp = category.name === "下载APP";
|
||||
const href = isDownloadApp && downloadHref ? downloadHref : categoryLink;
|
||||
const active = useNavActive(isDownloadApp ? categoryLink : href);
|
||||
const hasChildren = items.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
const parentHref = href === "#" ? resolveNavHref(items[0]?.urlValue) : href;
|
||||
return (
|
||||
<div className="group relative inline-flex h-5 items-center">
|
||||
<Link
|
||||
href={parentHref}
|
||||
className={linkClass(active)}
|
||||
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
|
||||
data-navigation-name={category.name || ""}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<ActiveMark active={active} />
|
||||
</Link>
|
||||
<div className="invisible absolute top-full left-1/2 z-50 pt-3 opacity-0 transition group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
|
||||
<div className="-translate-x-1/2 min-w-[140px] rounded-xl bg-[#171424] py-2 outline outline-1 -outline-offset-1 outline-[#403857] shadow-lg">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={resolveNavHref(item.urlValue)}
|
||||
className="block px-4 py-2 text-sm text-[#948CA8] hover:bg-white/5 hover:text-white"
|
||||
data-navigation-key={item.urlValue || `nav_item_${item.id || item.name}`}
|
||||
data-navigation-name={item.name || ""}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDownloadApp && downloadHref) {
|
||||
return (
|
||||
<a
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass(false)}
|
||||
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
|
||||
data-navigation-name={category.name || ""}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={linkClass(active)}
|
||||
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
|
||||
data-navigation-name={category.name || ""}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<ActiveMark active={active} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopNavItemFallback({
|
||||
category,
|
||||
items,
|
||||
}: {
|
||||
category: TranslationCategory;
|
||||
items: TranslationInfo[];
|
||||
}) {
|
||||
const href = categoryHref(category);
|
||||
const hasChildren = items.length > 0;
|
||||
|
||||
// 结构必须与 Inner(未选中态)一致,否则 Suspense fallback ↔ Inner 会 hydration mismatch
|
||||
if (hasChildren) {
|
||||
const parentHref = href === "#" ? resolveNavHref(items[0]?.urlValue) : href;
|
||||
return (
|
||||
<div className="group relative inline-flex h-5 items-center">
|
||||
<Link href={parentHref} className={linkClass(false)}>
|
||||
<span>{category.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={linkClass(false)}>
|
||||
<span>{category.name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopNavItem({
|
||||
category,
|
||||
items,
|
||||
}: {
|
||||
category: TranslationCategory;
|
||||
items: TranslationInfo[];
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={<DesktopNavItemFallback category={category} items={items} />}>
|
||||
<DesktopNavItemInner category={category} items={items} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TranslationCategory } from "@/lib/api/types";
|
||||
|
||||
import { DesktopNavItem } from "./desktop-nav-item";
|
||||
|
||||
/** 桌面主导航:服务端组件,链接文案与结构随 SSR 输出 */
|
||||
export function DesktopNav({ categoryList }: { categoryList: TranslationCategory[] }) {
|
||||
return (
|
||||
<nav
|
||||
data-component="desktop-nav"
|
||||
className="flex shrink-0 items-center gap-4 whitespace-nowrap xl:gap-6"
|
||||
aria-label="主导航"
|
||||
>
|
||||
{categoryList.map((category) => {
|
||||
const items = category.translationList?.filter((item) => item.name) ?? [];
|
||||
return <DesktopNavItem key={category.id} category={category} items={items} />;
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { infoLinkHref } from "@/components/layout/info-links";
|
||||
|
||||
export type FooterContactLink = {
|
||||
key: string;
|
||||
href: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** 页脚「客户服务」:仅帮助中心、问题反馈 */
|
||||
export const FOOTER_CUSTOMER_SERVICE_LINKS: FooterContactLink[] = [
|
||||
{ key: "help", href: infoLinkHref("帮助中心"), text: "帮助中心" },
|
||||
{ key: "feedback", href: infoLinkHref("问题反馈"), text: "问题反馈" },
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
|
||||
import { openDownloadDialog } from "@/components/download/download-dialog";
|
||||
|
||||
type Platform = "iOS" | "Android";
|
||||
|
||||
export function FooterDownloadButtons({ appAddr }: { appAddr?: string | null }) {
|
||||
const href = appAddr?.trim() || "";
|
||||
const platforms: Platform[] = ["iOS", "Android"];
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{platforms.map((platform) => (
|
||||
<button
|
||||
key={platform}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (href) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
openDownloadDialog();
|
||||
}}
|
||||
aria-label={`扫码下载 ${platform}`}
|
||||
className="flex h-[84px] w-[82px] cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-white p-1.5 transition-opacity hover:opacity-90"
|
||||
>
|
||||
{/* 不传 title:qrcode.react 会生成 SVG <title>,爬虫可能当成文档标题 */}
|
||||
{href ? (
|
||||
<QRCodeSVG value={href} size={70} level="M" className="size-[70px]" aria-hidden />
|
||||
) : (
|
||||
<span className="text-center text-[10px] leading-3 text-[#333]">暂无地址</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AppTranslationInfo, TranslationInfo } from "@/lib/api/types";
|
||||
|
||||
/** 接口失败时兜底:PC「关于平台」(与 home/base → bottonTranslations 对齐) */
|
||||
export const BOTTOM_NAV_FALLBACK_NAMES = ["用户条款", "隐私政策", "联系我们", "常见问题"] as const;
|
||||
|
||||
/** PC「客户服务」从底部导航拆出的名称(含意见反馈别名) */
|
||||
export const CUSTOMER_SERVICE_NAMES = ["帮助中心", "问题反馈"] as const;
|
||||
|
||||
function isCustomerServiceName(name?: string | null) {
|
||||
const value = name?.trim() || "";
|
||||
return value === "帮助中心" || value === "问题反馈" || value === "意见反馈";
|
||||
}
|
||||
|
||||
function isFeedbackName(name?: string | null) {
|
||||
const value = name?.trim() || "";
|
||||
return value === "问题反馈" || value === "意见反馈";
|
||||
}
|
||||
|
||||
/**
|
||||
* home/base → bottonTranslations 扁平化(底部导航)
|
||||
* 优先取 categoryType=3(底部导航)的分类;无 type=3 时用全部(字段本身即底部导航)。
|
||||
*/
|
||||
export function footerNavItems(bottonTranslations?: AppTranslationInfo | null): TranslationInfo[] {
|
||||
const categories = bottonTranslations?.categoryList ?? [];
|
||||
const bottomCats = categories.filter((category) => Number(category.categoryType) === 3);
|
||||
const source = bottomCats.length ? bottomCats : categories;
|
||||
return source
|
||||
.flatMap((category) => category.translationList ?? [])
|
||||
.filter((item): item is TranslationInfo => Boolean(item.name?.trim()));
|
||||
}
|
||||
|
||||
/** PC「关于平台」:底部导航去掉帮助中心 / 问题反馈(意见反馈) */
|
||||
export function footerAboutNavItems(bottonTranslations?: AppTranslationInfo | null): TranslationInfo[] {
|
||||
return footerNavItems(bottonTranslations).filter((item) => !isCustomerServiceName(item.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* PC「客户服务」:从底部导航拆出帮助中心、问题反馈/意见反馈;
|
||||
* 保留接口 name / urlValue;缺项时用同名兜底。
|
||||
*/
|
||||
export function footerCustomerServiceNavItems(
|
||||
bottonTranslations?: AppTranslationInfo | null,
|
||||
): TranslationInfo[] {
|
||||
const api = footerNavItems(bottonTranslations);
|
||||
const help = api.find((item) => item.name?.trim() === "帮助中心");
|
||||
const feedback = api.find((item) => isFeedbackName(item.name));
|
||||
return [
|
||||
help ?? ({ id: -100, name: "帮助中心" } satisfies TranslationInfo),
|
||||
feedback ?? ({ id: -101, name: "问题反馈" } satisfies TranslationInfo),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
|
||||
import { useMobileChromeless, useMobileHeaderless } from "./route-visibility";
|
||||
|
||||
function HeaderShellInner({ children }: { children: ReactNode }) {
|
||||
const mobileChromeless = useMobileChromeless();
|
||||
const mobileHeaderless = useMobileHeaderless();
|
||||
|
||||
return (
|
||||
<header
|
||||
data-component="site-header"
|
||||
className={`sticky top-0 z-50 lg:[html[data-play-theater]_&]:hidden border-b border-white/5 bg-[#090A0E] lg:bg-[#0D1012] ${
|
||||
mobileChromeless || mobileHeaderless ? "max-lg:hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据路由在 H5 隐藏顶栏;pathname hooks 包在 Suspense 内 */
|
||||
export function HeaderShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<header
|
||||
data-component="site-header"
|
||||
className="sticky top-0 z-50 border-b border-white/5 bg-[#090A0E] lg:bg-[#0D1012]"
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
}
|
||||
>
|
||||
<HeaderShellInner>{children}</HeaderShellInner>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
export function HeaderUserChip() {
|
||||
const { user } = useMockUser();
|
||||
const avatar = user?.avatar || fallbackAvatar;
|
||||
const displayName = user?.name || "游客";
|
||||
|
||||
return (
|
||||
<Link href="/profile" className="flex shrink-0 items-center gap-2">
|
||||
<DecryptedImage src={avatar} fallback={fallbackAvatar} priority alt="" className="size-10 rounded-full object-cover" />
|
||||
<span className="hidden max-w-[96px] truncate text-sm leading-5 text-white xl:inline">{displayName}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { toRichTextPath } from "./nav-href";
|
||||
|
||||
const infoLinkHrefs: Record<string, string> = {
|
||||
关于我们: "/about",
|
||||
用户协议: toRichTextPath("用户条款"),
|
||||
用户条款: toRichTextPath("用户条款"),
|
||||
隐私政策: toRichTextPath("隐私政策"),
|
||||
常见问题: toRichTextPath("常见问题"),
|
||||
帮助中心: "/help",
|
||||
问题反馈: "/feedback",
|
||||
意见反馈: "/feedback",
|
||||
联系我们: toRichTextPath("联系我们"),
|
||||
};
|
||||
|
||||
export function infoLinkHref(label: string, fallback = "/#about") {
|
||||
return infoLinkHrefs[label] ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import Form from "next/form";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
|
||||
|
||||
import { MobileMenu } from "./mobile-menu";
|
||||
import { SiteSearchInput } from "./site-search-input";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
export function MobileHeader({
|
||||
categoryList,
|
||||
bottonTranslations,
|
||||
config,
|
||||
}: {
|
||||
categoryList: TranslationCategory[];
|
||||
bottonTranslations?: AppTranslationInfo | null;
|
||||
config?: ConfigInfo | null;
|
||||
}) {
|
||||
const { user } = useMockUser();
|
||||
const avatar = user?.avatar || fallbackAvatar;
|
||||
const [panel, setPanel] = useState<"menu" | "search" | null>(null);
|
||||
const closePanel = useCallback(() => setPanel(null), []);
|
||||
const toggle = (next: "menu" | "search") => setPanel((current) => (current === next ? null : next));
|
||||
|
||||
return (
|
||||
<div data-component="mobile-header" className="relative lg:hidden">
|
||||
<div className="flex h-12 items-center justify-between px-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="菜单"
|
||||
aria-expanded={panel === "menu"}
|
||||
onClick={() => toggle("menu")}
|
||||
className="cursor-pointer size-6"
|
||||
>
|
||||
<img src="/images/menu.svg" alt="" className="size-full" />
|
||||
</button>
|
||||
<Link href="/" aria-label="海角社区" onClick={() => setPanel(null)}>
|
||||
<img src="/logo.svg" alt="海角社区" className="h-6 w-auto" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="搜索"
|
||||
aria-expanded={panel === "search"}
|
||||
onClick={() => toggle("search")}
|
||||
className="cursor-pointer size-6"
|
||||
>
|
||||
<img src="/images/search_h5.svg" alt="" className="size-full" />
|
||||
</button>
|
||||
<Link href="/profile" aria-label="我的" className="size-6 overflow-hidden rounded-full" onClick={() => setPanel(null)}>
|
||||
<DecryptedImage src={avatar} fallback={fallbackAvatar} priority alt="" className="size-full object-cover" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<MobileMenu
|
||||
open={panel === "menu"}
|
||||
onClose={closePanel}
|
||||
categoryList={categoryList}
|
||||
bottonTranslations={bottonTranslations}
|
||||
config={config}
|
||||
/>
|
||||
{panel === "search" ? <MobileSearchPopup onClose={closePanel} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSearchPopup({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-x-0 top-[49px] bottom-0 z-10 bg-black/60"
|
||||
aria-hidden="true"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Form
|
||||
action="/search"
|
||||
onSubmit={onClose}
|
||||
role="search"
|
||||
className="absolute top-[calc(100%+17px)] left-1/2 z-20 flex w-[343px] max-w-[calc(100vw-32px)] -translate-x-1/2 items-center justify-between gap-3 overflow-hidden rounded-[36px] bg-black py-2 pr-3 pl-3"
|
||||
>
|
||||
<SiteSearchInput
|
||||
name="q"
|
||||
autoFocus
|
||||
aria-label="搜索关键词"
|
||||
placeholder="输入你想搜索的关键词.."
|
||||
className="site-search-input h-[24.5px] min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-[#757575]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="cursor-pointer shrink-0 rounded-full bg-[#F12B9E] px-3 py-1.5 text-xs leading-[18px] text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
|
||||
import { footerNavItems } from "@/components/layout/footer-nav-items";
|
||||
import type { AppTranslationInfo, ConfigInfo, TranslationCategory, TranslationInfo } from "@/lib/api/types";
|
||||
|
||||
import { resolveAboutPlatformHref, resolveNavHref } from "./nav-href";
|
||||
import { categoryHref, isNavActive } from "./nav-utils";
|
||||
|
||||
function isDownloadAppNav(name?: string | null) {
|
||||
return (name || "").trim() === "下载APP";
|
||||
}
|
||||
|
||||
function withDividers(items: ReactNode[], divider: string) {
|
||||
return items.flatMap((item, index) =>
|
||||
index === 0 ? [item] : [<span key={`divider-${index}`} className={`shrink-0 ${divider}`} />, item],
|
||||
);
|
||||
}
|
||||
|
||||
/** 仅接口有值才展示,不造假数据 */
|
||||
function buildContacts(config?: ConfigInfo | null) {
|
||||
const email = config?.gmailAddr?.trim();
|
||||
const business = config?.telegram?.trim();
|
||||
const group = config?.tgGroup?.trim() || config?.telegramAdd?.trim();
|
||||
const items: { key: string; node: ReactNode }[] = [];
|
||||
|
||||
if (email) {
|
||||
items.push({
|
||||
key: "email",
|
||||
node: (
|
||||
<a href={`mailto:${email}`} className="text-xs leading-[18px] text-[#615970]">
|
||||
E-mail:{email}
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (business) {
|
||||
items.push({
|
||||
key: "business",
|
||||
node: (
|
||||
<a href={business} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
|
||||
商务合作: 立即联系
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (group) {
|
||||
items.push({
|
||||
key: "group",
|
||||
node: (
|
||||
<a href={group} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
|
||||
官方群: 立即加入
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSocials(config?: ConfigInfo | null) {
|
||||
const telegram = config?.tgGroup?.trim() || config?.telegramAdd?.trim() || config?.telegram?.trim();
|
||||
const twitter = config?.twitterAddr?.trim();
|
||||
return [
|
||||
telegram ? { label: "Telegram", icon: "/images/footer_tg.svg", href: telegram } : null,
|
||||
twitter ? { label: "X", icon: "/images/footer_x.svg", href: twitter } : null,
|
||||
].filter(Boolean) as { label: string; icon: string; href: string }[];
|
||||
}
|
||||
|
||||
function InfoNavLink({ item, onClose }: { item: TranslationInfo; onClose: () => void }) {
|
||||
const href = resolveAboutPlatformHref(item);
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const className = "text-xs leading-[18px] text-[#948AA3]/90";
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" onClick={onClose} className={className}>
|
||||
{item.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} onClick={onClose} className={className}>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileMenu({
|
||||
open,
|
||||
onClose,
|
||||
categoryList,
|
||||
bottonTranslations,
|
||||
config,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categoryList?: TranslationCategory[] | null;
|
||||
bottonTranslations?: AppTranslationInfo | null;
|
||||
config?: ConfigInfo | null;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const categories = categoryList ?? [];
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
// 仅渲染接口 bottonTranslations,不补自定义项
|
||||
const bottomNavLinks = footerNavItems(bottonTranslations);
|
||||
const downloadHref = useAppDownloadHref();
|
||||
const contacts = buildContacts(config);
|
||||
const socials = buildSocials(config);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
const { overflow } = document.body.style;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = overflow;
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="mobile-menu"
|
||||
className={`fixed inset-0 z-50 lg:hidden ${open ? "" : "pointer-events-none"}`}
|
||||
{...(open ? {} : { inert: true as const })}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${open ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="菜单"
|
||||
className={`absolute inset-y-0 left-0 flex w-[281px] max-w-[85vw] flex-col items-center gap-6 overflow-y-auto [scrollbar-width:none] bg-[#0B0914] px-9 py-[60px] transition-transform duration-300 ${
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<button type="button" aria-label="关闭菜单" onClick={onClose} className="absolute top-[26px] right-[13px] size-6 cursor-pointer">
|
||||
<svg viewBox="0 0 24 24" fill="none" className="size-full" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Link href="/" onClick={onClose} aria-label="海角社区" className="shrink-0">
|
||||
<img src="/logo.svg" alt="海角社区" className="h-[62px] w-[137px]" />
|
||||
</Link>
|
||||
|
||||
<nav className="flex w-full flex-col gap-[11px]" aria-label="主导航">
|
||||
{categories.map((category) => {
|
||||
const href = categoryHref(category);
|
||||
const active = isNavActive(pathname, href);
|
||||
const children = category.translationList?.filter((item) => item.name) ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const expanded = expandedId === category.id;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={category.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedId(expanded ? null : category.id)}
|
||||
className={`flex h-11 shrink-0 items-center justify-between rounded-[9px] bg-white/10 px-[18px] text-base leading-[22px] font-medium ${
|
||||
active ? "text-[#F12B9E]" : "text-white/90"
|
||||
}`}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<span className={`text-xs transition-transform ${expanded ? "rotate-180" : ""}`}>▾</span>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="flex flex-col gap-1 pl-2">
|
||||
{children.map((item) => {
|
||||
const childHref = resolveNavHref(item.urlValue);
|
||||
const childActive = isNavActive(pathname, childHref);
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={childHref}
|
||||
onClick={onClose}
|
||||
className={`flex h-9 items-center rounded-lg px-4 text-sm ${
|
||||
childActive ? "text-[#F12B9E]" : "text-white/70"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDownloadAppNav(category.name)) {
|
||||
const className =
|
||||
"flex h-11 w-full shrink-0 cursor-pointer items-center rounded-[9px] bg-white/10 px-[18px] text-left text-base leading-[22px] font-medium text-white/90";
|
||||
if (downloadHref) {
|
||||
return (
|
||||
<a
|
||||
key={category.id}
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className={className}
|
||||
>
|
||||
{category.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openAppDownload();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={href}
|
||||
onClick={onClose}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex h-11 shrink-0 items-center rounded-[9px] bg-white/10 px-[18px] text-base leading-[22px] font-medium ${
|
||||
active ? "text-[#F12B9E]" : "text-white/90"
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={onClose}
|
||||
aria-current={pathname === "/profile" || pathname.startsWith("/profile/") ? "page" : undefined}
|
||||
className={`flex h-11 shrink-0 items-center rounded-[9px] bg-white/10 px-[18px] text-base leading-[22px] font-medium ${
|
||||
pathname === "/profile" || pathname.startsWith("/profile/") ? "text-[#F12B9E]" : "text-white/90"
|
||||
}`}
|
||||
>
|
||||
我的
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{bottomNavLinks.length ? (
|
||||
<nav
|
||||
aria-label="底部导航"
|
||||
className="flex w-full flex-wrap items-center justify-center gap-1.5 py-0.5"
|
||||
>
|
||||
{withDividers(
|
||||
bottomNavLinks.map((link) => (
|
||||
<InfoNavLink key={link.id || link.name} item={link} onClose={onClose} />
|
||||
)),
|
||||
"h-5 w-px bg-white/20",
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{contacts.length ? (
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-3">
|
||||
{withDividers(
|
||||
contacts.map((item) => <span key={item.key}>{item.node}</span>),
|
||||
"h-[18px] w-px bg-[#615970]",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{socials.length ? (
|
||||
<div className="flex items-center gap-6">
|
||||
{withDividers(
|
||||
socials.map((social) => (
|
||||
<a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target={social.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={social.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
aria-label={social.label}
|
||||
className="size-6"
|
||||
>
|
||||
<img src={social.icon} alt="" className="size-full" />
|
||||
</a>
|
||||
)),
|
||||
"h-[15px] w-px bg-white/20",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 富文本统一为路径模式:/richtext/{title}
|
||||
* 兼容接口返回的 /richtext?title=xxx
|
||||
*/
|
||||
export function toRichTextPath(title?: string | null): string {
|
||||
const value = (title || "").trim();
|
||||
if (!value) return "#";
|
||||
try {
|
||||
return `/richtext/${decodeURIComponent(value)}`;
|
||||
} catch {
|
||||
return `/richtext/${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 urlValue 提取富文本 title(路径段或 query) */
|
||||
export function extractRichTextTitle(urlValue?: string | null): string | null {
|
||||
const raw = (urlValue || "").trim();
|
||||
if (!raw || !raw.includes("richtext")) return null;
|
||||
try {
|
||||
const url = new URL(raw.startsWith("http") ? raw : raw.startsWith("/") ? `http://local${raw}` : `http://local/${raw}`);
|
||||
const queryTitle = url.searchParams.get("title")?.trim();
|
||||
if (queryTitle) {
|
||||
try {
|
||||
return decodeURIComponent(queryTitle);
|
||||
} catch {
|
||||
return queryTitle;
|
||||
}
|
||||
}
|
||||
if (url.pathname.startsWith("/richtext/")) {
|
||||
const segment = url.pathname.slice("/richtext/".length).split("/")[0] || "";
|
||||
if (!segment) return null;
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将接口 urlValue 解析为本站可访问路径。
|
||||
* 仅做路径适配,不改接口字段名;富文本强制路径模式。
|
||||
*/
|
||||
export function resolveNavHref(urlValue?: string | null): string {
|
||||
const raw = (urlValue || "").trim();
|
||||
if (!raw || raw === "#") return "#";
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
|
||||
const richTitle = extractRichTextTitle(raw);
|
||||
if (richTitle) return toRichTextPath(richTitle);
|
||||
|
||||
const path = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
|
||||
if (path === "/" || path === "/#top") return "/";
|
||||
if (path === "/application" || path.startsWith("/application/")) return "/welfare";
|
||||
if (path === "/drama-rank") return "/ranking/drama";
|
||||
if (path === "/video-rank") return "/ranking/video";
|
||||
if (path.startsWith("/drama/")) return `/short-drama${path.slice("/drama".length)}`;
|
||||
if (path === "/drama") return "/short-drama/hot";
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 底部导航 / 页脚:优先接口 urlValue,无配置时再按 name 兜底 */
|
||||
export function resolveAboutPlatformHref(item: { name?: string; urlValue?: string | null }): string {
|
||||
const name = item.name?.trim();
|
||||
const fromRichText = extractRichTextTitle(item.urlValue);
|
||||
if (fromRichText) return toRichTextPath(fromRichText);
|
||||
|
||||
const fromNav = resolveNavHref(item.urlValue);
|
||||
if (fromNav && fromNav !== "#") return fromNav;
|
||||
|
||||
if (name === "帮助中心") return "/help";
|
||||
if (name === "问题反馈" || name === "意见反馈") return "/feedback";
|
||||
if (name === "关于我们") return "/about";
|
||||
if (name) return toRichTextPath(name);
|
||||
return "#";
|
||||
}
|
||||
|
||||
export function navMatchPrefix(href: string): string | null {
|
||||
if (!href || href === "#") return null;
|
||||
if (href === "/" || href === "/#top") return "/";
|
||||
try {
|
||||
const pathname = href.startsWith("http") ? new URL(href).pathname : href.split("?")[0].split("#")[0];
|
||||
if (!pathname || pathname === "/") return "/";
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
return parts.length ? `/${parts[0]}` : "/";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { isShortDramaSort } from "@/components/short-drama/route";
|
||||
import { isVideoSort } from "@/components/video/route";
|
||||
import type { TranslationCategory } from "@/lib/api/types";
|
||||
|
||||
import { navMatchPrefix, resolveNavHref } from "./nav-href";
|
||||
|
||||
export function categoryHref(category: TranslationCategory) {
|
||||
const direct = resolveNavHref(category.urlValue);
|
||||
if (direct !== "#") return direct;
|
||||
const first = category.translationList?.find((item) => item.urlValue);
|
||||
return first ? resolveNavHref(first.urlValue) : "#";
|
||||
}
|
||||
|
||||
function decodePathSegment(segment: string) {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
/** 短剧详情:/short-drama/{id|slug}(非列表排序) */
|
||||
export function isShortDramaDetailPath(pathname: string) {
|
||||
if (!pathname.startsWith("/short-drama/")) return false;
|
||||
const parts = pathname.slice("/short-drama/".length).split("/").filter(Boolean);
|
||||
if (parts.length !== 1) return false;
|
||||
return !isShortDramaSort(decodePathSegment(parts[0]));
|
||||
}
|
||||
|
||||
/** 短剧播放:/short-drama/{id|slug}/{ep} */
|
||||
export function isShortDramaPlayPath(pathname: string) {
|
||||
if (!pathname.startsWith("/short-drama/")) return false;
|
||||
const parts = pathname.slice("/short-drama/".length).split("/").filter(Boolean);
|
||||
if (parts.length !== 2) return false;
|
||||
return !isShortDramaSort(decodePathSegment(parts[0]));
|
||||
}
|
||||
|
||||
/** 视频详情/播放:/video/{id|slug}(非列表排序) */
|
||||
export function isVideoDetailPath(pathname: string) {
|
||||
if (!pathname.startsWith("/video/")) return false;
|
||||
const segment = pathname.slice("/video/".length).split("/")[0] || "";
|
||||
return !isVideoSort(decodePathSegment(segment));
|
||||
}
|
||||
|
||||
export function isNavActive(pathname: string, href: string) {
|
||||
const match = navMatchPrefix(href);
|
||||
if (!match) return false;
|
||||
if (match === "/") return pathname === "/";
|
||||
|
||||
// 短剧详情 / 播放、视频详情:不高亮「短剧」「视频」导航
|
||||
if (match === "/short-drama" && (isShortDramaDetailPath(pathname) || isShortDramaPlayPath(pathname))) {
|
||||
return false;
|
||||
}
|
||||
if (match === "/video" && isVideoDetailPath(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname === match || pathname.startsWith(`${match}/`);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
isShortDramaDetailPath,
|
||||
isShortDramaPlayPath,
|
||||
isVideoDetailPath,
|
||||
} from "@/components/layout/nav-utils";
|
||||
|
||||
export const mobileChromelessPaths = [
|
||||
"/profile",
|
||||
"/history",
|
||||
"/favorites",
|
||||
"/likes",
|
||||
"/settings",
|
||||
"/settings/profile",
|
||||
"/about",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
"/help",
|
||||
"/feedback",
|
||||
"/richtext",
|
||||
];
|
||||
|
||||
export function useMobileChromeless() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
mobileChromelessPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ||
|
||||
isShortDramaPlayPath(pathname)
|
||||
);
|
||||
}
|
||||
|
||||
export function useMobileHeaderless() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
isShortDramaDetailPath(pathname) ||
|
||||
isShortDramaPlayPath(pathname) ||
|
||||
isVideoDetailPath(pathname) ||
|
||||
pathname === "/way-home"
|
||||
);
|
||||
}
|
||||
|
||||
export const chromelessPaths = ["/nav"];
|
||||
|
||||
export function useChromeless() {
|
||||
return chromelessPaths.includes(usePathname());
|
||||
}
|
||||
|
||||
function HideOnMobileChromelessInner({ children }: { children: ReactNode }) {
|
||||
const hidden = useMobileChromeless();
|
||||
return (
|
||||
<div data-component="hide-on-mobile-chromeless" className={hidden ? "contents max-lg:hidden" : "contents"}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HideOnMobileChromeless({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="contents">{children}</div>}>
|
||||
<HideOnMobileChromelessInner>{children}</HideOnMobileChromelessInner>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function HideOnChromelessInner({ children }: { children: ReactNode }) {
|
||||
return useChromeless() ? null : children;
|
||||
}
|
||||
|
||||
/** cacheComponents:usePathname 必须在 Suspense 内;fallback 先输出 children,保证导航 SSR */
|
||||
export function HideOnChromeless({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={children}>
|
||||
<HideOnChromelessInner>{children}</HideOnChromelessInner>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
BOTTOM_NAV_FALLBACK_NAMES,
|
||||
footerAboutNavItems,
|
||||
footerCustomerServiceNavItems,
|
||||
footerNavItems,
|
||||
} from "@/components/layout/footer-nav-items";
|
||||
import { infoLinkHref } from "@/components/layout/info-links";
|
||||
import { resolveAboutPlatformHref } from "@/components/layout/nav-href";
|
||||
import type { AppTranslationInfo, ConfigInfo, TranslationInfo } from "@/lib/api/types";
|
||||
|
||||
import { FooterDownloadButtons } from "./footer-download-buttons";
|
||||
|
||||
const fallbackSocialLinks = [
|
||||
{ label: "商务", icon: "/images/footer_sw.svg", href: "/#community" },
|
||||
{ label: "Telegram", icon: "/images/footer_tg.svg", href: "/#community" },
|
||||
{ label: "X", icon: "/images/footer_x.svg", href: "/#community" },
|
||||
];
|
||||
const aboutLinksFallback = BOTTOM_NAV_FALLBACK_NAMES;
|
||||
const officialLinksFallback = [
|
||||
{ label: "官网", href: "https://HaijiaoPortal.com", text: "官网:HaijiaoPortal.com" },
|
||||
{ label: "最新下载地址", href: "https://HaijiaoPortal.com", text: "最新下载地址:HaijiaoPortal.com" },
|
||||
{ label: "永久地址", href: "https://HaijiaoPortal.com", text: "永久地址:HaijiaoPortal.com" },
|
||||
];
|
||||
|
||||
function hrefOf(value?: string | null) {
|
||||
const text = value?.trim();
|
||||
if (!text) return undefined;
|
||||
if (/^https?:\/\//i.test(text) || text.startsWith("mailto:")) return text;
|
||||
return `https://${text}`;
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
bottonTranslations?: AppTranslationInfo | null;
|
||||
config?: ConfigInfo | null;
|
||||
};
|
||||
|
||||
function buildSocialLinks(config?: ConfigInfo | null) {
|
||||
const business = config?.telegram?.trim();
|
||||
const telegram = config?.tgGroup?.trim() || config?.telegramAdd?.trim();
|
||||
const twitter = config?.twitterAddr?.trim();
|
||||
if (!business && !telegram && !twitter) return fallbackSocialLinks;
|
||||
return [
|
||||
business ? { label: "商务", icon: "/images/footer_sw.svg", href: business } : null,
|
||||
telegram ? { label: "Telegram", icon: "/images/footer_tg.svg", href: telegram } : null,
|
||||
twitter ? { label: "X", icon: "/images/footer_x.svg", href: twitter } : null,
|
||||
].filter(Boolean) as { label: string; icon: string; href: string }[];
|
||||
}
|
||||
|
||||
function buildOfficialLines(config?: ConfigInfo | null) {
|
||||
const lines = [
|
||||
config?.webAddr?.trim()
|
||||
? { label: "官网", href: hrefOf(config.webAddr)!, text: `官网:${config.webAddr.trim()}` }
|
||||
: null,
|
||||
config?.appAddr?.trim()
|
||||
? { label: "最新下载地址", href: hrefOf(config.appAddr)!, text: `最新下载地址:${config.appAddr.trim()}` }
|
||||
: null,
|
||||
config?.foreverUrlAddr?.trim()
|
||||
? {
|
||||
label: "永久地址",
|
||||
href: hrefOf(config.foreverUrlAddr)!,
|
||||
text: `永久地址:${config.foreverUrlAddr.trim()}`,
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as { label: string; href: string; text: string }[];
|
||||
return lines.length ? lines : officialLinksFallback;
|
||||
}
|
||||
|
||||
function FooterNavAnchor({ item }: { item: TranslationInfo }) {
|
||||
const href = resolveAboutPlatformHref(item);
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const className = "text-sm leading-5 text-[#989898] hover:text-white";
|
||||
const navKey = item.urlValue || `footer_${item.name || ""}`;
|
||||
const navName = item.name || "";
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
data-navigation-key={navKey}
|
||||
data-navigation-name={navName}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={className} data-navigation-key={navKey} data-navigation-name={navName}>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileFooterNavLink({ item }: { item: TranslationInfo }) {
|
||||
const href = resolveAboutPlatformHref(item);
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{item.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <Link href={href}>{item.name}</Link>;
|
||||
}
|
||||
|
||||
export function SiteFooter({ bottonTranslations, config }: FooterProps) {
|
||||
return (
|
||||
<>
|
||||
<MobileFooter bottonTranslations={bottonTranslations} config={config} />
|
||||
<DesktopFooter bottonTranslations={bottonTranslations} config={config} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
|
||||
// 仅渲染接口 bottonTranslations,不补自定义项
|
||||
const bottomNavLinks = footerNavItems(bottonTranslations);
|
||||
const socialLinks = buildSocialLinks(config);
|
||||
const latest = config?.webAddr?.trim() || config?.newUrlAddr?.trim();
|
||||
const forever = config?.foreverUrlAddr?.trim();
|
||||
const portal = config?.translationAddr?.trim() || config?.webAddr?.trim();
|
||||
|
||||
return (
|
||||
<footer data-component="mobile-footer" className="flex flex-col items-center gap-[18px] bg-white/10 px-4 py-[18px] text-center lg:hidden">
|
||||
<img src="/logo.svg" alt="海角社区" className="h-8 w-auto" />
|
||||
<p className="text-sm leading-5 text-white/55">
|
||||
Copyright @ 2026 {config?.webSiteName?.trim() || config?.webAddr?.trim() || "haijiao.com"} All rights reserved
|
||||
</p>
|
||||
{bottomNavLinks.length ? (
|
||||
<nav
|
||||
className="flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-2 text-xs text-white/80"
|
||||
aria-label="底部导航"
|
||||
>
|
||||
{bottomNavLinks.map((link, index) => (
|
||||
<span key={link.id || `${link.name}-${index}`} className="flex items-center gap-3">
|
||||
{index > 0 ? <span className="h-2 w-px bg-white/30" /> : null}
|
||||
<MobileFooterNavLink item={link} />
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<p className="flex items-center gap-1 rounded-full bg-[#F12B9E]/25 px-3 py-1.5 text-xs text-white">
|
||||
<img src="/images/tip.svg" alt="" className="size-5" />
|
||||
本网站仅限18周岁及以上用户访问
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5 text-xs leading-[18px] text-white/55">
|
||||
<p>
|
||||
海角社区最新地址:
|
||||
<a
|
||||
href={hrefOf(latest) || "https://Haijiaohaijiao.so"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#F12B9E] underline-offset-2 hover:underline"
|
||||
>
|
||||
{latest || "https://Haijiaohaijiao.so"}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
海角社区永久入口:
|
||||
<a
|
||||
href={hrefOf(forever) || "https://Haijiaohaijiao.so"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{forever || "https://Haijiaohaijiao.so"}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
海角社区官方永久地址:
|
||||
<a
|
||||
href={hrefOf(portal) || "https://HaijiaoPortal.com"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{portal || "HaijiaoPortal.com"}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{socialLinks.map((link, index) => (
|
||||
<span key={link.label} className="flex items-center gap-4">
|
||||
{index > 0 ? <span className="h-3 w-px bg-white/20" /> : null}
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
aria-label={link.label}
|
||||
className="flex size-[38px] items-center justify-center rounded-full bg-white/10"
|
||||
>
|
||||
<img src={link.icon} alt="" className="size-5" />
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopFooter({ bottonTranslations, config }: FooterProps) {
|
||||
const aboutLinks = footerAboutNavItems(bottonTranslations);
|
||||
const customerServiceLinks = footerCustomerServiceNavItems(bottonTranslations);
|
||||
const socialLinks = buildSocialLinks(config);
|
||||
const officialLines = buildOfficialLines(config);
|
||||
const siteName = config?.dhSiteName?.trim() || config?.webSiteName?.trim() || "海角社区";
|
||||
|
||||
return (
|
||||
<footer data-component="desktop-footer" className="design-gutter hidden bg-[#0E1012] py-[74px] lg:block">
|
||||
<div className="mx-auto flex w-full max-w-[1700px] flex-col items-center gap-6">
|
||||
<div className="flex w-full items-start justify-between gap-10">
|
||||
<div className="flex w-64 flex-col gap-5">
|
||||
<img src="/logo.svg" alt="海角社区" className="h-[43px] w-[95px]" />
|
||||
<p className="text-lg leading-6 font-medium text-white">{siteName} 官方社群</p>
|
||||
<div className="flex gap-6">
|
||||
{socialLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
aria-label={link.label}
|
||||
className="flex size-10 items-center justify-center rounded-full bg-white/10"
|
||||
>
|
||||
<img src={link.icon} alt="" className="size-5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-12 xl:gap-24">
|
||||
{aboutLinks.length ? (
|
||||
<div className="flex min-w-24 flex-col gap-4">
|
||||
<h2 className="text-lg leading-6 font-medium text-white">关于平台</h2>
|
||||
{aboutLinks.map((link) => (
|
||||
<FooterNavAnchor key={link.id || link.name} item={link} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<FooterColumn title="关于平台" links={aboutLinksFallback} />
|
||||
)}
|
||||
<div className="flex min-w-24 flex-col gap-4">
|
||||
<h2 className="text-lg leading-6 font-medium text-white">客户服务</h2>
|
||||
{customerServiceLinks.map((link) => (
|
||||
<FooterNavAnchor key={link.id || link.name} item={link} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-lg leading-6 font-medium text-white">官方入口</h2>
|
||||
{officialLines.map((line) => (
|
||||
<a
|
||||
key={line.label}
|
||||
href={line.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm leading-5 break-all text-[#989898] transition-colors hover:text-white"
|
||||
>
|
||||
{line.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-48 flex-col gap-4">
|
||||
<h2 className="text-lg leading-6 font-medium text-white">下载{siteName}APP</h2>
|
||||
<FooterDownloadButtons appAddr={config?.appAddr} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm leading-5 text-white/55">
|
||||
Copyright @ 2026 {config?.webAddr?.trim() || "haijiao.com"} All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterColumn({ title, links }: { title: string; links: readonly string[] }) {
|
||||
return (
|
||||
<div className="flex w-24 flex-col gap-4">
|
||||
<h2 className="text-lg leading-6 font-medium text-white">{title}</h2>
|
||||
{links.map((link) => (
|
||||
<Link key={link} href={infoLinkHref(link)} className="text-sm leading-5 text-[#989898] hover:text-white">
|
||||
{link}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Form from "next/form";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { HistoryIcon, SearchIcon } from "@/components/home/icons";
|
||||
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
|
||||
|
||||
import { HeaderShell } from "./header-shell";
|
||||
import { HeaderUserChip } from "./header-user-chip";
|
||||
import { MobileHeader } from "./mobile-header";
|
||||
import { SiteSearchInput } from "./site-search-input";
|
||||
|
||||
export function SiteHeader({
|
||||
categoryList,
|
||||
desktopNav,
|
||||
bottonTranslations,
|
||||
config,
|
||||
}: {
|
||||
categoryList?: TranslationCategory[] | null;
|
||||
/** 服务端渲染的桌面主导航插槽 */
|
||||
desktopNav?: ReactNode;
|
||||
bottonTranslations?: AppTranslationInfo | null;
|
||||
config?: ConfigInfo | null;
|
||||
}) {
|
||||
const categories = categoryList ?? [];
|
||||
|
||||
return (
|
||||
<HeaderShell>
|
||||
<MobileHeader categoryList={categories} bottonTranslations={bottonTranslations} config={config} />
|
||||
<div className="design-gutter hidden h-[72px] w-full items-center gap-4 lg:flex xl:gap-6">
|
||||
<Link href="/" className="shrink-0" aria-label="海角社区">
|
||||
<img src="/logo.svg" alt="海角社区" className="h-[43px] w-[95px]" />
|
||||
</Link>
|
||||
{desktopNav}
|
||||
<Form
|
||||
action="/search"
|
||||
className="ml-auto flex h-10 min-w-[120px] flex-1 items-center gap-2 overflow-hidden rounded-full border border-[#403857] bg-transparent px-3.5 xl:max-w-[630px]"
|
||||
role="search"
|
||||
>
|
||||
<SearchIcon className="size-[15px] shrink-0 text-[#8F8F9F]" />
|
||||
<SiteSearchInput
|
||||
name="q"
|
||||
placeholder="搜索剧名、题材或标签"
|
||||
className="site-search-input h-full w-full bg-transparent text-sm text-white outline-none placeholder:text-[#7A728C]/55"
|
||||
/>
|
||||
</Form>
|
||||
<Link href="/history" className="flex shrink-0 items-center gap-1 text-sm text-white/90">
|
||||
<HistoryIcon className="size-6" />
|
||||
历史
|
||||
</Link>
|
||||
<span className="h-3 w-px bg-white/30" />
|
||||
<HeaderUserChip />
|
||||
</div>
|
||||
</HeaderShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, type FocusEvent, type InputHTMLAttributes } from "react";
|
||||
|
||||
type InputEventHandler = NonNullable<InputHTMLAttributes<HTMLInputElement>["onInput"]>;
|
||||
type InputEventArg = Parameters<InputEventHandler>[0];
|
||||
|
||||
/** 聚焦/输入时锁定滚动,避免 sticky 顶栏 + html scroll-pt 把页面顶走 */
|
||||
function preserveScrollPosition() {
|
||||
const x = window.scrollX;
|
||||
const y = window.scrollY;
|
||||
const restore = () => {
|
||||
if (window.scrollX !== x || window.scrollY !== y) window.scrollTo(x, y);
|
||||
};
|
||||
restore();
|
||||
requestAnimationFrame(restore);
|
||||
window.setTimeout(restore, 0);
|
||||
}
|
||||
|
||||
export function SiteSearchInput({
|
||||
className,
|
||||
onFocus,
|
||||
onInput,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) {
|
||||
const handleFocus = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement>) => {
|
||||
preserveScrollPosition();
|
||||
onFocus?.(event);
|
||||
},
|
||||
[onFocus],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(event: InputEventArg) => {
|
||||
preserveScrollPosition();
|
||||
onInput?.(event);
|
||||
},
|
||||
[onInput],
|
||||
);
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
type={props.type ?? "search"}
|
||||
className={className}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
|
||||
import { getHomeBaseCached } from "@/lib/api/home";
|
||||
import { getNavigationHeaderCached } from "@/lib/api/navigation";
|
||||
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
|
||||
|
||||
import { DesktopNav } from "./desktop-nav";
|
||||
import { HideOnChromeless, HideOnMobileChromeless } from "./route-visibility";
|
||||
import { SiteFooter } from "./site-footer";
|
||||
import { SiteHeader } from "./site-header";
|
||||
|
||||
/** 仅展示接口导航;无数据时留空,不写死兜底 */
|
||||
async function loadNavigationCategories(): Promise<TranslationCategory[]> {
|
||||
try {
|
||||
const data = await getNavigationHeaderCached();
|
||||
return data.categoryList?.filter((item) => item.name?.trim()) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFooterData(): Promise<{
|
||||
bottonTranslations?: AppTranslationInfo;
|
||||
config?: ConfigInfo;
|
||||
}> {
|
||||
try {
|
||||
const base = await getHomeBaseCached();
|
||||
return {
|
||||
bottonTranslations: base.bottonTranslations,
|
||||
config: base.config,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function HeaderFallback() {
|
||||
return <div className="h-14 shrink-0 lg:h-[73px]" aria-hidden />;
|
||||
}
|
||||
|
||||
/** 导航数据在 Suspense 内 await,避免挡住整站 prerender / instant navigation */
|
||||
async function SiteHeaderNav() {
|
||||
const [categoryList, footer] = await Promise.all([loadNavigationCategories(), loadFooterData()]);
|
||||
return (
|
||||
<HideOnChromeless>
|
||||
<SiteHeader
|
||||
categoryList={categoryList}
|
||||
desktopNav={<DesktopNav categoryList={categoryList} />}
|
||||
bottonTranslations={footer.bottonTranslations}
|
||||
config={footer.config}
|
||||
/>
|
||||
</HideOnChromeless>
|
||||
);
|
||||
}
|
||||
|
||||
async function SiteFooterData() {
|
||||
const { bottonTranslations, config } = await loadFooterData();
|
||||
return (
|
||||
<HideOnChromeless>
|
||||
<HideOnMobileChromeless>
|
||||
<SiteFooter bottonTranslations={bottonTranslations} config={config} />
|
||||
</HideOnMobileChromeless>
|
||||
</HideOnChromeless>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
data-component="site-shell"
|
||||
id="top"
|
||||
className="flex min-h-full flex-1 flex-col overflow-x-clip bg-[#07060D] text-white max-lg:has-[[data-h5-bottom-bar]]:pb-[68px]"
|
||||
>
|
||||
<Suspense fallback={<HeaderFallback />}>
|
||||
<SiteHeaderNav />
|
||||
</Suspense>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
<Suspense fallback={null}>
|
||||
<SiteFooterData />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user