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 autoCoverBtn = document.getElementById("autoCoverBtn"); 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 loanedFilterBtn = document.getElementById("loanedFilterBtn"); 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 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; 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", () => { showLoanedOnly = !showLoanedOnly; renderGames(); }); 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 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); 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(); persist(); markLocalDataForImport(); render(); }); 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(); 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(); 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") { state.selectedBrand = brand; state.selectedConsole = consoleName; startEditMode(game); persist(); render(); return; } if (apiReachable && dataMode !== "local-pending-import") { try { if (action === "delete") { await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" }); 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); return; } catch (error) { console.error(error); alert("Action impossible via l'API."); } } if (action === "delete") { games.splice(idx, 1); 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(); }); 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."); } }); autoCoverBtn.addEventListener("click", async () => { if (!apiReachable) { alert("API indisponible. Enrichissement des pochettes impossible."); return; } const confirmed = window.confirm( "Lancer la recuperation automatique des pochettes depuis internet pour les jeux sans image ?", ); if (!confirmed) { return; } autoCoverBtn.disabled = true; const originalLabel = autoCoverBtn.textContent; autoCoverBtn.textContent = "Traitement en cours..."; try { const result = await apiRequest("/api/covers/autofill", { method: "POST", body: { limit: 350, overwrite: false }, timeoutMs: 180000, }); await refreshFromApi(state.selectedBrand, state.selectedConsole); alert( `Pochettes maj: ${result.updated || 0} / ${result.scanned || 0} jeu(x). Non trouves: ${result.notFound || 0}.`, ); } catch (error) { console.error(error); alert(`Echec auto-pochettes: ${error.message}`); } finally { autoCoverBtn.disabled = false; autoCoverBtn.textContent = originalLabel; } }); 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() { renderDataMode(); renderGoogleStatus(); renderBrandTabs(); renderConsoleTabs(); renderGames(); renderSearchResults(); renderCollectionStats(); } 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); totalGamesCount.textContent = String(totalCount); totalGamesValue.textContent = `${totalValue.toFixed(2)} EUR`; } function renderSearchResults() { if (!quickSearchResults) { return; } const query = quickSearchTerm.trim().toLowerCase(); if (!query) { quickSearchResults.innerHTML = '

Saisis un titre pour verifier si tu possedes deja le jeu.

'; 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 = '

Aucun jeu trouve dans ta collection.

'; return; } const maxShown = 20; const shown = matches.slice(0, maxShown); const header = exactMatches.length > 0 ? `

Deja possede: OUI (${exactMatches.length} correspondance${exactMatches.length > 1 ? "s" : ""} exacte${exactMatches.length > 1 ? "s" : ""}).

` : `

Jeu similaire trouve: ${matches.length} resultat${matches.length > 1 ? "s" : ""}.

`; 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 `
${escapeHtml(game.title || "")}

${escapeHtml(meta)}

`; }) .join(""); const more = matches.length > maxShown ? `

+${matches.length - maxShown} autre(s) resultat(s).

` : ""; 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 = `${consoleName}${count}`; consoleTabs.append(button); } } function renderGames() { const selectedConsole = state.selectedConsole; gameSectionTitle.textContent = showLoanedOnly ? "Jeux pretes - Toutes consoles" : selectedConsole ? `Jeux - ${selectedConsole}` : "Jeux"; gamesList.innerHTML = ""; loanedFilterBtn.textContent = showLoanedOnly ? "Voir tous les jeux" : "Voir jeux pretes"; if (!showLoanedOnly && !selectedConsole) { gamesList.innerHTML = '

Ajoute une section pour commencer.

'; return; } const games = showLoanedOnly ? collectAllGames().filter((game) => normalizeText(game.loanedTo)) : state.gamesByConsole[selectedConsole] || []; if (!games.length) { gamesList.innerHTML = showLoanedOnly ? '

Aucun jeu prete actuellement.

' : '

Aucun jeu sur cette console pour le moment.

'; return; } for (const game of games) { const card = gameCardTemplate.content.cloneNode(true); const article = card.querySelector(".game-card"); if (editingGameId === game.id) { article.classList.add("editing"); } card.querySelector(".game-title").textContent = game.title; const metaParts = [ 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 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"]'); editBtn.dataset.id = game.id; toggleBtn.dataset.id = game.id; toggleBtn.textContent = game.loanedTo ? "Marquer comme rendu" : "Marquer comme prete"; deleteBtn.dataset.id = game.id; 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()); }