Split randomizer views and align NDS toolbox vocabulary
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
This commit is contained in:
parent
6b85e58765
commit
fef7735a1d
57 changed files with 4932 additions and 4815 deletions
|
|
@ -0,0 +1,356 @@
|
|||
// Rôle : gère l'onglet équipes équilibrées du Randomizer et ses exclusions.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CompactDropdown } from "../../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../../components/Icon.jsx";
|
||||
import { useDraftForm } from "../../../../hooks/useDraftForm.js";
|
||||
import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js";
|
||||
import { RandomizerHistory } from "./RandomizerShared.jsx";
|
||||
import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js";
|
||||
|
||||
export function TeamsRandomizer({ data, textContent, onChange, context }) {
|
||||
const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [animationTeams, setAnimationTeams] = useState([]);
|
||||
const [reelPlayers, setReelPlayers] = useState([]);
|
||||
const [reelIndex, setReelIndex] = useState(0);
|
||||
const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]);
|
||||
const [namesText, setNamesText] = useState("");
|
||||
const timersRef = useRef([]);
|
||||
const activePlayers = data.players.filter((player) => player.active);
|
||||
const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount);
|
||||
const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount));
|
||||
const selectionCount = clampNumber(selectionCountDraft, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
const displayedTeams = animating ? animationTeams : data.teams;
|
||||
const exclusionMode = exclusionDraft.values.mode === "source" ? "source" : "group";
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionCountDraft(String(storedSelectionCount));
|
||||
}, [storedSelectionCount]);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
function queueTimer(callback, delay) {
|
||||
const timerId = window.setTimeout(callback, delay);
|
||||
timersRef.current.push(timerId);
|
||||
return timerId;
|
||||
}
|
||||
|
||||
function clearTeamTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
function commitSelectionCount(value = selectionCountDraft) {
|
||||
const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
setSelectionCountDraft(String(nextSelectionCount));
|
||||
onChange({ ...data, playerSelectionCount: nextSelectionCount });
|
||||
}
|
||||
|
||||
function importPlayers(event) {
|
||||
event.preventDefault();
|
||||
const availableSlots = RANDOMIZER_LIMITS.teamPlayersMax - data.players.length;
|
||||
const names = namesText
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, availableSlots);
|
||||
if (!names.length) return;
|
||||
onChange({
|
||||
...data,
|
||||
players: [
|
||||
...data.players,
|
||||
...names.map((name) => ({ id: context.uid("randomizer"), name, score: 0, active: true }))
|
||||
]
|
||||
});
|
||||
setNamesText("");
|
||||
}
|
||||
|
||||
function updatePlayer(playerId, patch) {
|
||||
onChange({ ...data, players: data.players.map((player) => player.id === playerId ? { ...player, ...patch } : player) });
|
||||
}
|
||||
|
||||
function removePlayer(playerId) {
|
||||
onChange({
|
||||
...data,
|
||||
players: data.players.filter((player) => player.id !== playerId),
|
||||
exclusions: data.exclusions
|
||||
.filter((exclusion) => exclusion.playerAId !== playerId)
|
||||
.map((exclusion) => ({ ...exclusion, playerBIds: (exclusion.playerBIds || []).filter((targetId) => targetId !== playerId) }))
|
||||
.filter((exclusion) => exclusion.playerBIds.length)
|
||||
});
|
||||
}
|
||||
|
||||
function clearPlayers() {
|
||||
onChange({ ...data, players: [], exclusions: [], teams: [] });
|
||||
}
|
||||
|
||||
function toggleExclusionTarget(playerId) {
|
||||
const currentIds = exclusionDraft.values.playerBIds || [];
|
||||
exclusionDraft.setField(
|
||||
"playerBIds",
|
||||
currentIds.includes(playerId) ? currentIds.filter((targetId) => targetId !== playerId) : [...currentIds, playerId]
|
||||
);
|
||||
}
|
||||
|
||||
function addExclusion(event) {
|
||||
exclusionDraft.handleSubmit(event, (draft, { reset }) => {
|
||||
const mode = draft.mode === "source" ? "source" : "group";
|
||||
const selectedIds = [...new Set(Array.isArray(draft.playerBIds) ? draft.playerBIds.filter(Boolean) : [])];
|
||||
const playerAId = mode === "group" ? selectedIds[0] : draft.playerAId;
|
||||
const playerBIds = mode === "group" ? selectedIds.slice(1) : selectedIds.filter((playerId) => playerId !== draft.playerAId);
|
||||
if (!playerAId || !playerBIds.length) return;
|
||||
const exists = data.exclusions.some((exclusion) => exclusion.playerAId === playerAId && (exclusion.mode || "source") === mode && [...(exclusion.playerBIds || [])].sort().join(":") === [...playerBIds].sort().join(":"));
|
||||
if (exists) return;
|
||||
onChange({ ...data, exclusions: [...data.exclusions, { id: context.uid("randomizer"), playerAId, playerBIds, mode }] });
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function launchTeams() {
|
||||
clearTeamTimers();
|
||||
const selectedPlayers = data.playerSelectionMode === "count" ? pickRandomItems(activePlayers, Math.min(selectionCount, activePlayers.length)) : activePlayers;
|
||||
const selectedExclusions = filterExclusionsForPlayers(data.exclusions, selectedPlayers);
|
||||
const result = createBalancedTeams(selectedPlayers, data.teamCount, selectedExclusions);
|
||||
if (!result.ok) {
|
||||
context.notify?.(result.error === "not_enough_players" ? textContent.notEnoughPlayers || "Pas assez de joueurs actifs." : textContent.constraintsImpossible || "Contraintes impossibles à satisfaire.");
|
||||
return;
|
||||
}
|
||||
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), teams: result.teams };
|
||||
if (hasReducedMotion()) {
|
||||
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
const emptyTeams = result.teams.map((team) => ({ ...team, score: 0, players: [] }));
|
||||
const assignments = result.teams.flatMap((team) => team.players.map((player) => ({ teamId: team.id, player })));
|
||||
setAnimating(true);
|
||||
setAnimationTeams(emptyTeams);
|
||||
setReelPlayers(selectedPlayers);
|
||||
setReelIndex(0);
|
||||
setReelDuration(TEAM_REEL_DELAYS[0]);
|
||||
|
||||
function animateAssignment(index) {
|
||||
const assignment = assignments[index];
|
||||
if (!assignment) {
|
||||
setAnimating(false);
|
||||
setReelPlayers([]);
|
||||
setReelIndex(0);
|
||||
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
ticks += 1;
|
||||
const isFinalTick = ticks >= TEAM_REEL_DELAYS.length;
|
||||
const tickDelay = TEAM_REEL_DELAYS[Math.min(ticks, TEAM_REEL_DELAYS.length - 1)];
|
||||
const nextIndex = isFinalTick
|
||||
? Math.max(0, selectedPlayers.findIndex((player) => player.id === assignment.player.id))
|
||||
: Math.floor(Math.random() * selectedPlayers.length);
|
||||
setReelIndex(nextIndex);
|
||||
setReelDuration(Math.max(54, Math.round(tickDelay * 0.86)));
|
||||
if (!isFinalTick) {
|
||||
queueTimer(tick, tickDelay);
|
||||
return;
|
||||
}
|
||||
queueTimer(() => {
|
||||
setAnimationTeams((currentTeams) => currentTeams.map((team) => {
|
||||
if (team.id !== assignment.teamId) return team;
|
||||
return {
|
||||
...team,
|
||||
score: team.score + assignment.player.score,
|
||||
players: [...team.players, { ...assignment.player, arriving: true }]
|
||||
};
|
||||
}));
|
||||
queueTimer(() => animateAssignment(index + 1), 260);
|
||||
}, 220);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
animateAssignment(0);
|
||||
}
|
||||
|
||||
function clearTeamsHistory() {
|
||||
onChange({ ...data, history: [] });
|
||||
}
|
||||
|
||||
function removeTeamsHistoryEntry(entryId) {
|
||||
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="randomizer-panel randomizer-teams-panel">
|
||||
<div className="randomizer-team-stage">
|
||||
<div className={`randomizer-team-reel ${animating ? "is-spinning" : ""}`} aria-live="polite">
|
||||
{reelPlayers.length ? (
|
||||
<>
|
||||
<div className="randomizer-team-reel-track" style={{ "--randomizer-reel-index": reelIndex, "--randomizer-reel-duration": `${reelDuration}ms` }} aria-hidden="true">
|
||||
{reelPlayers.map((player) => <span key={player.id}>{player.name}</span>)}
|
||||
</div>
|
||||
<span className="sr-only">{reelPlayers[reelIndex]?.name || ""}</span>
|
||||
</>
|
||||
) : <span className="randomizer-team-reel-placeholder" />}
|
||||
</div>
|
||||
<div className={`randomizer-teams ${displayedTeams.length === 1 ? "is-single-team" : ""} ${animating ? "is-animating" : ""}`} aria-live="polite">
|
||||
{displayedTeams.length ? displayedTeams.map((team) => (
|
||||
<article className="randomizer-team" key={team.id}>
|
||||
<h3>{team.name}</h3>
|
||||
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
|
||||
{team.players.map((player) => <b className={player.arriving ? "is-arriving" : ""} key={player.id}>{player.name}</b>)}
|
||||
</article>
|
||||
)) : <p className="randomizer-empty">{textContent.emptyTeams ?? ""}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="randomizer-team-settings-grid">
|
||||
<section className="randomizer-team-basic">
|
||||
<form className="randomizer-player-import-form" onSubmit={importPlayers}>
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.namesImportLabel || "Liste de noms"}</span>
|
||||
<textarea className="nds-textarea" data-size="sm" value={namesText} onChange={(event) => setNamesText(event.target.value)} placeholder={textContent.namesImportPlaceholder || "Un nom par ligne"} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.importNamesButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="randomizer-controls">
|
||||
<label>
|
||||
<span>{textContent.teamCountLabel || "Équipes"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min={RANDOMIZER_LIMITS.teamCountMin} max={RANDOMIZER_LIMITS.teamCountMax} value={data.teamCount} onChange={(event) => onChange({ ...data, teamCount: clampNumber(event.target.value, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, data.teamCount) })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.playerSelectionModeLabel || "Répartition"}</span>
|
||||
<select className="nds-select" data-size="sm" value={data.playerSelectionMode} onChange={(event) => onChange({ ...data, playerSelectionMode: event.target.value })}>
|
||||
<option value="all">{textContent.playerSelectionAll || "Tous"}</option>
|
||||
<option value="count">{textContent.playerSelectionCount || "Nombre défini"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{data.playerSelectionMode === "count" && (
|
||||
<label>
|
||||
<span>{textContent.playerSelectionCountLabel || "Nombre"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min={data.teamCount} max={RANDOMIZER_LIMITS.teamPlayersMax} value={selectionCountDraft} onChange={(event) => setSelectionCountDraft(event.target.value)} onBlur={(event) => commitSelectionCount(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={launchTeams} disabled={animating}>
|
||||
<Icon name="play-circle" />
|
||||
<span>{textContent.createTeamsButton || "Créer les équipes"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="randomizer-team-advanced">
|
||||
<section className="randomizer-team-advanced-section">
|
||||
<div className="randomizer-list-header randomizer-value-row randomizer-player-row" aria-hidden="true">
|
||||
<span>{textContent.activeLabel || "Actif"}</span>
|
||||
<span>{textContent.nameLabel || "Nom"}</span>
|
||||
<span>{textContent.scoreLabel || "Score"}</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost">
|
||||
{data.players.map((player) => (
|
||||
<div className="randomizer-value-row randomizer-player-row" key={player.id}>
|
||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={player.active} onChange={(event) => updatePlayer(player.id, { active: event.target.checked })} aria-label={`${textContent.activeLabel || "Actif"} ${player.name}`} />
|
||||
<input className="nds-input" data-size="sm" value={player.name} onChange={(event) => updatePlayer(player.id, { name: event.target.value })} aria-label={textContent.nameLabel || "Nom"} />
|
||||
<input className="nds-input" data-size="sm" type="number" min="0" max="9999" value={player.score} onChange={(event) => updatePlayer(player.id, { score: clampNumber(event.target.value, 0, 9999, player.score) })} aria-label={textContent.scoreLabel || "Score"} />
|
||||
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removePlayer(player.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${player.name}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={clearPlayers} disabled={!data.players.length || animating}>{textContent.clearNamesButton || "Supprimer tous les noms"}</button>
|
||||
</section>
|
||||
<section className="randomizer-team-advanced-section randomizer-exclusion-block">
|
||||
<form className={`inline-form randomizer-exclusion-form ${exclusionMode === "group" ? "is-group-mode" : ""}`} onSubmit={addExclusion}>
|
||||
<label className="randomizer-field randomizer-exclusion-mode-field">
|
||||
<span>{textContent.exclusionModeLabel || "Type"}</span>
|
||||
<select className="nds-select" data-size="sm" value={exclusionMode} onChange={(event) => exclusionDraft.updateValues({ mode: event.target.value, playerAId: event.target.value === "group" ? "" : exclusionDraft.values.playerAId })} aria-label={textContent.exclusionModeLabel || "Type"}>
|
||||
<option value="group">{textContent.exclusionModeGroup || "Tous séparés"}</option>
|
||||
<option value="source">{textContent.exclusionModeSource || "Exclusions granulaires"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{exclusionMode === "source" && (
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.firstPlayerLabel || "Nom source"}</span>
|
||||
<select className="nds-select" data-size="sm" value={exclusionDraft.values.playerAId} onChange={(event) => exclusionDraft.updateValues({ playerAId: event.target.value, playerBIds: (exclusionDraft.values.playerBIds || []).filter((playerId) => playerId !== event.target.value) })} aria-label={textContent.firstPlayerLabel || "Nom source"}>
|
||||
<option value="">{textContent.selectNamesButton || "Choisir"}</option>
|
||||
{activePlayers.map((player) => <option key={player.id} value={player.id}>{player.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="randomizer-field">
|
||||
<span>{textContent.secondPlayerLabel || "Noms exclus"}</span>
|
||||
<CompactDropdown
|
||||
className="randomizer-checkbox-dropdown"
|
||||
menuClassName="randomizer-checkbox-menu nds-scrollbox"
|
||||
label={textContent.secondPlayerLabel || "Noms exclus"}
|
||||
renderTrigger={({ open, toggle }) => (
|
||||
<button className={`randomizer-multi-trigger nds-select-trigger ${open ? "active" : ""}`} data-size="sm" type="button" onClick={toggle} aria-expanded={open}>
|
||||
<span className="randomizer-multi-trigger-label">{exclusionDraft.values.playerBIds?.length ? `${exclusionDraft.values.playerBIds.length} ${textContent.selectedNamesLabel || "noms"}` : textContent.selectNamesButton || "Choisir"}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
{activePlayers.map((player) => (
|
||||
<label className="randomizer-checkbox-option" key={player.id}>
|
||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={(exclusionDraft.values.playerBIds || []).includes(player.id)} disabled={exclusionMode === "source" && player.id === exclusionDraft.values.playerAId} onChange={() => toggleExclusionTarget(player.id)} />
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="submit">{textContent.addExclusionButton || "Exclure"}</button>
|
||||
</form>
|
||||
<span className="randomizer-section-label">{textContent.exclusionsListLabel || "Combinaisons exclues"}</span>
|
||||
<div className="randomizer-exclusions">
|
||||
{data.exclusions.map((exclusion) => {
|
||||
const playerA = data.players.find((player) => player.id === exclusion.playerAId);
|
||||
const playerBs = (exclusion.playerBIds || []).map((playerId) => data.players.find((player) => player.id === playerId)).filter(Boolean);
|
||||
if (!playerA || !playerBs.length) return null;
|
||||
const exclusionLabel = exclusion.mode === "group"
|
||||
? [playerA, ...playerBs].map((player) => player.name).join(" / ")
|
||||
: `${playerA.name} / ${playerBs.map((player) => player.name).join(" ou ")}`;
|
||||
return (
|
||||
<span className="randomizer-chip" key={exclusion.id}>
|
||||
<span className="randomizer-chip-label">{exclusionLabel}</span>
|
||||
<button className="nds-icon-button" data-size="xxs" data-variant="danger" type="button" onClick={() => onChange({ ...data, exclusions: data.exclusions.filter((item) => item.id !== exclusion.id) })} aria-label={`${textContent.deleteTitle || "Supprimer"} ${playerA.name}`}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RandomizerHistory
|
||||
title={textContent.historyTitle || "Historique"}
|
||||
history={data.history}
|
||||
renderEntry={(entry) => `${entry.teams.length} ${entry.teams.length > 1 ? textContent.teamsPluralLabel || "équipes" : textContent.teamsSingularLabel || "équipe"}`}
|
||||
renderDetails={(entry) => (
|
||||
<div className={`randomizer-history-teams ${entry.teams.length > 4 ? "has-team-rows" : ""}`}>
|
||||
{entry.teams.map((team) => (
|
||||
<div className="randomizer-history-team" key={team.id}>
|
||||
<strong>{team.name}</strong>
|
||||
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
|
||||
<ul>
|
||||
{team.players.map((player) => <li key={player.id}>{player.name}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
onClear={clearTeamsHistory}
|
||||
onRemove={removeTeamsHistoryEntry}
|
||||
disabled={animating}
|
||||
textContent={textContent}
|
||||
showEmpty
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue