Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+155
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+51
View File
@@ -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),
];
}
+39
View File
@@ -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>
);
}
+17
View File
@@ -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;
}
+111
View File
@@ -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>
</>
);
}
+304
View File
@@ -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>
);
}
+96
View File
@@ -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;
}
}
+59
View File
@@ -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>
);
}
+290
View File
@@ -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>
);
}
+56
View File
@@ -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}
/>
);
}
+83
View File
@@ -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>
);
}