@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user