From 9b7af13df41aab44091016cf1a62b434ddce4fff Mon Sep 17 00:00:00 2001 From: Ponte Date: Sat, 14 Feb 2026 23:21:55 +0100 Subject: [PATCH] UX: remove auto-cover feature and add inline game editing --- api/server.js | 180 -------------------------------------------------- app.js | 143 ++++++++++++++++++++++++++++----------- index.html | 1 - styles.css | 29 ++++++++ 4 files changed, 132 insertions(+), 221 deletions(-) diff --git a/api/server.js b/api/server.js index 3f86b23..5a10c94 100644 --- a/api/server.js +++ b/api/server.js @@ -837,175 +837,6 @@ async function lookupBarcode(barcodeRaw) { }; } -async function fetchJsonWithTimeout(url, timeoutMs = 5000) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }); - if (!response.ok) { - return null; - } - return await response.json(); - } catch { - return null; - } finally { - clearTimeout(timeout); - } -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function uniqueQueries(candidates) { - const out = []; - const seen = new Set(); - for (const value of candidates) { - const normalized = normalizeText(value); - if (!normalized) { - continue; - } - const key = normalized.toLowerCase(); - if (seen.has(key)) { - continue; - } - seen.add(key); - out.push(normalized); - } - return out; -} - -function extractWikipediaThumbnail(payload, targetTitle) { - if (!payload || !payload.query || !payload.query.pages) { - return ""; - } - const pages = Object.values(payload.query.pages); - if (!pages.length) { - return ""; - } - - const normalizedTitle = normalizeText(targetTitle).toLowerCase(); - const ranked = pages - .filter((page) => page && page.thumbnail && page.thumbnail.source) - .sort((a, b) => { - const aTitle = normalizeText(a.title).toLowerCase(); - const bTitle = normalizeText(b.title).toLowerCase(); - const aScore = aTitle.includes(normalizedTitle) ? 1 : 0; - const bScore = bTitle.includes(normalizedTitle) ? 1 : 0; - return bScore - aScore; - }); - - if (!ranked.length) { - return ""; - } - return normalizeText(ranked[0].thumbnail.source); -} - -async function fetchCoverFromWikipedia(title, consoleName, year) { - const cleanTitle = normalizeText(title); - if (!cleanTitle) { - return ""; - } - const cleanConsole = normalizeText(consoleName); - const cleanYear = year != null ? String(year) : ""; - - const queries = uniqueQueries([ - cleanConsole ? `${cleanTitle} jeu video ${cleanConsole}` : "", - cleanConsole ? `${cleanTitle} game ${cleanConsole}` : "", - cleanConsole ? `${cleanTitle} ${cleanConsole} video game` : "", - cleanYear ? `${cleanTitle} ${cleanYear} jeu video` : "", - cleanYear ? `${cleanTitle} ${cleanYear} video game` : "", - `${cleanTitle} jeu video`, - `${cleanTitle} video game`, - cleanTitle, - ]); - - const wikiHosts = ["fr.wikipedia.org", "en.wikipedia.org"]; - for (const host of wikiHosts) { - for (const query of queries) { - const searchUrl = - `https://${host}/w/api.php?action=query&format=json&generator=search&gsrlimit=8` + - "&prop=pageimages|info&inprop=url&piprop=thumbnail&pithumbsize=280&redirects=1&origin=*" + - `&gsrsearch=${encodeURIComponent(query)}`; - const payload = await fetchJsonWithTimeout(searchUrl, 5500); - const thumb = extractWikipediaThumbnail(payload, cleanTitle); - if (thumb) { - return thumb; - } - } - } - - return ""; -} - -async function autoFillCovers(options = {}) { - const overwrite = Boolean(options.overwrite); - const limitInput = Number(options.limit); - const limit = Number.isFinite(limitInput) && limitInput > 0 ? Math.min(limitInput, 500) : 250; - - const whereClause = overwrite ? "" : "WHERE COALESCE(g.cover_url, '') = ''"; - const rowsResult = await pool.query( - ` - SELECT - g.id::text AS id, - g.title, - g.release_year, - g.cover_url, - c.name AS console_name - FROM games g - JOIN consoles c ON c.id = g.console_id - ${whereClause} - ORDER BY g.created_at DESC - LIMIT $1; - `, - [limit], - ); - - let updated = 0; - let notFound = 0; - const sampleUpdated = []; - const sampleNotFound = []; - - for (const row of rowsResult.rows) { - const coverUrl = await fetchCoverFromWikipedia(row.title, row.console_name, row.release_year); - if (!coverUrl) { - notFound += 1; - if (sampleNotFound.length < 12) { - sampleNotFound.push({ - id: row.id, - title: row.title, - consoleName: row.console_name, - }); - } - continue; - } - - await pool.query("UPDATE games SET cover_url = $2 WHERE id = $1::uuid;", [row.id, coverUrl]); - updated += 1; - if (sampleUpdated.length < 12) { - sampleUpdated.push({ - id: row.id, - title: row.title, - consoleName: row.console_name, - }); - } - await sleep(80); - } - - return { - scanned: rowsResult.rows.length, - updated, - notFound, - overwrite, - sampleUpdated, - sampleNotFound, - }; -} - async function importCatalog(payload) { const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {}; const gamesByConsole = @@ -1555,17 +1386,6 @@ async function handleRequest(request, response) { return; } - if (request.method === "POST" && url.pathname === "/api/covers/autofill") { - try { - const body = await readJsonBody(request); - const result = await autoFillCovers(body || {}); - sendJson(response, 200, { status: "ok", ...result }); - } catch (error) { - sendJson(response, 400, { status: "error", message: error.message }); - } - return; - } - const barcodeLookupMatch = url.pathname.match(/^\/api\/barcode\/lookup\/([^/]+)$/); if (request.method === "GET" && barcodeLookupMatch) { try { diff --git a/app.js b/app.js index 091fa93..e3c3521 100644 --- a/app.js +++ b/app.js @@ -51,7 +51,6 @@ 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"); @@ -70,6 +69,7 @@ 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: "" }; @@ -416,10 +416,85 @@ gamesList.addEventListener("click", async (event) => { const { game, games, idx, consoleName, brand } = gameRef; if (action === "edit") { - state.selectedBrand = brand; - state.selectedConsole = consoleName; - startEditMode(game); + 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(); return; } @@ -546,41 +621,6 @@ backupBtn.addEventListener("click", async () => { } }); -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(); @@ -928,7 +968,7 @@ function renderGames() { for (const game of games) { const card = gameCardTemplate.content.cloneNode(true); const article = card.querySelector(".game-card"); - if (editingGameId === game.id) { + if (inlineEditingGameId === game.id) { article.classList.add("editing"); } @@ -972,6 +1012,29 @@ function renderGames() { deleteBtn.dataset.id = game.id; + if (inlineEditingGameId === game.id) { + const editor = document.createElement("div"); + editor.className = "inline-editor"; + editor.innerHTML = ` + + + + + + + + + + + +
+ + +
+ `; + card.querySelector(".game-main").append(editor); + } + gamesList.append(card); } } diff --git a/index.html b/index.html index 114507d..9219c7d 100644 --- a/index.html +++ b/index.html @@ -29,7 +29,6 @@ - diff --git a/styles.css b/styles.css index 5fe174b..97eabc7 100644 --- a/styles.css +++ b/styles.css @@ -473,6 +473,31 @@ button { gap: 0.45rem; } +.inline-editor { + margin-top: 0.75rem; + border-top: 1px dashed #ccd7e4; + padding-top: 0.65rem; + display: grid; + gap: 0.45rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.inline-editor label { + gap: 0.25rem; + font-size: 0.82rem; +} + +.inline-editor input { + padding: 0.45rem 0.55rem; + font-size: 0.9rem; +} + +.inline-editor-actions { + display: flex; + gap: 0.45rem; + align-items: center; +} + .btn-inline { background: #dde8f5; color: #1e3045; @@ -524,6 +549,10 @@ button { flex-direction: column; } + .inline-editor { + grid-template-columns: 1fr; + } + .game-cover { width: 48px; height: 64px;