Step 3 migration: frontend reads API with local fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
80
app.js
80
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();
|
||||
|
||||
@@ -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
25
nginx/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user