feat(vods): full vod support on backend and frontend #5
@@ -6,8 +6,11 @@ import ClipEdit from './pages/ClipEdit';
|
|||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import MyClips from './pages/MyClips';
|
import MyClips from './pages/MyClips';
|
||||||
|
import MyVods from './pages/MyVods';
|
||||||
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||||
|
import VodPlayer from "./pages/VodPlayer.tsx";
|
||||||
import EditClip from "./pages/EditClip.tsx";
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
|
import EditVod from "./pages/EditVod.tsx";
|
||||||
import Profile from "./pages/Profile.tsx";
|
import Profile from "./pages/Profile.tsx";
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
|
|
||||||
@@ -26,8 +29,11 @@ function App() {
|
|||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||||
|
<Route path="/vod/:id" element={<VodPlayer />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon, Video } from 'lucide-react';
|
||||||
import type {User} from "../utils/types.ts";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -31,6 +31,7 @@ const Sidebar = ({user, className}: props) => {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||||
|
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,17 +13,21 @@ type VideoCardProps = {
|
|||||||
onEdit?: () => void,
|
onEdit?: () => void,
|
||||||
onDelete?: () => void,
|
onDelete?: () => void,
|
||||||
className?: string,
|
className?: string,
|
||||||
|
mediaApiPath?: string,
|
||||||
|
playerPath?: string,
|
||||||
|
itemLabel?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
|
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
|
||||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -33,10 +37,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
}, [createdAt]);
|
}, [createdAt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isThumbnailAvailable(id)
|
isThumbnailAvailable(thumbnailUrl)
|
||||||
.then(setThumbnailAvailable)
|
.then(setThumbnailAvailable)
|
||||||
.catch(() => setThumbnailAvailable(false));
|
.catch(() => setThumbnailAvailable(false));
|
||||||
}, []);
|
}, [thumbnailUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
@@ -57,11 +61,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={"/video/" + id}>
|
<Link to={`${playerPath}/${id}`}>
|
||||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||||
<div className="relative overflow-hidden rounded-lg">
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
|
||||||
alt="Video Thumbnail"
|
alt="Video Thumbnail"
|
||||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +109,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</>)}
|
</>)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
|
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
|
import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse } from "./types.ts";
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
@@ -144,6 +144,69 @@ const deleteClip = async (id: number): Promise<void> => {
|
|||||||
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getVods = async (): Promise<Vod[]> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorResult: APIResponse = await response.json();
|
||||||
|
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVodById = async (id: string): Promise<Vod | null> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch vod: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVodBlob = async (id: string): Promise<Blob> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVod = async (id: number): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
const getStreamStatus = async (): Promise<boolean> => {
|
const getStreamStatus = async (): Promise<boolean> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||||
if (!response.ok) return false;
|
if (!response.ok) return false;
|
||||||
@@ -151,8 +214,8 @@ const getStreamStatus = async (): Promise<boolean> => {
|
|||||||
return result.status === 'success' && result.data?.isStreaming === true;
|
return result.status === 'success' && result.data?.isStreaming === true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||||
return response.ok;
|
return response.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +230,11 @@ export {
|
|||||||
getVideoBlob,
|
getVideoBlob,
|
||||||
patchClip,
|
patchClip,
|
||||||
deleteClip,
|
deleteClip,
|
||||||
|
getVods,
|
||||||
|
getVodById,
|
||||||
|
getVodBlob,
|
||||||
|
patchVod,
|
||||||
|
deleteVod,
|
||||||
getStreamStatus,
|
getStreamStatus,
|
||||||
isThumbnailAvailable,
|
isThumbnailAvailable,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ type Clip = {
|
|||||||
createdAt: string,
|
createdAt: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Vod = {
|
||||||
|
id: number,
|
||||||
|
userId: number,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
duration: number,
|
||||||
|
createdAt: string,
|
||||||
|
}
|
||||||
|
|
||||||
type JobResponse = {
|
type JobResponse = {
|
||||||
uuid: string,
|
uuid: string,
|
||||||
progress: number,
|
progress: number,
|
||||||
@@ -47,5 +56,6 @@ export type {
|
|||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
User,
|
User,
|
||||||
Clip,
|
Clip,
|
||||||
|
Vod,
|
||||||
JobResponse
|
JobResponse
|
||||||
}
|
}
|
||||||
@@ -94,4 +94,18 @@ public class GlobalExceptionHandler {
|
|||||||
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ClipNotFound.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
|
||||||
|
logger.error("ClipNotFound: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(VodNotFound.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
||||||
|
logger.error("VodNotFound: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user