UI: add bulk actions and pagination for games list
This commit is contained in:
269
app.js
269
app.js
@@ -61,6 +61,16 @@ const googleRestoreBtn = document.getElementById("googleRestoreBtn");
|
||||
const quickSearchInput = document.getElementById("quickSearchInput");
|
||||
const quickSearchResults = document.getElementById("quickSearchResults");
|
||||
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 v2SearchInput = document.getElementById("v2SearchInput");
|
||||
const v2ConsoleFilter = document.getElementById("v2ConsoleFilter");
|
||||
@@ -90,6 +100,10 @@ let scannerRunning = false;
|
||||
let scannerLoopId = null;
|
||||
let scannerLastCodeValue = "";
|
||||
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.
|
||||
const uiParam = new URLSearchParams(window.location.search).get("ui");
|
||||
const uiV2Enabled = uiParam !== "v1";
|
||||
@@ -218,12 +232,72 @@ quickSearchInput.addEventListener("input", (event) => {
|
||||
|
||||
loanedFilterBtn.addEventListener("click", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
if (v2SearchInput) {
|
||||
v2SearchInput.addEventListener("input", (event) => {
|
||||
v2SearchTerm = event.target.value.trim();
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
renderGames();
|
||||
});
|
||||
}
|
||||
@@ -231,6 +305,8 @@ if (v2SearchInput) {
|
||||
if (v2ConsoleFilter) {
|
||||
v2ConsoleFilter.addEventListener("change", (event) => {
|
||||
v2ConsoleValue = event.target.value;
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
renderGames();
|
||||
});
|
||||
}
|
||||
@@ -238,6 +314,8 @@ if (v2ConsoleFilter) {
|
||||
if (v2GenreFilter) {
|
||||
v2GenreFilter.addEventListener("change", (event) => {
|
||||
v2GenreValue = event.target.value;
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
renderGames();
|
||||
});
|
||||
}
|
||||
@@ -245,6 +323,8 @@ if (v2GenreFilter) {
|
||||
if (v2SortSelect) {
|
||||
v2SortSelect.addEventListener("change", (event) => {
|
||||
v2SortValue = event.target.value || "title_asc";
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
renderGames();
|
||||
});
|
||||
}
|
||||
@@ -409,6 +489,8 @@ gameForm.addEventListener("submit", async (event) => {
|
||||
}
|
||||
|
||||
resetEditMode();
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
persist();
|
||||
markLocalDataForImport();
|
||||
render();
|
||||
@@ -433,6 +515,8 @@ brandTabs.addEventListener("click", (event) => {
|
||||
const consoles = state.brands[brand] || [];
|
||||
state.selectedConsole = consoles[0] || "";
|
||||
resetEditMode();
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
persist();
|
||||
render();
|
||||
});
|
||||
@@ -453,6 +537,8 @@ consoleTabs.addEventListener("click", (event) => {
|
||||
|
||||
state.selectedConsole = consoleName;
|
||||
resetEditMode();
|
||||
resetPaging();
|
||||
selectedGameIds.clear();
|
||||
persist();
|
||||
render();
|
||||
});
|
||||
@@ -574,6 +660,7 @@ gamesList.addEventListener("click", async (event) => {
|
||||
try {
|
||||
if (action === "delete") {
|
||||
await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" });
|
||||
selectedGameIds.delete(id);
|
||||
if (editingGameId === id) {
|
||||
resetEditMode();
|
||||
}
|
||||
@@ -615,6 +702,7 @@ gamesList.addEventListener("click", async (event) => {
|
||||
return;
|
||||
}
|
||||
games.splice(idx, 1);
|
||||
selectedGameIds.delete(id);
|
||||
if (editingGameId === id) {
|
||||
resetEditMode();
|
||||
}
|
||||
@@ -643,6 +731,26 @@ gamesList.addEventListener("click", async (event) => {
|
||||
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", () => {
|
||||
resetEditMode();
|
||||
});
|
||||
@@ -1040,6 +1148,55 @@ function conditionBadgeClass(conditionValue) {
|
||||
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() {
|
||||
if (!quickSearchResults) {
|
||||
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() {
|
||||
const selectedConsole = state.selectedConsole;
|
||||
const inV2 = uiV2Enabled;
|
||||
@@ -1157,6 +1403,7 @@ function renderGames() {
|
||||
|
||||
if (!inV2 && !showLoanedOnly && !selectedConsole) {
|
||||
gamesList.innerHTML = '<p class="empty">Ajoute une section pour commencer.</p>';
|
||||
updateBulkAndPaginationUi([], 0);
|
||||
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
|
||||
? '<p class="empty">Aucun jeu prete actuellement.</p>'
|
||||
: '<p class="empty">Aucun jeu pour ces filtres.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const game of games) {
|
||||
for (const game of pageGames) {
|
||||
const card = gameCardTemplate.content.cloneNode(true);
|
||||
const article = card.querySelector(".game-card");
|
||||
article.classList.add(consoleThemeClass(game.consoleName));
|
||||
@@ -1282,6 +1542,7 @@ function renderGames() {
|
||||
const editBtn = card.querySelector('[data-action="edit"]');
|
||||
const toggleBtn = card.querySelector('[data-action="toggle-loan"]');
|
||||
const deleteBtn = card.querySelector('[data-action="delete"]');
|
||||
const selectInput = card.querySelector('input[type="checkbox"][data-action="select"]');
|
||||
|
||||
editBtn.dataset.id = game.id;
|
||||
editBtn.textContent = "✏️ Editer";
|
||||
@@ -1296,6 +1557,10 @@ function renderGames() {
|
||||
deleteBtn.textContent = "🗑️ Supprimer";
|
||||
deleteBtn.title = "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) {
|
||||
const editor = document.createElement("div");
|
||||
|
||||
Reference in New Issue
Block a user