UX: remove auto-cover feature and add inline game editing
This commit is contained in:
180
api/server.js
180
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) {
|
async function importCatalog(payload) {
|
||||||
const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {};
|
const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {};
|
||||||
const gamesByConsole =
|
const gamesByConsole =
|
||||||
@@ -1555,17 +1386,6 @@ async function handleRequest(request, response) {
|
|||||||
return;
|
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\/([^/]+)$/);
|
const barcodeLookupMatch = url.pathname.match(/^\/api\/barcode\/lookup\/([^/]+)$/);
|
||||||
if (request.method === "GET" && barcodeLookupMatch) {
|
if (request.method === "GET" && barcodeLookupMatch) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
143
app.js
143
app.js
@@ -51,7 +51,6 @@ const totalGamesValue = document.getElementById("totalGamesValue");
|
|||||||
const migrateBtn = document.getElementById("migrateBtn");
|
const migrateBtn = document.getElementById("migrateBtn");
|
||||||
const backupControls = document.getElementById("backupControls");
|
const backupControls = document.getElementById("backupControls");
|
||||||
const backupBtn = document.getElementById("backupBtn");
|
const backupBtn = document.getElementById("backupBtn");
|
||||||
const autoCoverBtn = document.getElementById("autoCoverBtn");
|
|
||||||
const restoreMergeBtn = document.getElementById("restoreMergeBtn");
|
const restoreMergeBtn = document.getElementById("restoreMergeBtn");
|
||||||
const restoreReplaceBtn = document.getElementById("restoreReplaceBtn");
|
const restoreReplaceBtn = document.getElementById("restoreReplaceBtn");
|
||||||
const restoreFileInput = document.getElementById("restoreFileInput");
|
const restoreFileInput = document.getElementById("restoreFileInput");
|
||||||
@@ -70,6 +69,7 @@ const scannerLastCode = document.getElementById("scannerLastCode");
|
|||||||
const gamesList = document.getElementById("gamesList");
|
const gamesList = document.getElementById("gamesList");
|
||||||
const gameCardTemplate = document.getElementById("gameCardTemplate");
|
const gameCardTemplate = document.getElementById("gameCardTemplate");
|
||||||
let editingGameId = null;
|
let editingGameId = null;
|
||||||
|
let inlineEditingGameId = null;
|
||||||
let pendingRestoreMode = "merge";
|
let pendingRestoreMode = "merge";
|
||||||
let quickSearchTerm = "";
|
let quickSearchTerm = "";
|
||||||
let googleStatus = { configured: false, connected: false, email: "" };
|
let googleStatus = { configured: false, connected: false, email: "" };
|
||||||
@@ -416,10 +416,85 @@ gamesList.addEventListener("click", async (event) => {
|
|||||||
const { game, games, idx, consoleName, brand } = gameRef;
|
const { game, games, idx, consoleName, brand } = gameRef;
|
||||||
|
|
||||||
if (action === "edit") {
|
if (action === "edit") {
|
||||||
state.selectedBrand = brand;
|
inlineEditingGameId = inlineEditingGameId === id ? null : id;
|
||||||
state.selectedConsole = consoleName;
|
render();
|
||||||
startEditMode(game);
|
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();
|
persist();
|
||||||
|
markLocalDataForImport();
|
||||||
render();
|
render();
|
||||||
return;
|
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", () => {
|
restoreMergeBtn.addEventListener("click", () => {
|
||||||
pendingRestoreMode = "merge";
|
pendingRestoreMode = "merge";
|
||||||
restoreFileInput.click();
|
restoreFileInput.click();
|
||||||
@@ -928,7 +968,7 @@ function renderGames() {
|
|||||||
for (const game of games) {
|
for (const game of games) {
|
||||||
const card = gameCardTemplate.content.cloneNode(true);
|
const card = gameCardTemplate.content.cloneNode(true);
|
||||||
const article = card.querySelector(".game-card");
|
const article = card.querySelector(".game-card");
|
||||||
if (editingGameId === game.id) {
|
if (inlineEditingGameId === game.id) {
|
||||||
article.classList.add("editing");
|
article.classList.add("editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +1012,29 @@ function renderGames() {
|
|||||||
|
|
||||||
deleteBtn.dataset.id = game.id;
|
deleteBtn.dataset.id = 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);
|
gamesList.append(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
<button id="backupBtn" type="button" class="btn-secondary">Sauvegarder JSON</button>
|
<button id="backupBtn" type="button" class="btn-secondary">Sauvegarder JSON</button>
|
||||||
<button id="restoreMergeBtn" type="button" class="btn-secondary">Restaurer (fusion)</button>
|
<button id="restoreMergeBtn" type="button" class="btn-secondary">Restaurer (fusion)</button>
|
||||||
<button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button>
|
<button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button>
|
||||||
<button id="autoCoverBtn" type="button" class="btn-secondary">Auto-remplir pochettes (internet)</button>
|
|
||||||
<input id="restoreFileInput" type="file" accept="application/json" class="hidden" />
|
<input id="restoreFileInput" type="file" accept="application/json" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
29
styles.css
29
styles.css
@@ -473,6 +473,31 @@ button {
|
|||||||
gap: 0.45rem;
|
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 {
|
.btn-inline {
|
||||||
background: #dde8f5;
|
background: #dde8f5;
|
||||||
color: #1e3045;
|
color: #1e3045;
|
||||||
@@ -524,6 +549,10 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-editor {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.game-cover {
|
.game-cover {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|||||||
Reference in New Issue
Block a user