Release: promote preprod to prod (bulk actions + pagination)

This commit is contained in:
Ponte
2026-02-15 00:31:23 +01:00
3 changed files with 350 additions and 2 deletions

269
app.js
View File

@@ -61,6 +61,16 @@ const googleRestoreBtn = document.getElementById("googleRestoreBtn");
const quickSearchInput = document.getElementById("quickSearchInput"); const quickSearchInput = document.getElementById("quickSearchInput");
const quickSearchResults = document.getElementById("quickSearchResults"); const quickSearchResults = document.getElementById("quickSearchResults");
const loanedFilterBtn = document.getElementById("loanedFilterBtn"); const loanedFilterBtn = document.getElementById("loanedFilterBtn");
const bulkActionsBar = document.getElementById("bulkActionsBar");
const bulkSelectPage = document.getElementById("bulkSelectPage");
const bulkSelectionInfo = document.getElementById("bulkSelectionInfo");
const bulkLoanBtn = document.getElementById("bulkLoanBtn");
const bulkReturnBtn = document.getElementById("bulkReturnBtn");
const bulkDeleteBtn = document.getElementById("bulkDeleteBtn");
const paginationBar = document.getElementById("paginationBar");
const prevPageBtn = document.getElementById("prevPageBtn");
const nextPageBtn = document.getElementById("nextPageBtn");
const pageInfo = document.getElementById("pageInfo");
const v2Toolbar = document.getElementById("v2Toolbar"); const v2Toolbar = document.getElementById("v2Toolbar");
const v2SearchInput = document.getElementById("v2SearchInput"); const v2SearchInput = document.getElementById("v2SearchInput");
const v2ConsoleFilter = document.getElementById("v2ConsoleFilter"); const v2ConsoleFilter = document.getElementById("v2ConsoleFilter");
@@ -90,6 +100,10 @@ let scannerRunning = false;
let scannerLoopId = null; let scannerLoopId = null;
let scannerLastCodeValue = ""; let scannerLastCodeValue = "";
let scannerLastCodeAt = 0; let scannerLastCodeAt = 0;
let selectedGameIds = new Set();
let currentPage = 1;
let currentPageGameIds = [];
let currentTotalPages = 1;
// V2 is now the default UI. Use ?ui=v1 to force legacy mode if needed. // V2 is now the default UI. Use ?ui=v1 to force legacy mode if needed.
const uiParam = new URLSearchParams(window.location.search).get("ui"); const uiParam = new URLSearchParams(window.location.search).get("ui");
const uiV2Enabled = uiParam !== "v1"; const uiV2Enabled = uiParam !== "v1";
@@ -218,12 +232,72 @@ quickSearchInput.addEventListener("input", (event) => {
loanedFilterBtn.addEventListener("click", () => { loanedFilterBtn.addEventListener("click", () => {
showLoanedOnly = !showLoanedOnly; showLoanedOnly = !showLoanedOnly;
resetPaging();
selectedGameIds.clear();
renderGames();
});
if (bulkSelectPage) {
bulkSelectPage.addEventListener("change", (event) => {
const checked = Boolean(event.target.checked);
if (checked) {
for (const id of currentPageGameIds) {
selectedGameIds.add(id);
}
} else {
for (const id of currentPageGameIds) {
selectedGameIds.delete(id);
}
}
renderGames();
});
}
if (bulkLoanBtn) {
bulkLoanBtn.addEventListener("click", async () => {
await performBulkAction("loan");
});
}
if (bulkReturnBtn) {
bulkReturnBtn.addEventListener("click", async () => {
await performBulkAction("return");
});
}
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener("click", async () => {
await performBulkAction("delete");
});
}
if (prevPageBtn) {
prevPageBtn.addEventListener("click", () => {
if (currentPage > 1) {
currentPage -= 1;
renderGames();
}
});
}
if (nextPageBtn) {
nextPageBtn.addEventListener("click", () => {
if (currentPage < currentTotalPages) {
currentPage += 1;
renderGames();
}
});
}
window.addEventListener("resize", () => {
renderGames(); renderGames();
}); });
if (v2SearchInput) { if (v2SearchInput) {
v2SearchInput.addEventListener("input", (event) => { v2SearchInput.addEventListener("input", (event) => {
v2SearchTerm = event.target.value.trim(); v2SearchTerm = event.target.value.trim();
resetPaging();
selectedGameIds.clear();
renderGames(); renderGames();
}); });
} }
@@ -231,6 +305,8 @@ if (v2SearchInput) {
if (v2ConsoleFilter) { if (v2ConsoleFilter) {
v2ConsoleFilter.addEventListener("change", (event) => { v2ConsoleFilter.addEventListener("change", (event) => {
v2ConsoleValue = event.target.value; v2ConsoleValue = event.target.value;
resetPaging();
selectedGameIds.clear();
renderGames(); renderGames();
}); });
} }
@@ -238,6 +314,8 @@ if (v2ConsoleFilter) {
if (v2GenreFilter) { if (v2GenreFilter) {
v2GenreFilter.addEventListener("change", (event) => { v2GenreFilter.addEventListener("change", (event) => {
v2GenreValue = event.target.value; v2GenreValue = event.target.value;
resetPaging();
selectedGameIds.clear();
renderGames(); renderGames();
}); });
} }
@@ -245,6 +323,8 @@ if (v2GenreFilter) {
if (v2SortSelect) { if (v2SortSelect) {
v2SortSelect.addEventListener("change", (event) => { v2SortSelect.addEventListener("change", (event) => {
v2SortValue = event.target.value || "title_asc"; v2SortValue = event.target.value || "title_asc";
resetPaging();
selectedGameIds.clear();
renderGames(); renderGames();
}); });
} }
@@ -409,6 +489,8 @@ gameForm.addEventListener("submit", async (event) => {
} }
resetEditMode(); resetEditMode();
resetPaging();
selectedGameIds.clear();
persist(); persist();
markLocalDataForImport(); markLocalDataForImport();
render(); render();
@@ -433,6 +515,8 @@ brandTabs.addEventListener("click", (event) => {
const consoles = state.brands[brand] || []; const consoles = state.brands[brand] || [];
state.selectedConsole = consoles[0] || ""; state.selectedConsole = consoles[0] || "";
resetEditMode(); resetEditMode();
resetPaging();
selectedGameIds.clear();
persist(); persist();
render(); render();
}); });
@@ -453,6 +537,8 @@ consoleTabs.addEventListener("click", (event) => {
state.selectedConsole = consoleName; state.selectedConsole = consoleName;
resetEditMode(); resetEditMode();
resetPaging();
selectedGameIds.clear();
persist(); persist();
render(); render();
}); });
@@ -574,6 +660,7 @@ gamesList.addEventListener("click", async (event) => {
try { try {
if (action === "delete") { if (action === "delete") {
await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" }); await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" });
selectedGameIds.delete(id);
if (editingGameId === id) { if (editingGameId === id) {
resetEditMode(); resetEditMode();
} }
@@ -615,6 +702,7 @@ gamesList.addEventListener("click", async (event) => {
return; return;
} }
games.splice(idx, 1); games.splice(idx, 1);
selectedGameIds.delete(id);
if (editingGameId === id) { if (editingGameId === id) {
resetEditMode(); resetEditMode();
} }
@@ -643,6 +731,26 @@ gamesList.addEventListener("click", async (event) => {
showToast("Action enregistree.", "success"); showToast("Action enregistree.", "success");
}); });
gamesList.addEventListener("change", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const target = event.target.closest('input[type="checkbox"][data-action="select"]');
if (!(target instanceof HTMLInputElement)) {
return;
}
const id = target.dataset.id;
if (!id) {
return;
}
if (target.checked) {
selectedGameIds.add(id);
} else {
selectedGameIds.delete(id);
}
renderGames();
});
cancelEditBtn.addEventListener("click", () => { cancelEditBtn.addEventListener("click", () => {
resetEditMode(); resetEditMode();
}); });
@@ -1040,6 +1148,55 @@ function conditionBadgeClass(conditionValue) {
return "status-low"; return "status-low";
} }
function pageSizeForViewport() {
return window.innerWidth <= 640 ? 12 : 24;
}
function resetPaging() {
currentPage = 1;
}
function updateBulkAndPaginationUi(pageGames, totalFilteredCount) {
const pageIds = pageGames.map((game) => game.id);
const selectedOnPage = pageIds.filter((id) => selectedGameIds.has(id)).length;
const hasAnySelection = selectedGameIds.size > 0;
currentPageGameIds = pageIds;
if (bulkSelectionInfo) {
bulkSelectionInfo.textContent = `${selectedGameIds.size} selectionne${selectedGameIds.size > 1 ? "s" : ""}`;
}
if (bulkLoanBtn) {
bulkLoanBtn.disabled = !hasAnySelection;
}
if (bulkReturnBtn) {
bulkReturnBtn.disabled = !hasAnySelection;
}
if (bulkDeleteBtn) {
bulkDeleteBtn.disabled = !hasAnySelection;
}
if (bulkSelectPage) {
bulkSelectPage.checked = pageIds.length > 0 && selectedOnPage === pageIds.length;
bulkSelectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < pageIds.length;
bulkSelectPage.disabled = pageIds.length === 0;
}
if (pageInfo) {
pageInfo.textContent = `Page ${currentPage}/${currentTotalPages}`;
}
if (prevPageBtn) {
prevPageBtn.disabled = currentPage <= 1;
}
if (nextPageBtn) {
nextPageBtn.disabled = currentPage >= currentTotalPages;
}
if (paginationBar) {
paginationBar.classList.toggle("hidden", totalFilteredCount <= pageSizeForViewport());
}
if (bulkActionsBar) {
bulkActionsBar.classList.toggle("hidden", totalFilteredCount === 0);
}
}
function renderSearchResults() { function renderSearchResults() {
if (!quickSearchResults) { if (!quickSearchResults) {
return; return;
@@ -1142,6 +1299,95 @@ function renderConsoleTabs() {
} }
} }
async function performBulkAction(action) {
const selectedIds = Array.from(selectedGameIds);
if (!selectedIds.length) {
return;
}
let loanedTo = "";
if (action === "loan") {
const borrower = window.prompt("Nom de la personne a qui tu pretes ces jeux :");
if (borrower === null) {
return;
}
loanedTo = borrower.trim();
if (!loanedTo) {
alert("Le nom est obligatoire pour marquer les jeux comme pretes.");
return;
}
}
if (action === "delete") {
const confirmed = window.confirm(`Supprimer ${selectedIds.length} jeu(x) selectionne(s) ?`);
if (!confirmed) {
return;
}
}
if (apiReachable && dataMode !== "local-pending-import") {
try {
for (const id of selectedIds) {
const gameRef = findGameById(id);
if (!gameRef) {
continue;
}
const { game, brand, consoleName } = gameRef;
if (action === "delete") {
await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" });
continue;
}
if (action === "return") {
if (!game.loanedTo) {
continue;
}
const payload = buildGamePayload(game, brand, consoleName, { loanedTo: "" });
await apiRequest(`/api/catalog/games/${id}`, { method: "PUT", body: payload });
continue;
}
if (action === "loan") {
const payload = buildGamePayload(game, brand, consoleName, { loanedTo });
await apiRequest(`/api/catalog/games/${id}`, { method: "PUT", body: payload });
}
}
selectedGameIds.clear();
await refreshFromApi(state.selectedBrand, state.selectedConsole);
showToast("Action groupée enregistree.", "success");
return;
} catch (error) {
console.error(error);
alert("Action groupée impossible via l'API.");
return;
}
}
for (const id of selectedIds) {
const gameRef = findGameById(id);
if (!gameRef) {
continue;
}
const { games, idx } = gameRef;
if (action === "delete") {
games.splice(idx, 1);
continue;
}
if (action === "return") {
games[idx].loanedTo = "";
continue;
}
if (action === "loan") {
games[idx].loanedTo = loanedTo;
}
}
selectedGameIds.clear();
persist();
markLocalDataForImport();
render();
showToast("Action groupée enregistree.", "success");
}
function renderGames() { function renderGames() {
const selectedConsole = state.selectedConsole; const selectedConsole = state.selectedConsole;
const inV2 = uiV2Enabled; const inV2 = uiV2Enabled;
@@ -1157,6 +1403,7 @@ function renderGames() {
if (!inV2 && !showLoanedOnly && !selectedConsole) { if (!inV2 && !showLoanedOnly && !selectedConsole) {
gamesList.innerHTML = '<p class="empty">Ajoute une section pour commencer.</p>'; gamesList.innerHTML = '<p class="empty">Ajoute une section pour commencer.</p>';
updateBulkAndPaginationUi([], 0);
return; return;
} }
@@ -1210,14 +1457,27 @@ function renderGames() {
} }
} }
if (!games.length) { const allCurrentIds = new Set(collectAllGames().map((game) => game.id));
selectedGameIds = new Set(Array.from(selectedGameIds).filter((id) => allCurrentIds.has(id)));
const totalFilteredCount = games.length;
const pageSize = pageSizeForViewport();
currentTotalPages = Math.max(1, Math.ceil(totalFilteredCount / pageSize));
if (currentPage > currentTotalPages) {
currentPage = currentTotalPages;
}
const startIdx = (currentPage - 1) * pageSize;
const pageGames = games.slice(startIdx, startIdx + pageSize);
updateBulkAndPaginationUi(pageGames, totalFilteredCount);
if (!totalFilteredCount) {
gamesList.innerHTML = showLoanedOnly gamesList.innerHTML = showLoanedOnly
? '<p class="empty">Aucun jeu prete actuellement.</p>' ? '<p class="empty">Aucun jeu prete actuellement.</p>'
: '<p class="empty">Aucun jeu pour ces filtres.</p>'; : '<p class="empty">Aucun jeu pour ces filtres.</p>';
return; return;
} }
for (const game of games) { for (const game of pageGames) {
const card = gameCardTemplate.content.cloneNode(true); const card = gameCardTemplate.content.cloneNode(true);
const article = card.querySelector(".game-card"); const article = card.querySelector(".game-card");
article.classList.add(consoleThemeClass(game.consoleName)); article.classList.add(consoleThemeClass(game.consoleName));
@@ -1282,6 +1542,7 @@ function renderGames() {
const editBtn = card.querySelector('[data-action="edit"]'); const editBtn = card.querySelector('[data-action="edit"]');
const toggleBtn = card.querySelector('[data-action="toggle-loan"]'); const toggleBtn = card.querySelector('[data-action="toggle-loan"]');
const deleteBtn = card.querySelector('[data-action="delete"]'); const deleteBtn = card.querySelector('[data-action="delete"]');
const selectInput = card.querySelector('input[type="checkbox"][data-action="select"]');
editBtn.dataset.id = game.id; editBtn.dataset.id = game.id;
editBtn.textContent = "✏️ Editer"; editBtn.textContent = "✏️ Editer";
@@ -1296,6 +1557,10 @@ function renderGames() {
deleteBtn.textContent = "🗑️ Supprimer"; deleteBtn.textContent = "🗑️ Supprimer";
deleteBtn.title = "Supprimer ce jeu"; deleteBtn.title = "Supprimer ce jeu";
deleteBtn.setAttribute("aria-label", "Supprimer ce jeu"); deleteBtn.setAttribute("aria-label", "Supprimer ce jeu");
if (selectInput instanceof HTMLInputElement) {
selectInput.dataset.id = game.id;
selectInput.checked = selectedGameIds.has(game.id);
}
if (inlineEditingGameId === game.id) { if (inlineEditingGameId === game.id) {
const editor = document.createElement("div"); const editor = document.createElement("div");

View File

@@ -132,6 +132,16 @@
<div class="games-actions-bar"> <div class="games-actions-bar">
<button id="loanedFilterBtn" type="button" class="btn-secondary">Voir jeux pretes</button> <button id="loanedFilterBtn" type="button" class="btn-secondary">Voir jeux pretes</button>
</div> </div>
<div id="bulkActionsBar" class="bulk-actions-bar">
<label class="checkbox-row">
<input id="bulkSelectPage" type="checkbox" />
Tout selectionner (page)
</label>
<span id="bulkSelectionInfo" class="bulk-selection-info">0 selectionne</span>
<button id="bulkLoanBtn" type="button" class="btn-secondary" disabled>Marquer prete</button>
<button id="bulkReturnBtn" type="button" class="btn-secondary" disabled>Marquer rendu</button>
<button id="bulkDeleteBtn" type="button" class="btn-inline danger" disabled>Supprimer</button>
</div>
<div class="scanner-zone"> <div class="scanner-zone">
<div class="scanner-header"> <div class="scanner-header">
<strong>Scan camera (mobile)</strong> <strong>Scan camera (mobile)</strong>
@@ -210,12 +220,21 @@
<input id="coverUrlInput" type="hidden" /> <input id="coverUrlInput" type="hidden" />
<div id="gamesList" class="games-list"></div> <div id="gamesList" class="games-list"></div>
<div id="paginationBar" class="pagination-bar">
<button id="prevPageBtn" type="button" class="btn-secondary">Precedent</button>
<span id="pageInfo" class="page-info">Page 1/1</span>
<button id="nextPageBtn" type="button" class="btn-secondary">Suivant</button>
</div>
</section> </section>
</main> </main>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div> <div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
<template id="gameCardTemplate"> <template id="gameCardTemplate">
<article class="game-card"> <article class="game-card">
<label class="game-select">
<input type="checkbox" data-action="select" />
<span>Selection</span>
</label>
<img class="game-cover hidden" alt="Pochette du jeu" loading="lazy" /> <img class="game-cover hidden" alt="Pochette du jeu" loading="lazy" />
<div class="game-main"> <div class="game-main">
<h3 class="game-title"></h3> <h3 class="game-title"></h3>

View File

@@ -244,6 +244,24 @@ h1 {
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
.bulk-actions-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.8rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 10px;
background: #f7faff;
}
.bulk-selection-info {
font-size: 0.85rem;
color: var(--muted);
margin-right: auto;
}
.scanner-zone { .scanner-zone {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
@@ -439,6 +457,20 @@ button {
align-items: center; align-items: center;
} }
.game-select {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--muted);
flex-shrink: 0;
}
.game-select input {
width: 16px;
height: 16px;
}
.console-theme-default { .console-theme-default {
background: #ffffff; background: #ffffff;
} }
@@ -520,6 +552,21 @@ button {
gap: 0.45rem; gap: 0.45rem;
} }
.pagination-bar {
margin-top: 0.8rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.6rem;
}
.page-info {
font-size: 0.88rem;
color: var(--muted);
min-width: 98px;
text-align: center;
}
.inline-editor { .inline-editor {
margin-top: 0.75rem; margin-top: 0.75rem;
border-top: 1px dashed #ccd7e4; border-top: 1px dashed #ccd7e4;
@@ -750,6 +797,10 @@ body.ui-v2 .game-card {
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
} }
body.ui-v2 .game-select span {
display: none;
}
body.ui-v2 .game-card:hover { body.ui-v2 .game-card:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: #d3cef9; border-color: #d3cef9;
@@ -814,6 +865,15 @@ body.ui-v2 .hero {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.bulk-actions-bar {
flex-direction: column;
align-items: stretch;
}
.bulk-selection-info {
margin-right: 0;
}
.grid-form, .grid-form,
.game-form { .game-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -864,4 +924,8 @@ body.ui-v2 .hero {
width: 100%; width: 100%;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.pagination-bar {
flex-wrap: wrap;
}
} }