committed by
GitHub
22 changed files with 2496 additions and 53 deletions
Binary file not shown.
File diff suppressed because it is too large
@ -0,0 +1,52 @@ |
|||||
|
import {Line} from "react-chartjs-2"; |
||||
|
import {Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend} from "chart.js"; |
||||
|
|
||||
|
ChartJS.register( |
||||
|
CategoryScale, |
||||
|
LinearScale, |
||||
|
PointElement, |
||||
|
LineElement, |
||||
|
Title, |
||||
|
Tooltip, |
||||
|
Legend |
||||
|
); |
||||
|
|
||||
|
export default function LinearGraph({ dataToGraph, className, legend, borderColor="rgba(75, 192, 192, 1)" }) { |
||||
|
|
||||
|
const prepareData = () => { |
||||
|
if (!dataToGraph || dataToGraph.length === 0) { |
||||
|
return { |
||||
|
labels: [], |
||||
|
datasets: [] |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const labels = dataToGraph.map(item => item.date.split("T")[0]); |
||||
|
const data = dataToGraph.map(item => item.count); |
||||
|
|
||||
|
return { |
||||
|
labels, |
||||
|
datasets: [{ |
||||
|
label: legend, |
||||
|
data, |
||||
|
fill: false, |
||||
|
pointRadius: 3, |
||||
|
borderColor: borderColor, |
||||
|
tension: 0, |
||||
|
stepped: false |
||||
|
}] |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const data = prepareData(); |
||||
|
|
||||
|
const options = { |
||||
|
|
||||
|
} |
||||
|
return ( |
||||
|
<div className={className}> |
||||
|
<Line options={options} data={data} className="w-full border-red-500 " /> |
||||
|
</div> |
||||
|
) |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
export default function Tag({ tag, onSuppress, doShowControls=true }) { |
||||
|
|
||||
|
return ( |
||||
|
<div className="glassmorphism px-2 py-1 w-max flex flex-row items-center gap-2"> |
||||
|
<span className="font-inter text-white">#{tag}</span> |
||||
|
{doShowControls && ( |
||||
|
<span className="tag-controls cursor-pointer" onClick={onSuppress}> |
||||
|
<svg |
||||
|
className="w-6 h-6 fill-white" |
||||
|
stroke="#FFF" |
||||
|
viewBox="0 0 24 24" |
||||
|
xmlns="http://www.w3.org/2000/svg" |
||||
|
> |
||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
||||
|
</svg> |
||||
|
|
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,417 @@ |
|||||
|
import Navbar from "../components/Navbar.jsx"; |
||||
|
import {useParams} from "react-router-dom"; |
||||
|
import {useAuth} from "../contexts/AuthContext.jsx"; |
||||
|
import {useEffect, useState} from "react"; |
||||
|
import LinearGraph from "../components/LinearGraph.jsx"; |
||||
|
import Comment from "../components/Comment.jsx"; |
||||
|
import Tag from "../components/Tag.jsx"; |
||||
|
|
||||
|
|
||||
|
export default function ManageVideo() { |
||||
|
|
||||
|
const { user } = useAuth(); |
||||
|
const token = localStorage.getItem("token"); |
||||
|
const {id} = useParams(); |
||||
|
|
||||
|
const [video, setVideo] = useState(null); |
||||
|
const [likesPerDay, setLikesPerDay] = useState([]); |
||||
|
const [viewsPerDay, setViewsPerDay] = useState([]); |
||||
|
const [videoTitle, setVideoTitle] = useState(null); |
||||
|
const [description, setDescription] = useState(null); |
||||
|
const [visibility, setVisibility] = useState("private"); |
||||
|
const [editMode, setEditMode] = useState(false); |
||||
|
const [thumbnailPreview, setThumbnailPreview] = useState(null); |
||||
|
const [videoFile, setVideoFile] = useState(null); |
||||
|
|
||||
|
const nonEditModeClasses = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none"; |
||||
|
const editModeClasses = nonEditModeClasses + " glassmorphism"; |
||||
|
|
||||
|
const nonEditModeClassesTextArea = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none w-full" |
||||
|
const editModeClassesTextArea = nonEditModeClassesTextArea + " glassmorphism h-48"; |
||||
|
|
||||
|
const fetchVideo = async () => { |
||||
|
const request = await fetch(`/api/videos/${id}`, { |
||||
|
method: 'GET', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
'Authorization': `Bearer ${token}` |
||||
|
} |
||||
|
}); |
||||
|
if (!request.ok) { |
||||
|
throw new Error("Failed to fetch video"); |
||||
|
} |
||||
|
const data = await request.json(); |
||||
|
setVideo(data); |
||||
|
setVisibility(data.visibility) |
||||
|
setVideoTitle(data.title); |
||||
|
setDescription(data.description); |
||||
|
} |
||||
|
const fetchLikesPerDay = async () => { |
||||
|
const request = await fetch(`/api/videos/${id}/likes/day`, { |
||||
|
method: 'GET', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
'Authorization': `Bearer ${token}` |
||||
|
} |
||||
|
}); |
||||
|
if (!request.ok) { |
||||
|
throw new Error("Failed to fetch likes per day"); |
||||
|
} |
||||
|
const data = await request.json(); |
||||
|
setLikesPerDay(data.likes); |
||||
|
setViewsPerDay(data.views); |
||||
|
} |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (user) { |
||||
|
fetchVideo() |
||||
|
fetchLikesPerDay() |
||||
|
} |
||||
|
}, [user, id, token]); |
||||
|
|
||||
|
const onSubmit = async (e) => { |
||||
|
e.preventDefault(); |
||||
|
if (!editMode) return; |
||||
|
|
||||
|
const request = await fetch(`/api/videos/${id}`, { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
'Authorization': `Bearer ${token}` |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
title: videoTitle, |
||||
|
description: description, |
||||
|
visibility: visibility, |
||||
|
channel: video.channel |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
if (!request.ok) { |
||||
|
console.error("Failed to update video"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const form = new FormData(); |
||||
|
if (videoFile) { |
||||
|
form.append('file', videoFile); |
||||
|
form.append('video', id); |
||||
|
form.append('channel', video.channel); |
||||
|
|
||||
|
const videoRequest = await fetch(`/api/videos/${id}/video`, { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Authorization': `Bearer ${token}` |
||||
|
}, |
||||
|
body: form |
||||
|
}); |
||||
|
|
||||
|
if (!videoRequest.ok) { |
||||
|
console.error("Failed to update video file"); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const data = await request.json(); |
||||
|
setVideo(data); |
||||
|
setEditMode(false); |
||||
|
} |
||||
|
|
||||
|
const handleThumbnailChange = async (e) => { |
||||
|
const file = e.target.files[0]; |
||||
|
if (file) { |
||||
|
const reader = new FileReader(); |
||||
|
reader.onload = (e) => { |
||||
|
setThumbnailPreview(e.target.result); |
||||
|
}; |
||||
|
reader.readAsDataURL(file); |
||||
|
} |
||||
|
|
||||
|
const formData = new FormData(); |
||||
|
formData.append('file', file); |
||||
|
formData.append('video', id); |
||||
|
formData.append('channel', video.channel); |
||||
|
|
||||
|
|
||||
|
|
||||
|
const request = await fetch(`/api/videos/thumbnail`, { |
||||
|
"method": 'POST', |
||||
|
"headers": { |
||||
|
"Authorization": `Bearer ${token}` |
||||
|
}, |
||||
|
body: formData |
||||
|
}) |
||||
|
if (!request.ok) { |
||||
|
console.error("Failed to upload thumbnail"); |
||||
|
return; |
||||
|
} |
||||
|
const data = await request.json(); |
||||
|
}; |
||||
|
|
||||
|
const onAddTag = async (e) => { |
||||
|
if (e.key !== 'Enter' || e.target.value.trim() === "") return; |
||||
|
|
||||
|
const newTag = e.target.value.trim(); |
||||
|
e.target.value = ""; |
||||
|
|
||||
|
const request = await fetch(`/api/videos/${id}/tags`, { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
'Authorization': `Bearer ${token}` |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
tags: [...video.tags, newTag], |
||||
|
channel: video.channel |
||||
|
}) |
||||
|
}); |
||||
|
if (!request.ok) { |
||||
|
console.error("Failed to add tag"); |
||||
|
return; |
||||
|
} |
||||
|
const data = await request.json(); |
||||
|
setVideo({ |
||||
|
...video, |
||||
|
tags: [...video.tags, newTag] |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const onSuppressTag = async (tag) => { |
||||
|
|
||||
|
const request = await fetch(`/api/videos/${id}/tags`, { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
'Authorization': `Bearer ${token}` |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
tags: video.tags.filter(t => t !== tag), |
||||
|
channel: video.channel |
||||
|
}) |
||||
|
}); |
||||
|
if (!request.ok) { |
||||
|
console.error("Failed to suppress tag"); |
||||
|
return; |
||||
|
} |
||||
|
const data = await request.json(); |
||||
|
const newTags = video.tags.filter(t => t !== tag); |
||||
|
setVideo({ |
||||
|
...video, |
||||
|
tags: newTags |
||||
|
}); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
||||
|
|
||||
|
<Navbar/> |
||||
|
|
||||
|
<main className="px-36 pb-36"> |
||||
|
|
||||
|
{ /* GRAPHS */ } |
||||
|
<div> |
||||
|
<div className="flex pt-[118px] gap-4" > |
||||
|
<LinearGraph |
||||
|
dataToGraph={viewsPerDay} |
||||
|
legend="Vues" |
||||
|
className="glassmorphism flex-1 h-[300px] p-4" |
||||
|
/> |
||||
|
<LinearGraph |
||||
|
dataToGraph={likesPerDay} |
||||
|
legend="Likes" |
||||
|
className="glassmorphism flex-1 h-[300px] p-4" |
||||
|
borderColor="#FF073A" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex gap-4 mt-4 "> |
||||
|
|
||||
|
{ /* LEFT SIDE */ } |
||||
|
<div className="flex-1" > |
||||
|
{ /* THUMBNAIL */ } |
||||
|
<label htmlFor="thumbnail" className="glassmorphism flex justify-center items-center py-5 relative overflow-hidden "> |
||||
|
<img src={thumbnailPreview || (video ? video.thumbnail : "")} alt="" className=" rounded-sm"/> |
||||
|
|
||||
|
<div className="absolute top-0 left-0 bg-[#00000080] w-full h-full flex justify-center items-center opacity-0 hover:opacity-100 transition" > |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" className="fill-white" viewBox="0 0 24 24"> |
||||
|
<path d="M19.045 7.401c.378-.378.586-.88.586-1.414s-.208-1.036-.586-1.414l-1.586-1.586c-.378-.378-.88-.586-1.414-.586s-1.036.208-1.413.585L4 13.585V18h4.413L19.045 7.401zm-3-3 1.587 1.585-1.59 1.584-1.586-1.585 1.589-1.584zM6 16v-1.585l7.04-7.018 1.586 1.586L7.587 16H6zm-2 4h16v2H4z"></path> |
||||
|
</svg> |
||||
|
</div> |
||||
|
|
||||
|
</label> |
||||
|
<input type="file" name="thumbnail" id="thumbnail" className="opacity-0 w-0 h-0 hidden" onChange={handleThumbnailChange} accept="image/*"/> |
||||
|
|
||||
|
{ /* VIDEO INFOS */ } |
||||
|
|
||||
|
<form className="glassmorphism p-4 mt-4 flex-1" > |
||||
|
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}> |
||||
|
Titre de la vidéo |
||||
|
</label> |
||||
|
<input |
||||
|
type="text" |
||||
|
id="name" |
||||
|
value={videoTitle || videoTitle === "" ? videoTitle : video ? video.title : "Chargement"} |
||||
|
onChange={(e) => setVideoTitle(e.target.value)} |
||||
|
className={(editMode ? editModeClasses : nonEditModeClasses) + " text-xl"} |
||||
|
placeholder="Titre de la vidéo" |
||||
|
disabled={!editMode} |
||||
|
/> |
||||
|
|
||||
|
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}> |
||||
|
Description |
||||
|
</label> |
||||
|
<textarea |
||||
|
name="description" |
||||
|
id="" |
||||
|
className={(editMode ? editModeClassesTextArea : nonEditModeClassesTextArea)} |
||||
|
value={description || description === "" ? description : video ? video.description : "Chargement"} |
||||
|
onChange={(e) => setDescription(e.target.value)} |
||||
|
placeholder="Description de votre chaine" |
||||
|
disabled={!editMode} |
||||
|
></textarea> |
||||
|
|
||||
|
<label htmlFor="visibility" className="text-2xl text-white mb-1 block font-montserrat"> |
||||
|
Visibilité |
||||
|
</label> |
||||
|
<select |
||||
|
className={(editMode ? editModeClasses : nonEditModeClasses)} |
||||
|
value={visibility} |
||||
|
name="visibility" |
||||
|
onChange={(e) => setVisibility(e.target.value)} |
||||
|
disabled={!editMode} |
||||
|
> |
||||
|
<option value="public">Publique</option> |
||||
|
<option value="private">Privée</option> |
||||
|
</select> |
||||
|
|
||||
|
<label htmlFor="video" className={`flex gap-2 glassmorphism p-2 items-center mt-4 cursor-pointer ${editMode ? "block" : "hidden"}`}> |
||||
|
<video src={video ? video.file : ""} className="w-1/8 aspect-video rounded-sm" ></video> |
||||
|
<p className="text-2xl text-white mb-1 block font-montserrat">Fichier vidéo</p> |
||||
|
</label> |
||||
|
<input |
||||
|
type="file" |
||||
|
name="video" |
||||
|
id="video" |
||||
|
className="hidden" |
||||
|
accept="video/*" |
||||
|
onChange={(e) => setVideoFile(e.target.files[0])} |
||||
|
/> |
||||
|
|
||||
|
{ |
||||
|
editMode ? ( |
||||
|
<div className="mt-4"> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer" |
||||
|
onClick={(e) => {onSubmit(e)}} |
||||
|
> |
||||
|
Enregistrer |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="bg-red-500 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer ml-3" |
||||
|
onClick={() => setEditMode(!editMode)} |
||||
|
> |
||||
|
Annuler |
||||
|
</button> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<button |
||||
|
type="button" |
||||
|
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer mt-4" |
||||
|
onClick={() => setEditMode(!editMode)} |
||||
|
> |
||||
|
Modifier |
||||
|
</button> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
</form> |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
|
||||
|
{ /* RIGHT SIDE */ } |
||||
|
<div className="flex-1"> |
||||
|
|
||||
|
<div className="flex gap-4"> |
||||
|
{ /* TOTAL VIEWS */ } |
||||
|
<div className="glassmorphism py-16 flex-1"> |
||||
|
<p className="text-2xl font-bold text-white mb-2 text-center">{video ? video.views : 0} vues</p> |
||||
|
</div> |
||||
|
|
||||
|
{ /* TOTAL LIKES */ } |
||||
|
<div className="glassmorphism py-16 flex-1"> |
||||
|
<p className="text-2xl font-bold text-white mb-2 text-center">{video ? video.likes : 0} likes</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{ /* COMMENTS */ } |
||||
|
<div className="glassmorphism p-4 mt-4 h-[500px] overflow-y-auto"> |
||||
|
|
||||
|
<h2 className="text-2xl font-bold text-white mb-4">Commentaires</h2> |
||||
|
|
||||
|
{video && video.comments && video.comments.length > 0 ? ( |
||||
|
video.comments.map((comment) => ( |
||||
|
<Comment comment={comment} doShowCommands={false} /> |
||||
|
)) |
||||
|
) : ( |
||||
|
<p className="text-gray-500">Aucun commentaire</p> |
||||
|
)} |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
{ /* TAGS */ } |
||||
|
<div className="glassmorphism p-4 mt-4"> |
||||
|
<h2 className="text-2xl font-bold text-white mb-4">Tags</h2> |
||||
|
<div className="flex flex-wrap gap-2"> |
||||
|
{ video && video.tags && video.tags.length > 0 ? ( |
||||
|
video.tags.map((tag) => ( |
||||
|
<Tag tag={tag} key={tag} onSuppress={() => onSuppressTag(tag)} /> |
||||
|
)) |
||||
|
) : ( |
||||
|
<p className="text-gray-500">Aucun tag</p> |
||||
|
)} |
||||
|
</div> |
||||
|
<input |
||||
|
type="text" |
||||
|
className="glassmorphism text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none mt-4" |
||||
|
placeholder="Ajouter un tag" |
||||
|
onKeyPress={(e) => onAddTag(e)} |
||||
|
/> |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
{ /* LINK */ } |
||||
|
|
||||
|
<div className="glassmorphism p-4 mt-4"> |
||||
|
|
||||
|
<h2 className="text-2xl font-bold text-white mb-4">Lien de la vidéo</h2> |
||||
|
<p className="text-md font-normal text-white"> |
||||
|
<a href={`/video/${id}`} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline"> |
||||
|
{window.location.origin}/video/{id} |
||||
|
</a> |
||||
|
<button |
||||
|
className="ml-2 bg-primary text-white p-2 rounded-sm cursor-pointer" |
||||
|
onClick={() => { |
||||
|
navigator.clipboard.writeText(`${window.location.origin}/video/${id}`); |
||||
|
}} |
||||
|
> |
||||
|
Copier |
||||
|
</button> |
||||
|
</p> |
||||
|
|
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
|
||||
|
</main> |
||||
|
</div> |
||||
|
) |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue