Step 3 migration: frontend reads API with local fallback

This commit is contained in:
Ponte
2026-02-11 15:00:40 +01:00
parent 89d9275e1a
commit adc7ec193e
7 changed files with 177 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY index.html ./index.html
COPY styles.css ./styles.css
COPY app.js ./app.js

View File

@@ -87,6 +87,7 @@ docker compose up -d --build
- [http://localhost:7002/health](http://localhost:7002/health)
- [http://localhost:7002/api/catalog/summary](http://localhost:7002/api/catalog/summary)
- [http://localhost:7002/api/catalog/tree](http://localhost:7002/api/catalog/tree)
- [http://localhost:7001/api/catalog/full](http://localhost:7001/api/catalog/full) (via proxy Nginx frontend)
### 4) Arreter
@@ -128,6 +129,7 @@ git pull
- tables: `brands`, `consoles`, `games`
- trigger `updated_at` sur `games`
- endpoints de lecture pour validation: `summary` et `tree`
- Etape 3: frontend lit l'API (`/api/catalog/full`) avec fallback `localStorage` si API vide ou indisponible
## Licence

View File

@@ -122,6 +122,60 @@ async function getCatalogTree() {
return Array.from(brandMap.values());
}
async function getCatalogFull() {
const brandConsoleRows = await pool.query(`
SELECT
b.name AS brand_name,
c.name AS console_name
FROM brands b
LEFT JOIN consoles c ON c.brand_id = b.id
ORDER BY b.name ASC, c.name ASC;
`);
const gameRows = await pool.query(`
SELECT
c.name AS console_name,
g.id::text AS id,
g.title,
g.genre,
g.publisher,
g.release_year,
g.estimated_value,
g.loaned_to,
g.created_at
FROM games g
JOIN consoles c ON c.id = g.console_id
ORDER BY g.created_at DESC;
`);
const brands = {};
for (const row of brandConsoleRows.rows) {
const brand = row.brand_name;
brands[brand] = brands[brand] || [];
if (row.console_name && !brands[brand].includes(row.console_name)) {
brands[brand].push(row.console_name);
}
}
const gamesByConsole = {};
for (const row of gameRows.rows) {
const consoleName = row.console_name;
gamesByConsole[consoleName] = gamesByConsole[consoleName] || [];
gamesByConsole[consoleName].push({
id: row.id,
title: row.title,
genre: row.genre || "",
publisher: row.publisher || "",
year: row.release_year || null,
value: row.estimated_value != null ? Number(row.estimated_value) : null,
loanedTo: row.loaned_to || "",
createdAt: row.created_at,
});
}
return { brands, gamesByConsole };
}
async function handleRequest(request, response) {
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
@@ -165,6 +219,16 @@ async function handleRequest(request, response) {
return;
}
if (request.method === "GET" && url.pathname === "/api/catalog/full") {
try {
const full = await getCatalogFull();
sendJson(response, 200, full);
} catch (error) {
sendJson(response, 500, { status: "error", message: error.message });
}
return;
}
sendJson(response, 404, {
status: "not_found",
message: "Route not found",

78
app.js
View File

@@ -11,7 +11,7 @@ const initialState = {
};
const state = loadState();
normalizeState();
let dataMode = "local";
const platformForm = document.getElementById("platformForm");
const gameForm = document.getElementById("gameForm");
@@ -29,6 +29,7 @@ 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 gamesList = document.getElementById("gamesList");
const gameCardTemplate = document.getElementById("gameCardTemplate");
let editingGameId = null;
@@ -188,11 +189,30 @@ cancelEditBtn.addEventListener("click", () => {
});
function render() {
renderDataMode();
renderBrandTabs();
renderConsoleTabs();
renderGames();
}
function renderDataMode() {
if (!dataModeInfo) {
return;
}
if (dataMode === "api") {
dataModeInfo.textContent = "Source: API (lecture). Ecriture DB prevue a l'etape 4.";
return;
}
if (dataMode === "local-fallback") {
dataModeInfo.textContent = "Source: localStorage (fallback). API indisponible ou vide.";
return;
}
dataModeInfo.textContent = "Source: localStorage";
}
function renderBrandTabs() {
const brands = Object.keys(state.brands);
brandTabs.innerHTML = "";
@@ -334,4 +354,60 @@ function persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
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 hydrateFromApi() {
try {
const response = await fetch("/api/catalog/full");
if (!response.ok) {
throw new Error(`API error ${response.status}`);
}
const payload = await response.json();
if (!payloadHasCatalogData(payload)) {
dataMode = "local-fallback";
return;
}
state.brands = payload.brands || {};
state.gamesByConsole = payload.gamesByConsole || {};
state.selectedBrand = Object.keys(state.brands)[0] || "";
state.selectedConsole = (state.brands[state.selectedBrand] || [])[0] || "";
dataMode = "api";
persist();
} catch {
dataMode = "local-fallback";
}
}
async function bootstrap() {
await hydrateFromApi();
normalizeState();
render();
}
bootstrap();

View File

@@ -49,6 +49,7 @@
<section class="panel games-panel">
<div class="panel-header">
<h2 id="gameSectionTitle">Jeux</h2>
<p id="dataModeInfo" class="data-mode"></p>
</div>
<form id="gameForm" class="grid-form game-form">

25
nginx/default.conf Normal file
View File

@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://video-games-api:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://video-games-api:3001/health;
proxy_http_version 1.1;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -74,6 +74,12 @@ h1 {
font-size: 1.1rem;
}
.data-mode {
margin: -0.4rem 0 0.8rem;
font-size: 0.82rem;
color: var(--muted);
}
.grid-form {
display: grid;
gap: 0.7rem;