Creiamo finalmente la Dashboard, la prima vera pagina Web consultabile !

mkdir -p /opt/pokemon/backend/app/static
nano /opt/pokemon/backend/app/static/dashboard.html

Inserire:
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Pokemon Dashboard</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head>
<body>

<h1>Pokemon Dashboard</h1>

<canvas id="statsChart" width="800" height="400"></canvas>

<script>
async function loadStats() {

    const response = await fetch('/stats?user_id=1');
    const stats = await response.json();

    Chart.register(ChartDataLabels);

    new Chart(document.getElementById('statsChart'), {
        type: 'bar',
        data: {
            labels: [
                'Carte Totali',
                'Carte Diverse',
                'Set Iniziati'
            ],
            datasets: [{
                label: 'Statistiche',
                data: [
                    stats.total_cards,
                    stats.unique_cards,
                    stats.sets_started
                ],
                backgroundColor: [
                    '#1976d2',
                    '#388e3c',
                    '#f57c00'
                ]
            }]
        },
        options: {
            responsive: true,
            plugins: {
                datalabels: {
                    anchor: 'end',
                    align: 'top',
                    color: '#000',
                    font: {
                        weight: 'bold',
                        size: 16
                    }
                }
            },
            scales: {
                y: {
                    beginAtZero: true
                }
            }
        }
    });
}

loadStats();
</script>

</body>
</html>

Salva e chiudi

Ricarica i file nel container:
cd /opt/pokemon
docker-compose down
docker-compose build --no-cache
docker-compose up -d

Verifichiamo aprendo la pagina web http://vm ip:8000/dashboard dovrebbe mostrare una pagina con un grafico

Creiamo finalmente la prima vera Home Page !

nano /opt/pokemon/backend/app/static/index.html

Inseriamo:
<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="utf-8">
    <title>Pokemon DB</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>

<div class="header">
    <div class="logo">Pokemon DB</div>
    <div class="user">Paolo ▼</div>
</div>

<div class="container">

    <div class="set-info">
        <label>Set:</label>
        <select>
            <option>Pitch Black</option>
        </select>
    </div>

    <div class="toolbar">
        <button>Ricerca</button>
        <button>Aggiungi</button>
        <button>Collezione</button>
        <button>Statistiche</button>
    </div>

    <div class="set-info">
        <h2>Pitch Black</h2>
        <p>Tutte le carte del set</p>
    </div>

    <div id="card-grid" class="card-grid"></div>

</div>

<script>
async function loadCards() {

    const response = await fetch('/set/me5/cards');
    const cards = await response.json();

    const grid = document.getElementById('card-grid');

    cards.forEach(card => {

        const div = document.createElement('div');
        div.className = 'card';

        div.innerHTML = `
            <img src="${card.image_small}" alt="${card.name}">

            <div class="card-body">
                <div class="card-number">#${card.card_number}</div>
                <div class="card-name">${card.name}</div>
                <div class="card-rarity">${card.rarity || ''}</div>
            </div>

            <button>Aggiungi</button>
        `;

        grid.appendChild(div);
    });
}

loadCards();
</script>

</body>
</html>


Salva ed esci poi
nano /opt/pokemon/backend/app/static/style.css

e inserisci:
body {
    margin: 0;
    padding: 0;
    background: #eef2f5;
    font-family: Arial, sans-serif;
}

.header {
    background: white;
    height: 70px;
    padding: 0 30px;

    display: flex;
    justify-content: space-between;
    align-items: center;

    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.logo {
    font-size: 28px;
    font-weight: bold;
    color: #1976d2;
}

.user {
    font-size: 18px;
}

.container {
    padding: 20px;
}

.set-info {
    background: white;
    padding: 20px;
    border-radius: 12px;
    margin-bottom: 20px;
}

.toolbar {
    background: white;
    padding: 15px;
    border-radius: 12px;
    margin-bottom: 20px;

    display: flex;
    gap: 10px;
}

.toolbar button {
    border: none;
    border-radius: 8px;
    padding: 10px 15px;
    cursor: pointer;
}

.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, 180px);
    gap: 20px;
}

.card {
    width: 180px;
}

.card img {
    width: 100%;
    display: block;
}

.card-body {
    padding: 10px;
}

.card-number {
    font-weight: bold;
}

.card-name {
    margin-top: 5px;
    font-size: 18px;
}

.card-rarity {
    color: #666;
    margin-top: 5px;
}

.card button {
    width: 100%;
    border: none;
    padding: 12px;
    background: #1976d2;
    color: white;
    cursor: pointer;
}

Salva ed esci poi sostituisci il primo blocco di main.py con questo:
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi import FastAPI
from pydantic import BaseModel
from sqlalchemy import text

from app.database import engine

