feat: 新增返回顶部按钮
torbox-admin / deploy (push) Successful in 1m16s

This commit is contained in:
2026-10-06 22:32:36 -07:00
parent aa2c0e33dd
commit bd1ac6bab8
6 changed files with 132 additions and 35 deletions
+1
View File
@@ -74,6 +74,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
- **例外一**:等宽卡片网格使用 grid(见 `FileGrid` 组件),列数随宽度 `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5`,**一行最多 5 张卡片**,卡片宽度保持在约 190–240px。flex-wrap 做不到"等宽 + 末行不拉伸",grid 也不属于定位。 - **例外一**:等宽卡片网格使用 grid(见 `FileGrid` 组件),列数随宽度 `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5`,**一行最多 5 张卡片**,卡片宽度保持在约 190–240px。flex-wrap 做不到"等宽 + 末行不拉伸",grid 也不属于定位。
- **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。手机宽度下资源库工具栏也用这种叠层浮在滚动容器上方(`max-md:grid`,宽屏仍是 flex 纵向排列),收起时只做 transform,滚动容器尺寸不变;列表从工具栏下方开始(虚拟器 `paddingStart` 加上工具栏高度)。 - **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。手机宽度下资源库工具栏也用这种叠层浮在滚动容器上方(`max-md:grid`,宽屏仍是 flex 纵向排列),收起时只做 transform,滚动容器尺寸不变;列表从工具栏下方开始(虚拟器 `paddingStart` 加上工具栏高度)。
- **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。标题吸在工具栏露出部分的下沿:`top` 读滚动容器上的 `--toolbar-inset`,由工具栏位移的 MotionValue 逐帧换算,与工具栏同帧移动(宽屏为 0)。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。 - **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。标题吸在工具栏露出部分的下沿:`top` 读滚动容器上的 `--toolbar-inset`,由工具栏位移的 MotionValue 逐帧换算,与工具栏同帧移动(宽屏为 0)。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。
- **例外四·返回顶部按钮**:资源库右下角的 `BackToTopButton` 用 `absolute` 贴在滚动容器外层的 `relative` 容器右下角(`right-6 bottom-6`),不随内容滚动;滑入起点的水平偏移小于留白,不会撑出容器、不产生额外滚动条。
- 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。 - 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。
- shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。 - shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。
- **浮层层级**:Dialog、Sheet、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。 - **浮层层级**:Dialog、Sheet、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。
@@ -0,0 +1,49 @@
// 资源列表右下角的返回顶部按钮
"use client";
import type { RefObject } from "react";
import { ArrowUpIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { BACK_TO_TOP } from "@/constants/motion";
import { useScrolledPast } from "@/hooks/use-scrolled-past";
/** 绝对定位在最近的 relative 祖先右下角,不占布局、不随内容滚动 */
export const BackToTopButton = ({
scrollRef
}: {
scrollRef: RefObject<HTMLElement | null>;
}) => {
const visible = useScrolledPast(scrollRef, BACK_TO_TOP.showOffset);
const reduced = useReducedMotion();
return (
<AnimatePresence>
{visible && (
<motion.div
className="absolute right-4 sm:right-10 bottom-4 sm:bottom-6 z-10"
initial={{ opacity: 0, x: BACK_TO_TOP.offsetX }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: BACK_TO_TOP.offsetX }}
transition={reduced ? { duration: 0 } : BACK_TO_TOP.transition}
>
<Button
variant="outline"
size="icon-lg"
className="border-foreground/25 bg-card shadow-lg hover:bg-muted"
aria-label="返回顶部"
onClick={() =>
scrollRef.current?.scrollTo({
top: 0,
behavior: reduced ? "auto" : "smooth"
})
}
>
<ArrowUpIcon />
</Button>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -34,12 +34,17 @@ import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
import { findLibraryItem, type LibraryFileTarget } from "./library-rows"; import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
import { VideoPlaylist } from "./video-playlist"; import { VideoPlaylist } from "./video-playlist";
/** 16:9 铺满主列,尺寸由主列宽度决定。手机宽度下贴边、去掉圆角 */
const PLAYER_CLASS = "aspect-video w-full max-md:rounded-none";
/** /**
* 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置 * 主列(视频 + 标题信息)的宽度上限由视口高度换算:高度 − 顶栏、边距与标题信息的留白,再按 16:9 折算,
* (sm 起还要让出顶栏与上边距)。手机宽度下贴边、去掉圆角 * 矮屏时视频等比缩小,标题与视频始终同宽对齐。
* 宽屏(lg 起)有下限 40rem,再矮就让主列内容滚动而不是继续缩小;
* 宽屏下主列与右侧播放列表作为一组居中,列表紧贴视频,不会被甩到屏幕边缘
*/ */
const PLAYER_CLASS = const COLUMN_CLASS =
"mx-auto aspect-video w-full max-w-[calc((100dvh-9rem)*16/9)] sm:max-w-[calc((100dvh-14rem)*16/9)] max-md:rounded-none"; "mx-auto w-full max-w-[calc((100dvh-9rem)*16/9)] sm:max-w-[calc((100dvh-14rem)*16/9)] lg:mx-0 lg:max-w-[max(40rem,calc((100dvh-14rem)*16/9))]";
/** 播放器较大,打开时才加载 */ /** 播放器较大,打开时才加载 */
const VideoPlayer = dynamic(importVideoPlayer, { const VideoPlayer = dynamic(importVideoPlayer, {
@@ -212,9 +217,14 @@ export const LibraryVideoDialog = ({
className="inset-0 flex max-w-none translate-x-0 translate-y-0 rounded-none p-0 ring-0 sm:top-14.25 sm:max-w-none" className="inset-0 flex max-w-none translate-x-0 translate-y-0 rounded-none p-0 ring-0 sm:top-14.25 sm:max-w-none"
> >
{/* 与顶栏、资源列表同样限宽 1920px 居中 */} {/* 与顶栏、资源列表同样限宽 1920px 居中 */}
<div className="mx-auto flex w-full max-w-[1920px] min-w-0 flex-col md:px-6 md:pt-6 lg:flex-row lg:gap-10"> <div className="mx-auto flex w-full max-w-[1920px] min-w-0 flex-col md:px-6 md:pt-6 lg:flex-row lg:justify-center lg:gap-10">
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */} {/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col",
COLUMN_CLASS
)}
>
<div className="shrink-0"> <div className="shrink-0">
<LimitAwareVideoPlayer <LimitAwareVideoPlayer
key={hlsUrl ?? streamUrl} key={hlsUrl ?? streamUrl}
+34 -29
View File
@@ -38,6 +38,7 @@ import {
} from "@/stores/library-view-store"; } from "@/stores/library-view-store";
import type { SourceType } from "@/types/torbox"; import type { SourceType } from "@/types/torbox";
import { BackToTopButton } from "./back-to-top-button";
import { LibraryList } from "./library-list"; import { LibraryList } from "./library-list";
import { getItemKey } from "./library-rows"; import { getItemKey } from "./library-rows";
import { LibrarySkeleton } from "./library-skeleton"; import { LibrarySkeleton } from "./library-skeleton";
@@ -172,35 +173,39 @@ export const LibraryView = ({ token }: { token: string }) => {
/> />
</motion.div> </motion.div>
{/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */} {/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */}
<motion.div {/* relative 容器是返回顶部按钮的定位参照,按钮不随滚动容器的内容移动 */}
ref={scrollRef} <div className="relative flex min-h-0 flex-1 flex-col">
className="min-h-0 flex-1 overflow-y-auto" <motion.div
style={ ref={scrollRef}
{ className="min-h-0 flex-1 overflow-y-auto"
// MotionStyle 未声明 CSS 变量,运行时按 MotionValue 逐帧写入 style={
"--toolbar-inset": stickyInset, {
scrollPaddingTop: `calc(3.5rem + ${toolbarInset}px)` // MotionStyle 未声明 CSS 变量,运行时按 MotionValue 逐帧写入
} as MotionStyle "--toolbar-inset": stickyInset,
} scrollPaddingTop: `calc(3.5rem + ${toolbarInset}px)`
> } as MotionStyle
{content ? ( }
<div >
className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6" {content ? (
style={{ marginTop: toolbarInset }} <div
> className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6"
{content} style={{ marginTop: toolbarInset }}
</div> >
) : ( {content}
<LibraryList </div>
scrollRef={scrollRef} ) : (
items={items} <LibraryList
notices={notices} scrollRef={scrollRef}
token={token} items={items}
toolbarInset={toolbarInset} notices={notices}
toolbarY={toolbarY} token={token}
/> toolbarInset={toolbarInset}
)} toolbarY={toolbarY}
</motion.div> />
)}
</motion.div>
<BackToTopButton scrollRef={scrollRef} />
</div>
</div> </div>
); );
}; };
+9
View File
@@ -158,6 +158,15 @@ export const TOOLBAR_COLLAPSE = {
transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] } transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
} as const; } as const;
/** 右下角返回顶部按钮:滚过一屏左右出现,淡入并从右侧滑入 */
export const BACK_TO_TOP = {
/** 滚动距离超过这个值(px)才显示 */
showOffset: 480,
/** 滑入起点相对终点的水平偏移(px),小于按钮到容器右缘的留白,不会撑出容器 */
offsetX: 20,
transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] }
} as const;
/** 手机顶栏菜单按钮上的筛选提示点:出现时弹出,消失时收回 */ /** 手机顶栏菜单按钮上的筛选提示点:出现时弹出,消失时收回 */
export const INDICATOR_DOT = { export const INDICATOR_DOT = {
transition: { type: "spring", stiffness: 640, damping: 22 } transition: { type: "spring", stiffness: 640, damping: 22 }
+23
View File
@@ -0,0 +1,23 @@
// 容器滚动超过指定距离后返回 true 的 hook
import { useEffect, useState, type RefObject } from "react";
/** 容器的 scrollTop 超过 offset(px)时返回 true;只在越过边界时触发渲染 */
export const useScrolledPast = (
containerRef: RefObject<HTMLElement | null>,
offset: number
) => {
const [past, setPast] = useState(false);
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const update = () => setPast(node.scrollTop > offset);
update();
node.addEventListener("scroll", update, { passive: true });
return () => node.removeEventListener("scroll", update);
}, [containerRef, offset]);
return past;
};