Files
ffsaf-site/src/main/webapp/src/pages/competition/editor/CMAdmin.jsx
Thibaut Valentin bf377c9c5e
Some checks failed
Deploy Production Server / if_merged (pull_request) Failing after 2m31s
feat: external website result connector
2025-12-29 21:03:05 +01:00

672 lines
29 KiB
JavaScript

import React, {useEffect, useRef, useState} from "react";
import {useRequestWS, useWS} from "../../../hooks/useWS.jsx";
import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {toast} from "react-toastify";
import {build_tree, resize_tree} from "../../../utils/TreeUtils.js"
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
import {CategoryContent} from "./CategoryAdminContent.jsx";
import {exportOBSConfiguration} from "../../../hooks/useOBS.jsx";
import {createPortal} from "react-dom";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {SimpleIconsOBS} from "../../../assets/SimpleIconsOBS.ts";
import JSZip from "jszip";
import {detectOptimalBackground} from "../../../components/SmartLogoBackground.jsx";
import {faGlobe} from "@fortawesome/free-solid-svg-icons";
const vite_url = import.meta.env.VITE_URL;
export function CMAdmin({compUuid}) {
const [catId, setCatId] = useState(null);
const [cat, setCat] = useState(null);
const menuActions = useRef({});
const {dispatch} = useWS();
useEffect(() => {
const categoryListener = ({data}) => {
if (!cat || data.id !== cat.id)
return
setCat(cat_ => ({
...cat_,
name: data.name,
liceName: data.liceName,
type: data.type
}))
}
dispatch({type: 'addListener', payload: {callback: categoryListener, code: 'sendCategory'}})
return () => dispatch({type: 'removeListener', payload: categoryListener})
}, [cat]);
return <>
<div className="card">
<div className='card-header'>
<CategoryHeader cat={cat} setCatId={setCatId}/>
</div>
<div className="card-body">
<LoadingProvider>
<div className="row">
<CategoryContent cat={cat} catId={catId} setCat={setCat} menuActions={menuActions}/>
</div>
</LoadingProvider>
</div>
<Menu menuActions={menuActions} compUuid={compUuid}/>
</div>
</>
}
let tto = [];
function resizeImageWithOptimalBackground(blob) {
return new Promise(async (resolve) => {
const background = await detectOptimalBackground(blob);
const imgUrl = URL.createObjectURL(blob);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 1080;
const ctx = canvas.getContext('2d');
// Dessiner l'image centrée
const scale = Math.min(1080 / img.width, 1080 / img.height);
const newWidth = img.width * scale;
const newHeight = img.height * scale;
const x = (1080 - newWidth) / 2;
const y = (1080 - newHeight) / 2;
ctx.filter = `drop-shadow(0 0 2rem ${background})`;
ctx.drawImage(img, x, y, newWidth, newHeight);
// Exporter en PNG
canvas.toBlob((newBlob) => {
resolve(newBlob);
URL.revokeObjectURL(imgUrl);
}, 'image/png', 1.0);
};
img.onerror = () => resolve(blob); // Retourne l'original en cas d'erreur
img.src = imgUrl;
});
}
async function downloadResourcesAsZip(resourceList) {
const zip = new JSZip();
const modal = new bootstrap.Modal(document.getElementById('progressModal'));
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
let completed = 0;
if (!resourceList.some(d => d.url === '/obs_template.json'))
resourceList.push({url: '/obs_template.json', name: 'saf_obs_template.json'});
// Afficher la modale
modal.show();
// Fonction pour télécharger une ressource et l'ajouter au ZIP
const addResourceToZip = async (data) => {
try {
const response = await fetch(data.url);
if (!response.ok) {
if (response.status === 404) {
return {success: false, filename: data.name || data.url.split('/').pop()};
}
// noinspection ExceptionCaughtLocallyJS
throw new Error(`Erreur HTTP: ${response.status}`);
}
const blob = await response.blob();
const filename = data.name || data.url.split('/').pop();
const format = filename.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg', 'svg'].includes(format)) {
const resizedBlob = await resizeImageWithOptimalBackground(blob);
const pngFilename = filename.replace(/\.[^/.]+$/, ".png");
zip.file(pngFilename, resizedBlob);
return {success: true, pngFilename};
} else {
zip.file(filename, blob);
return {success: true, filename};
}
} catch (error) {
console.error(`Impossible d'ajouter ${data.url} au ZIP:`, error);
return {success: false, filename: data.name || data.url.split('/').pop()};
}
};
// Télécharger toutes les ressources et mettre à jour la progression
await Promise.all(
resourceList.map(async (data) => {
const result = await addResourceToZip(data);
completed++;
const progress = Math.round((completed / resourceList.length) * 100);
progressBar.style.width = `${progress}%`;
progressText.textContent = `Téléchargement (${completed}/${resourceList.length}) : ${result.filename}`;
return result;
})
);
// Générer le ZIP et déclencher le téléchargement
const zipBlob = await zip.generateAsync({type: 'blob'});
const zipUrl = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = zipUrl;
a.download = 'ressources.zip';
a.click();
URL.revokeObjectURL(zipUrl);
// Fermer la modale
modal.hide();
progressText.textContent = "Téléchargement terminé !";
}
function Menu({menuActions, compUuid}) {
const e = document.getElementById("actionMenu")
const longPress = useRef({time: null, timer: null, button: null});
const obsModal = useRef(null);
for (const x of tto)
x.dispose();
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip2"]')
tto = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
const longTimeAction = (button) => {
if (button === "obs") {
obsModal.current.click();
}
}
const longPressDown = (button) => {
longPress.current.button = button;
longPress.current.time = new Date();
longPress.current.timer = setTimeout(() => {
longTimeAction(button);
longPress.current.time = null;
longPress.current.button = null;
}, 1000);
}
const longPressUp = (button) => {
clearTimeout(longPress.current.timer);
if (longPress.current.time) {
const diff = new Date() - longPress.current.time;
if (longPress.current.button === button) {
if (diff >= 1000) {
longTimeAction(button);
} else {
if (button === "obs") {
downloadResourcesAsZip(menuActions.current.resourceList || [])
.then(__ => console.log("Ressources téléchargées"));
}
}
}
longPress.current.time = null;
longPress.current.button = null;
}
}
const handleOBSSubmit = (e) => {
e.preventDefault();
const form = e.target;
const adresse = form[0].value;
const password = form[1].value;
const assets_dir = form[2].value;
exportOBSConfiguration(adresse, password, assets_dir)
}
const copyScriptToClipboard = () => {
navigator.clipboard.writeText(`<div id='safca_api_data'></div>
<script id="safca_api_script" type="text/javascript" src="${vite_url}/competition.js?id=${compUuid}"></script>`
).then(() => {
toast.success("Texte copié dans le presse-papier ! Collez-le dans une balise HTML sur votre WordPress.");
}).catch(err => {
toast.error("Erreur lors de la copie dans le presse-papier : " + err);
});
}
if (!e)
return <></>;
return <>
{createPortal(
<>
<div className="vr" style={{margin: "0 0.5em", height: "100%"}}></div>
<FontAwesomeIcon icon={SimpleIconsOBS} size="xl"
style={{color: "#6c757d", cursor: "pointer", marginRight: "0.25em"}}
onMouseDown={() => longPressDown("obs")}
onMouseUp={() => longPressUp("obs")}
data-bs-toggle="tooltip2" data-bs-placement="top"
data-bs-title="Clique court : Télécharger les ressources. Clique long : Créer la configuration obs"/>
<FontAwesomeIcon icon={faGlobe} size="xl"
style={{color: "#6c757d", cursor: "pointer"}}
onClick={() => copyScriptToClipboard()}
data-bs-toggle="tooltip2" data-bs-placement="top"
data-bs-title="Copier le scripte d'intégration"/>
</>, document.getElementById("actionMenu"))}
<button ref={obsModal} type="button" className="btn btn-link" data-bs-toggle="modal" data-bs-target="#OBSModal" style={{display: 'none'}}>
Launch OBS Modal
</button>
<div className="modal fade" id="OBSModal" tabIndex="-1" aria-labelledby="OBSModalLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Configuration OBS</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form onSubmit={handleOBSSubmit}>
<div className="modal-body">
<strong>/!\ Le mot de passe va être stoker en claire, il est recommandé de ne l'utiliser que sur obs websocket et d'en
changer entre chaque compétition</strong>
<div className="input-group mb-3">
<span className="input-group-text">Adresse du serveur</span>
<span className="input-group-text">ws://</span>
<input type="text" className="form-control" placeholder="127.0.0.1:4455" aria-label=""
defaultValue={"127.0.0.1:4455"}/>
<span className="input-group-text">/</span>
</div>
<div className="input-group mb-3">
<span className="input-group-text">Mot de passe du serveur</span>
<input type="password" className="form-control" placeholder="12345" aria-label=""
defaultValue={""}/>
</div>
<div className="input-group mb-3">
<span className="input-group-text">Dossier des resources</span>
<input type="text" className="form-control" placeholder="" aria-label="" required/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Exporter</button>
</div>
</form>
</div>
</div>
</div>
<div className="modal fade" id="progressModal" tabIndex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Téléchargement en cours...</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="progress">
<div id="progressBar" className="progress-bar" role="progressbar" style={{width: "0%"}}></div>
</div>
<div id="progressText" className="mt-2">Préparation...</div>
</div>
</div>
</div>
</div>
</>
}
function CategoryHeader({cat, setCatId}) {
const setLoading = useLoadingSwitcher()
const bthRef = useRef();
const confirmRef = useRef();
const [modal, setModal] = useState({})
const [confirm, setConfirm] = useState({})
const {data: cats, setData: setCats} = useRequestWS('getAllCategory', {}, setLoading);
const {dispatch} = useWS();
useEffect(() => {
const categoryListener = ({data}) => {
setCats([
...cats.filter(c => c.id !== data.id),
data
])
}
const sendAddCategory = ({data}) => {
setCats([...cats, data])
}
const sendDelCategory = ({data}) => {
setCatId(catId => {
if (catId === data) return null;
return catId;
})
setCats([...cats.filter(c => c.id !== data)])
}
dispatch({type: 'addListener', payload: {callback: categoryListener, code: 'sendCategory'}})
dispatch({type: 'addListener', payload: {callback: sendAddCategory, code: 'sendAddCategory'}})
dispatch({type: 'addListener', payload: {callback: sendDelCategory, code: 'sendDelCategory'}})
return () => {
dispatch({type: 'removeListener', payload: categoryListener})
dispatch({type: 'removeListener', payload: sendAddCategory})
dispatch({type: 'removeListener', payload: sendDelCategory})
}
}, [cats]);
useEffect(() => {
if (cats && cats.length > 0 && !cat || (cats && !cats.find(c => c.id === cat.id))) {
setCatId(cats.sort((a, b) => a.name.localeCompare(b.name))[0].id);
} else if (cats && cats.length === 0) {
setModal({});
bthRef.current.click();
}
}, [cats, cat]);
const handleCatChange = (e) => {
const selectedCatId = e.target.value;
if (selectedCatId !== "-1") {
setCatId(selectedCatId);
} else { // New category
setModal({});
bthRef.current.click();
e.target.value = cat?.id;
}
}
return <div className="row">
<div className="col-auto">
<div className="input-group">
<h5 style={{margin: "auto 0.5em auto 0"}}>Edition de la catégorie</h5>
<select className="form-select" onChange={handleCatChange} value={cat?.id || ""}>
{cats && cats.sort((a, b) => a.name.localeCompare(b.name)).map(c => (
<option key={c.id} value={c.id}>{c.name}</option>))}
{cats && <option value={-1}>Nouvelle...</option>}
</select>
</div>
</div>
<div className="col" style={{margin: "auto 0", textAlign: "center"}}>
{cat &&
<div>Type: {(cat.type & 1) !== 0 ? "Poule" : ""}{cat.type === 3 ? " & " : ""}{(cat.type & 2) !== 0 ? "Tournois" : ""} |
Zone: {cat.liceName}</div>}
</div>
<div className="col-auto">
<button className="btn btn-primary float-end" onClick={() => {
setModal(cat);
bthRef.current.click();
}} disabled={cat === null}>Modifier
</button>
</div>
<button ref={bthRef} data-bs-toggle="modal" data-bs-target="#CategorieModal" style={{display: "none"}}>open</button>
<div className="modal fade" id="CategorieModal" tabIndex="-1" aria-labelledby="CategorieModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<ModalContent state={modal} setCatId={setCatId} setConfirm={setConfirm} confirmRef={confirmRef}/>
</div>
</div>
</div>
<button ref={confirmRef} data-bs-toggle="modal" data-bs-target="#confirm-dialog" style={{display: "none"}}>open</button>
<ConfirmDialog id="confirm-dialog" onConfirm={confirm.confirm ? confirm.confirm : () => {
}} onCancel={confirm.cancel ? confirm.cancel : () => {
}} title={confirm ? confirm.title : ""} message={confirm ? confirm.message : ""}/>
</div>
}
function ModalContent({state, setCatId, setConfirm, confirmRef}) {
const [name, setName] = useState("")
const [lice, setLice] = useState("A")
const [poule, setPoule] = useState(true)
const [tournoi, setTournoi] = useState(false)
const [size, setSize] = useState(4)
const [loserMatch, setLoserMatch] = useState(1)
const {sendRequest} = useWS();
useEffect(() => {
setName(state.name || "");
setLice(state.liceName || "A");
setPoule(((state.type || 1) & 1) !== 0);
setTournoi((state.type & 2) !== 0);
if (state?.trees && state.trees.length >= 1) {
const tree = state.trees[0];
setSize(tree.getMaxChildrenAtDepth(tree.death() - 1) * 2);
if (state.trees.length === 1) {
setLoserMatch(0);
} else if (state.trees.length === 2) {
setLoserMatch(1);
} else {
setLoserMatch(-1);
}
} else {
setSize(4);
setLoserMatch(1);
}
}, [state])
const handleSubmit = (e) => {
e.preventDefault();
const regex = /^([^;]+;)*[^;]+$/;
if (regex.test(lice.trim()) === false) {
toast.error("Le format du nom des zones de combat est invalide. Veuillez séparer les noms par des ';'.");
return;
}
const nType = (poule ? 1 : 0) + (tournoi ? 2 : 0);
if (nType === 0) {
toast.error("Au moins un type (poule ou tournoi) doit être sélectionné.");
return;
}
if (state?.id) {
const applyChanges = () => {
const newData = {
id: state.id,
name: name.trim(),
liceName: lice.trim(),
type: nType
}
let nbMatch = -1;
let oldSubTree = -1;
const oldTrees = state?.trees || [];
if (oldTrees.length >= 1) {
const tree = state.trees[0];
nbMatch = tree.getMaxChildrenAtDepth(tree.death() - 1);
if (state.trees.length === 1)
oldSubTree = 0
else if (state.trees.length === 2)
oldSubTree = 1
}
console.log(tournoi, size, nbMatch, loserMatch, oldSubTree);
if (tournoi && (size !== nbMatch * 2 || loserMatch !== oldSubTree)) {
setConfirm({
title: "Changement de l'arbre du tournoi",
message: `Voulez-vous vraiment changer la taille de l'arbre du tournoi ou les matchs pour les perdants ? Cela va modifier les matchs existants (incluant des possibles suppressions)!`,
confirm: () => {
const trees2 = build_tree(size, loserMatch)
const newTrees = []
let i = 0;
for (; i < oldTrees.length && i < trees2.length; i++) {
newTrees.push(oldTrees[i]);
resize_tree(newTrees.at(i), trees2.at(i));
}
for (; i < trees2.length; i++) {
newTrees.push(trees2.at(i));
}
toast.promise(sendRequest('updateTrees', {categoryId: state.id, trees: newTrees}),
{
pending: 'Mise à jour des arbres du tournoi...',
success: 'Arbres mis à jour !',
error: 'Erreur lors de la mise à jour des arbres'
}
).then(__ => {
toast.promise(sendRequest('updateCategory', newData),
{
pending: 'Mise à jour de la catégorie...',
success: 'Catégorie mise à jour !',
error: 'Erreur lors de la mise à jour de la catégorie'
}
)
})
}
})
confirmRef.current.click();
} else {
toast.promise(sendRequest('updateCategory', newData),
{
pending: 'Mise à jour de la catégorie...',
success: 'Catégorie mise à jour !',
error: 'Erreur lors de la mise à jour de la catégorie'
}
)
}
}
if (nType !== state.type) {
let typeStr = "";
if ((state.type & 1) !== 0 && (nType & 1) === 0)
typeStr += "poule ";
if ((state.type & 2) !== 0 && (nType & 2) === 0)
typeStr += "tournoi ";
setConfirm({
title: "Changement de type de catégorie",
message: `Voulez-vous vraiment enlever la partie ${typeStr} de la catégorie. Cela va supprimer les matchs contenus dans cette partie !`,
confirm: () => {
setTimeout(() =>
applyChanges(), 500);
}
})
confirmRef.current.click();
} else {
applyChanges();
}
} else {
toast.promise(sendRequest('createCategory', {name: name.trim(), liceName: lice.trim(), type: nType}),
{
pending: 'Création de la catégorie...',
success: 'Catégorie créée !',
error: 'Erreur lors de la création de la catégorie'
}
).then(id => {
if (tournoi) {
const trees = build_tree(size, loserMatch)
console.log("Creating trees for new category:", trees);
toast.promise(sendRequest('updateTrees', {categoryId: id, trees: trees}),
{
pending: 'Création des arbres du tournoi...',
success: 'Arbres créés !',
error: 'Erreur lors de la création des arbres'
}
).finally(() => setCatId(id))
} else {
setCatId(id);
}
})
}
const data = {
name: name.trim(),
liceName: lice.trim(),
type: poule + (tournoi << 1),
size: size,
loserMatch: loserMatch
}
console.log("Submitting category data:", data);
}
return <form onSubmit={handleSubmit}>
<div className="modal-header">
<h1 className="modal-title fs-5" id="CategorieModalLabel">{state.id === undefined ? "Ajouter" : "Modifier"} une catégorie</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="mb-3">
<label htmlFor="nameInput1" className="form-label">Nom</label>
<input type="text" className="form-control" id="nameInput1" placeholder="Epée bouclier" name="name" value={name}
onChange={e => setName(e.target.value)}/>
</div>
<div className="mb-3">
<label htmlFor="liceInput1" className="form-label">Nom des zones de combat <small>(séparée par des ';')</small></label>
<input type="text" className="form-control" id="liceInput1" placeholder="A;B" name="zone de combat" value={lice}
onChange={e => setLice(e.target.value)}/>
</div>
<div className="mb-3">
<label className="form-label">Type</label>
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch" id="switchCheckDefault" name="poule" checked={poule}
onChange={e => setPoule(e.target.checked)}/>
<label className="form-check-label" htmlFor="switchCheckDefault">Poule</label>
</div>
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch" id="switchCheckDefault2" name="trournoi" checked={tournoi}
onChange={e => setTournoi(e.target.checked)}/>
<label className="form-check-label" htmlFor="switchCheckDefault2">Tournoi</label>
</div>
</div>
<div className="mb-3">
<label htmlFor="sizeInput1" className="form-label">Nombre de combattants</label>
<input type="number" className="form-control" id="sizeInput1" placeholder="4" name="size" disabled={!tournoi} value={size}
onChange={e => setSize(Number.parseInt(e.target.value))}/>
</div>
<div className="mb-3">
<span>Match pour les perdants du tournoi:</span>
<div className="form-check">
<input className="form-check-input" type="radio" name="radioDefault" id="radioDefault1" disabled={!tournoi}
checked={loserMatch === -1} onChange={e => {
if (e.target.checked) setLoserMatch(-1)
}}/>
<label className="form-check-label" htmlFor="radioDefault1">
Tous les matchs
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="radio" name="radioDefault" id="radioDefault2" disabled={!tournoi}
checked={loserMatch === 1} onChange={e => {
if (e.target.checked) setLoserMatch(1)
}}/>
<label className="form-check-label" htmlFor="radioDefault2">
Demi-finales et finales
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="radio" name="radioDefault" id="radioDefault3" disabled={!tournoi}
checked={loserMatch === 0} onChange={e => {
if (e.target.checked) setLoserMatch(0)
}}/>
<label className="form-check-label" htmlFor="radioDefault3">
Finales uniquement
</label>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Enregistrer</button>
{state.id !== undefined && <button type="button" className="btn btn-danger" data-bs-dismiss="modal" onClick={() => {
setConfirm({
title: "Suppression de la catégorie",
message: `Voulez-vous vraiment supprimer la catégorie ${state.name}. Cela va supprimer tous les matchs associés !`,
confirm: () => {
toast.promise(sendRequest('deleteCategory', state.id),
{
pending: 'Suppression de la catégorie...',
success: 'Catégorie supprimée !',
error: 'Erreur lors de la suppression de la catégorie'
}
).then(() => setCatId(null));
}
})
confirmRef.current.click();
}}>Supprimer</button>}
</div>
</form>
}