Feature: add game cover upload with compact thumbnails

This commit is contained in:
Ponte
2026-02-14 22:55:22 +01:00
parent 832004b591
commit 23352d85d0
4 changed files with 98 additions and 7 deletions

View File

@@ -121,6 +121,7 @@ async function runMigrations() {
condition_score NUMERIC(4,2), condition_score NUMERIC(4,2),
loaned_to TEXT, loaned_to TEXT,
barcode TEXT, barcode TEXT,
cover_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -130,6 +131,7 @@ async function runMigrations() {
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10,2);"); await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10,2);");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS condition_score NUMERIC(4,2);"); await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS condition_score NUMERIC(4,2);");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS barcode TEXT;"); await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS barcode TEXT;");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS cover_url TEXT;");
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS backup_snapshots ( CREATE TABLE IF NOT EXISTS backup_snapshots (
@@ -248,6 +250,7 @@ async function getCatalogFull() {
g.condition_score, g.condition_score,
g.loaned_to, g.loaned_to,
g.barcode, g.barcode,
g.cover_url,
g.created_at g.created_at
FROM games g FROM games g
JOIN consoles c ON c.id = g.console_id JOIN consoles c ON c.id = g.console_id
@@ -280,6 +283,7 @@ async function getCatalogFull() {
condition: row.condition_score != null ? Number(row.condition_score) : null, condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "", loanedTo: row.loaned_to || "",
barcode: row.barcode || "", barcode: row.barcode || "",
coverUrl: row.cover_url || "",
createdAt: row.created_at, createdAt: row.created_at,
}); });
} }
@@ -320,6 +324,7 @@ async function exportCatalogDumpWithClient(client) {
g.condition_score, g.condition_score,
g.loaned_to, g.loaned_to,
g.barcode, g.barcode,
g.cover_url,
g.created_at, g.created_at,
g.updated_at g.updated_at
FROM games g FROM games g
@@ -360,6 +365,7 @@ async function exportCatalogDumpWithClient(client) {
condition: row.condition_score != null ? Number(row.condition_score) : null, condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "", loanedTo: row.loaned_to || "",
barcode: row.barcode || "", barcode: row.barcode || "",
coverUrl: row.cover_url || "",
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
})), })),
@@ -504,6 +510,7 @@ async function restoreCatalogDump(mode, dump) {
const publisher = normalizeText(gameEntry && gameEntry.publisher) || null; const publisher = normalizeText(gameEntry && gameEntry.publisher) || null;
const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null; const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null;
const barcode = normalizeText(gameEntry && gameEntry.barcode) || null; const barcode = normalizeText(gameEntry && gameEntry.barcode) || null;
const coverUrl = normalizeText(gameEntry && gameEntry.coverUrl) || null;
const isDuplicate = Boolean(gameEntry && gameEntry.isDuplicate); const isDuplicate = Boolean(gameEntry && gameEntry.isDuplicate);
const purchasePrice = const purchasePrice =
gameEntry && gameEntry.purchasePrice != null && gameEntry.purchasePrice !== "" gameEntry && gameEntry.purchasePrice != null && gameEntry.purchasePrice !== ""
@@ -518,9 +525,9 @@ async function restoreCatalogDump(mode, dump) {
` `
INSERT INTO games( INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price, console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to, created_at estimated_value, condition_score, loaned_to, barcode, cover_url, created_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13::timestamptz, NOW())); VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, COALESCE($14::timestamptz, NOW()));
`, `,
[ [
consoleId, consoleId,
@@ -535,6 +542,7 @@ async function restoreCatalogDump(mode, dump) {
condition, condition,
loanedTo, loanedTo,
barcode, barcode,
coverUrl,
createdAt, createdAt,
], ],
); );
@@ -589,6 +597,7 @@ async function createGame(payload) {
const isDuplicate = Boolean(payload.isDuplicate); const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null; const loanedTo = normalizeText(payload.loanedTo) || null;
const barcode = normalizeText(payload.barcode) || null; const barcode = normalizeText(payload.barcode) || null;
const coverUrl = normalizeText(payload.coverUrl) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null; const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice = const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null; payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
@@ -599,9 +608,9 @@ async function createGame(payload) {
` `
INSERT INTO games( INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price, console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to, barcode estimated_value, condition_score, loaned_to, barcode, cover_url
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id::text AS id; RETURNING id::text AS id;
`, `,
[ [
@@ -617,6 +626,7 @@ async function createGame(payload) {
condition, condition,
loanedTo, loanedTo,
barcode, barcode,
coverUrl,
], ],
); );
@@ -647,6 +657,7 @@ async function updateGame(id, payload) {
const isDuplicate = Boolean(payload.isDuplicate); const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null; const loanedTo = normalizeText(payload.loanedTo) || null;
const barcode = normalizeText(payload.barcode) || null; const barcode = normalizeText(payload.barcode) || null;
const coverUrl = normalizeText(payload.coverUrl) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null; const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice = const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null; payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
@@ -668,7 +679,8 @@ async function updateGame(id, payload) {
estimated_value = $10, estimated_value = $10,
condition_score = $11, condition_score = $11,
loaned_to = $12, loaned_to = $12,
barcode = $13 barcode = $13,
cover_url = $14
WHERE id = $1::uuid WHERE id = $1::uuid
RETURNING id::text AS id; RETURNING id::text AS id;
`, `,
@@ -686,6 +698,7 @@ async function updateGame(id, payload) {
condition, condition,
loanedTo, loanedTo,
barcode, barcode,
coverUrl,
], ],
); );
@@ -904,6 +917,7 @@ async function importCatalog(payload) {
const publisher = normalizeText(game && game.publisher) || null; const publisher = normalizeText(game && game.publisher) || null;
const loanedTo = normalizeText(game && game.loanedTo) || null; const loanedTo = normalizeText(game && game.loanedTo) || null;
const barcode = normalizeText(game && game.barcode) || null; const barcode = normalizeText(game && game.barcode) || null;
const coverUrl = normalizeText(game && game.coverUrl) || null;
const year = game && game.year != null && game.year !== "" ? Number(game.year) : null; const year = game && game.year != null && game.year !== "" ? Number(game.year) : null;
const purchasePrice = const purchasePrice =
game && game.purchasePrice != null && game.purchasePrice !== "" ? Number(game.purchasePrice) : null; game && game.purchasePrice != null && game.purchasePrice !== "" ? Number(game.purchasePrice) : null;
@@ -931,9 +945,9 @@ async function importCatalog(payload) {
` `
INSERT INTO games( INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price, console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to, barcode estimated_value, condition_score, loaned_to, barcode, cover_url
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);
`, `,
[ [
consoleId, consoleId,
@@ -948,6 +962,7 @@ async function importCatalog(payload) {
condition, condition,
loanedTo, loanedTo,
barcode, barcode,
coverUrl,
], ],
); );

50
app.js
View File

@@ -24,6 +24,8 @@ const barcodeInput = document.getElementById("barcodeInput");
const versionInput = document.getElementById("versionInput"); const versionInput = document.getElementById("versionInput");
const genreInput = document.getElementById("genreInput"); const genreInput = document.getElementById("genreInput");
const publisherInput = document.getElementById("publisherInput"); const publisherInput = document.getElementById("publisherInput");
const coverFileInput = document.getElementById("coverFileInput");
const coverUrlInput = document.getElementById("coverUrlInput");
const yearInput = document.getElementById("yearInput"); const yearInput = document.getElementById("yearInput");
const valueInput = document.getElementById("valueInput"); const valueInput = document.getElementById("valueInput");
const purchasePriceInput = document.getElementById("purchasePriceInput"); const purchasePriceInput = document.getElementById("purchasePriceInput");
@@ -78,6 +80,30 @@ let scannerLoopId = null;
let scannerLastCodeValue = ""; let scannerLastCodeValue = "";
let scannerLastCodeAt = 0; 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 fileToDataUrl(file);
coverUrlInput.value = dataUrl;
} catch (error) {
console.error(error);
alert("Impossible de charger cette image.");
} finally {
input.value = "";
}
});
toolsToggleBtn.addEventListener("click", () => { toolsToggleBtn.addEventListener("click", () => {
gamesDrawer.classList.remove("open"); gamesDrawer.classList.remove("open");
toolsDrawer.classList.toggle("open"); toolsDrawer.classList.toggle("open");
@@ -246,6 +272,7 @@ gameForm.addEventListener("submit", async (event) => {
version: versionInput.value.trim(), version: versionInput.value.trim(),
genre: genreInput.value.trim(), genre: genreInput.value.trim(),
publisher: publisherInput.value.trim(), publisher: publisherInput.value.trim(),
coverUrl: coverUrlInput.value.trim(),
isDuplicate: isDuplicateInput.checked, isDuplicate: isDuplicateInput.checked,
year: yearInput.value ? Number(yearInput.value) : null, year: yearInput.value ? Number(yearInput.value) : null,
purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null, purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null,
@@ -289,6 +316,7 @@ gameForm.addEventListener("submit", async (event) => {
version: versionInput.value.trim(), version: versionInput.value.trim(),
genre: genreInput.value.trim(), genre: genreInput.value.trim(),
publisher: publisherInput.value.trim(), publisher: publisherInput.value.trim(),
coverUrl: coverUrlInput.value.trim(),
isDuplicate: isDuplicateInput.checked, isDuplicate: isDuplicateInput.checked,
year: yearInput.value ? Number(yearInput.value) : null, year: yearInput.value ? Number(yearInput.value) : null,
purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null, purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null,
@@ -305,6 +333,7 @@ gameForm.addEventListener("submit", async (event) => {
version: versionInput.value.trim(), version: versionInput.value.trim(),
genre: genreInput.value.trim(), genre: genreInput.value.trim(),
publisher: publisherInput.value.trim(), publisher: publisherInput.value.trim(),
coverUrl: coverUrlInput.value.trim(),
isDuplicate: isDuplicateInput.checked, isDuplicate: isDuplicateInput.checked,
year: yearInput.value ? Number(yearInput.value) : null, year: yearInput.value ? Number(yearInput.value) : null,
purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null, purchasePrice: purchasePriceInput.value ? Number(purchasePriceInput.value) : null,
@@ -694,6 +723,7 @@ function buildGamePayload(game, brand, consoleName, overrides = {}) {
version: game.version || "", version: game.version || "",
genre: game.genre || "", genre: game.genre || "",
publisher: game.publisher || "", publisher: game.publisher || "",
coverUrl: game.coverUrl || "",
isDuplicate: Boolean(game.isDuplicate), isDuplicate: Boolean(game.isDuplicate),
year: game.year != null ? Number(game.year) : null, year: game.year != null ? Number(game.year) : null,
purchasePrice: game.purchasePrice != null ? Number(game.purchasePrice) : null, purchasePrice: game.purchasePrice != null ? Number(game.purchasePrice) : null,
@@ -882,6 +912,15 @@ function renderGames() {
].filter(Boolean); ].filter(Boolean);
card.querySelector(".game-meta").textContent = metaParts.join(" | ") || "Aucune information complementaire"; 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 card.querySelector(".game-loan").textContent = game.loanedTo
? `Pret en cours: ${game.loanedTo}` ? `Pret en cours: ${game.loanedTo}`
@@ -908,6 +947,7 @@ function startEditMode(game) {
versionInput.value = game.version || ""; versionInput.value = game.version || "";
genreInput.value = game.genre || ""; genreInput.value = game.genre || "";
publisherInput.value = game.publisher || ""; publisherInput.value = game.publisher || "";
coverUrlInput.value = game.coverUrl || "";
isDuplicateInput.checked = Boolean(game.isDuplicate); isDuplicateInput.checked = Boolean(game.isDuplicate);
yearInput.value = game.year || ""; yearInput.value = game.year || "";
purchasePriceInput.value = game.purchasePrice != null ? game.purchasePrice : ""; purchasePriceInput.value = game.purchasePrice != null ? game.purchasePrice : "";
@@ -923,10 +963,20 @@ function startEditMode(game) {
function resetEditMode() { function resetEditMode() {
editingGameId = null; editingGameId = null;
gameForm.reset(); gameForm.reset();
coverUrlInput.value = "";
gameSubmitBtn.textContent = "Ajouter le jeu"; gameSubmitBtn.textContent = "Ajouter le jeu";
cancelEditBtn.classList.add("hidden"); 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);
});
}
function updateScannerStatus(message) { function updateScannerStatus(message) {
if (scannerStatus) { if (scannerStatus) {
scannerStatus.textContent = message; scannerStatus.textContent = message;

View File

@@ -167,6 +167,10 @@
Éditeur Éditeur
<input id="publisherInput" placeholder="Square Enix" /> <input id="publisherInput" placeholder="Square Enix" />
</label> </label>
<label>
Pochette (image)
<input id="coverFileInput" type="file" accept="image/*" />
</label>
<label> <label>
Année Année
<input id="yearInput" type="number" min="1970" max="2100" placeholder="2001" /> <input id="yearInput" type="number" min="1970" max="2100" placeholder="2001" />
@@ -194,6 +198,7 @@
<button id="gameSubmitBtn" type="submit">Ajouter le jeu</button> <button id="gameSubmitBtn" type="submit">Ajouter le jeu</button>
<button id="cancelEditBtn" type="button" class="btn-secondary hidden">Annuler edition</button> <button id="cancelEditBtn" type="button" class="btn-secondary hidden">Annuler edition</button>
</form> </form>
<input id="coverUrlInput" type="hidden" />
<div id="gamesList" class="games-list"></div> <div id="gamesList" class="games-list"></div>
</section> </section>
@@ -201,6 +206,7 @@
<template id="gameCardTemplate"> <template id="gameCardTemplate">
<article class="game-card"> <article class="game-card">
<img class="game-cover hidden" alt="Pochette du jeu" loading="lazy" />
<div class="game-main"> <div class="game-main">
<h3 class="game-title"></h3> <h3 class="game-title"></h3>
<p class="game-meta"></p> <p class="game-meta"></p>

View File

@@ -471,6 +471,21 @@ button {
align-items: center; align-items: center;
} }
.game-cover {
width: 56px;
height: 76px;
border-radius: 8px;
border: 1px solid #ccd8e6;
object-fit: cover;
background: #f0f4f9;
flex-shrink: 0;
}
.game-main {
min-width: 0;
flex: 1;
}
.game-card.editing { .game-card.editing {
border-color: color-mix(in hsl, var(--accent-2), white 35%); border-color: color-mix(in hsl, var(--accent-2), white 35%);
box-shadow: 0 0 0 2px color-mix(in hsl, var(--accent-2), white 80%); box-shadow: 0 0 0 2px color-mix(in hsl, var(--accent-2), white 80%);
@@ -557,6 +572,11 @@ button {
flex-direction: column; flex-direction: column;
} }
.game-cover {
width: 48px;
height: 64px;
}
.game-actions { .game-actions {
width: 100%; width: 100%;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;