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`。 - `--radius: 0.375rem`(6px,与官网一致),组件按 shadcn 派生规则取 `rounded-lg` / `rounded-xl`。
- 间距只用 `gap-*`,不用 `space-x/y-*`;常用节奏:控件间 `gap-2`,区块内 `gap-3`,区块间 `gap-8`。 - 间距只用 `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-*`。 - 等宽等高用 `size-*`,单行截断用 `truncate`,多行截断用 `line-clamp-*`。
## 5. 布局规则 ## 5. 布局规则
@@ -106,7 +106,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 下拉操作 | `DropdownMenu` | | 下拉操作 | `DropdownMenu` |
| 侧滑面板 | `Sheet`(右侧滑出、高度撑满、右上角关闭):手机顶栏菜单 | | 侧滑面板 | `Sheet`(右侧滑出、高度撑满、右上角关闭):手机顶栏菜单 |
| 图标按钮说明 | `Tooltip` | | 图标按钮说明 | `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):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | | 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) | | 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) |
| 错误提示 | `Alert variant="destructive"` | | 错误提示 | `Alert variant="destructive"` |
@@ -204,7 +204,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 | | 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 | | 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` | | 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `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`);顺序与资源列表一致,受类型、搜索筛选影响 | | 视频播放列表 | 同一资源的视频超过一个时始终显示,无边框,标题行只有「播放列表 3 / 12」:宽屏(`≥ lg`)在右侧占满高度,窄屏在信息行下方占满剩余高度(最低 12rem,更矮时整块滚动),内容多时只滚动列表;点击切换,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 |
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 | | 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
| 手机顶栏菜单 | 宽度 `< md` 时顶栏右侧的播放列表、退出登录与工具栏的来源 / 下载状态 / 类型三组筛选收进右上角汉堡按钮;点击后菜单从右侧滑出(高度撑满,右上角关闭):筛选按等宽网格排列并带「重置」(只清这三项,保留关键词),下面是 M3U 的「复制 M3U 地址」(手机上没有 PotPlayer,不提供「用 PotPlayer 打开」),退出登录固定在底部;三项中任一项不是「全部」时汉堡图标右上角弹出翠绿提示点,读屏名为「菜单(已启用筛选)」 | `MobileMenu`(`Sheet`)+ `LibraryFilters layout="stacked"`(与工具栏共用);提示点用单格 grid 叠层 + Motion `scale`,参数见 `INDICATOR_DOT`;来源计数只在菜单打开时计算 | | 手机顶栏菜单 | 宽度 `< 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 { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AddMagnetButton } from "./add-magnet-button"; import { AddMagnetButton } from "./add-magnet-button";
import { HeaderBrand } from "./header-brand";
import { LogoutButton } from "./logout-button"; import { LogoutButton } from "./logout-button";
import { MobileMenu } from "./mobile-menu"; import { MobileMenu } from "./mobile-menu";
import { PlaylistMenu } from "./playlist-menu"; import { PlaylistMenu } from "./playlist-menu";
@@ -19,8 +19,8 @@ type AppHeaderProps = {
export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => ( export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
<header className="shrink-0 border-b"> <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"> <div className="mx-auto flex h-14 w-full sm:max-w-[1920px] items-center gap-3 px-4 md:px-6">
<BrandMark /> <HeaderBrand />
{/* 手机宽度下放不下,演示模式只在登录页提示 */} {/* 手机宽度下放不下,演示模式只在登录页提示 */}
{mock && ( {mock && (
<Badge <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>(); const [contentRef, width] = useElementWidth<HTMLDivElement>();
return ( 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} />} {width > 0 && <VirtualRows {...props} width={width} />}
</div> </div>
+1 -1
View File
@@ -198,7 +198,7 @@ export const LibraryToolbar = ({
return ( return (
<div className="shrink-0 border-b"> <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"> <InputGroup className="w-full sm:w-64">
<InputGroupAddon> <InputGroupAddon>
<SearchIcon /> <SearchIcon />
+107 -75
View File
@@ -1,4 +1,4 @@
// 资源库视频播放:全屏打开,视频在上、信息在下,播放列表手机在下方、宽屏在右侧 // 资源库视频播放:手机全屏、宽屏铺满顶栏以下,视频在上、信息在下,播放列表手机在下方、宽屏在右侧
"use client"; "use client";
import { useEffect, useState } from "react"; 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 type { LibraryItemView } from "@/hooks/use-library";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useOrigin } from "@/hooks/use-origin"; import { useOrigin } from "@/hooks/use-origin";
import { useVideoDialogStore } from "@/stores/video-dialog-store";
import { formatBytes } from "@/utils/format"; import { formatBytes } from "@/utils/format";
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media"; import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
@@ -30,11 +31,11 @@ import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
import { VideoPlaylist } from "./video-playlist"; import { VideoPlaylist } from "./video-playlist";
/** /**
* 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置。 * 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置
* 手机宽度下贴边、去掉圆角 * (sm 起还要让出顶栏与上边距)。手机宽度下贴边、去掉圆角
*/ */
const PLAYER_CLASS = 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, { const VideoPlayer = dynamic(importVideoPlayer, {
@@ -98,6 +99,23 @@ export const LibraryVideoDialog = ({
// 手机返回键 / 返回手势先关闭播放,不离开页面 // 手机返回键 / 返回手势先关闭播放,不离开页面
useCloseOnBack(open, onClose); 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 引擎并行下载,不等播放器渲染出来再请求引擎 // 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎
const ready = streamUrl !== null; const ready = streamUrl !== null;
const needsHls = hlsUrl !== null; const needsHls = hlsUrl !== null;
@@ -111,89 +129,103 @@ export const LibraryVideoDialog = ({
onOpenChange={(next) => { onOpenChange={(next) => {
if (!next) onClose(); if (!next) onClose();
}} }}
// 非模态:看视频时顶栏照常可用;点击弹窗外或焦点移出都不关闭,
// 关闭按钮在视频左上角,也可 Esc 或返回键关闭
modal={false}
disablePointerDismissal
> >
{file && streamUrl && ( {file && streamUrl && (
<DialogContent <DialogContent
showCloseButton={false} 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"
> >
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */} {/* 与顶栏、资源列表同样限宽 1920px 居中 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <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="shrink-0 md:px-4 md:pt-4"> {/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */}
<VideoPlayer <div className="flex min-h-0 min-w-0 flex-1 flex-col">
key={hlsUrl ?? streamUrl} <div className="shrink-0">
src={hlsUrl ?? streamUrl} <VideoPlayer
hls={hlsUrl !== null} key={hlsUrl ?? streamUrl}
title={file.displayName} src={hlsUrl ?? streamUrl}
autoPlay hls={hlsUrl !== null}
autoFocus title={file.displayName}
onEnded={nextFile && (() => selectFile(nextFile))} autoPlay
onClose={onClose} autoFocus
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。" onEnded={nextFile && (() => selectFile(nextFile))}
className={PLAYER_CLASS} onClose={onClose}
/> errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
</div> className={PLAYER_CLASS}
{/* 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动 */} />
<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>
</div> </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 <VideoPlaylist
files={playlist} files={playlist}
currentId={file.id} currentId={file.id}
onSelect={selectFile} onSelect={selectFile}
className="min-h-48 flex-1" className="min-h-0 flex-1"
/> />
)} </div>
</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> </DialogContent>
)} )}
</Dialog> </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" className="min-h-0 flex-1 scroll-pt-14 overflow-y-auto"
> >
{content ? ( {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} {content}
</div> </div>
) : ( ) : (
+1 -1
View File
@@ -38,7 +38,7 @@ export const VideoPlaylist = ({
className={cn("flex min-h-0 flex-col gap-1", className)} className={cn("flex min-h-0 flex-col gap-1", className)}
> >
<header className="flex shrink-0 items-baseline gap-2"> <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"> <span className="font-mono text-xs text-muted-foreground tabular-nums">
{index + 1} / {files.length} {index + 1} / {files.length}
</span> </span>
+4 -1
View File
@@ -43,13 +43,16 @@ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
showOverlay = true,
...props ...props
}: DialogPrimitive.Popup.Props & { }: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean showCloseButton?: boolean
/** 关闭时不渲染变暗、模糊的遮罩;模态弹窗仍会拦截弹窗外的点击 */
showOverlay?: boolean
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> {showOverlay && <DialogOverlay />}
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
+13 -5
View File
@@ -1,9 +1,11 @@
// 弹层与浏览器返回键联动的 hook // 弹层与浏览器返回键联动的 hook
import { useEffect, useEffectEvent } from "react"; import { useEffect, useEffectEvent } from "react";
/** history.state 上的标记,区分本 hook 压入的记录 */ /** history.state 上的标记,值为每次打开时生成的 id,区分本 hook 压入的记录 */
const MARKER = "__closeOnBack"; const MARKER = "__closeOnBack";
let sequence = 0;
/** /**
* 弹层打开时压入一条同地址的历史记录:返回键 / 返回手势先关闭弹层,不离开页面; * 弹层打开时压入一条同地址的历史记录:返回键 / 返回手势先关闭弹层,不离开页面;
* 用关闭按钮、Esc 等其他方式关闭时撤回这条记录,历史栈保持原样。 * 用关闭按钮、Esc 等其他方式关闭时撤回这条记录,历史栈保持原样。
@@ -14,18 +16,24 @@ export const useCloseOnBack = (open: boolean, onClose: () => void) => {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
window.history.pushState({ [MARKER]: true }, ""); // 每次打开用新的 id:刷新页面后 history.state 会保留,旧记录上的标记不能当成本次的
const id = `${Date.now()}-${++sequence}`;
window.history.pushState({ [MARKER]: id }, "");
let popped = false; let popped = false;
const onPopState = (event: PopStateEvent) => { const onPopState = (event: PopStateEvent) => {
// 仍停在本 hook 压入的记录上(开发模式 effect 重跑时撤回的上一条),不处理 // 前进键回到本次压入的记录,不处理;回到其他任何记录都视为返回
if (event.state?.[MARKER]) return; if (event.state?.[MARKER] === id) return;
popped = true; popped = true;
close(); close();
}; };
window.addEventListener("popstate", onPopState); window.addEventListener("popstate", onPopState);
return () => { return () => {
window.removeEventListener("popstate", onPopState); 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]); }, [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 })
}));