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