feat(frontend): VideoPage react-player v3, fix data.videos, route /video/:id
Some checks failed
CI/CD — Build & Deploy / Build (push) Has been cancelled
CI/CD — Build & Deploy / Deploy to VPS (push) Has been cancelled

This commit is contained in:
2026-03-14 08:12:08 +01:00
parent 5d4bab7d99
commit 87d076313c
5 changed files with 751 additions and 6 deletions

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import ReactPlayer from 'react-player';
import { apiFetch } from '../lib/api';
interface Video {
id: number;
title: string;
description: string;
duration?: number;
storageType: 'youtube' | 's3' | 'local';
storageKey: string;
thumbnailUrl?: string;
requiredLevel: number;
locked: boolean;
}
interface VideoResponse {
success: boolean;
data: { video: Video };
}
function buildPlayerUrl(storageType: Video['storageType'], storageKey: string): string {
switch (storageType) {
case 'youtube': return `https://www.youtube.com/watch?v=${storageKey}`;
case 's3': return storageKey; // URL S3 complète attendue dans storageKey
case 'local': return `${import.meta.env.VITE_API_URL}/stream/${storageKey}`;
}
}
export default function VideoPage() {
const { id } = useParams<{ id: string }>();
const [video, setVideo] = useState<Video | null>(null);
const [error, setError] = useState<'not_found' | 'forbidden' | 'unknown' | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
apiFetch<VideoResponse>(`/videos/${id}`)
.then((res) => setVideo(res.data.video))
.catch((err: Error) => {
if (err.message.includes('403')) setError('forbidden');
else if (err.message.includes('404')) setError('not_found');
else setError('unknown');
})
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<div className="flex flex-col gap-4">
<div className="aspect-video w-full rounded bg-od-surface-hi animate-pulse" />
<div className="h-5 w-1/2 rounded bg-od-surface-hi animate-pulse" />
<div className="h-3 w-3/4 rounded bg-od-surface-hi animate-pulse" />
</div>
);
}
if (error === 'forbidden') {
return (
<div className="flex flex-col items-center gap-4 pt-16 text-center">
<span className="font-mono text-3xl text-od-accent"></span>
<p className="text-od-text font-medium">Contenu premium</p>
<p className="text-sm text-od-muted">Cette vidéo nécessite un abonnement supérieur.</p>
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour
</Link>
</div>
);
}
if (error || !video) {
return (
<div className="flex flex-col items-center gap-4 pt-16">
<p className="text-sm text-od-muted">Vidéo introuvable.</p>
<Link to="/" className="font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour
</Link>
</div>
);
}
const playerUrl = buildPlayerUrl(video.storageType, video.storageKey);
return (
<div className="flex flex-col gap-6">
{/* Player */}
<div className="overflow-hidden rounded border border-od-border bg-od-surface">
<div className="aspect-video w-full">
<ReactPlayer
src={playerUrl}
width="100%"
height="100%"
controls
/>
</div>
</div>
{/* Meta */}
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold text-od-text">{video.title}</h1>
{video.description && (
<p className="text-sm text-od-muted leading-relaxed">{video.description}</p>
)}
<div className="flex items-center gap-3 pt-1">
{video.requiredLevel === 0 ? (
<span className="rounded border border-od-border px-2 py-0.5 font-mono text-xs text-od-muted">
Libre
</span>
) : (
<span className="rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
Premium · niveau {video.requiredLevel}
</span>
)}
{video.duration && (
<span className="font-mono text-xs text-od-muted">
{Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
</span>
)}
</div>
</div>
<Link to="/" className="self-start font-mono text-xs text-od-muted hover:text-od-text transition-colors">
Retour aux vidéos
</Link>
</div>
);
}