Initial commit
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import VideoSidebar from "../components/VideoSidebar";
|
||||
import { fetchVideo, formatRunTime } from "../api/client";
|
||||
import type { VideoDetail } from "../types/video";
|
||||
|
||||
function getMapDisplayName(mapName: string): string {
|
||||
return mapName.replace(/_/g, " ").toUpperCase();
|
||||
}
|
||||
|
||||
function getMomUrl(mapName: string): string {
|
||||
return `https://dashboard.momentum-mod.org/maps/${mapName}`;
|
||||
}
|
||||
|
||||
function SkeletonBlock({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded bg-white/5 animate-pulse ${className ?? ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<SkeletonBlock className="h-8 w-64" />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
|
||||
<div className="lg:w-1/2 min-w-0">
|
||||
<SkeletonBlock className="aspect-video w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="lg:w-1/2 flex flex-col gap-4">
|
||||
<div className="rounded border border-white/5 bg-white/5 p-4">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
|
||||
<SkeletonBlock className="h-4 w-28" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
|
||||
<SkeletonBlock className="h-4 w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-10 mb-1.5" />
|
||||
<SkeletonBlock className="h-4 w-24" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
|
||||
<SkeletonBlock className="h-7 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [video, setVideo] = useState<VideoDetail | null>(null);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchVideo(id)
|
||||
.then(setVideo)
|
||||
.catch((err) => setError(err.message));
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<p className="text-red-400/60 mb-4">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!video || !videoReady) {
|
||||
return (
|
||||
<>
|
||||
<PageSkeleton />
|
||||
{video && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
preload="auto"
|
||||
onCanPlay={() => setVideoReady(true)}
|
||||
className="fixed opacity-0 pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="rounded-lg border border-white/5 bg-white/5 px-4 py-2 inline-block mb-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white/90">
|
||||
<a
|
||||
href={getMomUrl(video.mapName)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="no-underline text-white/90 hover:text-mom-accent transition-colors"
|
||||
>
|
||||
{getMapDisplayName(video.mapName)}
|
||||
</a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
|
||||
<div className="lg:w-1/2 min-w-0">
|
||||
<div className="rounded-lg border border-white/5 overflow-hidden">
|
||||
<div className="aspect-video bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
poster={video.thumbnailUrl}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
|
||||
<a
|
||||
href={video.mtvUrl}
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
.mtv replay
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-1/2">
|
||||
<VideoSidebar video={video} />
|
||||
{video.previousPbs && video.previousPbs.length > 0 && (
|
||||
<div className="rounded border border-white/5 bg-white/5 p-4 mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
Previous PBs
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{video.previousPbs.map((pb) => (
|
||||
<li
|
||||
key={pb.createdAt}
|
||||
className="text-sm text-white/60"
|
||||
>
|
||||
<span className="font-mono text-white/90">
|
||||
{formatRunTime(pb.runTime)}
|
||||
</span>
|
||||
{" · "}
|
||||
{new Date(
|
||||
pb.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user