init
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pause,
|
||||
Play,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize,
|
||||
Minimize2,
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
|
||||
type User = { nickname: string; avatar_url: string | null };
|
||||
type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User };
|
||||
|
||||
type VideoData = {
|
||||
type: "video";
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
duration_ms?: number | null;
|
||||
video_url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
type ImageData = {
|
||||
type: "image";
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
images: { id: string; url: string; width?: number; height?: number }[];
|
||||
music_url?: string | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
const SEGMENT_MS = 5000; // 图文每段 5s
|
||||
|
||||
export default function AwemeDetailClient(props: { data: VideoData | ImageData }) {
|
||||
const { data } = props;
|
||||
const isVideo = data.type === "video";
|
||||
|
||||
// ====== 布局 & 评论 ======
|
||||
const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏)
|
||||
const comments = useMemo(() => data.comments ?? [], [data]);
|
||||
|
||||
// ====== 媒体引用 ======
|
||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// ====== 统一控制状态 ======
|
||||
const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [volume, setVolume] = useState(1); // 视频音量 / 图文BGM音量
|
||||
const [rate, setRate] = useState(1); // 仅视频使用
|
||||
const [progress, setProgress] = useState(0); // 0..1 总进度
|
||||
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
||||
|
||||
// ====== 图文专用(分段) ======
|
||||
const images = (data as any).images as ImageData["images"] | undefined;
|
||||
const totalSegments = images?.length ?? 0;
|
||||
const [idx, setIdx] = useState(0); // 当前图片索引
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 用 ref 解决“闪回”
|
||||
const segStartRef = useRef<number | null>(null); // 当前段开始时间戳
|
||||
const idxRef = useRef<number>(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [segProgress, setSegProgress] = useState(0); // 段内 0..1
|
||||
|
||||
useEffect(() => { idxRef.current = idx; }, [idx]);
|
||||
|
||||
// ====== 视频:进度/播放/倍速/音量 ======
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTime = () => {
|
||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||
setProgress(v.currentTime / v.duration);
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("loadedmetadata", onTime);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("loadedmetadata", onTime);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
};
|
||||
}, [isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, isVideo]);
|
||||
|
||||
// ====== 图文:BGM & 初次自动播放尝试 ======
|
||||
useEffect(() => {
|
||||
if (isVideo) return;
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.volume = volume;
|
||||
if (isPlaying) {
|
||||
el.play().catch(() => {/* 被策略阻止无妨,用户点播放即可 */ });
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [isVideo]); // 初次挂载
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideo) return;
|
||||
const el = audioRef.current;
|
||||
if (el) el.volume = volume;
|
||||
}, [volume, isVideo]);
|
||||
|
||||
// ====== 全屏状态监听 ======
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||
}, []);
|
||||
|
||||
// ====== 图文:自动切页(消除“闪回”)======
|
||||
useEffect(() => {
|
||||
if (isVideo || !images?.length) return;
|
||||
|
||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!images?.length) return;
|
||||
|
||||
let start = segStartRef.current!;
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
// 暂停时只更新 UI,不推进时间
|
||||
if (!isPlaying) {
|
||||
const elapsed = Math.max(0, ts - start);
|
||||
const localSeg = Math.min(1, elapsed / SEGMENT_MS);
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
// 前进时间:处理跨多段情况(极少见,但更稳妥)
|
||||
let elapsed = ts - start;
|
||||
while (elapsed >= SEGMENT_MS) {
|
||||
elapsed -= SEGMENT_MS;
|
||||
localIdx = (localIdx + 1) % images.length;
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
if (localIdx !== idxRef.current) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS));
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, [isVideo, images?.length, isPlaying]);
|
||||
|
||||
// 横向滚动同步 idx(且重置段起点)
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const i = Math.round(el.scrollLeft / el.clientWidth);
|
||||
if (i !== idxRef.current) {
|
||||
idxRef.current = i;
|
||||
setIdx(i);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
setProgress(images && images.length ? i / images.length : 0);
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [images?.length]);
|
||||
|
||||
// ====== 统一操作 ======
|
||||
const seekTo = (ratio: number) => {
|
||||
ratio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * ratio;
|
||||
return;
|
||||
}
|
||||
if (!images?.length) return;
|
||||
const total = images.length;
|
||||
const exact = ratio * total;
|
||||
const targetIdx = Math.min(total - 1, Math.floor(exact));
|
||||
const remainder = exact - targetIdx;
|
||||
|
||||
idxRef.current = targetIdx;
|
||||
setIdx(targetIdx);
|
||||
segStartRef.current = performance.now() - remainder * SEGMENT_MS;
|
||||
setSegProgress(remainder);
|
||||
setProgress((targetIdx + remainder) / total);
|
||||
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) await v.play();
|
||||
else v.pause();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true);
|
||||
try { await el?.play(); } catch { }
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
el?.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen().catch(() => { });
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
};
|
||||
|
||||
const prevImg = () => {
|
||||
if (!images?.length) return;
|
||||
const next = Math.max(0, idxRef.current - 1);
|
||||
idxRef.current = next;
|
||||
setIdx(next);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const nextImg = () => {
|
||||
if (!images?.length) return;
|
||||
const next = Math.min(images.length - 1, idxRef.current + 1);
|
||||
idxRef.current = next;
|
||||
setIdx(next);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// ====== 侧栏(横屏)/ 抽屉(竖屏)样式(Tailwind)
|
||||
const asideClasses = [
|
||||
"z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white",
|
||||
// 竖屏:bottom sheet,从下向上弹出
|
||||
"portrait:fixed portrait:inset-x-0 portrait:bottom-0 portrait:w-full portrait:h-[min(80vh,88dvh)]",
|
||||
"portrait:transition-transform portrait:duration-200 portrait:ease-out",
|
||||
open ? "portrait:translate-y-0" : "portrait:translate-y-full",
|
||||
"portrait:border-t portrait:border-white/10",
|
||||
// 横屏:并排分栏,宽度过渡
|
||||
"landscape:relative landscape:h-full landscape:overflow-hidden",
|
||||
"landscape:transition-[width] landscape:duration-200 landscape:ease-out",
|
||||
open
|
||||
? "landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
||||
: "landscape:w-0",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
||||
<div className="relative h-full landscape:flex landscape:flex-row">
|
||||
{/* 主媒体区域 */}
|
||||
<section ref={mediaContainerRef} className="relative h-screen landscape:flex-1">
|
||||
<div
|
||||
className="relative h-screen overflow-hidden"
|
||||
>
|
||||
{isVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={(data as VideoData).video_url}
|
||||
className={[
|
||||
// 旋转 0/180:充满容器盒子,用 object-contain;
|
||||
// 旋转 90/270:用中心定位 + 100vh/100vw + object-cover,保证铺满全屏
|
||||
rotation % 180 === 0
|
||||
? "absolute inset-0 h-full w-full object-contain bg-black/70"
|
||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-contain bg-black/70",
|
||||
].join(" ")}
|
||||
style={{
|
||||
transform:
|
||||
rotation % 180 === 0
|
||||
? `rotate(${rotation}deg)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="absolute inset-0 overflow-x-auto overflow-y-hidden snap-x snap-mandatory flex no-scrollbar"
|
||||
>
|
||||
{(data as ImageData).images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="snap-center shrink-0 w-full h-screen relative bg-black/70"
|
||||
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img.url} alt="image" className="absolute inset-0 w-full h-full object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 左右切图(多张时显示) */}
|
||||
{images && images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
className="absolute top-1/2 -translate-y-1/2 left-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
||||
onClick={prevImg}
|
||||
aria-label="上一张"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<button
|
||||
className="absolute top-1/2 -translate-y-1/2 right-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
||||
onClick={nextImg}
|
||||
aria-label="下一张"
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统一控制条:desc 在上、进度在下 */}
|
||||
<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={data.author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
||||
<span 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">
|
||||
{data.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.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">
|
||||
{data.desc}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 进度条:图文=分段;视频=单段 */}
|
||||
{!isVideo && totalSegments > 0 ? (
|
||||
<div
|
||||
className="relative h-1.5 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo((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 < idx) fill = 1;
|
||||
else if (i === idx) fill = segProgress;
|
||||
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>
|
||||
) : (
|
||||
<div
|
||||
className="relative h-1 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo((e.clientX - rect.left) / rect.width);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="origin-left h-full bg-white"
|
||||
style={{ transform: `scaleX(${progress || 0})` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制按钮行 */}
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<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"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 倍速仅视频展示 */}
|
||||
{isVideo ? (
|
||||
<>
|
||||
<button
|
||||
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
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];
|
||||
setRate(next);
|
||||
}}
|
||||
aria-label="切换倍速"
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
|
||||
{/* 旋转:向左/向右各 90° */}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<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"
|
||||
onClick={() => setRotation((r) => (r + 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"
|
||||
onClick={() => setVolume((v) => (v > 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) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-28 accent-white"
|
||||
aria-label="音量"
|
||||
/>
|
||||
<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"
|
||||
onClick={() => setRotation((r) => (r + 90) % 360)}
|
||||
aria-label="向右旋转 90 度"
|
||||
title="向右旋转 90 度"
|
||||
>
|
||||
<RotateCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<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"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label="切换全屏"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||
{!isVideo && "music_url" in data && data.music_url ? (
|
||||
<audio ref={audioRef} src={data.music_url ?? undefined} loop preload="metadata" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评论开关(竖屏:底部居中;横屏:右侧中部悬浮) */}
|
||||
<button
|
||||
className={[
|
||||
"z-10 grid place-items-center w-[54px] h-[54px] rounded-full ",
|
||||
"absolute right-4 top-2/3 -translate-y-1/2",
|
||||
].join(" ")}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="切换评论"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||
<MessageSquareText size={40} className="" />
|
||||
<span className="text-xl text-white">
|
||||
{comments.length > 0 ? `${comments.length}` : "暂无评论"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}
|
||||
<aside className={asideClasses}>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<button
|
||||
className="text-white/90 text-xs px-2 py-1 rounded-lg bg-white/15 border border-white/20"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<div className="text-white font-semibold">
|
||||
评论 {comments.length > 0 ? `(${comments.length})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 overflow-auto">
|
||||
<header className="flex items-center gap-4 mb-5">
|
||||
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
||||
{data.author.avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={data.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">{data.author.nickname}</div>
|
||||
<div className="text-xs text-white/50">发布于 {new Date(data.created_at).toLocaleString()}</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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<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">{new Date(c.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">{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>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
|
||||
function ms(v?: number | null) {
|
||||
if (!v) return "";
|
||||
const s = Math.round(v / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
||||
const id = (await params).awemeId;
|
||||
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
})
|
||||
]);
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const data = isVideo
|
||||
? {
|
||||
type: "video" as const,
|
||||
aweme_id: video!.aweme_id,
|
||||
desc: video!.desc,
|
||||
created_at: video!.created_at,
|
||||
duration_ms: video!.duration_ms,
|
||||
video_url: video!.video_url,
|
||||
width: video!.width ?? null,
|
||||
height: video!.height ?? null,
|
||||
author: { nickname: video!.author.nickname, avatar_url: video!.author.avatar_url },
|
||||
comments: video!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
}
|
||||
: {
|
||||
type: "image" as const,
|
||||
aweme_id: post!.aweme_id,
|
||||
desc: post!.desc,
|
||||
created_at: post!.created_at,
|
||||
images: post!.images.map((i) => ({ id: i.id, url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
|
||||
music_url: post!.music_url,
|
||||
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
|
||||
comments: post!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full overflow-hidden">
|
||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||
<div className="fixed left-3 top-3 z-30">
|
||||
<BackButton
|
||||
hrefFallback="/"
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
||||
/>
|
||||
</div>
|
||||
<AwemeDetailClient data={data as any} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
Reference in New Issue
Block a user