Preview: add UI V2 mode with grid, filters, sort, and toasts

This commit is contained in:
Ponte
2026-02-14 23:29:41 +01:00
parent 9b7af13df4
commit 60b19a70e9
3 changed files with 465 additions and 22 deletions

215
app.js
View File

@@ -61,6 +61,16 @@ const googleRestoreBtn = document.getElementById("googleRestoreBtn");
const quickSearchInput = document.getElementById("quickSearchInput");
const quickSearchResults = document.getElementById("quickSearchResults");
const loanedFilterBtn = document.getElementById("loanedFilterBtn");
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");
@@ -80,6 +90,12 @@ let scannerRunning = false;
let scannerLoopId = null;
let scannerLastCodeValue = "";
let scannerLastCodeAt = 0;
const uiV2Enabled = new URLSearchParams(window.location.search).get("ui") === "v2";
let v2SearchTerm = "";
let v2ConsoleValue = "";
let v2GenreValue = "";
let v2SortValue = "title_asc";
let v2FormCollapsed = false;
coverFileInput.addEventListener("change", async (event) => {
const input = event.target;
@@ -203,6 +219,48 @@ loanedFilterBtn.addEventListener("click", () => {
renderGames();
});
if (v2SearchInput) {
v2SearchInput.addEventListener("input", (event) => {
v2SearchTerm = event.target.value.trim();
renderGames();
});
}
if (v2ConsoleFilter) {
v2ConsoleFilter.addEventListener("change", (event) => {
v2ConsoleValue = event.target.value;
renderGames();
});
}
if (v2GenreFilter) {
v2GenreFilter.addEventListener("change", (event) => {
v2GenreValue = event.target.value;
renderGames();
});
}
if (v2SortSelect) {
v2SortSelect.addEventListener("change", (event) => {
v2SortValue = event.target.value || "title_asc";
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();
@@ -258,6 +316,7 @@ platformForm.addEventListener("submit", async (event) => {
gameForm.addEventListener("submit", async (event) => {
event.preventDefault();
const wasEditing = Boolean(editingGameId);
const title = titleInput.value.trim();
if (!title || !state.selectedConsole) {
@@ -297,6 +356,7 @@ gameForm.addEventListener("submit", async (event) => {
resetEditMode();
await refreshFromApi(state.selectedBrand, state.selectedConsole);
showToast(wasEditing ? "Jeu mis a jour." : "Jeu ajoute.", "success");
return;
} catch (error) {
console.error(error);
@@ -350,6 +410,7 @@ gameForm.addEventListener("submit", async (event) => {
persist();
markLocalDataForImport();
render();
showToast(wasEditing ? "Jeu mis a jour." : "Jeu ajoute.", "success");
});
brandTabs.addEventListener("click", (event) => {
@@ -496,6 +557,7 @@ gamesList.addEventListener("click", async (event) => {
persist();
markLocalDataForImport();
render();
showToast("Jeu mis a jour.", "success");
return;
}
@@ -530,6 +592,7 @@ gamesList.addEventListener("click", async (event) => {
}
await refreshFromApi(state.selectedBrand, state.selectedConsole);
showToast("Action enregistree.", "success");
return;
} catch (error) {
console.error(error);
@@ -564,6 +627,7 @@ gamesList.addEventListener("click", async (event) => {
persist();
markLocalDataForImport();
render();
showToast("Action enregistree.", "success");
});
cancelEditBtn.addEventListener("click", () => {
@@ -678,10 +742,12 @@ restoreFileInput.addEventListener("change", async (event) => {
});
function render() {
renderV2Chrome();
renderDataMode();
renderGoogleStatus();
renderBrandTabs();
renderConsoleTabs();
updateV2FilterOptions();
renderGames();
renderSearchResults();
renderCollectionStats();
@@ -837,6 +903,78 @@ function renderCollectionStats() {
totalGamesValue.textContent = `${totalValue.toFixed(2)} EUR`;
}
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 renderSearchResults() {
if (!quickSearchResults) {
return;
@@ -941,7 +1079,10 @@ function renderConsoleTabs() {
function renderGames() {
const selectedConsole = state.selectedConsole;
gameSectionTitle.textContent = showLoanedOnly
const inV2 = uiV2Enabled;
gameSectionTitle.textContent = inV2
? "Catalogue jeux"
: showLoanedOnly
? "Jeux pretes - Toutes consoles"
: selectedConsole
? `Jeux - ${selectedConsole}`
@@ -949,19 +1090,65 @@ function renderGames() {
gamesList.innerHTML = "";
loanedFilterBtn.textContent = showLoanedOnly ? "Voir tous les jeux" : "Voir jeux pretes";
if (!showLoanedOnly && !selectedConsole) {
if (!inV2 && !showLoanedOnly && !selectedConsole) {
gamesList.innerHTML = '<p class="empty">Ajoute une section pour commencer.</p>';
return;
}
const games = showLoanedOnly
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" : ""}`;
}
}
if (!games.length) {
gamesList.innerHTML = showLoanedOnly
? '<p class="empty">Aucun jeu prete actuellement.</p>'
: '<p class="empty">Aucun jeu sur cette console pour le moment.</p>';
: '<p class="empty">Aucun jeu pour ces filtres.</p>';
return;
}
@@ -974,7 +1161,15 @@ function renderGames() {
card.querySelector(".game-title").textContent = game.title;
const metaParts = [
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,
@@ -988,6 +1183,16 @@ function renderGames() {
].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 = "";
}
const coverEl = card.querySelector(".game-cover");
const coverUrl = normalizeText(game.coverUrl);
if (coverUrl) {

View File

@@ -61,6 +61,15 @@
</aside>
<main class="app-shell">
<div id="v2StickyBar" class="v2-sticky hidden">
<div class="v2-sticky-title">UI V2 Preview</div>
<div id="v2StickyCount" class="v2-sticky-count">0 jeu</div>
<div class="v2-sticky-actions">
<button id="v2ToggleFormBtn" type="button" class="btn-secondary">Ajouter</button>
<button id="v2QuickBackupBtn" type="button" class="btn-secondary">Sauvegarder</button>
</div>
</div>
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Catalogue perso</p>
@@ -94,6 +103,32 @@
<h2 id="gameSectionTitle">Jeux</h2>
<p id="dataModeInfo" class="data-mode"></p>
</div>
<div id="v2Toolbar" class="v2-toolbar hidden">
<label>
Recherche
<input id="v2SearchInput" placeholder="Titre, editeur, code-barres..." />
</label>
<label>
Console
<select id="v2ConsoleFilter">
<option value="">Toutes</option>
</select>
</label>
<label>
Genre
<select id="v2GenreFilter">
<option value="">Tous</option>
</select>
</label>
<label>
Tri
<select id="v2SortSelect">
<option value="title_asc">Titre (A-Z)</option>
<option value="year_desc">Annee (recent d'abord)</option>
<option value="value_desc">Cote (elevee d'abord)</option>
</select>
</label>
</div>
<div class="games-actions-bar">
<button id="loanedFilterBtn" type="button" class="btn-secondary">Voir jeux pretes</button>
</div>
@@ -177,12 +212,14 @@
<div id="gamesList" class="games-list"></div>
</section>
</main>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
<template id="gameCardTemplate">
<article class="game-card">
<img class="game-cover hidden" alt="Pochette du jeu" loading="lazy" />
<div class="game-main">
<h3 class="game-title"></h3>
<div class="game-badges"></div>
<p class="game-meta"></p>
<p class="game-loan"></p>
</div>

View File

@@ -8,6 +8,9 @@
--danger: #bf2f47;
--border: #d6dde6;
--shadow: 0 10px 24px rgba(17, 36, 57, 0.08);
--v2-primary: #6c5ce7;
--v2-success: #00b894;
--v2-bg: #f8f9fa;
}
* {
@@ -519,7 +522,201 @@ button {
display: none;
}
.v2-sticky {
position: sticky;
top: 0.55rem;
z-index: 20;
background: linear-gradient(130deg, #f2eeff, #edf7f4);
border: 1px solid #d8d8ea;
border-radius: 14px;
padding: 0.55rem 0.7rem;
display: flex;
align-items: center;
gap: 0.7rem;
}
.v2-sticky-title {
font-weight: 700;
color: #2f2a66;
}
.v2-sticky-count {
font-size: 0.88rem;
color: #3c4e61;
}
.v2-sticky-actions {
margin-left: auto;
display: flex;
gap: 0.45rem;
}
.v2-toolbar {
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fbff;
padding: 0.7rem;
margin-bottom: 0.8rem;
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.v2-toolbar select {
width: 100%;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.62rem 0.65rem;
font-family: inherit;
font-size: 0.95rem;
}
.game-badges {
margin: 0.4rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.33rem;
}
.genre-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.01em;
border: 1px solid transparent;
}
.genre-badge.default {
background: #e8eef8;
color: #234361;
border-color: #d4dfed;
}
.genre-badge.rpg {
background: #efe7ff;
color: #4f2f9a;
border-color: #ddccff;
}
.genre-badge.action {
background: #ffe9ec;
color: #a23649;
border-color: #ffd2d9;
}
.genre-badge.sport {
background: #e6fbf4;
color: #146c57;
border-color: #c7f2e4;
}
.genre-badge.racing {
background: #fff4df;
color: #9c6515;
border-color: #ffe6b9;
}
.toast-container {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 80;
display: grid;
gap: 0.45rem;
}
.toast {
min-width: 220px;
max-width: 360px;
border-radius: 10px;
border: 1px solid #d7e0ec;
background: #ffffff;
color: #1d2f44;
padding: 0.58rem 0.7rem;
box-shadow: var(--shadow);
font-size: 0.88rem;
}
.toast.success {
border-color: #bdecd8;
background: #eefbf5;
}
.toast.error {
border-color: #ffd1da;
background: #fff1f4;
}
body.ui-v2 {
background: radial-gradient(circle at top right, #efeaff 0, transparent 34%), var(--v2-bg);
}
body.ui-v2 .games-list {
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
body.ui-v2 .game-card {
flex-direction: column;
align-items: flex-start;
gap: 0.55rem;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
body.ui-v2 .game-card:hover {
transform: translateY(-2px);
border-color: #d3cef9;
box-shadow: 0 12px 24px rgba(50, 50, 93, 0.14);
}
body.ui-v2 .game-cover {
width: 72px;
height: 98px;
}
body.ui-v2 .game-actions {
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (prefers-color-scheme: dark) {
body.ui-v2 {
background: #10141a;
color: #e8edf6;
}
body.ui-v2 .panel,
body.ui-v2 .hero,
body.ui-v2 .v2-toolbar,
body.ui-v2 .v2-sticky,
body.ui-v2 .game-card,
body.ui-v2 .search-zone,
body.ui-v2 .scanner-zone {
background: #161d26;
border-color: #2b3747;
}
body.ui-v2 .btn-secondary,
body.ui-v2 .btn-inline {
background: #2a3647;
color: #dbe5f2;
}
body.ui-v2 input,
body.ui-v2 select {
background: #10161f;
border-color: #314157;
color: #e7eef8;
}
}
@media (max-width: 900px) {
.v2-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-form,
.game-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -532,6 +729,10 @@ button {
grid-template-columns: 1fr;
}
.v2-toolbar {
grid-template-columns: 1fr;
}
.tools-toggle-btn {
top: auto;
bottom: 1rem;