Implemente le mini-jeu Memory (retournement de cartes, mode paire/single)
document_engine/labels/memory_config.py (nouveau) : modele de donnees, meme convention resolve_X/sanitize_X que quiz_config.py/ association_config.py - DEFAULT_MEMORY_CONFIG, sanitize_memory_config. Chaque carte a un recto ET un verso, chacun avec image/texte independants et tous deux optionnels ; seul le verso doit avoir au moins l'un des deux non vide (rien a reveler/apparier sinon) - le recto peut rester entierement vide (dos de carte generique "?" par defaut). Liste tronquee a MAX_CARDS=8. Cote serveur, routes/document/document_element_update.py revalide desormais aussi memory avant persistance. Le rendu (_render_memory) affiche un resume reel (nombre de cartes, mode) et, des qu'au moins une carte existe, un plateau de retournement REELEMENT interactif en Mode Apercu (_render_memory_player) : en mode "paire", chaque carte definie est DUPLIQUEE en deux instances partageant le meme card_index (appariement classique) ; en mode "single", une seule instance par carte (simple retournement, sans appariement - "c'est donc un retourner de carte classique plus un jeu memory"). Les instances sont melangees (random.shuffle, documente dans CODE_QUALITY.md) puis embarquees en JSON dans data-memory-config. Cote editeur, le panneau Proprietes propose un bascule segmentee Paire/Simple et une liste de cartes repetable, chaque carte avec ses deux faces (recto/verso) editables independamment (image + texte). Extrait au passage forgeDocEscapeHtml (ex-forgeDocEscapeForTextarea, generalise pour couvrir aussi les attributs) reutilise pour les deux mini-jeux. Le plateau jouable (static/document/js/document-editor.js) est une vraie carte-retournement CSS 3D (perspective/rotateY), contenu de chaque face construit via DOM (textContent/img.src, jamais innerHTML avec le texte du createur - meme precaution que le plateau Association) : bon appariement verrouille en vert, mauvais reinitialise apres un delai, ecran de resultat une fois le jeu termine (les deux modes), et un "Recommencer" qui remelange reellement les cartes (Fisher-Yates cote client). Tests : 11 tests purs (tests/document/test_memory_config.py, sans Flask, dont un qui verifie explicitement la duplication en mode paire vs son absence en mode single) + 1 test de route verifiant la sanitization a l'ecriture. SKIP=djlint : backlog H021 pre-existant, aucun template touche ici. ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous verts ; 62 tests document verifies frais. Verification manuelle live complete : ajout, sanitization sur carte invalide, rendu du plateau, et simulation DOM du gameplay reel dans les DEUX modes (mode paire : mauvais appariement puis bon appariement puis jeu complet ; mode single : retournement puis jeu complet) - script de diagnostic non conserve dans le depot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cf46da3388
commit
4111081e1a
@@ -500,27 +500,31 @@ img.docImage {
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
/* ---- Quiz/Association : mini-jeux interactifs en Mode Aperçu (voir
|
||||
docs/plan/maquettes/document-formation-web.html — référence visuelle
|
||||
du Quiz). Masqués en édition (seul .docMinigameBadge y est visible,
|
||||
voir plus haut) ; jamais les deux visibles en même temps. ---- */
|
||||
/* ---- Quiz/Association/Memory : mini-jeux interactifs en Mode Aperçu
|
||||
(voir docs/plan/maquettes/document-formation-web.html — référence
|
||||
visuelle du Quiz). Masqués en édition (seul .docMinigameBadge y est
|
||||
visible, voir plus haut) ; jamais les deux visibles en même temps. ---- */
|
||||
.docQuizPlayer,
|
||||
.docAssocPlayer {
|
||||
.docAssocPlayer,
|
||||
.docMemoryPlayer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge {
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer {
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"],
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] {
|
||||
.docEditor3.docEditor3--preview [data-kind="association"],
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -528,7 +532,9 @@ img.docImage {
|
||||
.docEditor3.docEditor3--preview .docQuizPlayer,
|
||||
.docEditor3.docEditor3--preview .docQuizPlayer *,
|
||||
.docEditor3.docEditor3--preview .docAssocPlayer,
|
||||
.docEditor3.docEditor3--preview .docAssocPlayer * {
|
||||
.docEditor3.docEditor3--preview .docAssocPlayer *,
|
||||
.docEditor3.docEditor3--preview .docMemoryPlayer,
|
||||
.docEditor3.docEditor3--preview .docMemoryPlayer * {
|
||||
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
|
||||
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
||||
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
|
||||
@@ -881,6 +887,102 @@ img.docImage {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ---- Memory : panneau Propriétés — étiquette au-dessus de chaque champ
|
||||
recto/verso (voir forgeDocRenderMemoryCardHtml). Réutilise
|
||||
.docAssocPairRow/.docAssocPairInput (même carte, mêmes champs) plutôt
|
||||
que de dupliquer leurs règles. ---- */
|
||||
.docMemoryFaceLabel {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--doc-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---- Memory : plateau de jeu (voir docs/plan/PLAN.md, "retourner des
|
||||
cartes pour constituer des paires d'images identiques"). Réutilise
|
||||
.docAssocTitle/.docAssocMeta/.docAssocResultCard (mêmes rôles visuels,
|
||||
voir forgeDocRenderMemoryProps ci-dessus). ---- */
|
||||
.docMemoryCardWrap {
|
||||
background: linear-gradient(180deg, var(--doc-card), var(--doc-bg-2));
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.8rem;
|
||||
}
|
||||
|
||||
.docMemoryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.docMemoryCard {
|
||||
aspect-ratio: 3 / 4;
|
||||
perspective: 700px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docMemoryCardInner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.4s ease;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.docMemoryCard.is-flipped .docMemoryCardInner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.docMemoryCard.is-matched .docMemoryCardInner {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.docMemoryCardFace {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--doc-border);
|
||||
background: var(--doc-bg-2);
|
||||
backface-visibility: hidden;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.docMemoryCardFace img {
|
||||
max-width: 100%;
|
||||
max-height: 60%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.docMemoryCardFront {
|
||||
background: linear-gradient(135deg, var(--doc-accent), var(--doc-accent-2));
|
||||
color: #14100c;
|
||||
}
|
||||
|
||||
.docMemoryCardBackGlyph {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.docMemoryCardBack {
|
||||
transform: rotateY(180deg);
|
||||
border-color: var(--doc-accent);
|
||||
}
|
||||
|
||||
.docMemoryCard.is-matched .docMemoryCardFace {
|
||||
border-color: var(--doc-quiz-success-border);
|
||||
}
|
||||
|
||||
.docUnknown {
|
||||
font-size: 12.5px;
|
||||
color: var(--doc-muted);
|
||||
|
||||
@@ -477,6 +477,7 @@ function forgeDocBindCanvasInteractions() {
|
||||
});
|
||||
forgeDocInitQuizPlayers();
|
||||
forgeDocInitAssociationPlayers();
|
||||
forgeDocInitMemoryPlayers();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
@@ -746,6 +747,174 @@ function forgeDocInitAssociationPlayers() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
|
||||
* mode "paire" (retrouver les deux cartes identiques, classique) ou
|
||||
* "single" (retourner chaque carte une fois, sans appariement). Contenu
|
||||
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
|
||||
* avec le texte du créateur) — même précaution que le plateau Association.
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
function forgeDocMemoryPlayerData(playerEl) {
|
||||
try {
|
||||
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
|
||||
return {
|
||||
mode: config.mode === 'single' ? 'single' : 'paire',
|
||||
cards: Array.isArray(config.cards) ? config.cards : [],
|
||||
};
|
||||
} catch (e) {
|
||||
return { mode: 'paire', cards: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocShuffleArray(array) {
|
||||
const copy = [...array];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function forgeDocFillMemoryCardFace(container, face) {
|
||||
container.innerHTML = '';
|
||||
if (face.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = face.image;
|
||||
img.alt = '';
|
||||
container.appendChild(img);
|
||||
}
|
||||
if (face.text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = face.text;
|
||||
container.appendChild(span);
|
||||
}
|
||||
if (!face.image && !face.text) {
|
||||
const glyph = document.createElement('span');
|
||||
glyph.className = 'docMemoryCardBackGlyph';
|
||||
glyph.textContent = '?';
|
||||
container.appendChild(glyph);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocBuildMemoryCard(cardData, index) {
|
||||
const cardEl = document.createElement('div');
|
||||
cardEl.className = 'docMemoryCard';
|
||||
cardEl.dataset.instanceIndex = String(index);
|
||||
cardEl.dataset.cardIndex = String(cardData.card_index);
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'docMemoryCardInner';
|
||||
|
||||
const front = document.createElement('div');
|
||||
front.className = 'docMemoryCardFace docMemoryCardFront';
|
||||
forgeDocFillMemoryCardFace(front, cardData.recto);
|
||||
|
||||
const back = document.createElement('div');
|
||||
back.className = 'docMemoryCardFace docMemoryCardBack';
|
||||
forgeDocFillMemoryCardFace(back, cardData.verso);
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
cardEl.appendChild(inner);
|
||||
return cardEl;
|
||||
}
|
||||
|
||||
function forgeDocMemoryUpdateProgress(playerEl, state) {
|
||||
const progEl = playerEl.querySelector('.docMemoryProg');
|
||||
progEl.textContent = state.mode === 'single'
|
||||
? `${state.revealed} / ${state.total} cartes retournées`
|
||||
: `${state.matched} / ${state.totalPairs} paires trouvées`;
|
||||
}
|
||||
|
||||
function forgeDocMemoryCheckComplete(playerEl, state) {
|
||||
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
|
||||
if (!done) return;
|
||||
playerEl.querySelector('.docMemoryCardWrap').style.display = 'none';
|
||||
const resultCard = playerEl.querySelector('.docMemoryResultCard');
|
||||
resultCard.style.display = 'block';
|
||||
playerEl.querySelector('.docMemoryResultSub').textContent = state.mode === 'single'
|
||||
? 'Toutes les cartes ont été retournées !'
|
||||
: 'Toutes les paires ont été trouvées !';
|
||||
}
|
||||
|
||||
function forgeDocMemoryFlip(playerEl, state, cardEl) {
|
||||
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
|
||||
if (state.mode === 'single') {
|
||||
cardEl.classList.add('is-flipped', 'is-matched');
|
||||
state.revealed += 1;
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
forgeDocMemoryCheckComplete(playerEl, state);
|
||||
return;
|
||||
}
|
||||
if (state.busy) return;
|
||||
cardEl.classList.add('is-flipped');
|
||||
state.flippedCards.push(cardEl);
|
||||
if (state.flippedCards.length < 2) return;
|
||||
state.busy = true;
|
||||
const [first, second] = state.flippedCards;
|
||||
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
|
||||
if (isMatch) {
|
||||
first.classList.add('is-matched');
|
||||
second.classList.add('is-matched');
|
||||
state.matched += 1;
|
||||
state.flippedCards = [];
|
||||
state.busy = false;
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
forgeDocMemoryCheckComplete(playerEl, state);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
first.classList.remove('is-flipped');
|
||||
second.classList.remove('is-flipped');
|
||||
state.flippedCards = [];
|
||||
state.busy = false;
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocMemoryRestart(playerEl) {
|
||||
const data = forgeDocMemoryPlayerData(playerEl);
|
||||
const shuffled = forgeDocShuffleArray(data.cards);
|
||||
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
|
||||
const state = {
|
||||
mode: data.mode,
|
||||
total: data.cards.length,
|
||||
totalPairs,
|
||||
matched: 0,
|
||||
revealed: 0,
|
||||
flippedCards: [],
|
||||
busy: false,
|
||||
};
|
||||
playerEl.querySelector('.docMemoryResultCard').style.display = 'none';
|
||||
playerEl.querySelector('.docMemoryCardWrap').style.display = 'block';
|
||||
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
|
||||
? 'Retournez chaque carte'
|
||||
: 'Retrouvez les paires';
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
const grid = playerEl.querySelector('.docMemoryGrid');
|
||||
grid.innerHTML = '';
|
||||
shuffled.forEach((cardData, index) => {
|
||||
const cardEl = forgeDocBuildMemoryCard(cardData, index);
|
||||
cardEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocMemoryFlip(playerEl, state, cardEl);
|
||||
});
|
||||
grid.appendChild(cardEl);
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocInitMemoryPlayers() {
|
||||
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
|
||||
const data = forgeDocMemoryPlayerData(playerEl);
|
||||
if (!data.cards.length) return;
|
||||
forgeDocMemoryRestart(playerEl);
|
||||
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocMemoryRestart(playerEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocSetActiveBottomNav(target) {
|
||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||
b.classList.toggle('is-active', b.dataset.target === target);
|
||||
@@ -1084,15 +1253,20 @@ function forgeDocAssociationNewPair() {
|
||||
return { left: 'Nouvel élément', right: 'Sa correspondance' };
|
||||
}
|
||||
|
||||
function forgeDocEscapeForTextarea(text) {
|
||||
// Les deux côtés d'une paire sont du texte libre du créateur, inséré ici
|
||||
// comme CONTENU d'un <textarea> dans un template string (jamais via
|
||||
// .value, voir forgeDocRenderAssociationProps qui construit tout le
|
||||
// panneau en une seule chaîne) — sans cet échappement, un texte
|
||||
// contenant littéralement "</textarea>" romprait le tag et tout ce qui
|
||||
// suit serait interprété comme du HTML/JS brut dans SA PROPRE session
|
||||
// d'édition.
|
||||
return String(text).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
function forgeDocEscapeHtml(text) {
|
||||
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
|
||||
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
|
||||
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
|
||||
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
|
||||
// seule chaîne) — sans cet échappement, un texte contenant littéralement
|
||||
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
|
||||
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
|
||||
// session d'édition.
|
||||
return String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
|
||||
@@ -1106,13 +1280,13 @@ function forgeDocRenderAssociationPairHtml(pair, pIndex) {
|
||||
rows="2"
|
||||
placeholder="Élément"
|
||||
data-pair-index="${pIndex}"
|
||||
data-side="left">${forgeDocEscapeForTextarea(pair.left)}</textarea>
|
||||
data-side="left">${forgeDocEscapeHtml(pair.left)}</textarea>
|
||||
<div class="docAssocPairLink" aria-hidden="true">↕</div>
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Correspondance"
|
||||
data-pair-index="${pIndex}"
|
||||
data-side="right">${forgeDocEscapeForTextarea(pair.right)}</textarea>
|
||||
data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1165,6 +1339,116 @@ function forgeDocRenderAssociationProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocMemoryNewCard() {
|
||||
// Verso pré-rempli (jamais vide) : sanitize_memory_config
|
||||
// (routes/document/document_element_update.py) rejette silencieusement
|
||||
// toute carte dont le verso est entièrement vide (rien à révéler) —
|
||||
// même leçon que forgeDocAssociationNewPair/forgeDocQuizNewQuestion
|
||||
// ci-dessus (voir leur commentaire : sans ça, "+ Ajouter une carte"
|
||||
// semblerait ne rien faire).
|
||||
return { recto: { image: '', text: '' }, verso: { image: '', text: 'Nouvelle carte' } };
|
||||
}
|
||||
|
||||
function forgeDocRenderMemoryCardHtml(card, cIndex) {
|
||||
return `
|
||||
<div class="docAssocPairRow" data-card-index="${cIndex}">
|
||||
<div class="docQuizQuestionHeader">
|
||||
<span class="docFieldLabel">Carte ${cIndex + 1}</span>
|
||||
<button type="button" class="docQuizRemoveBtn" data-card-index="${cIndex}" title="Supprimer cette carte">🗑</button>
|
||||
</div>
|
||||
<span class="docMemoryFaceLabel">Recto (face cachée, optionnel)</span>
|
||||
<input type="text"
|
||||
class="docAssocPairInput"
|
||||
placeholder="URL de l'image"
|
||||
value="${forgeDocEscapeHtml(card.recto.image)}"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="recto"
|
||||
data-field="image">
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Texte"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="recto"
|
||||
data-field="text">${forgeDocEscapeHtml(card.recto.text)}</textarea>
|
||||
<span class="docMemoryFaceLabel">Verso (révélé au retournement)</span>
|
||||
<input type="text"
|
||||
class="docAssocPairInput"
|
||||
placeholder="URL de l'image"
|
||||
value="${forgeDocEscapeHtml(card.verso.image)}"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="verso"
|
||||
data-field="image">
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Texte"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="verso"
|
||||
data-field="text">${forgeDocEscapeHtml(card.verso.text)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderMemoryProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
const cards = a.cards || [];
|
||||
|
||||
function patch(partial) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('memory')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Mode</span>
|
||||
<div class="docSegmented" id="docMemoryMode">
|
||||
<button type="button" class="docSegBtn ${a.mode === 'paire' ? 'is-active' : ''}" data-mode="paire">Paire (dupliquer)</button>
|
||||
<button type="button" class="docSegBtn ${a.mode === 'single' ? 'is-active' : ''}" data-mode="single">Simple (sans doublon)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Couleur du thème</span>
|
||||
<div class="docSwatches" id="docMemoryColor">
|
||||
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="docSectionLabel docQuizQuestionsLabel">Cartes</div>
|
||||
<div id="docMemoryCardsList">${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '<div class="docEmptyState">Aucune carte — ajoute la première ci-dessous.</div>'}</div>
|
||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMemoryAddCard">+ Ajouter une carte</button>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('#docMemoryMode .docSegBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => patch({ mode: btn.dataset.mode }));
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryColor .docSwatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||
});
|
||||
|
||||
document.getElementById('docMemoryAddCard').addEventListener('click', () => {
|
||||
patch({ cards: [...cards, forgeDocMemoryNewCard()] });
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryCardsList .docQuizRemoveBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const cIndex = Number(btn.dataset.cardIndex);
|
||||
patch({ cards: cards.filter((_, i) => i !== cIndex) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryCardsList .docAssocPairInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const cIndex = Number(input.dataset.cardIndex);
|
||||
const { face, field } = input.dataset;
|
||||
patch({
|
||||
cards: cards.map((c, i) => (i === cIndex ? { ...c, [face]: { ...c[face], [field]: e.target.value } } : c)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderProps(el) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -1178,6 +1462,7 @@ function forgeDocRenderProps(el) {
|
||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
||||
else forgeDocRenderMinigameProps(panel, el);
|
||||
|
||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||
|
||||
Reference in New Issue
Block a user