feat(ui): 全站画布氛围层、侧栏 sticky 对齐与长页回顶
用顶栏/主栏高度令牌消除 sticky 首滚空走,面包屑叠到中栏上方,公开页增加回顶浮钮。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
39
frontend/components/BackToTop.tsx
Normal file
39
frontend/components/BackToTop.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
|
||||
const SHOW_AFTER = 400;
|
||||
|
||||
/**
|
||||
* 公开站长页回顶:滚过阈值后右下角浮现。
|
||||
* 短页自然不出现;帖子移动端底栏时抬高,避免叠住操作条。
|
||||
*/
|
||||
export default function BackToTop({
|
||||
liftForMobileBar = false,
|
||||
}: {
|
||||
liftForMobileBar?: boolean;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > SHOW_AFTER);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`j13-back-top j13-toast-in${liftForMobileBar ? " is-lifted" : ""}`}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: scrollBehavior() })}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<ArrowUp size={18} strokeWidth={2.25} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export default function Header({
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[68px] flex items-center gap-4 lg:gap-7">
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
||||
{/* 左组:品牌 + 主导航 */}
|
||||
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, MessageSquare } from "lucide-react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import LikeButton from "./LikeButton";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* 帖子详情移动端浮动操作条(lg 以下显示):
|
||||
* 长文读到中途也能点赞 / 跳评论框 / 回顶部,不必滚到文末。
|
||||
* 长文读到中途也能点赞 / 跳评论框;回顶由全局 BackToTop 承担。
|
||||
*/
|
||||
export default function MobilePostBar({
|
||||
postId,
|
||||
@@ -20,15 +19,6 @@ export default function MobilePostBar({
|
||||
likeCount: number;
|
||||
allowComments?: boolean;
|
||||
}) {
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setShowTop(window.scrollY > 400);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToComments = () => {
|
||||
document.getElementById("comment-input")?.scrollIntoView({
|
||||
behavior: scrollBehavior(),
|
||||
@@ -36,13 +26,6 @@ export default function MobilePostBar({
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: scrollBehavior(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
|
||||
<div
|
||||
@@ -66,21 +49,9 @@ export default function MobilePostBar({
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
{/* 放大点赞按钮的触控区域(样式见 globals.css .j13-bar-like) */}
|
||||
<div className="j13-bar-like">
|
||||
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||
</div>
|
||||
{showTop && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-toast-in"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import BackToTop from "@/components/BackToTop";
|
||||
import type { User } from "@/lib/api";
|
||||
|
||||
function pathIsAdmin(pathname: string | null) {
|
||||
@@ -11,6 +12,17 @@ function pathIsAdmin(pathname: string | null) {
|
||||
return pathname === "/admin" || pathname.startsWith("/admin/");
|
||||
}
|
||||
|
||||
function pathIsChat(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
return pathname === "/chat" || pathname.startsWith("/chat/");
|
||||
}
|
||||
|
||||
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
|
||||
function pathIsPostDetail(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
return /^\/post\/[^/]+$/.test(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。
|
||||
* 首屏用 middleware 的 x-pathname 初值保证 hydration 一致;
|
||||
@@ -63,6 +75,9 @@ export default function SiteChrome({
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 聊天页锁视口、主滚动在对话区,窗口回顶无意义
|
||||
const showBackToTop = !pathIsChat(pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
@@ -76,11 +91,14 @@ export default function SiteChrome({
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
|
||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-[var(--main-pad-y)] outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer siteName={siteName} />
|
||||
{showBackToTop ? (
|
||||
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,8 +283,11 @@ function BoardPanelsBody(props: BoardPanelsProps) {
|
||||
|
||||
export function BoardPanels(props: BoardPanelsProps) {
|
||||
return (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
|
||||
<div className="sticky top-24 space-y-4">
|
||||
<aside
|
||||
className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]"
|
||||
aria-label="板块面板"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<BoardPanelsBody {...props} />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -302,18 +305,19 @@ export function BoardPanelsMobile(props: BoardPanelsProps) {
|
||||
/** 板块不存在时的简卡 */
|
||||
export function BoardMissingPanel() {
|
||||
return (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
|
||||
<div className="sticky top-24">
|
||||
<section className="j13-module">
|
||||
<p className="j13-module-title mb-2">
|
||||
<LayoutGrid size={15} style={{ color: "var(--ink-3)" }} /> 板块
|
||||
</p>
|
||||
<p className="meta">板块不存在或已删除</p>
|
||||
<Link href="/" className="btn btn-line btn-sm mt-3 w-full justify-center">
|
||||
返回全部讨论
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
<aside
|
||||
className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]"
|
||||
aria-label="板块面板"
|
||||
>
|
||||
<section className="j13-module">
|
||||
<p className="j13-module-title mb-2">
|
||||
<LayoutGrid size={15} style={{ color: "var(--ink-3)" }} /> 板块
|
||||
</p>
|
||||
<p className="meta">板块不存在或已删除</p>
|
||||
<Link href="/" className="btn btn-line btn-sm mt-3 w-full justify-center">
|
||||
返回全部讨论
|
||||
</Link>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -450,8 +450,11 @@ function CommunityPanelsBody({
|
||||
|
||||
export function CommunityPanels(props: PanelsProps) {
|
||||
return (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||
<div className="sticky top-24 space-y-4">
|
||||
<aside
|
||||
className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]"
|
||||
aria-label="社区面板"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<CommunityPanelsBody {...props} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -16,8 +16,12 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:block w-56 xl:w-60 shrink-0" aria-label="工作台导航">
|
||||
<div className="sticky top-24 space-y-5">
|
||||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||||
<nav
|
||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] j13-rail-scroll"
|
||||
aria-label="工作台导航"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
|
||||
<div className="space-y-0.5">
|
||||
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
|
||||
|
||||
@@ -3,24 +3,29 @@ import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { href?: string; label: string };
|
||||
|
||||
/** 公文页面包屑 */
|
||||
/** 位置指引:桌面叠在中栏卡片上方的内边距里,不拉低卡片、不对齐到左栏 */
|
||||
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
|
||||
return (
|
||||
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]" aria-label="面包屑">
|
||||
<nav
|
||||
className="meta mb-2 flex items-center gap-1 flex-wrap text-[12.5px] leading-none lg:absolute lg:bottom-full lg:left-0 lg:right-0 lg:mb-0 lg:pb-2 lg:pl-1"
|
||||
aria-label="面包屑"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const last = i === items.length - 1;
|
||||
const cls = last ? "truncate" : "shrink-0";
|
||||
return (
|
||||
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1.5 min-w-0">
|
||||
{i > 0 ? <ChevronRight size={13} className="shrink-0" /> : null}
|
||||
{item.href && !last ? (
|
||||
<Link href={item.href} className="hover:text-[var(--accent)] transition-colors shrink-0">
|
||||
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1 min-w-0">
|
||||
{i > 0 ? <ChevronRight size={12} className="shrink-0 opacity-70" aria-hidden /> : null}
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`hover:text-[var(--accent)] transition-colors ${cls}`}
|
||||
style={last ? { color: "var(--accent)" } : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={last ? "truncate" : "shrink-0"}
|
||||
style={last ? { color: "var(--accent)" } : undefined}
|
||||
>
|
||||
<span className={cls} style={last ? { color: "var(--accent)" } : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,14 @@ export default function SiteDocShell({
|
||||
left,
|
||||
right,
|
||||
children,
|
||||
breadcrumb,
|
||||
mobileExtras,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 中栏卡片上方的位置指引;桌面绝对定位,不占栅格高度 */
|
||||
breadcrumb?: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
}) {
|
||||
@@ -20,16 +23,19 @@ export default function SiteDocShell({
|
||||
<>
|
||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
|
||||
<aside
|
||||
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||
aria-label="站点导航"
|
||||
>
|
||||
{left}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">{children}</div>
|
||||
<div className="min-w-0 relative">
|
||||
{breadcrumb}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||
aria-label="阅读辅助"
|
||||
>
|
||||
{right}
|
||||
|
||||
Reference in New Issue
Block a user