Per rendere operativo il DB manca un solo fondamentale step, poter aggiungere le carte possedute ! Con questa funzione potremo finalmente iniziare a inserire le nostre carte. Chiaramente ci saranno molte altre funzioni da aggiungere come ad esempio la multiutenza, l’import ed export dei dati, il poter gestire più di una collezione etc..
Creare una finestra di Pop-Up quando si clicca sul pulsante Aggiungi carta è un’operazione molto più complessa di quello che si possa pensare, non si tratta infatti di aggiungere una banale quantità ad un articolo di un DB, volendo partire con una funzione già completa, i parametri da aggiungere sono molti. Una finestra di aggiunta deve contenere in visualizzazione: l’immagine della carta, il nome, il numero di carta comprensivo del numero totale di carte di quel SET, il nome del SET, ma soprattutto il tipo di carte (Standar, Reverse, Holo etc..) e in scrittura: la lingua, la qualità (Mint, Near Mint etc..) la quantità.
Creazione della finestra di PopUp
nano /opt/pokemon/backend/app/static/index.html
Sostituire l'intero blocco 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 onclick="openPopup(card)">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>
let allCards = [];
let selectedCardIndex = -1;
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', () => {
document.getElementById('setTitle').textContent =
select.options[select.selectedIndex].text;
loadCards(select.value);
});
}
async function loadCards(setId = 'me5') {
const response = await fetch(`/set/${setId}/cards`);
const cards = await response.json();
allCards = cards;
const grid = document.getElementById('card-grid');
grid.innerHTML = '';
cards.forEach((card, index) => {
const div = document.createElement('div');
div.className = card.owned
? 'card'
: 'card not-owned';
const bigImage =
card.image_small.replace('.png', '_hires.png');
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 onclick="openPopup(${index})">
Aggiungi
</button>
`;
grid.appendChild(div);
});
}
let selectedCard = null;
function openPopup(index) {
selectedCard = allCards[index];
document.getElementById('popupTitle').textContent =
selectedCard.name;
document.getElementById('popupImage').src =
selectedCard.image_small.replace('.png', '_hires.png');
document.getElementById('popupNumber').textContent =
selectedCard.card_number.toString().padStart(3, '0');
document.getElementById('popupTotal').textContent =
selectedCard.total_cards;
document.getElementById('popupSet').textContent =
selectedCard.set_name;
document.getElementById('popupRarity').textContent =
selectedCard.rarity || '';
document.getElementById('popup').style.display =
'flex';
}
function previousCard() {
if (selectedCardIndex <= 0) {
return;
}
openPopup(selectedCardIndex - 1);
}
function nextCard() {
if (selectedCardIndex >= allCards.length - 1) {
return;
}
openPopup(selectedCardIndex + 1);
}
function closePopup() {
document.getElementById('popup').style.display =
'none';
}
async function addStandardCard() {
const payload = {
user_id: 1,
card_id: selectedCard.id,
language:
document.getElementById(
'standardLanguage'
).value,
condition:
document.getElementById(
'standardCondition'
).value,
variant: 'Standard',
graded: false,
quantity: parseInt(
document.getElementById(
'standardQuantity'
).value
)
};
const response = await fetch(
'/collection/add',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
}
);
const result = await response.json();
console.log(result);
closePopup();
loadCards(
document.getElementById('setSelect').value
);
}
loadSets();
document.getElementById('setTitle').textContent =
'Pitch Black';
loadCards();
</script>
<div id="popup" class="popup-overlay">
<div class="popup">
<h2 id="popupTitleBar">
<span id="popupTitle"></span>
| Set: <span id="popupSet"></span>
| Numero: <span id="popupNumber"></span> / <span id="popupTotal">120</span>
| Rarità: <span id="popupRarity"></span>
</h2>
<div class="popup-top">
<img id="popupImage">
<div class="popup-right">
<h3>STANDARD</h3>
<div class="form-group">
<label>Lingua</label>
<select id="standardLanguage">
<option value="IT">Italiano</option>
<option value="EN">Inglese</option>
<option value="JP">Giapponese</option>
</select>
</div>
<div class="form-group">
<label>Condizione</label>
<select id="standardCondition">
<option value="NM">Near Mint</option>
<option value="LP">Light Played</option>
<option value="MP">Moderately Played</option>
<option value="HP">Heavily Played</option>
</select>
</div>
<div class="form-group">
<label>Quantità</label>
<input
id="standardQuantity"
type="number"
value="1"
min="1"
>
</div>
<button id="addStandardButton" onclick="addStandardCard()">Aggiungi alla collezione</button>
</div>
</div>
<hr>
<div style="display:flex; justify-content:space-between; margin-top:20px;">
<button onclick="previousCard()">
← Carta precedente
</button>
<button onclick="nextCard()">
Carta successiva →
</button>
</div>
<hr>
<button onclick="closePopup()">
Chiudi
</button>
</div>
</div>
</body>
</html>
Modificare il CSS:
nano /opt/pokemon/backend/app/static/style.css
Sostituire l'intero blocco con:
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: 32px;
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;
}
.not-owned img {
filter: grayscale(100%);
opacity: 0.30;
}
.popup-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.6);
justify-content: center;
align-items: center;
z-index: 9999;
}
.popup {
background: white;
padding: 20px;
border-radius: 10px;
width: 900px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
}
.popup img {
max-width: 250px;
display: block;
margin: 0px;
}
.popup-form {
display: flex;
flex-direction: column;
gap: 10px;
}
#popupInfo {
margin-top: 15px;
}
#popupNumberLine {
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
}
#popupSet {
font-size: 18px;
margin-bottom: 10px;
}
#popupRarity {
color: #666;
font-size: 18px;
}
.popup-details {
flex-grow: 1;
}
.popup-details div {
margin-bottom: 12px;
}
#popupTitleBar {
font-size: 22px;
margin-bottom: 20px;
line-height: 1.4;
}
#popupNumber,
#popupTotal {
font-weight: bold;
}
.popup-top {
display: flex;
align-items: flex-start;
gap: 10px;
}
.popup-right {
flex: 1;
padding-left: 20px;
}
.popup-right h3 {
margin-top: 0;
margin-bottom: 20px;
}
.popup-right input,
.popup-right select {
padding: 6px;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 15px;
}
.form-group label {
font-weight: bold;
margin-bottom: 5px;
}
.form-group select,
.form-group input {
width: 100%;
box-sizing: border-box;
padding: 8px;
}
#addStandardButton {
margin-top: 10px;
width: 220px;
height: 40px;
}
Aprere il file main.py con:
nano /opt/pokemon/backend/app/main.py
Sostituire l'intero blocco con:
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"
)
class CollectionAdd(BaseModel):
user_id: int
card_id: str
language: str
condition: str
variant: str
graded: bool = False
quantity: int = 1
class CollectionUpdate(BaseModel):
quantity: int
language: str
condition: str
variant: str
graded: bool
@app.get("/")
def root():
return {
"app": "Pokemon Collection",
"status": "running"
}
@app.get("/health")
def health():
return {
"status": "ok"
}
@app.get("/db")
def db_test():
with engine.connect() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM users")
)
count = result.scalar()
return {
"users": count
}
@app.get("/users")
def get_users():
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
id,
username,
display_name,
is_admin
FROM users
ORDER BY id
""")
)
rows = result.mappings().all()
return rows
@app.get("/sets")
def get_sets():
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT *
FROM sets
ORDER BY name
""")
)
rows = result.mappings().all()
return rows
@app.get("/cards")
def get_cards():
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT *
FROM cards
ORDER BY name
LIMIT 100
""")
)
rows = result.mappings().all()
return rows
@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,
c.image_large,
s.name AS set_name,
s.total_cards,
EXISTS (
SELECT 1
FROM collection col
WHERE col.card_id = c.id
AND col.user_id = 1
) AS owned
FROM cards c
JOIN sets s
ON s.id = c.set_id
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()
@app.post("/collection/add")
def add_to_collection(item: CollectionAdd):
with engine.connect() as conn:
conn.execute(
text("""
INSERT INTO collection (
user_id,
card_id,
quantity,
language,
condition,
variant,
graded
)
VALUES (
:user_id,
:card_id,
:quantity,
:language,
:condition,
:variant,
:graded
)
ON CONFLICT (
user_id,
card_id,
language,
condition,
variant,
graded
)
DO UPDATE
SET quantity = collection.quantity + EXCLUDED.quantity
"""),
{
"user_id": item.user_id,
"card_id": item.card_id,
"quantity": item.quantity,
"language": item.language,
"condition": item.condition,
"variant": item.variant,
"graded": item.graded
}
)
conn.commit()
return {
"success": True,
"card_id": item.card_id
}
@app.get("/collection")
def get_collection(user_id: int):
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
col.id,
c.name,
c.card_number,
s.name AS set_name,
col.quantity,
col.language,
col.condition,
col.variant,
col.graded
FROM collection col
JOIN cards c
ON c.id = col.card_id
JOIN sets s
ON s.id = c.set_id
WHERE col.user_id = :user_id
ORDER BY c.name
"""),
{"user_id": user_id}
)
return result.mappings().all()
@app.delete("/collection/{item_id}")
def delete_collection_item(item_id: int):
with engine.connect() as conn:
result = conn.execute(
text("""
DELETE FROM collection
WHERE id = :item_id
"""),
{"item_id": item_id}
)
conn.commit()
return {
"success": True,
"deleted_id": item_id
}
@app.put("/collection/{item_id}")
def update_collection_item(
item_id: int,
item: CollectionUpdate
):
with engine.connect() as conn:
conn.execute(
text("""
UPDATE collection
SET
quantity = :quantity,
language = :language,
condition = :condition,
variant = :variant,
graded = :graded
WHERE id = :item_id
"""),
{
"item_id": item_id,
"quantity": item.quantity,
"language": item.language,
"condition": item.condition,
"variant": item.variant,
"graded": item.graded
}
)
conn.commit()
return {
"success": True,
"updated_id": item_id
}
@app.get("/stats")
def get_stats(user_id: int):
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
COALESCE(SUM(quantity), 0) AS total_cards,
COUNT(*) AS unique_cards,
COUNT(DISTINCT c.set_id) AS sets_started
FROM collection col
JOIN cards c
ON c.id = col.card_id
WHERE col.user_id = :user_id
"""),
{"user_id": user_id}
)
stats = result.mappings().first()
return stats
@app.get("/index")
def index():
return FileResponse(
"app/static/index.html"
)
@app.get("/dashboard")
def dashboard():
return FileResponse(
"app/static/dashboard.html"
)
@app.get("/set/{set_id}/info")
def get_set_info(set_id: str):
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
id,
name,
printed_total
FROM sets
WHERE id = :set_id
"""),
{"set_id": set_id}
)
return result.mappings().first()
Ricompilare con:
docker-compose down
docker-compose build --no-cache
docker-compose up -d
Se tutto è andato a buon fine cliccando sul pulsante aggiungi posto sotto le carte, si aprirà un PopUp con cui potrete inserire le vostre carte nel DB.
<—– Articolo Parte 6 | Articolo Parte 8 —–>




