2164 lines
65 KiB
JavaScript
2164 lines
65 KiB
JavaScript
const STORAGE_KEY = "video_game_collection_v1";
|
|
|
|
const initialState = {
|
|
brands: {
|
|
SONY: ["PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4", "PlayStation 5"],
|
|
MICROSOFT: ["Xbox", "Xbox 360", "Xbox One", "Xbox One X", "Xbox Series X"],
|
|
},
|
|
gamesByConsole: {},
|
|
selectedBrand: "SONY",
|
|
selectedConsole: "PlayStation",
|
|
};
|
|
|
|
const state = loadState();
|
|
let pendingLocalImport = payloadHasCatalogData(state) ? structuredClone(state) : null;
|
|
let dataMode = "local";
|
|
let apiReachable = false;
|
|
|
|
const platformForm = document.getElementById("platformForm");
|
|
const gameForm = document.getElementById("gameForm");
|
|
const brandInput = document.getElementById("brandInput");
|
|
const consoleInput = document.getElementById("consoleInput");
|
|
const titleInput = document.getElementById("titleInput");
|
|
const barcodeInput = document.getElementById("barcodeInput");
|
|
const versionInput = document.getElementById("versionInput");
|
|
const genreInput = document.getElementById("genreInput");
|
|
const publisherInput = document.getElementById("publisherInput");
|
|
const coverFileInput = document.getElementById("coverFileInput");
|
|
const coverUrlInput = document.getElementById("coverUrlInput");
|
|
const yearInput = document.getElementById("yearInput");
|
|
const valueInput = document.getElementById("valueInput");
|
|
const purchasePriceInput = document.getElementById("purchasePriceInput");
|
|
const conditionInput = document.getElementById("conditionInput");
|
|
const isDuplicateInput = document.getElementById("isDuplicateInput");
|
|
const loanedToInput = document.getElementById("loanedToInput");
|
|
const gameSubmitBtn = document.getElementById("gameSubmitBtn");
|
|
const cancelEditBtn = document.getElementById("cancelEditBtn");
|
|
|
|
const brandTabs = document.getElementById("brandTabs");
|
|
const consoleTabs = document.getElementById("consoleTabs");
|
|
const gameSectionTitle = document.getElementById("gameSectionTitle");
|
|
const dataModeInfo = document.getElementById("dataModeInfo");
|
|
const storageState = document.getElementById("storageState");
|
|
const toolsDrawer = document.getElementById("toolsDrawer");
|
|
const toolsToggleBtn = document.getElementById("toolsToggleBtn");
|
|
const toolsCloseBtn = document.getElementById("toolsCloseBtn");
|
|
const gamesDrawer = document.getElementById("gamesDrawer");
|
|
const gamesToggleBtn = document.getElementById("gamesToggleBtn");
|
|
const gamesCloseBtn = document.getElementById("gamesCloseBtn");
|
|
const totalGamesCount = document.getElementById("totalGamesCount");
|
|
const totalGamesValue = document.getElementById("totalGamesValue");
|
|
const migrateBtn = document.getElementById("migrateBtn");
|
|
const backupControls = document.getElementById("backupControls");
|
|
const backupBtn = document.getElementById("backupBtn");
|
|
const restoreMergeBtn = document.getElementById("restoreMergeBtn");
|
|
const restoreReplaceBtn = document.getElementById("restoreReplaceBtn");
|
|
const restoreFileInput = document.getElementById("restoreFileInput");
|
|
const googleDriveState = document.getElementById("googleDriveState");
|
|
const googleConnectBtn = document.getElementById("googleConnectBtn");
|
|
const googleBackupBtn = document.getElementById("googleBackupBtn");
|
|
const googleRestoreBtn = document.getElementById("googleRestoreBtn");
|
|
const quickSearchInput = document.getElementById("quickSearchInput");
|
|
const quickSearchResults = document.getElementById("quickSearchResults");
|
|
const topNavButtons = document.querySelectorAll(".topnav-btn[data-view]");
|
|
const gamesPanel = document.getElementById("gamesPanel");
|
|
const statsView = document.getElementById("statsView");
|
|
const scannerZone = document.getElementById("scannerZone");
|
|
const searchZone = document.getElementById("searchZone");
|
|
const statsTotalGames = document.getElementById("statsTotalGames");
|
|
const statsTotalValue = document.getElementById("statsTotalValue");
|
|
const statsLoanedGames = document.getElementById("statsLoanedGames");
|
|
const statsConsoleCount = document.getElementById("statsConsoleCount");
|
|
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");
|
|
const v2GenreFilter = document.getElementById("v2GenreFilter");
|
|
const v2SortSelect = document.getElementById("v2SortSelect");
|
|
const v2StickyBar = document.getElementById("v2StickyBar");
|
|
const v2StickyCount = document.getElementById("v2StickyCount");
|
|
const v2ToggleFormBtn = document.getElementById("v2ToggleFormBtn");
|
|
const v2QuickBackupBtn = document.getElementById("v2QuickBackupBtn");
|
|
const toastContainer = document.getElementById("toastContainer");
|
|
const scannerStatus = document.getElementById("scannerStatus");
|
|
const scannerStartBtn = document.getElementById("scannerStartBtn");
|
|
const scannerStopBtn = document.getElementById("scannerStopBtn");
|
|
const scannerVideo = document.getElementById("scannerVideo");
|
|
const scannerLastCode = document.getElementById("scannerLastCode");
|
|
const gamesList = document.getElementById("gamesList");
|
|
const gameCardTemplate = document.getElementById("gameCardTemplate");
|
|
let editingGameId = null;
|
|
let inlineEditingGameId = null;
|
|
let pendingRestoreMode = "merge";
|
|
let quickSearchTerm = "";
|
|
let googleStatus = { configured: false, connected: false, email: "" };
|
|
let showLoanedOnly = false;
|
|
let scannerDetector = null;
|
|
let scannerStream = null;
|
|
let scannerRunning = false;
|
|
let scannerLoopId = null;
|
|
let scannerLastCodeValue = "";
|
|
let scannerLastCodeAt = 0;
|
|
let resizeRenderTimeout = null;
|
|
let selectedGameIds = new Set();
|
|
let currentPage = 1;
|
|
let currentPageGameIds = [];
|
|
let currentTotalPages = 1;
|
|
let currentView = "catalogue";
|
|
// 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";
|
|
let v2SearchTerm = "";
|
|
let v2ConsoleValue = "";
|
|
let v2GenreValue = "";
|
|
let v2SortValue = "title_asc";
|
|
let v2FormCollapsed = false;
|
|
|
|
coverFileInput.addEventListener("change", async (event) => {
|
|
const input = event.target;
|
|
const file = input.files && input.files[0] ? input.files[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
if (!file.type.startsWith("image/")) {
|
|
alert("Le fichier doit etre une image.");
|
|
input.value = "";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const dataUrl = await imageFileToOptimizedDataUrl(file);
|
|
coverUrlInput.value = dataUrl;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Impossible de charger/comprimer cette image.");
|
|
} finally {
|
|
input.value = "";
|
|
}
|
|
});
|
|
|
|
toolsToggleBtn.addEventListener("click", () => {
|
|
gamesDrawer.classList.remove("open");
|
|
toolsDrawer.classList.toggle("open");
|
|
});
|
|
|
|
toolsCloseBtn.addEventListener("click", () => {
|
|
toolsDrawer.classList.remove("open");
|
|
});
|
|
|
|
gamesToggleBtn.addEventListener("click", () => {
|
|
toolsDrawer.classList.remove("open");
|
|
gamesDrawer.classList.toggle("open");
|
|
});
|
|
|
|
gamesCloseBtn.addEventListener("click", () => {
|
|
gamesDrawer.classList.remove("open");
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (!(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
const toolsOpen = toolsDrawer.classList.contains("open");
|
|
const gamesOpen = gamesDrawer.classList.contains("open");
|
|
if (!toolsOpen && !gamesOpen) {
|
|
return;
|
|
}
|
|
const clickedInsideTools = toolsDrawer.contains(event.target) || toolsToggleBtn.contains(event.target);
|
|
const clickedInsideGames = gamesDrawer.contains(event.target) || gamesToggleBtn.contains(event.target);
|
|
if (clickedInsideTools || clickedInsideGames) {
|
|
return;
|
|
}
|
|
toolsDrawer.classList.remove("open");
|
|
gamesDrawer.classList.remove("open");
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
toolsDrawer.classList.remove("open");
|
|
gamesDrawer.classList.remove("open");
|
|
if (scannerRunning) {
|
|
stopScanner("Camera arretee.");
|
|
}
|
|
}
|
|
});
|
|
|
|
googleConnectBtn.addEventListener("click", async () => {
|
|
try {
|
|
const payload = await apiRequest("/api/google/connect-url");
|
|
if (!payload.url) {
|
|
throw new Error("Missing Google connect URL");
|
|
}
|
|
window.location.href = payload.url;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Connexion Google indisponible. Verifie la configuration OAuth.");
|
|
}
|
|
});
|
|
|
|
googleBackupBtn.addEventListener("click", async () => {
|
|
try {
|
|
const payload = await apiRequest("/api/google/backup/upload", { method: "POST" });
|
|
await refreshGoogleStatus();
|
|
alert(`Sauvegarde Drive OK: ${payload.fileName || "fichier cree"}.`);
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Echec sauvegarde Google Drive.");
|
|
}
|
|
});
|
|
|
|
googleRestoreBtn.addEventListener("click", async () => {
|
|
const confirmed = window.confirm("Restaurer depuis le dernier backup Google Drive ?");
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await apiRequest("/api/google/backup/restore", {
|
|
method: "POST",
|
|
body: { mode: "merge" },
|
|
});
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
alert(`Restauration Drive OK (${payload.mode})`);
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Echec restauration Google Drive.");
|
|
}
|
|
});
|
|
|
|
quickSearchInput.addEventListener("input", (event) => {
|
|
quickSearchTerm = event.target.value.trim();
|
|
renderSearchResults();
|
|
});
|
|
|
|
loanedFilterBtn.addEventListener("click", () => {
|
|
setCurrentView(currentView === "loans" ? "catalogue" : "loans");
|
|
});
|
|
|
|
for (const navButton of topNavButtons) {
|
|
navButton.addEventListener("click", () => {
|
|
const view = navButton.dataset.view;
|
|
if (!view) {
|
|
return;
|
|
}
|
|
setCurrentView(view);
|
|
});
|
|
}
|
|
|
|
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", () => {
|
|
// Mobile virtual keyboards trigger resize events. Avoid rerendering while editing,
|
|
// otherwise the list rebuild can jump to top and break inline input edits.
|
|
if (inlineEditingGameId || editingGameId) {
|
|
return;
|
|
}
|
|
|
|
const active = document.activeElement;
|
|
if (
|
|
active instanceof HTMLInputElement ||
|
|
active instanceof HTMLTextAreaElement ||
|
|
active instanceof HTMLSelectElement
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (resizeRenderTimeout) {
|
|
clearTimeout(resizeRenderTimeout);
|
|
}
|
|
resizeRenderTimeout = window.setTimeout(() => {
|
|
renderGames();
|
|
}, 120);
|
|
});
|
|
|
|
if (v2SearchInput) {
|
|
v2SearchInput.addEventListener("input", (event) => {
|
|
v2SearchTerm = event.target.value.trim();
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
renderGames();
|
|
});
|
|
}
|
|
|
|
if (v2ConsoleFilter) {
|
|
v2ConsoleFilter.addEventListener("change", (event) => {
|
|
v2ConsoleValue = event.target.value;
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
renderGames();
|
|
});
|
|
}
|
|
|
|
if (v2GenreFilter) {
|
|
v2GenreFilter.addEventListener("change", (event) => {
|
|
v2GenreValue = event.target.value;
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
renderGames();
|
|
});
|
|
}
|
|
|
|
if (v2SortSelect) {
|
|
v2SortSelect.addEventListener("change", (event) => {
|
|
v2SortValue = event.target.value || "title_asc";
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
renderGames();
|
|
});
|
|
}
|
|
|
|
if (v2ToggleFormBtn) {
|
|
v2ToggleFormBtn.addEventListener("click", () => {
|
|
v2FormCollapsed = !v2FormCollapsed;
|
|
gameForm.classList.toggle("hidden", v2FormCollapsed);
|
|
v2ToggleFormBtn.textContent = v2FormCollapsed ? "Afficher formulaire" : "Ajouter";
|
|
});
|
|
}
|
|
|
|
if (v2QuickBackupBtn) {
|
|
v2QuickBackupBtn.addEventListener("click", () => {
|
|
backupBtn.click();
|
|
});
|
|
}
|
|
|
|
if (scannerStartBtn) {
|
|
scannerStartBtn.addEventListener("click", async () => {
|
|
await startScanner();
|
|
});
|
|
}
|
|
|
|
if (scannerStopBtn) {
|
|
scannerStopBtn.addEventListener("click", () => {
|
|
stopScanner("Camera arretee.");
|
|
});
|
|
}
|
|
|
|
platformForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
|
|
const brand = brandInput.value.trim().toUpperCase();
|
|
const consoleName = consoleInput.value.trim();
|
|
|
|
if (!brand || !consoleName) {
|
|
return;
|
|
}
|
|
|
|
if (apiReachable && dataMode !== "local-pending-import") {
|
|
try {
|
|
await apiRequest("/api/catalog/consoles", {
|
|
method: "POST",
|
|
body: { brand, consoleName },
|
|
});
|
|
|
|
platformForm.reset();
|
|
await refreshFromApi(brand, consoleName);
|
|
return;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Impossible d'ajouter cette section via l'API.");
|
|
}
|
|
}
|
|
|
|
state.brands[brand] = state.brands[brand] || [];
|
|
if (!state.brands[brand].includes(consoleName)) {
|
|
state.brands[brand].push(consoleName);
|
|
}
|
|
|
|
state.selectedBrand = brand;
|
|
state.selectedConsole = consoleName;
|
|
state.gamesByConsole[consoleName] = state.gamesByConsole[consoleName] || [];
|
|
|
|
platformForm.reset();
|
|
persist();
|
|
markLocalDataForImport();
|
|
render();
|
|
});
|
|
|
|
gameForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const wasEditing = Boolean(editingGameId);
|
|
|
|
const title = titleInput.value.trim();
|
|
if (!title || !state.selectedConsole) {
|
|
return;
|
|
}
|
|
|
|
if (apiReachable && dataMode !== "local-pending-import") {
|
|
const payload = {
|
|
brand: state.selectedBrand,
|
|
consoleName: state.selectedConsole,
|
|
title,
|
|
barcode: barcodeInput.value.trim(),
|
|
version: versionInput.value.trim(),
|
|
genre: genreInput.value.trim(),
|
|
publisher: publisherInput.value.trim(),
|
|
coverUrl: coverUrlInput.value.trim(),
|
|
isDuplicate: isDuplicateInput.checked,
|
|
year: yearInput.value ? Number(yearInput.value) : null,
|
|
purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null,
|
|
value: valueInput.value ? Number(valueInput.value) : null,
|
|
condition: conditionInput.value ? Number(conditionInput.value) : null,
|
|
loanedTo: loanedToInput.value.trim(),
|
|
};
|
|
|
|
try {
|
|
if (editingGameId) {
|
|
await apiRequest(`/api/catalog/games/${editingGameId}`, {
|
|
method: "PUT",
|
|
body: payload,
|
|
});
|
|
} else {
|
|
await apiRequest("/api/catalog/games", {
|
|
method: "POST",
|
|
body: payload,
|
|
});
|
|
}
|
|
|
|
resetEditMode();
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
showToast(wasEditing ? "Jeu mis a jour." : "Jeu ajoute.", "success");
|
|
return;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert(`Impossible d'enregistrer ce jeu via l'API: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
state.gamesByConsole[state.selectedConsole] = state.gamesByConsole[state.selectedConsole] || [];
|
|
|
|
if (editingGameId) {
|
|
const games = state.gamesByConsole[state.selectedConsole];
|
|
const idx = games.findIndex((game) => game.id === editingGameId);
|
|
if (idx !== -1) {
|
|
games[idx] = {
|
|
...games[idx],
|
|
title,
|
|
barcode: barcodeInput.value.trim(),
|
|
version: versionInput.value.trim(),
|
|
genre: genreInput.value.trim(),
|
|
publisher: publisherInput.value.trim(),
|
|
coverUrl: coverUrlInput.value.trim(),
|
|
isDuplicate: isDuplicateInput.checked,
|
|
year: yearInput.value ? Number(yearInput.value) : null,
|
|
purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null,
|
|
value: valueInput.value ? Number(valueInput.value) : null,
|
|
condition: conditionInput.value ? Number(conditionInput.value) : null,
|
|
loanedTo: loanedToInput.value.trim(),
|
|
};
|
|
}
|
|
} else {
|
|
const game = {
|
|
id: crypto.randomUUID(),
|
|
title,
|
|
barcode: barcodeInput.value.trim(),
|
|
version: versionInput.value.trim(),
|
|
genre: genreInput.value.trim(),
|
|
publisher: publisherInput.value.trim(),
|
|
coverUrl: coverUrlInput.value.trim(),
|
|
isDuplicate: isDuplicateInput.checked,
|
|
year: yearInput.value ? Number(yearInput.value) : null,
|
|
purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null,
|
|
value: valueInput.value ? Number(valueInput.value) : null,
|
|
condition: conditionInput.value ? Number(conditionInput.value) : null,
|
|
loanedTo: loanedToInput.value.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
state.gamesByConsole[state.selectedConsole].unshift(game);
|
|
}
|
|
|
|
resetEditMode();
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
persist();
|
|
markLocalDataForImport();
|
|
render();
|
|
showToast(wasEditing ? "Jeu mis a jour." : "Jeu ajoute.", "success");
|
|
});
|
|
|
|
brandTabs.addEventListener("click", (event) => {
|
|
if (!(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
const target = event.target.closest("button[data-brand]");
|
|
if (!(target instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const brand = target.dataset.brand;
|
|
if (!brand) {
|
|
return;
|
|
}
|
|
|
|
state.selectedBrand = brand;
|
|
const consoles = state.brands[brand] || [];
|
|
state.selectedConsole = consoles[0] || "";
|
|
resetEditMode();
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
persist();
|
|
render();
|
|
});
|
|
|
|
consoleTabs.addEventListener("click", (event) => {
|
|
if (!(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
const target = event.target.closest("button[data-console]");
|
|
if (!(target instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const consoleName = target.dataset.console;
|
|
if (!consoleName) {
|
|
return;
|
|
}
|
|
|
|
state.selectedConsole = consoleName;
|
|
resetEditMode();
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
persist();
|
|
render();
|
|
});
|
|
|
|
gamesList.addEventListener("click", async (event) => {
|
|
if (!(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
const target = event.target.closest("button[data-action]");
|
|
if (!(target instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const action = target.dataset.action;
|
|
const id = target.dataset.id;
|
|
if (!action || !id) {
|
|
return;
|
|
}
|
|
|
|
const gameRef = findGameById(id);
|
|
if (!gameRef) {
|
|
return;
|
|
}
|
|
const { game, games, idx, consoleName, brand } = gameRef;
|
|
|
|
if (action === "edit") {
|
|
inlineEditingGameId = inlineEditingGameId === id ? null : id;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "inline-cancel") {
|
|
inlineEditingGameId = null;
|
|
renderGames();
|
|
return;
|
|
}
|
|
|
|
if (action === "inline-save") {
|
|
const article = target.closest(".game-card");
|
|
if (!(article instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const getInlineValue = (key) => {
|
|
const input = article.querySelector(`[data-inline="${key}"]`);
|
|
return input instanceof HTMLInputElement ? input.value.trim() : "";
|
|
};
|
|
const getInlineNumber = (key) => {
|
|
const value = getInlineValue(key);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
const getInlineChecked = (key) => {
|
|
const input = article.querySelector(`[data-inline="${key}"]`);
|
|
return input instanceof HTMLInputElement ? input.checked : false;
|
|
};
|
|
|
|
const title = getInlineValue("title");
|
|
if (!title) {
|
|
alert("Le titre est obligatoire.");
|
|
return;
|
|
}
|
|
|
|
const updatedFields = {
|
|
title,
|
|
barcode: getInlineValue("barcode"),
|
|
version: getInlineValue("version"),
|
|
genre: getInlineValue("genre"),
|
|
publisher: getInlineValue("publisher"),
|
|
year: getInlineNumber("year"),
|
|
purchasePrice: getInlineNumber("purchasePrice"),
|
|
value: getInlineNumber("value"),
|
|
condition: getInlineNumber("condition"),
|
|
loanedTo: getInlineValue("loanedTo"),
|
|
isDuplicate: getInlineChecked("isDuplicate"),
|
|
coverUrl: game.coverUrl || "",
|
|
};
|
|
|
|
if (apiReachable && dataMode !== "local-pending-import") {
|
|
try {
|
|
const payload = buildGamePayload(game, brand, consoleName, updatedFields);
|
|
await apiRequest(`/api/catalog/games/${id}`, {
|
|
method: "PUT",
|
|
body: payload,
|
|
});
|
|
inlineEditingGameId = null;
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
return;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Mise a jour impossible via l'API.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
games[idx] = {
|
|
...games[idx],
|
|
...updatedFields,
|
|
};
|
|
inlineEditingGameId = null;
|
|
persist();
|
|
markLocalDataForImport();
|
|
render();
|
|
showToast("Jeu mis a jour.", "success");
|
|
return;
|
|
}
|
|
|
|
if (apiReachable && dataMode !== "local-pending-import") {
|
|
if (action === "delete") {
|
|
const confirmed = window.confirm("Etes-vous sur de vouloir supprimer ce jeu ?");
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (action === "delete") {
|
|
await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" });
|
|
selectedGameIds.delete(id);
|
|
if (editingGameId === id) {
|
|
resetEditMode();
|
|
}
|
|
}
|
|
|
|
if (action === "toggle-loan") {
|
|
if (game.loanedTo) {
|
|
await apiRequest(`/api/catalog/games/${id}/toggle-loan`, { method: "POST" });
|
|
} else {
|
|
const borrower = window.prompt("Nom de la personne a qui tu pretes ce jeu :");
|
|
if (borrower === null) {
|
|
return;
|
|
}
|
|
const loanedTo = borrower.trim();
|
|
if (!loanedTo) {
|
|
alert("Le nom est obligatoire pour marquer le jeu comme prete.");
|
|
return;
|
|
}
|
|
const payload = buildGamePayload(game, brand, consoleName, { loanedTo });
|
|
await apiRequest(`/api/catalog/games/${id}`, {
|
|
method: "PUT",
|
|
body: payload,
|
|
});
|
|
}
|
|
}
|
|
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
showToast("Action enregistree.", "success");
|
|
return;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Action impossible via l'API.");
|
|
}
|
|
}
|
|
|
|
if (action === "delete") {
|
|
const confirmed = window.confirm("Etes-vous sur de vouloir supprimer ce jeu ?");
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
games.splice(idx, 1);
|
|
selectedGameIds.delete(id);
|
|
if (editingGameId === id) {
|
|
resetEditMode();
|
|
}
|
|
}
|
|
|
|
if (action === "toggle-loan") {
|
|
if (games[idx].loanedTo) {
|
|
games[idx].loanedTo = "";
|
|
} else {
|
|
const borrower = window.prompt("Nom de la personne a qui tu pretes ce jeu :");
|
|
if (borrower === null) {
|
|
return;
|
|
}
|
|
const loanedTo = borrower.trim();
|
|
if (!loanedTo) {
|
|
alert("Le nom est obligatoire pour marquer le jeu comme prete.");
|
|
return;
|
|
}
|
|
games[idx].loanedTo = loanedTo;
|
|
}
|
|
}
|
|
|
|
persist();
|
|
markLocalDataForImport();
|
|
render();
|
|
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();
|
|
});
|
|
|
|
migrateBtn.addEventListener("click", async () => {
|
|
if (!apiReachable || !pendingLocalImport || !payloadHasCatalogData(pendingLocalImport)) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = window.confirm(
|
|
"Importer les donnees locales dans la base de donnees ? (deduplication par console + titre + annee)",
|
|
);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await apiRequest("/api/catalog/import", {
|
|
method: "POST",
|
|
body: pendingLocalImport,
|
|
});
|
|
|
|
pendingLocalImport = null;
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
alert(`Migration terminee: ${result.insertedGames || 0} jeu(x) importe(s).`);
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Echec de la migration locale vers DB.");
|
|
}
|
|
});
|
|
|
|
backupBtn.addEventListener("click", async () => {
|
|
if (!apiReachable) {
|
|
alert("API indisponible. Sauvegarde JSON impossible.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const dump = await apiRequest("/api/backup/export");
|
|
const blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
a.href = url;
|
|
a.download = `video-games-backup-${stamp}.json`;
|
|
document.body.append(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Echec de la sauvegarde JSON.");
|
|
}
|
|
});
|
|
|
|
restoreMergeBtn.addEventListener("click", () => {
|
|
pendingRestoreMode = "merge";
|
|
restoreFileInput.click();
|
|
});
|
|
|
|
restoreReplaceBtn.addEventListener("click", () => {
|
|
pendingRestoreMode = "replace";
|
|
restoreFileInput.click();
|
|
});
|
|
|
|
restoreFileInput.addEventListener("change", async (event) => {
|
|
const input = event.target;
|
|
const file = input.files && input.files[0] ? input.files[0] : null;
|
|
input.value = "";
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
if (!apiReachable) {
|
|
alert("API indisponible. Restauration impossible.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const fileText = await file.text();
|
|
const dump = JSON.parse(fileText);
|
|
|
|
if (pendingRestoreMode === "replace") {
|
|
const confirmed = window.confirm(
|
|
"Mode remplacement: la base actuelle sera remplacee. Une sauvegarde pre-restore sera creee. Continuer ?",
|
|
);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const result = await apiRequest("/api/backup/restore", {
|
|
method: "POST",
|
|
body: {
|
|
mode: pendingRestoreMode,
|
|
dump,
|
|
},
|
|
});
|
|
|
|
pendingLocalImport = null;
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
alert(
|
|
`Restauration terminee (${result.mode}): ${result.insertedGames || 0} jeu(x), ${result.insertedConsoles || 0} console(s).`,
|
|
);
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert("Echec de la restauration JSON.");
|
|
}
|
|
});
|
|
|
|
function render() {
|
|
renderV2Chrome();
|
|
renderViewChrome();
|
|
renderDataMode();
|
|
renderGoogleStatus();
|
|
renderBrandTabs();
|
|
renderConsoleTabs();
|
|
updateV2FilterOptions();
|
|
renderGames();
|
|
renderSearchResults();
|
|
renderCollectionStats();
|
|
}
|
|
|
|
function setCurrentView(view) {
|
|
const allowed = new Set(["catalogue", "loans", "stats"]);
|
|
if (!allowed.has(view)) {
|
|
return;
|
|
}
|
|
currentView = view;
|
|
showLoanedOnly = view === "loans";
|
|
resetPaging();
|
|
selectedGameIds.clear();
|
|
render();
|
|
}
|
|
|
|
function renderViewChrome() {
|
|
const isStats = currentView === "stats";
|
|
const isLoans = currentView === "loans";
|
|
|
|
for (const navButton of topNavButtons) {
|
|
navButton.classList.toggle("active", navButton.dataset.view === currentView);
|
|
}
|
|
|
|
if (gamesPanel) {
|
|
gamesPanel.classList.toggle("hidden", isStats);
|
|
}
|
|
if (statsView) {
|
|
statsView.classList.toggle("hidden", !isStats);
|
|
}
|
|
if (gameForm) {
|
|
gameForm.classList.toggle("hidden", isLoans || isStats || v2FormCollapsed);
|
|
}
|
|
if (scannerZone) {
|
|
scannerZone.classList.toggle("hidden", isLoans || isStats);
|
|
}
|
|
if (searchZone) {
|
|
searchZone.classList.toggle("hidden", isStats);
|
|
}
|
|
if (bulkActionsBar) {
|
|
bulkActionsBar.classList.toggle("hidden", isStats);
|
|
}
|
|
if (paginationBar) {
|
|
paginationBar.classList.toggle("hidden", isStats);
|
|
}
|
|
if (v2ToggleFormBtn) {
|
|
v2ToggleFormBtn.classList.toggle("hidden", isLoans || isStats);
|
|
}
|
|
if (v2QuickBackupBtn) {
|
|
v2QuickBackupBtn.classList.toggle("hidden", isStats);
|
|
}
|
|
if (loanedFilterBtn) {
|
|
loanedFilterBtn.textContent = isLoans ? "Retour catalogue" : "Aller a la vue prets";
|
|
loanedFilterBtn.classList.toggle("hidden", isStats);
|
|
}
|
|
}
|
|
|
|
function renderDataMode() {
|
|
if (!dataModeInfo) {
|
|
return;
|
|
}
|
|
|
|
if (dataMode === "api") {
|
|
dataModeInfo.textContent = "Source: API (lecture/ecriture active sur la base de donnees).";
|
|
if (storageState) {
|
|
storageState.textContent = "Etat: Base de donnees active";
|
|
}
|
|
} else if (dataMode === "api-empty") {
|
|
dataModeInfo.textContent = "Source: API (base vide). Ajoute une section pour demarrer.";
|
|
if (storageState) {
|
|
storageState.textContent = "Etat: Base de donnees active (vide)";
|
|
}
|
|
} else if (dataMode === "local-pending-import") {
|
|
dataModeInfo.textContent = "Source: localStorage detectee. Migration vers DB disponible.";
|
|
if (storageState) {
|
|
storageState.textContent = "Etat: LocalStorage (migration en attente)";
|
|
}
|
|
} else if (dataMode === "local-fallback") {
|
|
dataModeInfo.textContent = "Source: localStorage (fallback). API indisponible.";
|
|
if (storageState) {
|
|
storageState.textContent = "Etat: LocalStorage (API indisponible)";
|
|
}
|
|
} else {
|
|
dataModeInfo.textContent = "Source: localStorage";
|
|
if (storageState) {
|
|
storageState.textContent = "Etat: LocalStorage";
|
|
}
|
|
}
|
|
|
|
const showMigrateBtn =
|
|
dataMode === "local-pending-import" &&
|
|
apiReachable &&
|
|
pendingLocalImport &&
|
|
payloadHasCatalogData(pendingLocalImport);
|
|
if (showMigrateBtn) {
|
|
migrateBtn.classList.remove("hidden");
|
|
} else {
|
|
migrateBtn.classList.add("hidden");
|
|
}
|
|
|
|
if (apiReachable) {
|
|
backupControls.classList.remove("hidden");
|
|
} else {
|
|
backupControls.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
function renderGoogleStatus() {
|
|
if (!googleDriveState) {
|
|
return;
|
|
}
|
|
|
|
if (!googleStatus.configured) {
|
|
googleDriveState.textContent = "Etat Google Drive: non configure (OAuth manquant).";
|
|
googleConnectBtn.disabled = true;
|
|
googleBackupBtn.disabled = true;
|
|
googleRestoreBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
if (!googleStatus.connected) {
|
|
googleDriveState.textContent = "Etat Google Drive: non connecte.";
|
|
googleConnectBtn.disabled = false;
|
|
googleBackupBtn.disabled = true;
|
|
googleRestoreBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
const email = googleStatus.email ? ` (${googleStatus.email})` : "";
|
|
googleDriveState.textContent = `Etat Google Drive: connecte${email}.`;
|
|
googleConnectBtn.disabled = false;
|
|
googleBackupBtn.disabled = false;
|
|
googleRestoreBtn.disabled = false;
|
|
}
|
|
|
|
function findBrandByConsole(consoleName) {
|
|
for (const [brand, consoles] of Object.entries(state.brands)) {
|
|
if (Array.isArray(consoles) && consoles.includes(consoleName)) {
|
|
return brand;
|
|
}
|
|
}
|
|
return "INCONNUE";
|
|
}
|
|
|
|
function findGameById(id) {
|
|
for (const [consoleName, games] of Object.entries(state.gamesByConsole)) {
|
|
const idx = (games || []).findIndex((game) => game.id === id);
|
|
if (idx !== -1) {
|
|
return {
|
|
consoleName,
|
|
brand: findBrandByConsole(consoleName),
|
|
games,
|
|
idx,
|
|
game: games[idx],
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildGamePayload(game, brand, consoleName, overrides = {}) {
|
|
return {
|
|
brand,
|
|
consoleName,
|
|
title: game.title || "",
|
|
barcode: game.barcode || "",
|
|
version: game.version || "",
|
|
genre: game.genre || "",
|
|
publisher: game.publisher || "",
|
|
coverUrl: game.coverUrl || "",
|
|
isDuplicate: Boolean(game.isDuplicate),
|
|
year: game.year != null ? Number(game.year) : null,
|
|
purchasePrice: game.purchasePrice != null ? Number(game.purchasePrice) : null,
|
|
value: game.value != null ? Number(game.value) : null,
|
|
condition: game.condition != null ? Number(game.condition) : null,
|
|
loanedTo: game.loanedTo || "",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function collectAllGames() {
|
|
const all = [];
|
|
for (const [consoleName, games] of Object.entries(state.gamesByConsole)) {
|
|
const brand = findBrandByConsole(consoleName);
|
|
for (const game of games || []) {
|
|
all.push({ ...game, consoleName, brand });
|
|
}
|
|
}
|
|
return all;
|
|
}
|
|
|
|
function renderCollectionStats() {
|
|
if (!totalGamesCount || !totalGamesValue) {
|
|
return;
|
|
}
|
|
|
|
const allGames = collectAllGames();
|
|
const totalCount = allGames.length;
|
|
const totalValue = allGames.reduce((sum, game) => {
|
|
const value = typeof game.value === "number" ? game.value : Number(game.value);
|
|
return Number.isFinite(value) ? sum + value : sum;
|
|
}, 0);
|
|
const loanedCount = allGames.filter((game) => normalizeText(game.loanedTo)).length;
|
|
const activeConsoles = Object.values(state.gamesByConsole).filter((games) => Array.isArray(games) && games.length > 0)
|
|
.length;
|
|
|
|
totalGamesCount.textContent = String(totalCount);
|
|
totalGamesValue.textContent = `${totalValue.toFixed(2)} EUR`;
|
|
if (statsTotalGames) {
|
|
statsTotalGames.textContent = String(totalCount);
|
|
}
|
|
if (statsTotalValue) {
|
|
statsTotalValue.textContent = `${totalValue.toFixed(2)} EUR`;
|
|
}
|
|
if (statsLoanedGames) {
|
|
statsLoanedGames.textContent = String(loanedCount);
|
|
}
|
|
if (statsConsoleCount) {
|
|
statsConsoleCount.textContent = String(activeConsoles);
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = "info", timeoutMs = 2600) {
|
|
if (!toastContainer || !message) {
|
|
return;
|
|
}
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast ${type}`.trim();
|
|
toast.textContent = message;
|
|
toastContainer.append(toast);
|
|
window.setTimeout(() => {
|
|
toast.remove();
|
|
}, timeoutMs);
|
|
}
|
|
|
|
function renderV2Chrome() {
|
|
if (!uiV2Enabled) {
|
|
return;
|
|
}
|
|
|
|
document.body.classList.add("ui-v2");
|
|
if (v2Toolbar) {
|
|
v2Toolbar.classList.remove("hidden");
|
|
}
|
|
if (v2StickyBar) {
|
|
v2StickyBar.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function updateV2FilterOptions() {
|
|
if (!uiV2Enabled || !v2ConsoleFilter || !v2GenreFilter) {
|
|
return;
|
|
}
|
|
|
|
const allGames = collectAllGames();
|
|
const consoles = Array.from(new Set(allGames.map((game) => normalizeText(game.consoleName)).filter(Boolean))).sort();
|
|
const genres = Array.from(new Set(allGames.flatMap((game) => splitGenres(game.genre)))).sort();
|
|
|
|
v2ConsoleFilter.innerHTML = `<option value="">Toutes</option>${consoles
|
|
.map((consoleName) => `<option value="${escapeHtml(consoleName)}">${escapeHtml(consoleName)}</option>`)
|
|
.join("")}`;
|
|
v2GenreFilter.innerHTML = `<option value="">Tous</option>${genres
|
|
.map((genre) => `<option value="${escapeHtml(genre)}">${escapeHtml(genre)}</option>`)
|
|
.join("")}`;
|
|
|
|
v2ConsoleFilter.value = v2ConsoleValue;
|
|
v2GenreFilter.value = v2GenreValue;
|
|
v2SortSelect.value = v2SortValue;
|
|
}
|
|
|
|
function splitGenres(genreRaw) {
|
|
return normalizeText(genreRaw)
|
|
.split(/[\/,|]/)
|
|
.map((item) => normalizeText(item))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function badgeClassForGenre(genreValue) {
|
|
const normalized = normalizeText(genreValue).toLowerCase();
|
|
if (normalized.includes("rpg")) {
|
|
return "rpg";
|
|
}
|
|
if (normalized.includes("action") || normalized.includes("aventure") || normalized.includes("adventure")) {
|
|
return "action";
|
|
}
|
|
if (normalized.includes("sport") || normalized.includes("football") || normalized.includes("course")) {
|
|
return "sport";
|
|
}
|
|
if (normalized.includes("racing") || normalized.includes("kart")) {
|
|
return "racing";
|
|
}
|
|
return "default";
|
|
}
|
|
|
|
function consoleThemeClass(consoleName) {
|
|
const normalized = normalizeText(consoleName).toLowerCase();
|
|
if (normalized.includes("playstation 5") || normalized === "ps5") {
|
|
return "console-theme-ps5";
|
|
}
|
|
if (normalized.includes("playstation 4") || normalized === "ps4") {
|
|
return "console-theme-ps4";
|
|
}
|
|
if (normalized.includes("playstation 3") || normalized === "ps3") {
|
|
return "console-theme-ps3";
|
|
}
|
|
if (normalized.includes("playstation 2") || normalized === "ps2") {
|
|
return "console-theme-ps2";
|
|
}
|
|
if (normalized.includes("playstation") || normalized === "ps1") {
|
|
return "console-theme-ps1";
|
|
}
|
|
if (normalized.includes("switch")) {
|
|
return "console-theme-switch";
|
|
}
|
|
if (normalized.includes("xbox")) {
|
|
return "console-theme-xbox";
|
|
}
|
|
if (normalized.includes("wii")) {
|
|
return "console-theme-wii";
|
|
}
|
|
if (normalized.includes("snes")) {
|
|
return "console-theme-snes";
|
|
}
|
|
if (normalized.includes("nes")) {
|
|
return "console-theme-nes";
|
|
}
|
|
return "console-theme-default";
|
|
}
|
|
|
|
function conditionBadgeClass(conditionValue) {
|
|
if (conditionValue == null || Number.isNaN(Number(conditionValue))) {
|
|
return "status-neutral";
|
|
}
|
|
const value = Number(conditionValue);
|
|
if (value >= 9) {
|
|
return "status-good";
|
|
}
|
|
if (value >= 7) {
|
|
return "status-medium";
|
|
}
|
|
if (value >= 5) {
|
|
return "status-warning";
|
|
}
|
|
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) {
|
|
const shouldHidePagination = currentView === "stats" || totalFilteredCount <= pageSizeForViewport();
|
|
paginationBar.classList.toggle("hidden", shouldHidePagination);
|
|
}
|
|
if (bulkActionsBar) {
|
|
const shouldHideBulk = currentView === "stats" || totalFilteredCount === 0;
|
|
bulkActionsBar.classList.toggle("hidden", shouldHideBulk);
|
|
}
|
|
}
|
|
|
|
function renderSearchResults() {
|
|
if (!quickSearchResults) {
|
|
return;
|
|
}
|
|
|
|
const query = quickSearchTerm.trim().toLowerCase();
|
|
if (!query) {
|
|
quickSearchResults.innerHTML =
|
|
'<p class="empty">Saisis un titre pour verifier si tu possedes deja le jeu.</p>';
|
|
return;
|
|
}
|
|
|
|
const normalizedQuery = query.replace(/\s+/g, " ");
|
|
const allGames = collectAllGames();
|
|
const matches = allGames.filter((game) => {
|
|
const title = String(game.title || "").toLowerCase();
|
|
return title.includes(normalizedQuery);
|
|
});
|
|
|
|
const exactMatches = allGames.filter((game) => {
|
|
const title = String(game.title || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return title === normalizedQuery;
|
|
});
|
|
|
|
if (!matches.length) {
|
|
quickSearchResults.innerHTML =
|
|
'<p class="search-status">Aucun jeu trouve dans ta collection.</p>';
|
|
return;
|
|
}
|
|
|
|
const maxShown = 20;
|
|
const shown = matches.slice(0, maxShown);
|
|
const header =
|
|
exactMatches.length > 0
|
|
? `<p class="search-status">Deja possede: OUI (${exactMatches.length} correspondance${exactMatches.length > 1 ? "s" : ""} exacte${exactMatches.length > 1 ? "s" : ""}).</p>`
|
|
: `<p class="search-status">Jeu similaire trouve: ${matches.length} resultat${matches.length > 1 ? "s" : ""}.</p>`;
|
|
|
|
const items = shown
|
|
.map((game) => {
|
|
const meta = [
|
|
`${game.brand} / ${game.consoleName}`,
|
|
game.version ? `Version: ${game.version}` : null,
|
|
game.isDuplicate ? "Double: OUI" : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
return `<article class="search-hit"><strong>${escapeHtml(game.title || "")}</strong><p>${escapeHtml(meta)}</p></article>`;
|
|
})
|
|
.join("");
|
|
|
|
const more =
|
|
matches.length > maxShown
|
|
? `<p class="empty">+${matches.length - maxShown} autre(s) resultat(s).</p>`
|
|
: "";
|
|
|
|
quickSearchResults.innerHTML = `${header}${items}${more}`;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function renderBrandTabs() {
|
|
const brands = Object.keys(state.brands);
|
|
brandTabs.innerHTML = "";
|
|
|
|
for (const brand of brands) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `tab ${state.selectedBrand === brand ? "active" : ""}`;
|
|
button.textContent = brand;
|
|
button.dataset.brand = brand;
|
|
brandTabs.append(button);
|
|
}
|
|
}
|
|
|
|
function renderConsoleTabs() {
|
|
const consoles = state.brands[state.selectedBrand] || [];
|
|
if (!consoles.includes(state.selectedConsole)) {
|
|
state.selectedConsole = consoles[0] || "";
|
|
}
|
|
|
|
consoleTabs.innerHTML = "";
|
|
for (const consoleName of consoles) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `tab ${state.selectedConsole === consoleName ? "active" : ""}`;
|
|
button.dataset.console = consoleName;
|
|
const count = (state.gamesByConsole[consoleName] || []).length;
|
|
button.innerHTML = `<span>${consoleName}</span><span class="count-badge">${count}</span>`;
|
|
consoleTabs.append(button);
|
|
}
|
|
}
|
|
|
|
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;
|
|
gameSectionTitle.textContent =
|
|
currentView === "loans"
|
|
? "Jeux pretes"
|
|
: inV2
|
|
? "Catalogue jeux"
|
|
: selectedConsole
|
|
? `Jeux - ${selectedConsole}`
|
|
: "Jeux";
|
|
gamesList.innerHTML = "";
|
|
|
|
if (!inV2 && !showLoanedOnly && !selectedConsole) {
|
|
gamesList.innerHTML = '<p class="empty">Ajoute une section pour commencer.</p>';
|
|
updateBulkAndPaginationUi([], 0);
|
|
return;
|
|
}
|
|
|
|
let games = inV2
|
|
? collectAllGames()
|
|
: showLoanedOnly
|
|
? collectAllGames().filter((game) => normalizeText(game.loanedTo))
|
|
: state.gamesByConsole[selectedConsole] || [];
|
|
|
|
if (showLoanedOnly) {
|
|
games = games.filter((game) => normalizeText(game.loanedTo));
|
|
}
|
|
|
|
if (inV2) {
|
|
const searchTerm = v2SearchTerm.toLowerCase();
|
|
if (searchTerm) {
|
|
games = games.filter((game) => {
|
|
const fields = [
|
|
game.title,
|
|
game.publisher,
|
|
game.barcode,
|
|
game.consoleName,
|
|
game.genre,
|
|
]
|
|
.map((value) => normalizeText(value).toLowerCase())
|
|
.join(" ");
|
|
return fields.includes(searchTerm);
|
|
});
|
|
}
|
|
if (v2ConsoleValue) {
|
|
games = games.filter((game) => normalizeText(game.consoleName) === v2ConsoleValue);
|
|
}
|
|
if (v2GenreValue) {
|
|
games = games.filter((game) =>
|
|
splitGenres(game.genre).some((genre) => genre.toLowerCase() === v2GenreValue.toLowerCase()),
|
|
);
|
|
}
|
|
|
|
games.sort((a, b) => {
|
|
if (v2SortValue === "year_desc") {
|
|
return Number(b.year || 0) - Number(a.year || 0);
|
|
}
|
|
if (v2SortValue === "value_desc") {
|
|
return Number(b.value || 0) - Number(a.value || 0);
|
|
}
|
|
return normalizeText(a.title).localeCompare(normalizeText(b.title), "fr", { sensitivity: "base" });
|
|
});
|
|
|
|
if (v2StickyCount) {
|
|
v2StickyCount.textContent = `${games.length} jeu${games.length > 1 ? "x" : ""} affiche${games.length > 1 ? "s" : ""}`;
|
|
}
|
|
}
|
|
|
|
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 pageGames) {
|
|
const card = gameCardTemplate.content.cloneNode(true);
|
|
const article = card.querySelector(".game-card");
|
|
article.classList.add(consoleThemeClass(game.consoleName));
|
|
if (inlineEditingGameId === game.id) {
|
|
article.classList.add("editing");
|
|
}
|
|
|
|
card.querySelector(".game-title").textContent = game.title;
|
|
|
|
const metaParts = inV2
|
|
? [
|
|
game.publisher ? `Editeur: ${game.publisher}` : null,
|
|
game.year ? `Annee: ${game.year}` : null,
|
|
game.version ? `Version: ${game.version}` : null,
|
|
game.value != null ? `Cote: ${Number(game.value).toFixed(2)} EUR` : null,
|
|
game.consoleName ? `Console: ${game.consoleName}` : null,
|
|
].filter(Boolean)
|
|
: [
|
|
showLoanedOnly ? `${game.brand} / ${game.consoleName}` : null,
|
|
game.barcode ? `Code: ${game.barcode}` : null,
|
|
game.version ? `Version: ${game.version}` : null,
|
|
game.genre ? `Genre: ${game.genre}` : null,
|
|
game.publisher ? `Editeur: ${game.publisher}` : null,
|
|
game.isDuplicate ? "Double: OUI" : null,
|
|
game.year ? `Annee: ${game.year}` : null,
|
|
game.purchasePrice != null ? `Prix achat: ${game.purchasePrice.toFixed(2)} EUR` : null,
|
|
game.value != null ? `Cote: ${game.value.toFixed(2)} EUR` : null,
|
|
game.condition != null ? `Etat: ${game.condition}` : null,
|
|
].filter(Boolean);
|
|
|
|
card.querySelector(".game-meta").textContent = metaParts.join(" | ") || "Aucune information complementaire";
|
|
const badgesContainer = card.querySelector(".game-badges");
|
|
const genres = splitGenres(game.genre);
|
|
if (genres.length) {
|
|
badgesContainer.innerHTML = genres
|
|
.slice(0, 4)
|
|
.map((genre) => `<span class="genre-badge ${badgeClassForGenre(genre)}">${escapeHtml(genre)}</span>`)
|
|
.join("");
|
|
} else {
|
|
badgesContainer.innerHTML = "";
|
|
}
|
|
if (game.condition != null && !Number.isNaN(Number(game.condition))) {
|
|
badgesContainer.insertAdjacentHTML(
|
|
"beforeend",
|
|
`<span class="status-badge ${conditionBadgeClass(game.condition)}">Etat ${escapeHtml(String(game.condition))}/10</span>`,
|
|
);
|
|
}
|
|
const coverEl = card.querySelector(".game-cover");
|
|
const coverUrl = normalizeText(game.coverUrl);
|
|
if (coverUrl) {
|
|
coverEl.src = coverUrl;
|
|
coverEl.classList.remove("hidden");
|
|
} else {
|
|
coverEl.removeAttribute("src");
|
|
coverEl.classList.add("hidden");
|
|
}
|
|
|
|
card.querySelector(".game-loan").textContent = game.loanedTo
|
|
? `Pret en cours: ${game.loanedTo}`
|
|
: "Disponible dans ta collection";
|
|
|
|
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";
|
|
editBtn.title = "Editer ce jeu";
|
|
editBtn.setAttribute("aria-label", "Editer ce jeu");
|
|
toggleBtn.dataset.id = game.id;
|
|
toggleBtn.textContent = game.loanedTo ? "📥 Rendu" : "📤 Preter";
|
|
toggleBtn.title = game.loanedTo ? "Marquer comme rendu" : "Marquer comme prete";
|
|
toggleBtn.setAttribute("aria-label", toggleBtn.title);
|
|
|
|
deleteBtn.dataset.id = game.id;
|
|
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");
|
|
editor.className = "inline-editor";
|
|
editor.innerHTML = `
|
|
<label>Titre<input data-inline="title" value="${escapeHtml(game.title || "")}" /></label>
|
|
<label>Code-barres<input data-inline="barcode" value="${escapeHtml(game.barcode || "")}" /></label>
|
|
<label>Version<input data-inline="version" value="${escapeHtml(game.version || "")}" /></label>
|
|
<label>Genre<input data-inline="genre" value="${escapeHtml(game.genre || "")}" /></label>
|
|
<label>Editeur<input data-inline="publisher" value="${escapeHtml(game.publisher || "")}" /></label>
|
|
<label>Annee<input data-inline="year" type="number" min="1970" max="2100" value="${game.year != null ? escapeHtml(String(game.year)) : ""}" /></label>
|
|
<label>Prix achat (EUR)<input data-inline="purchasePrice" type="number" min="0" step="0.01" value="${game.purchasePrice != null ? escapeHtml(String(game.purchasePrice)) : ""}" /></label>
|
|
<label>Cote (EUR)<input data-inline="value" type="number" min="0" step="0.01" value="${game.value != null ? escapeHtml(String(game.value)) : ""}" /></label>
|
|
<label>Etat (0-10)<input data-inline="condition" type="number" min="0" max="10" step="0.1" value="${game.condition != null ? escapeHtml(String(game.condition)) : ""}" /></label>
|
|
<label>Prete a<input data-inline="loanedTo" value="${escapeHtml(game.loanedTo || "")}" /></label>
|
|
<label class="checkbox-row"><input data-inline="isDuplicate" type="checkbox" ${game.isDuplicate ? "checked" : ""} />Jeu en double</label>
|
|
<div class="inline-editor-actions">
|
|
<button type="button" class="btn-inline" data-action="inline-save" data-id="${game.id}">Enregistrer</button>
|
|
<button type="button" class="btn-inline danger" data-action="inline-cancel" data-id="${game.id}">Annuler</button>
|
|
</div>
|
|
`;
|
|
card.querySelector(".game-main").append(editor);
|
|
}
|
|
|
|
gamesList.append(card);
|
|
}
|
|
}
|
|
|
|
function startEditMode(game) {
|
|
editingGameId = game.id;
|
|
titleInput.value = game.title || "";
|
|
barcodeInput.value = game.barcode || "";
|
|
versionInput.value = game.version || "";
|
|
genreInput.value = game.genre || "";
|
|
publisherInput.value = game.publisher || "";
|
|
coverUrlInput.value = game.coverUrl || "";
|
|
isDuplicateInput.checked = Boolean(game.isDuplicate);
|
|
yearInput.value = game.year || "";
|
|
purchasePriceInput.value = game.purchasePrice != null ? game.purchasePrice : "";
|
|
valueInput.value = game.value != null ? game.value : "";
|
|
conditionInput.value = game.condition != null ? game.condition : "";
|
|
loanedToInput.value = game.loanedTo || "";
|
|
|
|
gameSubmitBtn.textContent = "Mettre a jour le jeu";
|
|
cancelEditBtn.classList.remove("hidden");
|
|
renderGames();
|
|
}
|
|
|
|
function resetEditMode() {
|
|
editingGameId = null;
|
|
gameForm.reset();
|
|
coverUrlInput.value = "";
|
|
gameSubmitBtn.textContent = "Ajouter le jeu";
|
|
cancelEditBtn.classList.add("hidden");
|
|
}
|
|
|
|
function fileToDataUrl(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(String(reader.result || ""));
|
|
reader.onerror = () => reject(new Error("read failed"));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function imageFileToOptimizedDataUrl(file) {
|
|
const originalDataUrl = await fileToDataUrl(file);
|
|
const image = await loadImageFromDataUrl(originalDataUrl);
|
|
|
|
const maxWidth = 240;
|
|
const maxHeight = 320;
|
|
const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
|
|
const targetWidth = Math.max(1, Math.round(image.width * scale));
|
|
const targetHeight = Math.max(1, Math.round(image.height * scale));
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
throw new Error("canvas unavailable");
|
|
}
|
|
|
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
|
let quality = 0.82;
|
|
let optimized = canvas.toDataURL("image/jpeg", quality);
|
|
|
|
// Stay comfortably below API payload thresholds.
|
|
while (optimized.length > 380_000 && quality > 0.45) {
|
|
quality -= 0.08;
|
|
optimized = canvas.toDataURL("image/jpeg", quality);
|
|
}
|
|
|
|
if (optimized.length > 520_000) {
|
|
throw new Error("image too large after compression");
|
|
}
|
|
|
|
return optimized;
|
|
}
|
|
|
|
function loadImageFromDataUrl(dataUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error("image decode failed"));
|
|
image.src = dataUrl;
|
|
});
|
|
}
|
|
|
|
function updateScannerStatus(message) {
|
|
if (scannerStatus) {
|
|
scannerStatus.textContent = message;
|
|
}
|
|
}
|
|
|
|
function scannerSupported() {
|
|
return typeof window !== "undefined" && "BarcodeDetector" in window && navigator.mediaDevices;
|
|
}
|
|
|
|
async function startScanner() {
|
|
if (!scannerVideo || !scannerStartBtn || !scannerStopBtn) {
|
|
return;
|
|
}
|
|
|
|
if (!scannerSupported()) {
|
|
updateScannerStatus("Scan non supporte sur ce navigateur. Utilise Chrome mobile recente.");
|
|
return;
|
|
}
|
|
|
|
if (scannerRunning) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
scannerDetector = new window.BarcodeDetector({
|
|
formats: ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "code_39", "qr_code"],
|
|
});
|
|
scannerStream = await navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
facingMode: { ideal: "environment" },
|
|
},
|
|
audio: false,
|
|
});
|
|
|
|
scannerVideo.srcObject = scannerStream;
|
|
await scannerVideo.play();
|
|
scannerRunning = true;
|
|
scannerVideo.classList.remove("hidden");
|
|
scannerStartBtn.classList.add("hidden");
|
|
scannerStopBtn.classList.remove("hidden");
|
|
updateScannerStatus("Scan en cours... vise le code-barres de la boite.");
|
|
scanLoop();
|
|
} catch (error) {
|
|
console.error(error);
|
|
updateScannerStatus("Impossible d'acceder a la camera. Verifie les permissions.");
|
|
stopScanner();
|
|
}
|
|
}
|
|
|
|
function stopScanner(message) {
|
|
if (scannerLoopId) {
|
|
cancelAnimationFrame(scannerLoopId);
|
|
scannerLoopId = null;
|
|
}
|
|
|
|
if (scannerVideo) {
|
|
scannerVideo.pause();
|
|
scannerVideo.srcObject = null;
|
|
scannerVideo.classList.add("hidden");
|
|
}
|
|
|
|
if (scannerStream) {
|
|
for (const track of scannerStream.getTracks()) {
|
|
track.stop();
|
|
}
|
|
scannerStream = null;
|
|
}
|
|
|
|
scannerRunning = false;
|
|
if (scannerStartBtn) {
|
|
scannerStartBtn.classList.remove("hidden");
|
|
}
|
|
if (scannerStopBtn) {
|
|
scannerStopBtn.classList.add("hidden");
|
|
}
|
|
updateScannerStatus(message || "Camera inactive.");
|
|
}
|
|
|
|
async function scanLoop() {
|
|
if (!scannerRunning || !scannerDetector || !scannerVideo) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const barcodes = await scannerDetector.detect(scannerVideo);
|
|
if (barcodes.length > 0) {
|
|
const rawValue = normalizeText(barcodes[0].rawValue);
|
|
if (rawValue) {
|
|
const now = Date.now();
|
|
if (rawValue !== scannerLastCodeValue || now - scannerLastCodeAt > 1800) {
|
|
scannerLastCodeValue = rawValue;
|
|
scannerLastCodeAt = now;
|
|
applyScannedCode(rawValue);
|
|
stopScanner(`Code detecte: ${rawValue}`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
|
|
scannerLoopId = requestAnimationFrame(() => {
|
|
scanLoop();
|
|
});
|
|
}
|
|
|
|
function applyScannedCode(codeValue) {
|
|
if (barcodeInput) {
|
|
barcodeInput.value = codeValue;
|
|
}
|
|
|
|
if (scannerLastCode) {
|
|
scannerLastCode.textContent = `Dernier code detecte: ${codeValue}`;
|
|
scannerLastCode.classList.remove("hidden");
|
|
}
|
|
|
|
if (quickSearchInput) {
|
|
quickSearchInput.value = codeValue;
|
|
quickSearchTerm = codeValue;
|
|
renderSearchResults();
|
|
}
|
|
|
|
if (titleInput && !normalizeText(titleInput.value)) {
|
|
titleInput.value = codeValue;
|
|
}
|
|
|
|
lookupScannedBarcode(codeValue).catch((error) => {
|
|
console.error(error);
|
|
updateScannerStatus(`Code detecte: ${codeValue} (lookup indisponible).`);
|
|
});
|
|
}
|
|
|
|
async function lookupScannedBarcode(codeValue) {
|
|
const normalized = normalizeText(codeValue);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
const result = await apiRequest(`/api/barcode/lookup/${encodeURIComponent(normalized)}`, { timeoutMs: 7000 });
|
|
if (!result || result.status !== "ok") {
|
|
updateScannerStatus(`Code detecte: ${normalized}`);
|
|
return;
|
|
}
|
|
|
|
const owned = result.owned && result.game;
|
|
if (owned) {
|
|
const ownedTitle = normalizeText(result.game.title) || "Jeu inconnu";
|
|
const ownedConsole = normalizeText(result.game.consoleName);
|
|
const ownedLabel = ownedConsole ? `${ownedTitle} (${ownedConsole})` : ownedTitle;
|
|
updateScannerStatus(`Deja possede: ${ownedLabel}`);
|
|
alert(`Deja dans ta collection: ${ownedLabel}`);
|
|
|
|
if (quickSearchInput) {
|
|
quickSearchInput.value = ownedTitle;
|
|
quickSearchTerm = ownedTitle;
|
|
renderSearchResults();
|
|
}
|
|
if (titleInput) {
|
|
titleInput.value = ownedTitle;
|
|
}
|
|
if (publisherInput && !normalizeText(publisherInput.value) && result.game.publisher) {
|
|
publisherInput.value = result.game.publisher;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const hasAutoData = result.lookup && normalizeText(result.lookup.title);
|
|
if (hasAutoData) {
|
|
titleInput.value = result.lookup.title;
|
|
if (publisherInput && !normalizeText(publisherInput.value) && result.lookup.publisher) {
|
|
publisherInput.value = result.lookup.publisher;
|
|
}
|
|
if (quickSearchInput) {
|
|
quickSearchInput.value = result.lookup.title;
|
|
quickSearchTerm = result.lookup.title;
|
|
renderSearchResults();
|
|
}
|
|
updateScannerStatus(`Titre trouve automatiquement: ${result.lookup.title}`);
|
|
return;
|
|
}
|
|
|
|
updateScannerStatus(`Code detecte: ${normalized} (aucune fiche auto).`);
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
if (value == null) {
|
|
return "";
|
|
}
|
|
return String(value).trim();
|
|
}
|
|
|
|
function loadState() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) {
|
|
return structuredClone(initialState);
|
|
}
|
|
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return structuredClone(initialState);
|
|
}
|
|
}
|
|
|
|
function normalizeState() {
|
|
state.brands = state.brands || {};
|
|
state.gamesByConsole = state.gamesByConsole || {};
|
|
|
|
const brands = Object.keys(state.brands);
|
|
if (!brands.length && !apiReachable) {
|
|
state.brands = structuredClone(initialState.brands);
|
|
}
|
|
|
|
if (!state.selectedBrand || !state.brands[state.selectedBrand]) {
|
|
state.selectedBrand = Object.keys(state.brands)[0] || "";
|
|
}
|
|
|
|
const consoles = state.brands[state.selectedBrand] || [];
|
|
if (!state.selectedConsole || !consoles.includes(state.selectedConsole)) {
|
|
state.selectedConsole = consoles[0] || "";
|
|
}
|
|
}
|
|
|
|
function persist() {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
}
|
|
|
|
function markLocalDataForImport() {
|
|
pendingLocalImport = payloadHasCatalogData(state) ? structuredClone(state) : null;
|
|
}
|
|
|
|
async function apiRequest(path, options = {}) {
|
|
const controller = new AbortController();
|
|
const timeoutMs = options.timeoutMs || 6000;
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
const requestOptions = {
|
|
method: options.method || "GET",
|
|
headers: {},
|
|
signal: controller.signal,
|
|
};
|
|
|
|
if (options.body !== undefined) {
|
|
requestOptions.headers["Content-Type"] = "application/json";
|
|
requestOptions.body = JSON.stringify(options.body);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(path, requestOptions);
|
|
const rawText = await response.text();
|
|
const payload = rawText ? JSON.parse(rawText) : {};
|
|
|
|
if (!response.ok) {
|
|
const message = payload && payload.message ? payload.message : `HTTP ${response.status}`;
|
|
throw new Error(message);
|
|
}
|
|
|
|
return payload;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
function applyCatalogPayload(payload, preferredBrand, preferredConsole) {
|
|
state.brands = payload.brands || {};
|
|
state.gamesByConsole = payload.gamesByConsole || {};
|
|
|
|
normalizeState();
|
|
|
|
if (preferredBrand && state.brands[preferredBrand]) {
|
|
state.selectedBrand = preferredBrand;
|
|
const consoles = state.brands[preferredBrand] || [];
|
|
if (preferredConsole && consoles.includes(preferredConsole)) {
|
|
state.selectedConsole = preferredConsole;
|
|
} else {
|
|
state.selectedConsole = consoles[0] || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
function payloadHasCatalogData(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const brands = payload.brands && typeof payload.brands === "object" ? payload.brands : {};
|
|
const gamesByConsole =
|
|
payload.gamesByConsole && typeof payload.gamesByConsole === "object" ? payload.gamesByConsole : {};
|
|
|
|
const consolesCount = Object.values(brands).reduce((count, consoles) => {
|
|
if (!Array.isArray(consoles)) {
|
|
return count;
|
|
}
|
|
return count + consoles.length;
|
|
}, 0);
|
|
|
|
const gamesCount = Object.values(gamesByConsole).reduce((count, games) => {
|
|
if (!Array.isArray(games)) {
|
|
return count;
|
|
}
|
|
return count + games.length;
|
|
}, 0);
|
|
|
|
return consolesCount > 0 || gamesCount > 0;
|
|
}
|
|
|
|
async function refreshFromApi(preferredBrand, preferredConsole) {
|
|
const payload = await apiRequest("/api/catalog/full");
|
|
apiReachable = true;
|
|
const payloadHasData = payloadHasCatalogData(payload);
|
|
|
|
if (payloadHasData) {
|
|
dataMode = "api";
|
|
applyCatalogPayload(payload, preferredBrand, preferredConsole);
|
|
} else if (pendingLocalImport && payloadHasCatalogData(pendingLocalImport)) {
|
|
dataMode = "local-pending-import";
|
|
applyCatalogPayload(pendingLocalImport, preferredBrand, preferredConsole);
|
|
} else {
|
|
dataMode = "api-empty";
|
|
applyCatalogPayload(payload, preferredBrand, preferredConsole);
|
|
}
|
|
|
|
persist();
|
|
render();
|
|
}
|
|
|
|
async function refreshGoogleStatus() {
|
|
try {
|
|
googleStatus = await apiRequest("/api/google/status");
|
|
} catch {
|
|
googleStatus = { configured: false, connected: false, email: "" };
|
|
}
|
|
renderGoogleStatus();
|
|
}
|
|
|
|
async function hydrateFromApi() {
|
|
try {
|
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
|
} catch {
|
|
apiReachable = false;
|
|
dataMode = "local-fallback";
|
|
}
|
|
}
|
|
|
|
async function bootstrap() {
|
|
await refreshGoogleStatus();
|
|
await hydrateFromApi();
|
|
normalizeState();
|
|
render();
|
|
if (scannerSupported()) {
|
|
updateScannerStatus("Camera inactive. Appuie sur Demarrer scan.");
|
|
} else {
|
|
updateScannerStatus("Scan non supporte sur ce navigateur.");
|
|
}
|
|
handleGoogleCallbackResult();
|
|
}
|
|
|
|
bootstrap();
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
stopScanner();
|
|
});
|
|
|
|
function handleGoogleCallbackResult() {
|
|
const url = new URL(window.location.href);
|
|
const googleParam = url.searchParams.get("google");
|
|
if (!googleParam) {
|
|
return;
|
|
}
|
|
|
|
if (googleParam === "connected") {
|
|
alert("Google Drive connecte avec succes.");
|
|
refreshGoogleStatus();
|
|
} else if (googleParam === "error") {
|
|
alert("Connexion Google Drive echouee.");
|
|
}
|
|
|
|
url.searchParams.delete("google");
|
|
window.history.replaceState({}, "", url.toString());
|
|
}
|