重构client,拆分组件

This commit is contained in:
2025-10-22 20:14:11 +08:00
parent 5868407216
commit fe9bc8fd6c
23 changed files with 1601 additions and 1137 deletions
@@ -0,0 +1,15 @@
import { forwardRef } from "react";
interface BackgroundCanvasProps {}
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
return (
<canvas
ref={ref}
className="fixed inset-0 w-full h-full -z-10"
style={{ filter: "blur(40px)" }}
/>
);
});
BackgroundCanvas.displayName = "BackgroundCanvas";
@@ -0,0 +1,56 @@
import { ThumbsUp } from "lucide-react";
import type { Comment, User } from "../types";
import { formatRelativeTime } from "../utils";
import { CommentText } from "./CommentText";
interface CommentListProps {
author: User;
createdAt: string | Date;
comments: Comment[];
}
export function CommentList({ author, createdAt, comments }: CommentListProps) {
return (
<>
<header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{author.avatar_url ? (
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
) : null}
</div>
<div>
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
<div className="text-xs text-white/50" title={new Date(createdAt).toLocaleString()}>
发布于 {formatRelativeTime(createdAt)}
</div>
</div>
</header>
<ul className="space-y-4 sm:space-y-5">
{comments.map((c) => (
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
{c.user.avatar_url ? (
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
</p>
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
<ThumbsUp size={14} />
<span>{c.digg_count}</span>
</div>
</div>
</li>
))}
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
</ul>
</>
);
}
@@ -0,0 +1,71 @@
import { X } from "lucide-react";
import type { Comment, User } from "../types";
import { CommentList } from "./CommentList";
interface CommentPanelProps {
open: boolean;
onClose: () => void;
author: User;
createdAt: string | Date;
comments: Comment[];
mounted: boolean;
}
export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) {
return (
<>
{/* 横屏评论面板:并排分栏 */}
<aside
className={`
hidden landscape:flex
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
relative h-full overflow-hidden
${mounted ? "transition-[width] duration-200 ease-out" : ""}
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
</div>
<div className="p-3 overflow-auto">
<CommentList author={author} createdAt={createdAt} comments={comments} />
</div>
</aside>
{/* 竖屏评论面板:bottom sheet */}
<aside
className={`
landscape:hidden
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
${mounted ? "transition-transform duration-200 ease-out" : ""}
border-t border-white/10
${open ? "translate-y-0" : "translate-y-full"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div className="p-3 overflow-auto">
<CommentList author={author} createdAt={createdAt} comments={comments} />
</div>
</aside>
</>
);
}
@@ -0,0 +1,30 @@
import { parseCommentText } from "../utils";
// 渲染评论文本(包含表情)
export function CommentText({ text }: { text: string }) {
const parts = parseCommentText(text);
return (
<>
{parts.map((part, idx: number) => {
if (typeof part === "string") {
return <span key={idx}>{part}</span>;
}
return (
<img
key={idx}
src={`/emojis/${part.name}.webp`}
alt={part.name}
className="inline-block w-5 h-5 align-text-bottom mx-0.5"
onError={(e) => {
// 如果图片加载失败,显示原始文本
e.currentTarget.style.display = "none";
const textNode = document.createTextNode(`[${part.name}]`);
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
}}
/>
);
})}
</>
);
}
@@ -0,0 +1,39 @@
import { forwardRef } from "react";
import type { ImageData } from "../types";
interface ImageCarouselProps {
images: ImageData["images"];
currentIndex: number;
onTogglePlay: () => void;
}
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => {
return (
<div
ref={ref}
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
>
{images.map((img, i) => (
<div
key={img.id}
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
onClick={onTogglePlay}
>
<img
src={img.url}
alt={`image-${i + 1}`}
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
}}
/>
</div>
))}
</div>
);
}
);
ImageCarousel.displayName = "ImageCarousel";
@@ -0,0 +1,56 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
interface ImageNavigationButtonsProps {
onPrev: () => void;
onNext: () => void;
currentIndex: number;
totalImages: number;
}
export function ImageNavigationButtons({
onPrev,
onNext,
currentIndex,
totalImages,
}: ImageNavigationButtonsProps) {
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < totalImages - 1;
return (
<>
{/* 左侧按钮 */}
<button
className={`
absolute left-4 top-7/16 -translate-y-1/2 z-20
w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20
flex items-center justify-center
text-white transition-all
${hasPrev ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`}
onClick={onPrev}
disabled={!hasPrev}
aria-label="上一张图片"
>
<ChevronLeft size={24} />
</button>
{/* 右侧按钮 */}
<button
className={`
absolute right-4 top-7/16 -translate-y-1/2 z-20
w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20
flex items-center justify-center
text-white transition-all
${hasNext ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`}
onClick={onNext}
disabled={!hasNext}
aria-label="下一张图片"
>
<ChevronRight size={24} />
</button>
</>
);
}
@@ -0,0 +1,256 @@
import {
ArrowDownUp,
Download,
Maximize,
Maximize2,
Minimize,
Minimize2,
Pause,
Play,
Repeat1,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
} from "lucide-react";
import type { RefObject } from "react";
import type { LoopMode, ObjectFit, User } from "../types";
import { formatRelativeTime, formatTime } from "../utils";
import { ProgressBar } from "./ProgressBar";
import { SegmentedProgressBar } from "./SegmentedProgressBar";
interface MediaControlsProps {
isVideo: boolean;
isPlaying: boolean;
progress: number;
volume: number;
rate: number;
rotation: number;
objectFit: ObjectFit;
loopMode: LoopMode;
isFullscreen: boolean;
author: User;
createdAt: string | Date;
desc: string;
// 视频专用
videoRef?: RefObject<HTMLVideoElement | null>;
// 图文专用
currentIndex?: number;
totalSegments?: number;
segmentProgress?: number;
musicUrl?: string | null;
audioRef?: RefObject<HTMLAudioElement | null>;
// 回调
onTogglePlay: () => void;
onSeek: (ratio: number) => void;
onVolumeChange: (volume: number) => void;
onRateChange: (rate: number) => void;
onRotationChange: (rotation: number) => void;
onObjectFitChange: (fit: ObjectFit) => void;
onLoopModeChange: (mode: LoopMode) => void;
onDownload: () => void;
onToggleFullscreen: () => void;
}
export function MediaControls({
isVideo,
isPlaying,
progress,
volume,
rate,
rotation,
objectFit,
loopMode,
isFullscreen,
author,
createdAt,
desc,
videoRef,
currentIndex = 0,
totalSegments = 0,
segmentProgress = 0,
musicUrl,
audioRef,
onTogglePlay,
onSeek,
onVolumeChange,
onRateChange,
onRotationChange,
onObjectFitChange,
onLoopModeChange,
onDownload,
onToggleFullscreen,
}: MediaControlsProps) {
return (
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
{/* 描述行 */}
<div className="pointer-events-none flex items-center gap-2.5 mb-1">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
<span
className="text-[11px] leading-tight text-white/95 drop-shadow"
title={new Date(createdAt).toLocaleString()}
>
{formatRelativeTime(createdAt)}
</span>
</div>
{desc ? (
<div className="pointer-events-none">
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
{desc}
</p>
</div>
) : null}
{/* 进度条:图文=分段;视频=单段 */}
{!isVideo && totalSegments > 0 ? (
<SegmentedProgressBar
totalSegments={totalSegments}
currentIndex={currentIndex}
segmentProgress={segmentProgress}
onSeek={onSeek}
/>
) : (
<ProgressBar progress={progress} onSeek={onSeek} />
)}
{/* 控制按钮行 - 响应式布局 */}
<div className="flex items-center justify-between gap-1.5 sm:gap-2.5">
{/* 左侧:播放控制 + 时间/进度 */}
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
<button
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onTogglePlay}
aria-label={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
{/* 播放进度显示 - 所有设备都显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{isVideo ? (
(() => {
const v = videoRef?.current;
const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0;
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--";
})()
) : (
`${currentIndex + 1} / ${totalSegments}`
)}
</div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
{isVideo && (
<button
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0"
onClick={() => {
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
const i = steps.indexOf(rate);
const next = steps[(i + 1) % steps.length];
onRateChange(next);
}}
aria-label="切换倍速"
>
{rate}x
</button>
)}
</div>
{/* 中间:音量控制 - 中等屏幕以上显示 */}
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
{/* 旋转按钮 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onRotationChange((rotation + 270) % 360)}
aria-label="向左旋转 90 度"
title="向左旋转 90 度"
>
<RotateCcw size={18} />
</button>
<button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"}
>
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
className="w-20 lg:w-28 accent-white cursor-pointer"
aria-label="音量"
/>
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onRotationChange((rotation + 90) % 360)}
aria-label="向右旋转 90 度"
title="向右旋转 90 度"
>
<RotateCw size={18} />
</button>
</div>
{/* 右侧:功能按钮组 */}
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
<button
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"}
>
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
{/* 循环模式 - 中等屏幕以上显示 */}
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
>
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
</button>
{/* 适配模式 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
>
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
</button>
{/* 下载 - 中等屏幕以上显示 */}
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onDownload}
aria-label={isVideo ? "下载视频" : "下载当前图片"}
title={isVideo ? "下载视频" : "下载当前图片"}
>
<Download size={18} />
</button>
{/* 全屏 - 所有设备都显示 */}
<button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onToggleFullscreen}
aria-label="切换全屏"
>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
{/* 图文 BGM(隐藏控件,仅用于播放) */}
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
</div>
);
}
@@ -0,0 +1,60 @@
import { ChevronDown, ChevronUp, MessageSquareText } from "lucide-react";
import type { Neighbors } from "../types";
interface NavigationButtonsProps {
neighbors: Neighbors;
commentsCount: number;
onNavigatePrev: () => void;
onNavigateNext: () => void;
onToggleComments: () => void;
}
export function NavigationButtons({
neighbors,
commentsCount,
onNavigatePrev,
onNavigateNext,
onToggleComments,
}: NavigationButtonsProps) {
return (
<>
{/* 评论开关(右侧中部) */}
<button
className="z-10 grid place-items-center w-[54px] h-[54px] rounded-full absolute right-4 top-2/3 -translate-y-1/2 cursor-pointer"
onClick={onToggleComments}
aria-label="切换评论"
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[18px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span>
}
</div>
</button>
{/* 上下切换按钮(右侧胶囊形状) */}
<div className="absolute right-4 top-6/11 -translate-y-1/2 z-10">
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden">
<button
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors"
onClick={onNavigatePrev}
disabled={!neighbors.prev}
aria-label="上一条"
>
<ChevronUp size={24} />
</button>
<button
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20"
onClick={onNavigateNext}
disabled={!neighbors.next}
aria-label="下一条"
>
<ChevronDown size={24} />
</button>
</div>
</div>
</>
);
}
@@ -0,0 +1,18 @@
interface ProgressBarProps {
progress: number;
onSeek: (ratio: number) => void;
}
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
return (
<div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
}}
>
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
</div>
);
}
@@ -0,0 +1,40 @@
interface SegmentedProgressBarProps {
totalSegments: number;
currentIndex: number;
segmentProgress: number;
onSeek: (ratio: number) => void;
}
export function SegmentedProgressBar({
totalSegments,
currentIndex,
segmentProgress,
onSeek,
}: SegmentedProgressBarProps) {
return (
<div
className="relative h-1.5 cursor-pointer"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
}}
>
<div className="flex gap-1.5 h-full">
{Array.from({ length: totalSegments }).map((_, i) => {
let fill = 0;
if (i < currentIndex) fill = 1;
else if (i === currentIndex) fill = segmentProgress;
return (
<div
key={i}
aria-label={`第 ${i + 1} 段`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
>
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} />
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { forwardRef } from "react";
import type { ObjectFit } from "../types";
interface VideoPlayerProps {
videoUrl: string;
rotation: number;
objectFit: ObjectFit;
loop: boolean;
onTogglePlay: () => void;
}
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
return (
<video
ref={ref}
src={videoUrl}
className={[
// 旋转 0/180:充满容器盒子;
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
rotation % 180 === 0
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer",
].join(" ")}
style={{
transform:
rotation % 180 === 0
? `rotate(${rotation}deg)`
: `translate(-50%, -50%) rotate(${rotation}deg)`,
transformOrigin: "center center",
objectFit,
}}
playsInline
loop={loop}
onClick={onTogglePlay}
/>
);
}
);
VideoPlayer.displayName = "VideoPlayer";
+12
View File
@@ -0,0 +1,12 @@
// 组件导出
export { BackgroundCanvas } from "./BackgroundCanvas";
export { CommentPanel } from "./CommentPanel";
export { CommentList } from "./CommentList";
export { CommentText } from "./CommentText";
export { ImageCarousel } from "./ImageCarousel";
export { ImageNavigationButtons } from "./ImageNavigationButtons";
export { MediaControls } from "./MediaControls";
export { NavigationButtons } from "./NavigationButtons";
export { ProgressBar } from "./ProgressBar";
export { SegmentedProgressBar } from "./SegmentedProgressBar";
export { VideoPlayer } from "./VideoPlayer";