diff --git a/routes/document/document_element_add.py b/routes/document/document_element_add.py index b51ddd84..4d569348 100644 --- a/routes/document/document_element_add.py +++ b/routes/document/document_element_add.py @@ -26,6 +26,22 @@ def document_element_add(slug: str) -> Response | tuple[Response, int]: if page_id is None or document_engine.get_document_page(slug, page_id) is None: return jsonify({"error": "page introuvable"}), 404 parent_id = request.form.get("parent_id", type=int) + + # Un mini-jeu occupe toute la page, à lui seul (retour utilisateur du + # 23/09/2026 : "un mini jeu dois occupper toute une page" -> "une page + # avec mini-jeu = uniquement ce mini-jeu"). Vérifié ici, POINT D'ENTRÉE + # UNIQUE de tout ajout d'élément (bibliothèque, glisser-déposer, + # création de rangée à la volée, Annuler/Rétablir) : jamais dupliqué + # côté client, qui se contente d'afficher l'erreur renvoyée. + is_minigame = kind in document_engine.MINIGAME_KINDS + existing = document_engine.list_document_elements(slug, page_id) + if is_minigame and parent_id is not None: + return jsonify({"error": "Un mini-jeu ne peut pas être placé dans une rangée."}), 400 + if is_minigame and existing: + return jsonify({"error": "Un mini-jeu doit être seul sur sa page — ajoutez-le sur une nouvelle page."}), 400 + if not is_minigame and any(el["kind"] in document_engine.MINIGAME_KINDS for el in existing): + return jsonify({"error": "Cette page contient déjà un mini-jeu qui occupe toute la page."}), 400 + element_id = document_engine.add_document_element(slug, kind, page_id=page_id, parent_id=parent_id) element = db.assert_not_none( document_engine.get_document_element(slug, element_id), diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 39613d39..87c08686 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -353,16 +353,27 @@ body.objectEditBody > main.content { position: relative; } +/* Page au format A4 PAYSAGE, taille FIXE comme une vraie diapositive + (retour utilisateur du 23/09/2026 : "une page [...] dois etre un + format a4 orienter horizontalement comme une slide [...] le contenu + ne defile pas, si ya plus de place les contenu va sur une autre + page") — ratio 297:210 (A4 paysage) fixé par aspect-ratio, jamais + height:fit-content comme avant (la page ne grandit plus avec son + contenu). overflow:hidden : le contenu qui dépasse est clipsé, PAS + de défilement interne — au créateur de répartir sur plusieurs pages, + comme un vrai logiciel de diaporama ne reflow jamais automatiquement + une diapositive trop pleine. */ .docPage { - width: 760px; + width: 960px; max-width: 100%; - height: fit-content; + aspect-ratio: 297 / 210; background: var(--doc-card); border: 1px solid var(--doc-border); border-radius: 16px; box-shadow: 0 1px 3px rgb(0 0 0 / 30%); padding: 48px 52px; position: relative; + overflow: hidden; transform-origin: top center; transition: width 0.2s ease; } @@ -374,6 +385,7 @@ body.objectEditBody > main.content { display: flex; flex-direction: column; gap: 18px; + height: 100%; } .docPage.docPage--fixedWidth { @@ -381,6 +393,36 @@ body.objectEditBody > main.content { padding: 28px 20px; } +/* Un mini-jeu occupe TOUTE la page, à lui seul (retour utilisateur du + 23/09/2026 : "un mini jeu dois occupper toute une page" — confirmé : + "une page avec mini-jeu = uniquement ce mini-jeu"). Garantie posée + côté serveur (routes/document/document_element_add.py : impossible + d'ajouter un mini-jeu sur une page non vide, ni d'ajouter autre chose + sur une page qui en contient déjà un) — :only-child ici n'est donc + jamais un cas ambigu, juste la mise en forme plein-cadre de cette + garantie. Le padding de .docPage disparaît (plein bord) et le badge + mini-jeu (nom/description courts) laisse toute la place au joueur. */ +.docPage:has(> .docPageContent > .docMinigame:only-child) { + padding: 0; +} + +.docPageContent > .docMinigame:only-child { + flex: 1; + display: flex; + flex-direction: column; + border-radius: 0; + border-width: 0; +} + +.docPageContent > .docMinigame:only-child .docMinigameBadge { + flex-shrink: 0; +} + +.docPageContent > .docMinigame:only-child > [class$="Player"] { + flex: 1; + min-height: 0; +} + .docRow { position: relative; } diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 05188d69..3cb7912e 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -52,7 +52,21 @@ function forgeDocApiAdd(kind, parentId) { body.set('page_id', String(window.forgeDocState.activePageId)); if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId)); return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body }) - .then((r) => r.json()); + .then((r) => r.json()) + .then((result) => { + // Point d'entrée UNIQUE de tout ajout (voir routes/document/ + // document_element_add.py) : gérer l'erreur ICI, une fois, couvre + // tous les appelants (bibliothèque, glisser-déposer, création de + // rangée à la volée, Annuler/Rétablir) sans dupliquer la logique + // dans chacun — la promesse rejetée interrompt simplement leur + // .then() sans autre effet de bord (pas de commande Annuler pour + // un ajout qui n'a jamais eu lieu). + if (result.error) { + alert(result.error); + return Promise.reject(new Error(result.error)); + } + return result; + }); } function forgeDocApiUpdate(id, attributes) { diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index d8424973..38e2d0ac 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -204,6 +204,45 @@ def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404 +def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None: + """Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper + toute une page" -> "une page avec mini-jeu = uniquement ce + mini-jeu". Un mini-jeu ne peut donc jamais rejoindre une page qui a + déjà du contenu.""" + page_id = _page_id(support) + client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}) + + resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id}) + assert resp.status_code == 400 + assert "mini-jeu" in resp.get_json()["error"] + + +def test_document_element_add_rejects_other_content_on_a_page_with_a_minigame( + client: FlaskClient, support: str +) -> None: + page_id = _page_id(support) + client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id}) + + resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}) + assert resp.status_code == 400 + assert "mini-jeu" in resp.get_json()["error"] + + +def test_document_element_add_rejects_a_minigame_inside_a_row(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + row_id = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": page_id}).get_json()["id"] + + resp = client.post( + f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id, "parent_id": row_id} + ) + assert resp.status_code == 400 + + +def test_document_element_add_allows_a_lone_minigame_on_an_empty_page(client: FlaskClient, support: str) -> None: + resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)}) + assert resp.status_code == 200 + + def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None: page_id = _page_id(support) first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[