39 changed files with 4103 additions and 72 deletions
@ -0,0 +1,9 @@ |
|||
/backend/app/uploads/ |
|||
# Ignore all files in the uploads directory |
|||
|
|||
/frontend/node_modules |
|||
/backend/node_modules |
|||
# Ignore node_modules directories in both frontend and backend |
|||
|
|||
/frontend/dist |
|||
# Ignore the build output directory for the frontend |
|||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
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,19 @@ |
|||
|
|||
|
|||
export default function VideoStatListElement ({ video, onClick }) { |
|||
return ( |
|||
<div className="flex p-4 gap-4 glassmorphism cursor-pointer" onClick={onClick} > |
|||
<img |
|||
src={video.thumbnail} |
|||
alt="" |
|||
className="w-1/4 aspect-video rounded-sm" |
|||
/> |
|||
<div> |
|||
<h3 className="text-white text-2xl font-montserrat font-bold" >{video.title}</h3> |
|||
<p className="text-white text-lg font-montserrat font-normal">Vues: {video.views}</p> |
|||
<p className="text-white text-lg font-montserrat font-normal">Likes: {video.likes}</p> |
|||
<p className="text-white text-lg font-montserrat font-normal">Commentaires: {video.comments}</p> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
import {useState} from "react"; |
|||
|
|||
|
|||
export default function CreateChannelModal({isOpen, onClose}) { |
|||
|
|||
const [name, setName] = useState(''); |
|||
const [description, setDescription] = useState(''); |
|||
|
|||
const token = localStorage.getItem('token'); |
|||
const userStored = localStorage.getItem('user'); |
|||
const user = userStored ? JSON.parse(userStored) : {}; |
|||
|
|||
const onSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
|
|||
const request = await fetch(`/api/channels/`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'Authorization': `Bearer ${token}`, |
|||
}, |
|||
body: JSON.stringify({ |
|||
"name": name, |
|||
"description": description, |
|||
"owner": user.id |
|||
}) |
|||
}) |
|||
|
|||
if (!request.ok) { |
|||
console.error("Not able to create channel"); |
|||
return; // Prevent further execution if the request failed |
|||
} |
|||
|
|||
const data = await request.json(); |
|||
console.log(data); |
|||
|
|||
} |
|||
|
|||
return isOpen && ( |
|||
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center" > |
|||
<div className="glassmorphism p-4 w-1/4" > |
|||
<h2 className="text-2xl text-white font-montserrat font-bold" >Créer une chaine</h2> |
|||
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la chaine</label> |
|||
<input |
|||
type="text" |
|||
id="name" |
|||
name="name" |
|||
placeholder="Nom de la chaine" |
|||
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none" |
|||
onChange={(e) => setName(e.target.value)} |
|||
value={name} |
|||
/> |
|||
|
|||
<label htmlFor="description" className="block text-xl text-white font-montserrat font-semibold mt-2" >Description</label> |
|||
<textarea |
|||
id="description" |
|||
name="description" |
|||
placeholder="Description de votre chaine" |
|||
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none" |
|||
onChange={(e) => setDescription(e.target.value)} |
|||
value={description} |
|||
> |
|||
</textarea> |
|||
<button |
|||
className="bg-primary mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer" |
|||
onClick={(e) => onSubmit(e) } |
|||
> |
|||
Valider |
|||
</button> |
|||
<button |
|||
className="bg-red-500 ml-2 mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer" |
|||
onClick={onClose} |
|||
> |
|||
Annuler |
|||
</button> |
|||
</div> |
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,275 @@ |
|||
import Navbar from "../components/Navbar.jsx"; |
|||
import {useEffect, useState} from "react"; |
|||
import Tag from "../components/Tag.jsx"; |
|||
|
|||
|
|||
export default function AddVideo() { |
|||
|
|||
const storedUser = localStorage.getItem("user"); |
|||
const user = storedUser ? JSON.parse(storedUser) : null; |
|||
const token = localStorage.getItem("token"); |
|||
|
|||
const [videoTitle, setVideoTitle] = useState(""); |
|||
const [videoDescription, setVideoDescription] = useState(""); |
|||
const [videoTags, setVideoTags] = useState([]); |
|||
const [visibility, setVisibility] = useState("public"); // Default visibility |
|||
const [videoThumbnail, setVideoThumbnail] = useState(null); |
|||
const [videoFile, setVideoFile] = useState(null); |
|||
const [channel, setChannel] = useState(null); // Assuming user.channel is the channel ID |
|||
|
|||
useEffect(() => { |
|||
fetchChannel(); |
|||
}, []) |
|||
|
|||
const fetchChannel = async () => { |
|||
try { |
|||
const response = await fetch(`/api/users/${user.id}/channel`, { |
|||
headers: { |
|||
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication |
|||
}, |
|||
}); |
|||
if (!response.ok) { |
|||
throw new Error("Erreur lors de la récupération de la chaîne"); |
|||
} |
|||
const data = await response.json(); |
|||
setChannel(data.channel); |
|||
} catch (error) { |
|||
console.error("Erreur lors de la récupération de la chaîne :", error); |
|||
} |
|||
} |
|||
|
|||
const handleTagKeyDown = (e) => { |
|||
if (e.key === 'Enter' && videoTags.length < 10) { |
|||
e.preventDefault(); |
|||
const newTag = e.target.value.trim(); |
|||
if (newTag && !videoTags.includes(newTag)) { |
|||
setVideoTags([...videoTags, newTag]); |
|||
e.target.value = ''; |
|||
} |
|||
} |
|||
} |
|||
const handleTagRemove = (tagToRemove) => { |
|||
setVideoTags(videoTags.filter(tag => tag !== tagToRemove)); |
|||
}; |
|||
|
|||
// This function handles the submission of the video form |
|||
const handleSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
|
|||
if (!videoTitle || !videoDescription || !videoThumbnail || !videoFile) { |
|||
alert("Veuillez remplir tous les champs requis."); |
|||
return; |
|||
} |
|||
if (!channel || !channel.id) { |
|||
alert("Erreur: aucune chaîne trouvée. Veuillez actualiser la page."); |
|||
return; |
|||
} |
|||
if (videoTags.length > 10) { |
|||
alert("Vous ne pouvez pas ajouter plus de 10 tags."); |
|||
return; |
|||
} |
|||
|
|||
const formData = new FormData(); |
|||
formData.append("title", videoTitle); |
|||
formData.append("description", videoDescription); |
|||
formData.append("channel", channel.id.toString()); // Ensure it's a string |
|||
formData.append("visibility", visibility); |
|||
formData.append("file", videoFile); |
|||
|
|||
const request = await fetch("/api/videos", { |
|||
method: "POST", |
|||
headers: { |
|||
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication |
|||
}, |
|||
body: formData |
|||
}); |
|||
if (!request.ok) { |
|||
const errorData = await request.json(); |
|||
console.error("Backend validation errors:", errorData); |
|||
|
|||
// Display specific validation errors if available |
|||
if (errorData.errors && errorData.errors.length > 0) { |
|||
const errorMessages = errorData.errors.map(error => |
|||
`${error.path}: ${error.msg}` |
|||
).join('\n'); |
|||
alert(`Erreurs de validation:\n${errorMessages}`); |
|||
} else { |
|||
alert(`Erreur lors de l'ajout de la vidéo : ${errorData.message || 'Erreur inconnue'}`); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// If the video was successfully created, we can now upload the thumbnail |
|||
const response = await request.json(); |
|||
const videoId = response.id; |
|||
const thumbnailFormData = new FormData(); |
|||
thumbnailFormData.append("video", videoId); |
|||
thumbnailFormData.append("file", videoThumbnail); |
|||
thumbnailFormData.append("channel", channel.id.toString()); |
|||
const thumbnailRequest = await fetch("/api/videos/thumbnail", { |
|||
method: "POST", |
|||
headers: { |
|||
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication |
|||
}, |
|||
body: thumbnailFormData |
|||
}); |
|||
if (!thumbnailRequest.ok) { |
|||
const errorData = await thumbnailRequest.json(); |
|||
console.error("Backend validation errors:", errorData); |
|||
alert(`Erreur lors de l'ajout de la miniature : ${errorData.message || 'Erreur inconnue'}`); |
|||
return; |
|||
} |
|||
|
|||
// if the thumbnail was successfully uploaded, we can send the tags |
|||
const tagsRequest = await fetch(`/api/videos/${videoId}/tags`, { |
|||
method: "PUT", |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication |
|||
}, |
|||
body: JSON.stringify({ tags: videoTags, channel: channel.id.toString() }) // Ensure channel ID is a string |
|||
}); |
|||
if (!tagsRequest.ok) { |
|||
const errorData = await tagsRequest.json(); |
|||
console.error("Backend validation errors:", errorData); |
|||
alert(`Erreur lors de l'ajout des tags : ${errorData.message || 'Erreur inconnue'}`); |
|||
return; |
|||
} |
|||
// If everything is successful, redirect to the video management page |
|||
alert("Vidéo ajoutée avec succès !"); |
|||
|
|||
|
|||
|
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
|||
<Navbar isSearchPage={false} /> |
|||
|
|||
<main className="px-36 pt-[118px]"> |
|||
<h1 className="font-montserrat text-2xl font-black text-white"> |
|||
Ajouter une vidéo |
|||
</h1> |
|||
|
|||
<div className="flex gap-8 mt-8"> |
|||
{/* Left side: Form for adding video details */} |
|||
<form className="flex-1"> |
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoTitle">Titre de la vidéo</label> |
|||
<input |
|||
type="text" |
|||
id="videoTitle" |
|||
name="videoTitle" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Entrez le titre de la vidéo" |
|||
value={videoTitle} |
|||
onChange={(e) => setVideoTitle(e.target.value)} |
|||
required |
|||
/> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoDescription">Description de la vidéo</label> |
|||
<textarea |
|||
id="videoDescription" |
|||
name="videoDescription" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Entrez la description de la vidéo" |
|||
rows="4" |
|||
value={videoDescription} |
|||
onChange={(e) => setVideoDescription(e.target.value)} |
|||
required |
|||
></textarea> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-1" htmlFor="videoDescription">Tags</label> |
|||
<input |
|||
type="text" |
|||
id="videoTags" |
|||
name="videoTags" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Entrez les tags de la vidéo (entrée pour valider) 10 maximum" |
|||
onKeyDown={handleTagKeyDown} |
|||
|
|||
/> |
|||
<div className="flex flex-wrap gap-2 mb-2"> |
|||
{videoTags.map((tag, index) => ( |
|||
<Tag tag={tag} doShowControls={true} key={index} onSuppress={() => handleTagRemove(tag)} /> |
|||
))} |
|||
</div> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="visibility">Visibilité</label> |
|||
<select |
|||
name="visibility" |
|||
id="visibility" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
value={visibility} |
|||
onChange={(e) => setVisibility(e.target.value)} |
|||
> |
|||
<option value="public">Public</option> |
|||
<option value="private">Privé</option> |
|||
</select> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoThumbnail">Miniature</label> |
|||
<input |
|||
type="file" |
|||
id="videoThumbnail" |
|||
name="videoThumbnail" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
accept="image/*" |
|||
onChange={(e) => setVideoThumbnail(e.target.files[0])} |
|||
required |
|||
/> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoFile">Fichier vidéo</label> |
|||
<input |
|||
type="file" |
|||
id="videoFile" |
|||
name="videoFile" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
accept="video/*" |
|||
onChange={(e) => setVideoFile(e.target.files[0])} |
|||
required |
|||
/> |
|||
|
|||
<button |
|||
type="submit" |
|||
className="bg-primary text-white font-montserrat p-3 rounded-lg text-2xl font-bold w-full cursor-pointer" |
|||
onClick={(e) => { handleSubmit(e) }} |
|||
> |
|||
Ajouter la vidéo |
|||
</button> |
|||
|
|||
</form> |
|||
|
|||
{/* Right side: Preview of the video being added */} |
|||
<div className="flex-1 flex justify-center"> |
|||
<div className="glassmorphism p-4 rounded-lg"> |
|||
<img |
|||
src={videoThumbnail ? URL.createObjectURL(videoThumbnail) : "https://placehold.co/1280x720"} alt={videoTitle} |
|||
className="w-[480px] h-auto mb-4 rounded-lg" |
|||
/> |
|||
<h2 className="text-white text-xl font-montserrat font-semibold mb-2">{videoTitle || "Titre de la vidéo"}</h2> |
|||
|
|||
<div className="glassmorphism p-4 rounded-sm"> |
|||
<p className="text-white font-inter mb-2"> |
|||
{videoDescription || "Description de la vidéo"} |
|||
</p> |
|||
|
|||
<div className="flex flex-wrap gap-2"> |
|||
{videoTags.length > 0 ? ( |
|||
videoTags.map((tag, index) => ( |
|||
<Tag tag={tag} doShowControls={false} key={index} /> |
|||
)) |
|||
) : ( |
|||
<span className="text-gray-400">Aucun tag ajouté</span> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
</main> |
|||
|
|||
</div> |
|||
); |
|||
|
|||
} |
|||
@ -0,0 +1,208 @@ |
|||
import Navbar from "../components/Navbar.jsx"; |
|||
import {useEffect, useState} from "react"; |
|||
import {useNavigate, useParams} from "react-router-dom"; |
|||
import {useAuth} from "../contexts/AuthContext.jsx"; |
|||
import VideoStatListElement from "../components/VideoStatListElement.jsx"; |
|||
|
|||
|
|||
export default function ManageChannel() { |
|||
|
|||
const {id} = useParams(); |
|||
const {user} = useAuth(); |
|||
const navigate = useNavigate(); |
|||
|
|||
const [channel, setChannel] = useState(); |
|||
const [channelStats, setChannelStats] = useState(); |
|||
const [channelName, setChannelName] = useState(null); |
|||
const [description, setDescription] = useState(null); |
|||
const [editMode, setEditMode] = useState(false); |
|||
|
|||
const token = localStorage.getItem("token"); |
|||
const nonEditModeClasses = "text-2xl font-bold text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none text-center"; |
|||
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"; |
|||
|
|||
useEffect(() => { |
|||
fetchChannelData() |
|||
fetchChannelStats() |
|||
}, []); |
|||
|
|||
const fetchChannelData = async () => { |
|||
try { |
|||
const request = await fetch(`/api/channels/${id}`, { |
|||
method: "GET", |
|||
headers: { |
|||
"Authorization": `Bearer ${token}` |
|||
} |
|||
}) |
|||
const result = await request.json(); |
|||
setChannel(result); |
|||
|
|||
} catch (error) { |
|||
console.error("Error fetching channel data:", error); |
|||
} |
|||
|
|||
} |
|||
const fetchChannelStats = async () => { |
|||
try { |
|||
const request = await fetch(`/api/channels/${id}/stats`, { |
|||
method: "GET", |
|||
headers: { |
|||
"Authorization": `Bearer ${token}` |
|||
} |
|||
}) |
|||
const result = await request.json(); |
|||
setChannelStats(result); |
|||
} catch (error) { |
|||
console.error("Error fetching channel stats", error); |
|||
} |
|||
} |
|||
|
|||
const handleUpdateChannel = async () => { |
|||
if (!editMode) return; |
|||
|
|||
try { |
|||
const response = await fetch(`/api/channels/${id}`, { |
|||
method: "PUT", |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
"Authorization": `Bearer ${token}` |
|||
}, |
|||
body: JSON.stringify({ |
|||
name: channelName || channel.name, |
|||
description: description || channel.description, |
|||
}) |
|||
}); |
|||
|
|||
if (response.ok) { |
|||
setEditMode(false); |
|||
fetchChannelData(); // Refresh channel data after update |
|||
} else { |
|||
console.error("Failed to update channel"); |
|||
} |
|||
} catch (error) { |
|||
console.error("Error updating channel:", error); |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
|||
<Navbar isSearchPage={false} /> |
|||
|
|||
<main className="pt-[118px] px-36 flex"> |
|||
|
|||
{/* LEFT SIDE */} |
|||
<form className="glassmorphism w-1/3 h-screen py-10 px-4"> |
|||
<img src={user.picture} className="w-1/3 aspect-square object-cover rounded-full mx-auto" alt=""/> |
|||
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat ${editMode ? "block" : "hidden"} `}> |
|||
Nom de chaine |
|||
</label> |
|||
<input |
|||
type="text" |
|||
id="name" |
|||
value={channelName || channelName === "" ? channelName : channel ? channel.name : "Chargement"} |
|||
className={(editMode ? editModeClasses : nonEditModeClasses)} |
|||
onChange={(e) => setChannelName(e.target.value)} |
|||
placeholder="Nom d'utilisateur" |
|||
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 : channel ? channel.description : "Chargement"} |
|||
onChange={(e) => setDescription(e.target.value)} |
|||
placeholder="Description de votre chaine" |
|||
disabled={!editMode} |
|||
></textarea> |
|||
|
|||
{ |
|||
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={handleUpdateChannel} |
|||
> |
|||
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> |
|||
|
|||
{/* RIGHT SIDE */} |
|||
<div className="w-2/3 h-screen pl-10" > |
|||
{/* VIEW / SUBSCRIBERS STATS */} |
|||
|
|||
<div className="flex gap-4" > |
|||
<div className="glassmorphism flex-1 h-32 flex flex-col justify-center items-center" > |
|||
{/* TOTAL VIEWS */} |
|||
<p className="text-white text-xl font-montserrat font-semibold" >Vues totales</p> |
|||
<p className="text-white text-2xl font-montserrat font-bold" >{channelStats ? channelStats.views : "0"}</p> |
|||
</div> |
|||
<div className="glassmorphism flex-1 h-32 flex flex-col justify-center items-center" > |
|||
{/* TOTAL SUBSCRIBERS */} |
|||
<p className="text-white text-xl font-montserrat font-semibold" >Abonnés</p> |
|||
<p className="text-white text-2xl font-montserrat font-bold" >{channelStats ? channelStats.subscribers : "0"}</p> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* VIDEOS */} |
|||
<div className="flex justify-between"> |
|||
<h2 className="text-white text-3xl font-montserrat font-bold mt-10" >Vidéos</h2> |
|||
<button |
|||
className="bg-primary px-2 py-1 rounded-sm text-white font-montserrat text-md font-semibold cursor-pointer mt-4" |
|||
onClick={() => navigate("/add-video")} |
|||
> |
|||
Ajouter une vidéo |
|||
</button> |
|||
|
|||
</div> |
|||
|
|||
{ channel?.videos?.length > 0 ? ( |
|||
<div className="flex flex-col gap-4 mt-5"> |
|||
{channel.videos.map((video) => ( |
|||
<VideoStatListElement |
|||
video={video} |
|||
onClick={() => navigate("/manage-video/" + video.id)} |
|||
key={video.id} |
|||
/> |
|||
))} |
|||
</div> |
|||
) : ( |
|||
<p className="text-white text-xl font-montserrat mt-4">Aucune vidéo trouvée pour cette chaîne.</p> |
|||
)} |
|||
|
|||
|
|||
|
|||
</div> |
|||
|
|||
</main> |
|||
|
|||
</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> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
-----BEGIN CERTIFICATE----- |
|||
MIID5zCCAs+gAwIBAgIUXzNzqa/12lyIcoxXf+v371J3fWkwDQYJKoZIhvcNAQEL |
|||
BQAwgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQIDAhOb3JtYW5keTENMAsGA1UEBwwE |
|||
Q2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTATBgNVBAMMDFNhY2hhIEdVRVJJTjEn |
|||
MCUGCSqGSIb3DQEJARYYc2FjaGEuZ3VlcmluQHN1cGluZm8uY29tMB4XDTI1MDcy |
|||
MTEzMzgwMVoXDTI2MDcyMTEzMzgwMVowgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQI |
|||
DAhOb3JtYW5keTENMAsGA1UEBwwEQ2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTAT |
|||
BgNVBAMMDFNhY2hhIEdVRVJJTjEnMCUGCSqGSIb3DQEJARYYc2FjaGEuZ3Vlcmlu |
|||
QHN1cGluZm8uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvLg7 |
|||
nR0UqRZ7UadhI8jrUjRMV1SZj+ljxEnV6tDOVMsvafsym1MhDZHb+cyv8769yqPv |
|||
CKtIOQKhMH0PkSqau8szNlF1Tg/1UzT+Mkd4zvLvGE5+aW/oDMg7E2LMJZuCyO4X |
|||
9SzWDVA5+b1QFIw6vvb3mCkUOtVDkOFreBBwryZKcWJ0b8o1hT60oB2wr18P14j0 |
|||
0C2/TmHMtim0o4r3gKGvpatqt1fXJo0UlYOwTvfMrYhu2VHqsQ2qP7ocazXEWt5u |
|||
Alf1vNPkAenF0ZV/2UiaL41Q8GMoV1enDP7k7/qfgXvta/hOeYnLtmv5Qpi4XiWz |
|||
xKjSukTUD2sRtSX+YQIDAQABo1MwUTAdBgNVHQ4EFgQUVj9KtmjLFy4xWzkNI9Kq |
|||
NAxNsfUwHwYDVR0jBBgwFoAUVj9KtmjLFy4xWzkNI9KqNAxNsfUwDwYDVR0TAQH/ |
|||
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGpUPMoF/ASIqOOfX5anDtaPvnslj |
|||
DuEVbU7Uoyc4EuSD343DPV7iMUKoFvVLPxJJqMLhSo3aEGJyqOF6q3fvq/vX2VE7 |
|||
9MhwS1t2DBGb5foWRosnT1EuqFU1/S0RJ/Y+GNcoY1PrUES4+r7zqqJJjwKOzneV |
|||
ktUVCdKl0C1gtw6W4Ajxse3fm9DNLxnZZXbyNqn+KbI8QdO0xSEl+gyiycvPu/NT |
|||
+EesdlFoYjO7gdA8dXkmu+Z7R61MYhE9Zvyop5KVMqgU8/Ym04UUWjWQYWWLMyuu |
|||
bxngE4XNEI5fhg+0e/I25xJJ9wVV/ZNAF4+XOylHz/CmU8V/SPKuGXBGHg== |
|||
-----END CERTIFICATE----- |
|||
@ -0,0 +1,28 @@ |
|||
-----BEGIN PRIVATE KEY----- |
|||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8uDudHRSpFntR |
|||
p2EjyOtSNExXVJmP6WPESdXq0M5Uyy9p+zKbUyENkdv5zK/zvr3Ko+8Iq0g5AqEw |
|||
fQ+RKpq7yzM2UXVOD/VTNP4yR3jO8u8YTn5pb+gMyDsTYswlm4LI7hf1LNYNUDn5 |
|||
vVAUjDq+9veYKRQ61UOQ4Wt4EHCvJkpxYnRvyjWFPrSgHbCvXw/XiPTQLb9OYcy2 |
|||
KbSjiveAoa+lq2q3V9cmjRSVg7BO98ytiG7ZUeqxDao/uhxrNcRa3m4CV/W80+QB |
|||
6cXRlX/ZSJovjVDwYyhXV6cM/uTv+p+Be+1r+E55icu2a/lCmLheJbPEqNK6RNQP |
|||
axG1Jf5hAgMBAAECggEAAj+hmDRx6jafAAf67sqi3ZgEGEmBkXNeeLGBTPc/qhxd |
|||
ip6krTELnz8TE26RG5LYXzslasUNrn42nIImvBT5ZkcjcosKpWfEqQEAjc1PQovC |
|||
9eyKnKfw4TpUvvmiveT4T98vCYEOOqHE0/WTdlOoaBY/f+sZKQYu+1NMtAjFcg2r |
|||
vVqwsZb5vGyh7CKmIHZnz3UP8P+7G5digiNRne18pGnE2oTnSoQ3/QIqUWBs69DS |
|||
k5ew+CSyTLiUFFnMnE4adwyg6wAud5fBlzowF6UF2agToX7pxEaGxGvpBGG034kk |
|||
1UXaB/d5YwcsBeH+x5cNMLKZy4zqjoxEEW31Q466NQKBgQDtKk1R/slpTpRqvtBT |
|||
NC7InvjcCBXkXttylQHJRN9glqhmflEOe8iMW1/qRwBPlQgK1wq/sXySanVv2+gO |
|||
JGq8XNRLbHyG3YRyshdnJHP1HoWQE0uedD/rfqgkNaW5S1IvHrD7Q7tOvCrF+KbS |
|||
612pmIgNVzn+inafDXPhMZc4pQKBgQDLtQGAu2eK58ewndyL8+7+VHZSTEtKpt+h |
|||
G/U/ccv+6NGqdxI5YUkrJ7k6vV81VeRMvmN9uUS/i8znORFQmm6noRVkhXytwW5B |
|||
HXq2co4WRvv9b/XqcqS0GSYVPJ1u4YNH6lvtDZ4UWPyBzYl700GdHrGa+erT44yL |
|||
tnibHx9GDQKBgFW1J+Qt85O+9hvtgVPQU+fkq4K42VCCh0PNXavi2+cICyufEqPt |
|||
T/iJPQxpRE9+SD3CoPvNpHs1ReN60U3rEzenRIFNX2NNwoPAoHyBy/YVZac/keBd |
|||
mov8Zb9QM+fWtIiaytLDE3nMvph017T5ogucN+66SxcV6vBn6CzFwySRAoGAcUf2 |
|||
Tv1ohkGAtgIDrLx5cmvL5NZSpHAKOpDOoHqLA/W66v4OX2RviRUtF7JJ6OIb9GWH |
|||
9Fl8Fr0KtKbyrw1CbevRdrYY8JN52bIoFJ+9zjupVHXXnookd5boq7SqpAe6ttpo |
|||
RnplJ1GZEiIXy4lemp6AC/vhD/YhqWxOw4zaGl0CgYBslhqVt5F0EHf94p7NrCuY |
|||
hNHKHaNaULYP0VXKefQamt/ssDuktqb6DNSIvx2rbbB5+33nTlLTya67gimY1lKt |
|||
WeNB33/yBkCjfSP/J5UDD9mE/oPLt3vAOkOUgMCfp2IpC2Wez1QGqLHS260zpotP |
|||
VpgalHuSWtn8D4nO2pk1hg== |
|||
-----END PRIVATE KEY----- |
|||
Loading…
Reference in new issue