app = FastAPI(
    title="Pokemon Collection API"
)
app.mount(
    "/static",
    StaticFiles(directory="app/static"),
    name="static"
)

Ricarica i file nel container:
cd /opt/pokemon
docker-compose down
docker-compose build --no-cache
docker-compose up -d

Verifichiamo aprendo la pagina web http://vm ip:8000/index dovrebbe mostrare una bozza della pagina simile a questa

Un miglioramento potrebbe essere che le carte possedute non si vedono a colori ma in bianco e nero, per farlo servono tre modifiche:

Modificare il file main.py in modo da sotiture il blocco @app.get("/set/{set_id}/cards") con questo:

@app.get("/set/{set_id}/cards")
def get_set_cards(set_id: str):

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    c.id,
                    c.name,
                    c.card_number,
                    c.rarity,
                    c.image_small,

                    EXISTS (
                        SELECT 1
                        FROM collection col
                        WHERE col.card_id = c.id
                        AND col.user_id = 1
                    ) AS owned

                FROM cards c

                WHERE c.set_id = :set_id

                ORDER BY
                    CASE
                        WHEN c.card_number ~ '^[0-9]+$'
                        THEN c.card_number::integer
                    END NULLS LAST,
                    c.card_number
            """),
            {"set_id": set_id}
        )

        return result.mappings().all()

Modificare il file index.html in modo da sostiture la riga:
div.className = 'card';
con questa:
div.className = card.owned
    ? 'card'
    : 'card not-owned';

Modificare il file css e aggiungere:
.not-owned img {
    filter: grayscale(100%);
    opacity: 0.30;
}

Ricompilare:
docker-compose down
docker-compose build --no-cache
docker-compose up -d

Adesso la pagina dovrebbe essere così perchè non abbiamo ancora inserito alcuna carta

La pagina adesso è di base perfettamente configurata. Per il SET scelto si ha il relativo Titolo e le relative carte, chiaramente al momento solo l’elenco delle carte attinge al DB, non è ancora possibile scegliere il SET ne tanto meno aggiungere, modificare etc..

Il prossimo passo importante è configurare il menù “SET” per la scelta del SET da DB.

Editare il file index.html
nano /opt/pokemon/backend/app/static/index.html

sostituire l'intero contenuto con:
<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="utf-8">
    <title>Pokemon DB</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>

<div class="header">
    <div class="logo">Pokemon DB</div>
    <div class="user">Paolo ▼</div>
</div>

<div class="container">

    <div class="set-info">
        <label>Set:</label>
        <select id="setSelect"></select>
    </div>

    <div class="toolbar">
        <button>Ricerca</button>
        <button>Aggiungi</button>
        <button>Collezione</button>
        <button>Statistiche</button>
    </div>

    <div class="set-info">
       <h2 id="setTitle">Pitch Black</h2>
       <p id="setDescription">Tutte le carte del set</p>
    </div>

    <div id="card-grid" class="card-grid"></div>

</div>

<script>
async function loadSets() {

    const response = await fetch('/sets');
    const sets = await response.json();

    const select = document.getElementById('setSelect');

    sets.forEach(set => {

        const option = document.createElement('option');

        option.value = set.id;
        option.textContent = set.name;

        if (set.id === 'me5') {
            option.selected = true;
        }

        select.appendChild(option);
    });

    select.addEventListener('change', () => {

    const selectedText =
        select.options[select.selectedIndex].text;

    document.getElementById('setTitle').textContent =
        selectedText;

    loadCards(select.value);
    });
}

async function loadCards(setId = 'me5') {

    const response = await fetch(`/set/${setId}/cards`);
    const cards = await response.json();

    const grid = document.getElementById('card-grid');

    grid.innerHTML = '';

    cards.forEach(card => {

        const div = document.createElement('div');

        div.className = card.owned
            ? 'card'
            : 'card not-owned';

        div.innerHTML = `
            <img src="${card.image_small}" alt="${card.name}">

            <div class="card-body">
                <div class="card-number">#${card.card_number}</div>
                <div class="card-name">${card.name}</div>
                <div class="card-rarity">${card.rarity || ''}</div>
            </div>

            <button>Aggiungi</button>
        `;

        grid.appendChild(div);
    });
}
loadSets();

document.getElementById('setTitle').textContent =
    'Pitch Black';

loadCards();
</script>

</body>
</html>

Ricarica il container con:
docker-compose down
docker-compose build --no-cache
docker-compose up -d

Adesso la pagina index.html ha il menù a tendina dei SET da cui so potrà scegliere il SET voluto e di conseguenza mostrerà le relative carte (vedi figura sottostante).

Il resto della guida nei prossimi giorni..

<—– Articolo Parte 5 | Articolo Parte 7 —–>