feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -26,13 +26,13 @@ import type {
|
||||
SidebarPageItem,
|
||||
PublicAdItem,
|
||||
PublicSponsorItem,
|
||||
FriendLink,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { AdText } from "@/components/AdText";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
import { AboutSiteHeader } from "@/components/site-doc";
|
||||
@@ -396,38 +396,53 @@ export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[
|
||||
|
||||
/* ---------- 友情链接 ---------- */
|
||||
|
||||
export function FriendLinks() {
|
||||
if (FRIEND_LINKS.length === 0) return null;
|
||||
export function FriendLinks({ links }: { links: FriendLink[] }) {
|
||||
// 块显隐由侧栏布局配置控制;列表为空时仍渲染(保留「申请」入口),仅展示占位提示
|
||||
return (
|
||||
<section className="j13-module" aria-label="友情链接">
|
||||
<p className="j13-module-title mb-3">
|
||||
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
|
||||
<Link
|
||||
href="/compose"
|
||||
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
申请
|
||||
</Link>
|
||||
<span className="ml-auto inline-flex items-center gap-3 text-[12px] font-normal">
|
||||
<Link href="/links" className="meta transition-colors hover:text-[var(--accent)]">
|
||||
全部
|
||||
</Link>
|
||||
<Link
|
||||
href="/links#apply"
|
||||
className="meta transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
申请
|
||||
</Link>
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FRIEND_LINKS.map((l) =>
|
||||
l.href.startsWith("http") ? (
|
||||
{links.length === 0 ? (
|
||||
<p className="meta text-[12.5px]">暂无友链,欢迎申请</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.name}
|
||||
href={l.href}
|
||||
key={l.id}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="j13-chip"
|
||||
className="j13-chip inline-flex items-center gap-1.5"
|
||||
title={l.description || l.name}
|
||||
>
|
||||
{l.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={l.logo_url}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="object-contain"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
) : null}
|
||||
{l.name}
|
||||
</a>
|
||||
) : (
|
||||
<span key={l.name} className="j13-chip" data-tip="友链待配置">
|
||||
{l.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -452,6 +467,7 @@ type PanelsProps = {
|
||||
sponsors?: PublicSponsorItem[];
|
||||
sponsorsPanelTitle?: string;
|
||||
sponsorsEnabled?: boolean;
|
||||
friendLinks?: FriendLink[];
|
||||
};
|
||||
|
||||
export function AdsPanel({
|
||||
@@ -615,6 +631,7 @@ function CommunityPanelsBody({
|
||||
sponsors = [],
|
||||
sponsorsPanelTitle = "赞助商",
|
||||
sponsorsEnabled = true,
|
||||
friendLinks = [],
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -625,7 +642,7 @@ function CommunityPanelsBody({
|
||||
<HotRanking posts={hot} allowComments={allowComments} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||
<FriendLinks />
|
||||
<FriendLinks links={friendLinks} />
|
||||
<AboutSite
|
||||
pages={sidebarPages}
|
||||
siteDescription={siteDescription}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Home, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
function boardHref(id: number) {
|
||||
@@ -116,13 +117,27 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
|
||||
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
|
||||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载。
|
||||
// 板块条两端嵌左右抽屉入口(☰ 导航 / ▦ 面板),与书籍阅读页的左右工具栏同构:
|
||||
// 左抽屉 = 桌面左栏(板块目录 + 工作台),右抽屉 = 桌面右栏面板;内容为空则对应入口不渲染
|
||||
export function WorkspaceNavMobile({
|
||||
boards,
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
boards: BoardWithCount[];
|
||||
/** 左抽屉内容(桌面左栏,服务端渲染节点) */
|
||||
left?: ReactNode;
|
||||
/** 右抽屉内容(桌面右栏,服务端渲染节点) */
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
|
||||
useDrawerChrome(drawer !== null, () => setDrawer(null));
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -151,43 +166,88 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
|
||||
});
|
||||
}, [activeBoard, boards]);
|
||||
|
||||
const triggerBtn =
|
||||
"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
|
||||
// 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
|
||||
"before:absolute before:-inset-2 before:rounded-full before:content-['']";
|
||||
|
||||
return (
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="j13-chips"
|
||||
onScroll={updateScrollState}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
{left ? (
|
||||
<button
|
||||
type="button"
|
||||
className={triggerBtn}
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
|
||||
onClick={() => setDrawer("left")}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="打开导航抽屉"
|
||||
>
|
||||
<Menu size={15} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="j13-chips"
|
||||
onScroll={updateScrollState}
|
||||
>
|
||||
<Home size={13} /> 全部
|
||||
</Link>
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
<Home size={13} /> 全部
|
||||
</Link>
|
||||
))}
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{right ? (
|
||||
<button
|
||||
type="button"
|
||||
className={triggerBtn}
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
|
||||
onClick={() => setDrawer("right")}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="打开面板抽屉"
|
||||
>
|
||||
<LayoutGrid size={15} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
|
||||
{left ? (
|
||||
<RailDrawerPanel side="left" title="导航" open={drawer === "left"} onClose={() => setDrawer(null)}>
|
||||
{left}
|
||||
</RailDrawerPanel>
|
||||
) : null}
|
||||
{right ? (
|
||||
<RailDrawerPanel side="right" title="面板" open={drawer === "right"} onClose={() => setDrawer(null)}>
|
||||
{right}
|
||||
</RailDrawerPanel>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user