feat(frontend): implement vods in frontend
This commit is contained in:
@@ -6,8 +6,11 @@ import ClipEdit from './pages/ClipEdit';
|
||||
import Home from './pages/Home';
|
||||
import {useEffect} from "react";
|
||||
import MyClips from './pages/MyClips';
|
||||
import MyVods from './pages/MyVods';
|
||||
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||
import VodPlayer from "./pages/VodPlayer.tsx";
|
||||
import EditClip from "./pages/EditClip.tsx";
|
||||
import EditVod from "./pages/EditVod.tsx";
|
||||
import Profile from "./pages/Profile.tsx";
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||
|
||||
@@ -26,8 +29,11 @@ function App() {
|
||||
<Route path="/create" element={<ClipUpload />} />
|
||||
<Route path="/create/:id" element={<ClipEdit />} />
|
||||
<Route path="/my-clips" element={<MyClips />} />
|
||||
<Route path="/my-vods" element={<MyVods />} />
|
||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||
<Route path="/vod/:id" element={<VodPlayer />} />
|
||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
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";
|
||||
|
||||
type props = {
|
||||
@@ -31,6 +31,7 @@ const Sidebar = ({user, className}: props) => {
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,17 +13,21 @@ type VideoCardProps = {
|
||||
onEdit?: () => void,
|
||||
onDelete?: () => void,
|
||||
className?: string,
|
||||
mediaApiPath?: string,
|
||||
playerPath?: string,
|
||||
itemLabel?: string,
|
||||
}
|
||||
|
||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||
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 [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -33,10 +37,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
}, [createdAt]);
|
||||
|
||||
useEffect(() => {
|
||||
isThumbnailAvailable(id)
|
||||
isThumbnailAvailable(thumbnailUrl)
|
||||
.then(setThumbnailAvailable)
|
||||
.catch(() => setThumbnailAvailable(false));
|
||||
}, []);
|
||||
}, [thumbnailUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -57,11 +61,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={"/video/" + id}>
|
||||
<Link to={`${playerPath}/${id}`}>
|
||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
||||
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
|
||||
alt="Video Thumbnail"
|
||||
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 && (
|
||||
<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">
|
||||
<button
|
||||
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;
|
||||
|
||||
@@ -144,6 +144,69 @@ const deleteClip = async (id: number): Promise<void> => {
|
||||
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 response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||
if (!response.ok) return false;
|
||||
@@ -151,8 +214,8 @@ const getStreamStatus = async (): Promise<boolean> => {
|
||||
return result.status === 'success' && result.data?.isStreaming === true;
|
||||
};
|
||||
|
||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
||||
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
@@ -167,6 +230,11 @@ export {
|
||||
getVideoBlob,
|
||||
patchClip,
|
||||
deleteClip,
|
||||
getVods,
|
||||
getVodById,
|
||||
getVodBlob,
|
||||
patchVod,
|
||||
deleteVod,
|
||||
getStreamStatus,
|
||||
isThumbnailAvailable,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,15 @@ type Clip = {
|
||||
createdAt: string,
|
||||
}
|
||||
|
||||
type Vod = {
|
||||
id: number,
|
||||
userId: number,
|
||||
title: string,
|
||||
description: string,
|
||||
duration: number,
|
||||
createdAt: string,
|
||||
}
|
||||
|
||||
type JobResponse = {
|
||||
uuid: string,
|
||||
progress: number,
|
||||
@@ -47,5 +56,6 @@ export type {
|
||||
VideoMetadata,
|
||||
User,
|
||||
Clip,
|
||||
Vod,
|
||||
JobResponse
|
||||
}
|
||||
@@ -94,4 +94,18 @@ public class GlobalExceptionHandler {
|
||||
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
||||
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