feat(frontend): VideoPage react-player v3, fix data.videos, route /video/:id
This commit is contained in:
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Layout from './components/layout/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import CallbackPage from './pages/CallbackPage';
|
||||
import VideoPage from './pages/VideoPage';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
@@ -23,6 +24,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/video/:id" element={<VideoPage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Video {
|
||||
|
||||
interface VideosResponse {
|
||||
success: boolean;
|
||||
data: Video[];
|
||||
data: { videos: Video[] };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -22,7 +22,7 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<VideosResponse>('/videos')
|
||||
.then((res) => setVideos(res.data))
|
||||
.then((res) => setVideos(res.data.videos))
|
||||
.catch(() => setVideos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
131
frontend/src/pages/VideoPage.tsx
Normal file
131
frontend/src/pages/VideoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user