From adc7ec193e03664dcabf269f94c27bfd94c67deb Mon Sep 17 00:00:00 2001 From: Ponte Date: Wed, 11 Feb 2026 15:00:40 +0100 Subject: [PATCH] Step 3 migration: frontend reads API with local fallback --- Dockerfile | 1 + README.md | 2 ++ api/server.js | 64 +++++++++++++++++++++++++++++++++++++ app.js | 80 ++++++++++++++++++++++++++++++++++++++++++++-- index.html | 1 + nginx/default.conf | 25 +++++++++++++++ styles.css | 6 ++++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 nginx/default.conf diff --git a/Dockerfile b/Dockerfile index 716dfc1..6c8372c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index bc8250a..e7fa430 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/server.js b/api/server.js index d467214..b22d62c 100644 --- a/api/server.js +++ b/api/server.js @@ -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", diff --git a/app.js b/app.js index a337f6b..95a0008 100644 --- a/app.js +++ b/app.js @@ -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)); } -render(); +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(); diff --git a/index.html b/index.html index c582f08..c461df3 100644 --- a/index.html +++ b/index.html @@ -49,6 +49,7 @@

Jeux

+

diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..604eae4 --- /dev/null +++ b/nginx/default.conf @@ -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; + } +} diff --git a/styles.css b/styles.css index b8ba4ff..b472cc2 100644 --- a/styles.css +++ b/styles.css @@ -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;