feat: 交互布局优化
torbox-admin / deploy (push) Successful in 2m17s

This commit is contained in:
2026-10-06 10:04:55 -07:00
parent e8f62bc89a
commit 77f7c9b0ea
11 changed files with 184 additions and 91 deletions
+3 -3
View File
@@ -53,7 +53,7 @@ TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页
- `--radius: 0.375rem`(6px,与官网一致),组件按 shadcn 派生规则取 `rounded-lg` / `rounded-xl`。
- 间距只用 `gap-*`,不用 `space-x/y-*`;常用节奏:控件间 `gap-2`,区块内 `gap-3`,区块间 `gap-8`。
- 内容最大宽度 `sm:max-w-5/6`,水平内边距 `px-4 md:px-6`;header、工具栏的内层容器与内容区对齐。
- 内容最大宽度 `sm:max-w-[1920px]`,水平内边距 `px-4 md:px-6`;header、工具栏的内层容器与内容区对齐。
- 等宽等高用 `size-*`,单行截断用 `truncate`,多行截断用 `line-clamp-*`。
## 5. 布局规则
@@ -106,7 +106,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 下拉操作 | `DropdownMenu` |
| 侧滑面板 | `Sheet`(右侧滑出、高度撑满、右上角关闭):手机顶栏菜单 |
| 图标按钮说明 | `Tooltip` |
| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在全屏 `Dialog` 中播放(类似视频网站的播放页),`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),信息行注明「TorBox 转码」;`onClose` 在画面左上角放关闭按钮(皮肤的 `topStart` 插槽,样式与显隐同右上角按钮组);标题右侧提供下载与「PotPlayer 播放」(手机宽度 `< md` 隐藏 PotPlayer);播放错误说明替换为中文并提示换用 PotPlayer |
| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放(类似视频网站的播放页):手机全屏,`sm` 起铺满顶栏以下、顶栏保持可见,内容限宽 1920px 居中;非模态(`modal={false}`),看视频时顶栏照常可用,被盖住的 `<main>` 设为 `inert`;不渲染变暗模糊的遮罩(`showOverlay={false}`),点击弹窗外不关闭(`disablePointerDismissal`),`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),信息行注明「TorBox 转码」;`onClose` 在画面左上角放关闭按钮(皮肤的 `topStart` 插槽,样式与显隐同右上角按钮组);标题右侧提供下载与「PotPlayer 播放」(手机宽度 `< md` 隐藏 PotPlayer);播放错误说明替换为中文并提示换用 PotPlayer |
| 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) |
| 错误提示 | `Alert variant="destructive"` |
@@ -204,7 +204,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
| 视频播放 | 点击视频封面或「播放」全屏打开并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);视频在顶部(手机贴边),下方依次是标题、信息行与操作;视频宽度上限按视口高度换算(`(100dvh - 9rem) × 16/9`),矮屏时等比缩小居中;关闭按钮在视频左上角、随控制栏显隐,也可 Esc 或返回键 / 返回手势关闭(打开时压入一条历史记录,用其他方式关闭时撤回,不留多余记录);「PotPlayer 播放」交给外部播放器后关闭 | `LibraryVideoDialog` + `VideoPlayer` + `useCloseOnBack`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
| 视频播放 | 点击视频封面或「播放」全屏打开并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);视频在顶部(手机贴边),下方依次是标题、信息行与操作;视频宽度上限按视口高度换算(`(100dvh - 9rem) × 16/9`,`sm` 起扣掉顶栏用 `14rem`),矮屏时等比缩小居中;关闭按钮在视频左上角、随控制栏显隐,也可点顶栏品牌标识(看视频时变成「返回资源库」按钮,经 `useVideoDialogStore` 登记的关闭方法)、Esc 或返回键 / 返回手势关闭(打开时压入一条历史记录,用其他方式关闭时撤回,不留多余记录);「PotPlayer 播放」交给外部播放器后关闭 | `LibraryVideoDialog` + `VideoPlayer` + `useCloseOnBack`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
| 视频播放列表 | 同一资源的视频超过一个时始终显示,无边框,标题行只有「播放列表 3 / 12」:宽屏(`≥ lg`)在右侧占满高度,窄屏在信息行下方占满剩余高度(最低 12rem,更矮时整块滚动),内容多时只滚动列表;点击切换,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 |
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
| 手机顶栏菜单 | 宽度 `< md` 时顶栏右侧的播放列表、退出登录与工具栏的来源 / 下载状态 / 类型三组筛选收进右上角汉堡按钮;点击后菜单从右侧滑出(高度撑满,右上角关闭):筛选按等宽网格排列并带「重置」(只清这三项,保留关键词),下面是 M3U 的「复制 M3U 地址」(手机上没有 PotPlayer,不提供「用 PotPlayer 打开」),退出登录固定在底部;三项中任一项不是「全部」时汉堡图标右上角弹出翠绿提示点,读屏名为「菜单(已启用筛选)」 | `MobileMenu`(`Sheet`)+ `LibraryFilters layout="stacked"`(与工具栏共用);提示点用单格 grid 叠层 + Motion `scale`,参数见 `INDICATOR_DOT`;来源计数只在菜单打开时计算 |
+3 -3
View File
@@ -1,9 +1,9 @@
// 应用顶栏
import { BrandMark } from "@/components/brand-mark";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { AddMagnetButton } from "./add-magnet-button";
import { HeaderBrand } from "./header-brand";
import { LogoutButton } from "./logout-button";
import { MobileMenu } from "./mobile-menu";
import { PlaylistMenu } from "./playlist-menu";
@@ -19,8 +19,8 @@ type AppHeaderProps = {
export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
<header className="shrink-0 border-b">
<div className="mx-auto flex h-14 w-full sm:max-w-5/6 items-center gap-3 px-4 md:px-6">
<BrandMark />
<div className="mx-auto flex h-14 w-full sm:max-w-[1920px] items-center gap-3 px-4 md:px-6">
<HeaderBrand />
{/* 手机宽度下放不下,演示模式只在登录页提示 */}
{mock && (
<Badge
+33
View File
@@ -0,0 +1,33 @@
// 顶栏品牌标识:看视频时点击关闭视频弹窗,回到资源库
"use client";
import { BrandMark } from "@/components/brand-mark";
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from "@/components/ui/tooltip";
import { useVideoDialogStore } from "@/stores/video-dialog-store";
export const HeaderBrand = () => {
const closeVideo = useVideoDialogStore((state) => state.close);
if (!closeVideo) return <BrandMark />;
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="关闭视频,返回资源库"
className="cursor-pointer rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={closeVideo}
/>
}
>
<BrandMark />
</TooltipTrigger>
<TooltipContent side="bottom">返回资源库</TooltipContent>
</Tooltip>
);
};
+4 -1
View File
@@ -229,7 +229,10 @@ export const LibraryList = (props: LibraryListProps) => {
const [contentRef, width] = useElementWidth<HTMLDivElement>();
return (
<div ref={contentRef} className="mx-auto w-full sm:max-w-5/6 px-4 md:px-6">
<div
ref={contentRef}
className="mx-auto w-full sm:max-w-[1920px] px-4 md:px-6"
>
{/* 先拿到容器宽度再创建虚拟器,保证首帧列数与快照恢复一致 */}
{width > 0 && <VirtualRows {...props} width={width} />}
</div>
+1 -1
View File
@@ -198,7 +198,7 @@ export const LibraryToolbar = ({
return (
<div className="shrink-0 border-b">
<div className="mx-auto flex w-full sm:max-w-5/6 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 md:px-6">
<div className="mx-auto flex w-full sm:max-w-[1920px] flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 md:px-6">
<InputGroup className="w-full sm:w-64">
<InputGroupAddon>
<SearchIcon />
+107 -75
View File
@@ -1,4 +1,4 @@
// 资源库视频播放:全屏打开,视频在上、信息在下,播放列表手机在下方、宽屏在右侧
// 资源库视频播放:手机全屏、宽屏铺满顶栏以下,视频在上、信息在下,播放列表手机在下方、宽屏在右侧
"use client";
import { useEffect, useState } from "react";
@@ -23,6 +23,7 @@ import { useCloseOnBack } from "@/hooks/use-close-on-back";
import type { LibraryItemView } from "@/hooks/use-library";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useOrigin } from "@/hooks/use-origin";
import { useVideoDialogStore } from "@/stores/video-dialog-store";
import { formatBytes } from "@/utils/format";
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
@@ -30,11 +31,11 @@ import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
import { VideoPlaylist } from "./video-playlist";
/**
* 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置。
* 手机宽度下贴边、去掉圆角
* 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置
* (sm 起还要让出顶栏与上边距)。手机宽度下贴边、去掉圆角
*/
const PLAYER_CLASS =
"mx-auto aspect-video w-full max-w-[calc((100dvh-9rem)*16/9)] max-md:rounded-none";
"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";
/** 播放器较大,打开时才加载 */
const VideoPlayer = dynamic(importVideoPlayer, {
@@ -98,6 +99,23 @@ export const LibraryVideoDialog = ({
// 手机返回键 / 返回手势先关闭播放,不离开页面
useCloseOnBack(open, onClose);
// 非模态弹窗不会让页面其余部分失效:把被它盖住的内容区(顶栏以下的 <main>)设为 inert,
// Tab 与「/」聚焦搜索等快捷键不会落到看不见的资源列表上;顶栏不受影响
useEffect(() => {
if (!open) return;
const main = document.querySelector("main");
main?.setAttribute("inert", "");
return () => main?.removeAttribute("inert");
}, [open]);
// 打开期间登记关闭方法:点顶栏品牌标识也能关闭,回到资源库
const setClose = useVideoDialogStore((state) => state.setClose);
useEffect(() => {
if (!open) return;
setClose(onClose);
return () => setClose(null);
}, [open, onClose, setClose]);
// 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎
const ready = streamUrl !== null;
const needsHls = hlsUrl !== null;
@@ -111,89 +129,103 @@ export const LibraryVideoDialog = ({
onOpenChange={(next) => {
if (!next) onClose();
}}
// 非模态:看视频时顶栏照常可用;点击弹窗外或焦点移出都不关闭,
// 关闭按钮在视频左上角,也可 Esc 或返回键关闭
modal={false}
disablePointerDismissal
>
{file && streamUrl && (
<DialogContent
showCloseButton={false}
// 全屏:关闭按钮在视频左上角,返回键也能关闭
className="inset-0 flex max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none p-0 ring-0 sm:max-w-none lg:flex-row"
// 不变暗、不模糊,顶栏保持清晰可见
showOverlay={false}
// 手机全屏;sm 起从顶栏下方开始(h-14 + 1px 边框),顶栏露在外面
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"
>
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="shrink-0 md:px-4 md:pt-4">
<VideoPlayer
key={hlsUrl ?? streamUrl}
src={hlsUrl ?? streamUrl}
hls={hlsUrl !== null}
title={file.displayName}
autoPlay
autoFocus
onEnded={nextFile && (() => selectFile(nextFile))}
onClose={onClose}
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
className={PLAYER_CLASS}
/>
</div>
{/* 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动 */}
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<div className="flex shrink-0 items-start gap-3">
<DialogHeader className="min-w-0 flex-1 gap-1.5">
<DialogTitle
className="line-clamp-2 leading-snug wrap-anywhere"
title={file.name}
>
{file.displayName}
</DialogTitle>
<DialogDescription className="font-mono text-xs tabular-nums">
<span className="uppercase">{file.ext || "file"}</span> ·{" "}
{formatBytes(file.size)}
{hlsUrl && " · TorBox 转码"}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
render={<a href={streamUrl} download={file.displayName} />}
nativeButton={false}
>
<DownloadIcon data-icon="inline-start" />
下载
</Button>
{/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */}
<Button
size="sm"
className="hidden md:inline-flex"
render={<a href={toPotPlayerUrl(streamUrl)} />}
nativeButton={false}
onClick={onClose}
>
<MonitorPlayIcon data-icon="inline-start" />
PotPlayer 播放
</Button>
</div>
{/* 与顶栏、资源列表同样限宽 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="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="shrink-0">
<VideoPlayer
key={hlsUrl ?? streamUrl}
src={hlsUrl ?? streamUrl}
hls={hlsUrl !== null}
title={file.displayName}
autoPlay
autoFocus
onEnded={nextFile && (() => selectFile(nextFile))}
onClose={onClose}
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
className={PLAYER_CLASS}
/>
</div>
{hasPlaylist && !wide && (
{/*
播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动。
md 起向两侧外扩 1rem 再补回内边距:文字与视频左缘对齐,列表项底色外扩时不被裁切
*/}
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 md:-mx-4 md:pb-6">
<div className="flex shrink-0 items-start gap-3">
<DialogHeader className="min-w-0 flex-1 gap-1.5">
<DialogTitle
className="line-clamp-2 leading-snug wrap-anywhere sm:text-2xl"
title={file.name}
>
{file.displayName}
</DialogTitle>
<DialogDescription className="font-mono text-xs tabular-nums">
<span className="uppercase">{file.ext || "file"}</span> ·{" "}
{formatBytes(file.size)}
{hlsUrl && " · TorBox 转码"}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
render={
<a href={streamUrl} download={file.displayName} />
}
nativeButton={false}
>
<DownloadIcon data-icon="inline-start" />
下载
</Button>
{/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */}
<Button
size="sm"
className="hidden md:inline-flex"
render={<a href={toPotPlayerUrl(streamUrl)} />}
nativeButton={false}
onClick={onClose}
>
<MonitorPlayIcon data-icon="inline-start" />
PotPlayer 播放
</Button>
</div>
</div>
{hasPlaylist && !wide && (
<VideoPlaylist
files={playlist}
currentId={file.id}
onSelect={selectFile}
className="min-h-48 flex-1"
/>
)}
</div>
</div>
{/* 宽屏:播放列表在右侧,占满高度、单独滚动 */}
{hasPlaylist && wide && (
<div className="flex w-88 shrink-0 flex-col pb-6">
<VideoPlaylist
files={playlist}
currentId={file.id}
onSelect={selectFile}
className="min-h-48 flex-1"
className="min-h-0 flex-1"
/>
)}
</div>
</div>
)}
</div>
{/* 宽屏:播放列表在右侧,占满高度、单独滚动 */}
{hasPlaylist && wide && (
<div className="flex w-88 shrink-0 flex-col py-4 pr-4">
<VideoPlaylist
files={playlist}
currentId={file.id}
onSelect={selectFile}
className="min-h-0 flex-1"
/>
</div>
)}
</DialogContent>
)}
</Dialog>
+1 -1
View File
@@ -135,7 +135,7 @@ export const LibraryView = ({ token }: { token: string }) => {
className="min-h-0 flex-1 scroll-pt-14 overflow-y-auto"
>
{content ? (
<div className="mx-auto flex w-full sm:max-w-5/6 flex-col gap-8 p-4 md:p-6">
<div className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6">
{content}
</div>
) : (
+1 -1
View File
@@ -38,7 +38,7 @@ export const VideoPlaylist = ({
className={cn("flex min-h-0 flex-col gap-1", className)}
>
<header className="flex shrink-0 items-baseline gap-2">
<h3 className="text-sm font-medium">播放列表</h3>
<h3 className="text-sm sm:text-lg sm:pb-2 font-medium">播放列表</h3>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{index + 1} / {files.length}
</span>
+4 -1
View File
@@ -43,13 +43,16 @@ function DialogContent({
className,
children,
showCloseButton = true,
showOverlay = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
/** 关闭时不渲染变暗、模糊的遮罩;模态弹窗仍会拦截弹窗外的点击 */
showOverlay?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
{showOverlay && <DialogOverlay />}
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
+13 -5
View File
@@ -1,9 +1,11 @@
// 弹层与浏览器返回键联动的 hook
import { useEffect, useEffectEvent } from "react";
/** history.state 上的标记,区分本 hook 压入的记录 */
/** history.state 上的标记,值为每次打开时生成的 id,区分本 hook 压入的记录 */
const MARKER = "__closeOnBack";
let sequence = 0;
/**
* 弹层打开时压入一条同地址的历史记录:返回键 / 返回手势先关闭弹层,不离开页面;
* 用关闭按钮、Esc 等其他方式关闭时撤回这条记录,历史栈保持原样。
@@ -14,18 +16,24 @@ export const useCloseOnBack = (open: boolean, onClose: () => void) => {
useEffect(() => {
if (!open) return;
window.history.pushState({ [MARKER]: true }, "");
// 每次打开用新的 id:刷新页面后 history.state 会保留,旧记录上的标记不能当成本次的
const id = `${Date.now()}-${++sequence}`;
window.history.pushState({ [MARKER]: id }, "");
let popped = false;
const onPopState = (event: PopStateEvent) => {
// 仍停在本 hook 压入的记录上(开发模式 effect 重跑时撤回的上一条),不处理
if (event.state?.[MARKER]) return;
// 前进键回到本次压入的记录,不处理;回到其他任何记录都视为返回
if (event.state?.[MARKER] === id) return;
popped = true;
close();
};
window.addEventListener("popstate", onPopState);
return () => {
window.removeEventListener("popstate", onPopState);
if (!popped && window.history.state?.[MARKER]) window.history.back();
if (popped) return;
// 延后一拍再撤回:开发模式下 effect 会立即重跑并压入新记录,此时当前记录已不是本次的,不再撤回
queueMicrotask(() => {
if (window.history.state?.[MARKER] === id) window.history.back();
});
};
}, [open]);
};
+14
View File
@@ -0,0 +1,14 @@
// 视频弹窗与顶栏共享的状态 store
import { create } from "zustand";
type VideoDialogState = {
/** 打开中的视频弹窗的关闭方法,未打开时为 null;顶栏品牌标识据此变成「返回资源库」按钮 */
close: (() => void) | null;
setClose: (close: (() => void) | null) => void;
};
/** 视频弹窗打开时登记关闭方法,关闭后清除 */
export const useVideoDialogStore = create<VideoDialogState>()((set) => ({
close: null,
setClose: (close) => set({ close })
}));