Fix: eagerload series.sets i admin og API (MissingGreenlet i async mode)

This commit is contained in:
2026-07-01 22:15:14 +02:00
parent c0952a3bd3
commit 8361066f2f
20 changed files with 1075 additions and 554 deletions
+3 -1
View File
@@ -3,4 +3,6 @@ __pycache__/
*.egg-info/
.env
.venv/
venv/
venv/
dev/
*.tar.gz
+4
View File
@@ -6,6 +6,7 @@ from fastapi.templating import Jinja2Templates
from contextlib import asynccontextmanager
from sqlalchemy import select
from pathlib import Path
from .database import engine, async_session, Base
from .routers import auth, admin, cards
from .auth import get_current_user, hash_password
@@ -27,6 +28,9 @@ async def lifespan(_app: FastAPI):
role="admin",
))
await session.commit()
# Ensure image upload directory exists
Path("app/static/images").mkdir(parents=True, exist_ok=True)
yield
+29 -11
View File
@@ -9,20 +9,34 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
role = Column(String(20), nullable=False, default="user") # "admin" or "user"
role = Column(String(20), nullable=False, default="user")
created_at = Column(DateTime(timezone=True), server_default=func.now())
class Serie(Base):
__tablename__ = "series"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), unique=True, nullable=False) # "Sword & Shield"
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
sets = relationship("Set", back_populates="serie", cascade="all, delete-orphan",
order_by="Set.code")
class Set(Base):
__tablename__ = "sets"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), unique=True, nullable=False, index=True) # e.g. "CRI"
name = Column(String(200), nullable=False) # e.g. "Chaos Rising"
serie_id = Column(Integer, ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True)
code = Column(String(10), unique=True, nullable=False, index=True) # "EVS"
name = Column(String(200), nullable=False) # "Evolving Skies"
release_date = Column(String(20), nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
serie = relationship("Serie", back_populates="sets")
cards = relationship("Card", back_populates="set", cascade="all, delete-orphan",
order_by="Card.number")
@@ -32,17 +46,21 @@ class Card(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True)
number = Column(String(10), nullable=False) # e.g. "068"
name = Column(String(200), nullable=False) # e.g. "Garchomp EX"
type = Column(String(50), nullable=True) # e.g. "Colorless", "Fire"
rarity = Column(String(50), nullable=True) # e.g. "Rare", "Uncommon"
hp = Column(Integer, nullable=True)
stage = Column(String(50), nullable=True) # e.g. "Basic", "Stage 1", "Stage 2"
description = Column(Text, nullable=True)
image_url = Column(String(500), nullable=True)
number = Column(String(10), nullable=False) # "050"
name = Column(String(200), nullable=False) # "Raichu"
image_path = Column(String(500), nullable=True) # URL eller sti "images/.../050.jpg"
set = relationship("Set", back_populates="cards")
@property
def full_code(self):
return f"{self.set.code}-{self.number}"
@property
def image_url(self):
"""Returner URL'en for visning — absolut URL eller /static/ relativ sti."""
if not self.image_path:
return None
if self.image_path.startswith(("http://", "https://")):
return self.image_path
return f"/static/{self.image_path}"
+316 -93
View File
@@ -1,155 +1,378 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
import csv
import io
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from ..database import get_db
from ..models import Set, Card, User
from ..auth import require_admin
from ..models import User, Serie, Set, Card
from ..auth import require_admin, hash_password
router = APIRouter()
async def require_admin_html(request: Request) -> User:
"""Admin check from cookie-based auth (HTML pages)."""
user = request.state.user
if not user or user.role != "admin":
raise HTTPException(status_code=401, detail="Admin login required")
return user
templates = Jinja2Templates(directory="app/templates")
UPLOAD_DIR = Path("app/static/images")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
class SetForm(BaseModel):
# ─── Pydantic schemas ───────────────────────────────────────────────
class SerieCreate(BaseModel):
name: str
description: str | None = None
class SetCreate(BaseModel):
code: str
name: str
release_date: str | None = None
description: str | None = None
class CardForm(BaseModel):
class CardCreate(BaseModel):
number: str
name: str
type: str | None = None
rarity: str | None = None
hp: int | None = None
stage: str | None = None
description: str | None = None
image_url: str | None = None
image: str | None = None # URL
class NewUserForm(BaseModel):
username: str
password: str
role: str = "user"
# --- Admin pages ---
# ─── Admin HTML pages ───────────────────────────────────────────────
@router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, user: User = Depends(require_admin_html)):
return templates.TemplateResponse("admin/dashboard.html", {"request": request, "user": user})
async def admin_dashboard(request: Request, user: User = Depends(require_admin)):
return templates.TemplateResponse("admin/dashboard.html", {"request": request})
@router.get("/admin/series", response_class=HTMLResponse)
async def admin_series(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
result = await db.execute(select(Serie).options(selectinload(Serie.sets)).order_by(Serie.name))
series = result.scalars().all()
return templates.TemplateResponse("admin/series.html", {"request": request, "series": series})
@router.get("/admin/sets", response_class=HTMLResponse)
async def admin_sets(request: Request, user: User = Depends(require_admin_html)):
return templates.TemplateResponse("admin/sets.html", {"request": request, "user": user})
async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
result = await db.execute(
select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
)
sets = result.scalars().all()
series_result = await db.execute(select(Serie).order_by(Serie.name))
series = series_result.scalars().all()
return templates.TemplateResponse("admin/sets.html", {"request": request, "sets": sets, "series": series})
@router.get("/admin/cards/{set_code}", response_class=HTMLResponse)
async def admin_cards(request: Request, set_code: str, user: User = Depends(require_admin_html)):
return templates.TemplateResponse("admin/cards.html", {"request": request, "user": user, "set_code": set_code})
async def admin_cards_page(request: Request, set_code: str, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(
selectinload(Set.cards), selectinload(Set.serie)
)
)
s = result.scalar_one_or_none()
if not s:
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
return templates.TemplateResponse("admin/cards.html", {"request": request, "set": s})
# ─── Users (admin) ─────────────────────────────────────────────────
@router.post("/api/admin/users")
async def create_user(data: NewUserForm, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
exists = await db.execute(select(User).where(User.username == data.username))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Bruger findes allerede")
user = User(username=data.username, password_hash=hash_password(data.password), role=data.role)
db.add(user)
await db.commit()
return {"ok": True, "username": user.username, "role": user.role}
@router.get("/api/admin/users")
async def list_users(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
result = await db.execute(select(User).order_by(User.username))
return [{"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)}
for u in result.scalars().all()]
# --- CRUD: Sets ---
# ─── API: Series ────────────────────────────────────────────────────
@router.post("/api/admin/sets")
async def create_set(
data: SetForm,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
@router.post("/api/admin/series")
async def create_serie(data: SerieCreate, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Serie).where(Serie.name == data.name))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Serie findes allerede")
serie = Serie(name=data.name, description=data.description)
db.add(serie)
await db.commit()
return {"ok": True, "id": serie.id, "name": serie.name}
@router.get("/api/admin/series")
async def list_series(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
result = await db.execute(select(Serie).options(selectinload(Serie.sets)).order_by(Serie.name))
return [{"id": s.id, "name": s.name, "description": s.description or "",
"set_count": len(s.sets)} for s in result.scalars().all()]
@router.delete("/api/admin/series/{serie_id}")
async def delete_serie(serie_id: int, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Serie).where(Serie.id == serie_id))
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Serie ikke fundet")
await db.delete(s)
await db.commit()
return {"ok": True}
@router.post("/api/admin/series/import")
async def import_series(file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
content = await file.read()
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
created = 0
errors = []
for i, row in enumerate(reader, start=2):
name = row.get("name", "").strip()
if not name:
errors.append(f"Linje {i}: mangler navn")
continue
try:
exists = await db.execute(select(Serie).where(Serie.name == name))
if exists.scalar_one_or_none():
errors.append(f"Linje {i}: '{name}' findes allerede")
continue
serie = Serie(name=name, description=row.get("description", "").strip() or None)
db.add(serie)
created += 1
except Exception as e:
errors.append(f"Linje {i}: {str(e)}")
await db.commit()
return {"ok": True, "created": created, "errors": errors}
# ─── API: Sets ──────────────────────────────────────────────────────
@router.post("/api/admin/series/{serie_name}/sets")
async def create_set(serie_name: str, data: SetCreate, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Serie).where(Serie.name == serie_name))
serie = result.scalar_one_or_none()
if not serie:
raise HTTPException(status_code=404, detail="Serie ikke fundet")
exists = await db.execute(select(Set).where(Set.code == data.code.upper()))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Set '{data.code.upper()}' findes allerede")
s = Set(code=data.code.upper(), name=data.name, release_date=data.release_date, description=data.description)
raise HTTPException(status_code=400, detail="Set-kode findes allerede")
s = Set(serie_id=serie.id, code=data.code.upper(), name=data.name,
release_date=data.release_date, description=data.description)
db.add(s)
await db.commit()
await db.refresh(s)
return {"ok": True, "id": s.id, "code": s.code}
return {"ok": True, "id": s.id, "code": s.code, "name": s.name}
@router.get("/api/admin/series/{serie_name}/sets")
async def list_sets(serie_name: str, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(
select(Serie).where(Serie.name == serie_name).options(selectinload(Serie.sets))
)
serie = result.scalar_one_or_none()
if not serie:
raise HTTPException(status_code=404, detail="Serie ikke fundet")
return [{"id": s.id, "code": s.code, "name": s.name, "card_count": len(s.cards)}
for s in serie.sets]
@router.get("/api/admin/sets")
async def list_sets_admin(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
result = await db.execute(select(Set).order_by(Set.code))
async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
result = await db.execute(
select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
)
sets = result.scalars().all()
return [{"id": s.id, "code": s.code, "name": s.name, "release_date": s.release_date or ""} for s in sets]
return [{"id": s.id, "code": s.code, "name": s.name, "serie": s.serie.name,
"card_count": len(s.cards), "release_date": s.release_date or ""}
for s in sets]
@router.delete("/api/admin/sets/{set_id}")
async def delete_set(
set_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
s = await db.get(Set, set_id)
async def delete_set(set_id: int, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Set).where(Set.id == set_id))
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
await db.delete(s)
await db.commit()
return {"ok": True}
@router.post("/api/admin/sets/import")
async def import_sets(file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
content = await file.read()
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
created = 0
errors = []
for i, row in enumerate(reader, start=2):
code = row.get("code", "").strip().upper()
name = row.get("name", "").strip()
serie_name = row.get("serie", "").strip()
if not code or not name or not serie_name:
errors.append(f"Linje {i}: mangler kode, navn eller serie")
continue
try:
serie = await db.execute(select(Serie).where(Serie.name == serie_name))
serie = serie.scalar_one_or_none()
if not serie:
errors.append(f"Linje {i}: serie '{serie_name}' ikke fundet")
continue
exists = await db.execute(select(Set).where(Set.code == code))
if exists.scalar_one_or_none():
errors.append(f"Linje {i}: kode '{code}' findes allerede")
continue
s = Set(serie_id=serie.id, code=code, name=name,
release_date=row.get("release_date", "").strip() or None,
description=row.get("description", "").strip() or None)
db.add(s)
created += 1
except Exception as e:
errors.append(f"Linje {i}: {str(e)}")
await db.commit()
return {"ok": True, "created": created, "errors": errors}
# --- CRUD: Cards ---
# ─── API: Cards ─────────────────────────────────────────────────────
@router.post("/api/admin/sets/{set_code}/cards")
async def create_card(
set_code: str,
data: CardForm,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
result = await db.execute(select(Set).where(Set.code == set_code.upper()))
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail=f"Set '{set_code.upper()}' ikke fundet")
card = Card(
set_id=s.id, number=data.number, name=data.name,
type=data.type, rarity=data.rarity, hp=data.hp,
stage=data.stage, description=data.description, image_url=data.image_url,
)
db.add(card)
await db.commit()
await db.refresh(card)
return {"ok": True, "id": card.id, "full_code": card.full_code}
@router.get("/api/admin/sets/{set_code}/cards")
async def list_cards_admin(
set_code: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
async def create_card(set_code: str, data: CardCreate, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Set).where(Set.code == set_code.upper()))
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
return [
{"id": c.id, "number": c.number, "name": c.name, "type": c.type or "",
"rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code}
for c in s.cards
]
card = Card(set_id=s.id, number=data.number, name=data.name)
if data.image:
card.image_path = data.image
db.add(card)
await db.commit()
return {"ok": True, "id": card.id, "full_code": card.full_code, "name": card.name}
@router.get("/api/admin/sets/{set_code}/cards")
async def list_cards(set_code: str, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(
selectinload(Set.cards), selectinload(Set.serie)
)
)
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
return [{"id": c.id, "number": c.number, "name": c.name, "full_code": c.full_code,
"image_url": c.image_url} for c in s.cards]
@router.delete("/api/admin/cards/{card_id}")
async def delete_card(
card_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
card = await db.get(Card, card_id)
async def delete_card(card_id: int, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Card).where(Card.id == card_id))
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=404, detail="Kort ikke fundet")
# Slet billedfil hvis lokal
if card.image_path and not card.image_path.startswith(("http://", "https://")):
fp = Path("app/static") / card.image_path
if fp.exists():
fp.unlink()
await db.delete(card)
await db.commit()
return {"ok": True}
@router.post("/api/admin/cards/import")
async def import_cards(file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
content = await file.read()
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
created = 0
errors = []
for i, row in enumerate(reader, start=2):
set_code = row.get("set_code", "").strip().upper()
number = row.get("number", "").strip()
name = row.get("name", "").strip()
if not set_code or not number or not name:
errors.append(f"Linje {i}: mangler set_code, number eller name")
continue
try:
result = await db.execute(select(Set).where(Set.code == set_code))
s = result.scalar_one_or_none()
if not s:
errors.append(f"Linje {i}: set '{set_code}' ikke fundet")
continue
image = row.get("image", "").strip() or None
card = Card(set_id=s.id, number=number, name=name, image_path=image)
db.add(card)
created += 1
except Exception as e:
errors.append(f"Linje {i}: {str(e)}")
await db.commit()
return {"ok": True, "created": created, "errors": errors}
@router.post("/api/admin/cards/{card_id}/upload")
async def upload_card_image(card_id: int, file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(
select(Card).where(Card.id == card_id).options(
selectinload(Card.set).selectinload(Set.serie)
)
)
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=404, detail="Kort ikke fundet")
# Bestem filtype
ext = os.path.splitext(file.filename or "image.png")[1].lower()
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
raise HTTPException(status_code=400, detail="Kun .jpg, .png, .gif, .webp tilladt")
serie_name = card.set.serie.name.replace(" ", "_").replace("/", "_")
set_code = card.set.code
number = card.number
folder = UPLOAD_DIR / serie_name / set_code
folder.mkdir(parents=True, exist_ok=True)
filename = f"{number}{ext}"
filepath = folder / filename
content = await file.read()
filepath.write_bytes(content)
# Gem relativ sti
rel_path = f"images/{serie_name}/{set_code}/{filename}"
card.image_path = rel_path
await db.commit()
return {"ok": True, "image_url": f"/static/{rel_path}", "path": rel_path}
# ─── CSV Templates ──────────────────────────────────────────────────
@router.get("/api/admin/templates/series.csv", response_class=PlainTextResponse)
async def series_csv_template(_: User = Depends(require_admin)):
return "name,description\nSword & Shield,The Sword & Shield series\n"
@router.get("/api/admin/templates/sets.csv", response_class=PlainTextResponse)
async def sets_csv_template(_: User = Depends(require_admin)):
return "serie,code,name,release_date,description\nSword & Shield,EVS,Evolving Skies,2021-08-27,\n"
@router.get("/api/admin/templates/cards.csv", response_class=PlainTextResponse)
async def cards_csv_template(_: User = Depends(require_admin)):
return "set_code,number,name,image\nEVS,050,Raichu,\n"
+4 -39
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,7 +7,7 @@ from pydantic import BaseModel
from ..database import get_db
from ..models import User
from ..auth import verify_password, create_token, hash_password, require_admin
from ..auth import verify_password, create_token, hash_password
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -37,7 +37,7 @@ async def login(data: LoginForm, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=401, detail="Forkert brugernavn eller adgangskode")
token = create_token(user.username, user.role)
resp = RedirectResponse(url="/", status_code=302)
resp = JSONResponse(content={"ok": True, "redirect": "/"})
resp.set_cookie(
key="token", value=token, httponly=True, max_age=30 * 24 * 3600,
samesite="lax"
@@ -47,41 +47,6 @@ async def login(data: LoginForm, db: AsyncSession = Depends(get_db)):
@router.post("/api/logout")
async def logout():
resp = RedirectResponse(url="/login", status_code=302)
resp = JSONResponse(content={"ok": True, "redirect": "/login"})
resp.delete_cookie("token")
return resp
# --- Admin-only: user management ---
@router.post("/api/admin/users")
async def create_user(
data: CreateUserForm,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
exists = await db.execute(select(User).where(User.username == data.username))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Bruger findes allerede")
user = User(
username=data.username,
password_hash=hash_password(data.password),
role=data.role,
)
db.add(user)
await db.commit()
return {"ok": True, "username": user.username, "role": user.role}
@router.get("/api/admin/users")
async def list_users(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
result = await db.execute(select(User).order_by(User.username))
users = result.scalars().all()
return [
{"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)}
for u in users
]
+116 -59
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models import Set, Card
from ..models import Serie, Set, Card
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -14,14 +14,40 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse)
async def index(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code))
sets = result.scalars().all()
return templates.TemplateResponse("index.html", {"request": request, "sets": sets})
result = await db.execute(
select(Serie).options(selectinload(Serie.sets).selectinload(Set.cards)).order_by(Serie.name)
)
series = result.scalars().all()
return templates.TemplateResponse("index.html", {"request": request, "series": series})
@router.get("/series", response_class=HTMLResponse)
async def list_series(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Serie).options(selectinload(Serie.sets).selectinload(Set.cards)).order_by(Serie.name)
)
series = result.scalars().all()
return templates.TemplateResponse("series.html", {"request": request, "series": series})
@router.get("/series/{serie_name}", response_class=HTMLResponse)
async def serie_detail(request: Request, serie_name: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Serie).where(Serie.name == serie_name).options(
selectinload(Serie.sets).selectinload(Set.cards)
)
)
serie = result.scalar_one_or_none()
if not serie:
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
return templates.TemplateResponse("serie_detail.html", {"request": request, "serie": serie})
@router.get("/sets", response_class=HTMLResponse)
async def list_sets(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code))
result = await db.execute(
select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
)
sets = result.scalars().all()
return templates.TemplateResponse("sets.html", {"request": request, "sets": sets})
@@ -29,7 +55,9 @@ async def list_sets(request: Request, db: AsyncSession = Depends(get_db)):
@router.get("/sets/{set_code}", response_class=HTMLResponse)
async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.cards))
select(Set).where(Set.code == set_code.upper()).options(
selectinload(Set.cards), selectinload(Set.serie)
)
)
s = result.scalar_one_or_none()
if not s:
@@ -37,65 +65,61 @@ async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends
return templates.TemplateResponse("set_detail.html", {"request": request, "set": s})
@router.get("/card/{set_code}/{number}", response_class=HTMLResponse)
async def card_detail(request: Request, set_code: str, number: str,
db: AsyncSession = Depends(get_db)):
# Prøv med og uden leading zero (robust overfor "50" vs "050")
candidates = set([number, number.zfill(3), number.lstrip("0")])
for num in candidates:
result = await db.execute(
select(Card)
.join(Card.set)
.where(Set.code == set_code.upper(), Card.number == num)
.options(selectinload(Card.set).selectinload(Set.serie))
)
card = result.scalar_one_or_none()
if card:
return templates.TemplateResponse("card_detail.html", {"request": request, "card": card})
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
@router.get("/search", response_class=HTMLResponse)
async def search_page(request: Request):
return templates.TemplateResponse("search.html", {"request": request})
@router.get("/api/search")
async def search_cards(
q: str = Query(""),
db: AsyncSession = Depends(get_db),
):
"""Search cards by full code (e.g. CRI-068) or by name."""
q = q.strip()
# ─── JSON API ───────────────────────────────────────────────────────
# Try full-code search: CODE-NUMBER
if "-" in q:
parts = q.split("-", 1)
code_part = parts[0].strip().upper()
num_part = parts[1].strip().lstrip("0") # remove leading zeros
stmt = (
select(Card)
.join(Card.set)
.where(Set.code == code_part, Card.number == num_part)
.options(selectinload(Card.set))
@router.get("/api/series")
async def api_list_series(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Serie).options(selectinload(Serie.sets)).order_by(Serie.name)
)
return [{"id": s.id, "name": s.name, "description": s.description or "",
"set_count": len(s.sets)} for s in result.scalars().all()]
@router.get("/api/series/{serie_name}/sets")
async def api_series_sets(serie_name: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Serie).where(Serie.name == serie_name).options(
selectinload(Serie.sets).selectinload(Set.cards)
)
else:
# Search by name
stmt = (
select(Card)
.join(Card.set)
.where(Card.name.ilike(f"%{q}%"))
.options(selectinload(Card.set))
.limit(50)
)
result = await db.execute(stmt)
cards = result.scalars().all()
return [
{
"id": c.id,
"full_code": c.full_code,
"name": c.name,
"type": c.type or "",
"rarity": c.rarity or "",
"set_name": c.set.name,
}
for c in cards
]
)
serie = result.scalar_one_or_none()
if not serie:
return {"error": "Serie ikke fundet"}
return [{"code": s.code, "name": s.name, "card_count": len(s.cards)} for s in serie.sets]
@router.get("/api/sets")
async def api_list_sets(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code))
sets = result.scalars().all()
return [
{"code": s.code, "name": s.name, "release_date": s.release_date or "",
"card_count": len(s.cards)}
for s in sets
]
result = await db.execute(
select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
)
return [{"code": s.code, "name": s.name, "serie": s.serie.name,
"card_count": len(s.cards)} for s in result.scalars().all()]
@router.get("/api/sets/{set_code}/cards")
@@ -106,8 +130,41 @@ async def api_set_cards(set_code: str, db: AsyncSession = Depends(get_db)):
s = result.scalar_one_or_none()
if not s:
return []
return [
{"id": c.id, "number": c.number, "name": c.name, "type": c.type or "",
"rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code}
for c in s.cards
]
return [{"id": c.id, "number": c.number, "name": c.name,
"full_code": c.full_code, "image_url": c.image_url} for c in s.cards]
@router.get("/api/search")
async def search_cards(q: str = Query(""), db: AsyncSession = Depends(get_db)):
"""Search cards by full code (CRI-068) or by name."""
q = q.strip()
if not q:
return []
if "\u002D" in q or " " in q or "=" in q:
# Split på bindestreg, mellemrum eller =: "CRI-068" eller "CRI 068"
sep = "\u002D" if "\u002D" in q else (" " if " " in q else "=")
parts = q.split(sep, 1)
code_part = parts[0].strip().upper()
num_part = parts[1].strip()
candidates = set([num_part, num_part.zfill(3), num_part.lstrip("0")])
stmt = select(Card).join(Card.set).where(
Set.code == code_part, Card.number.in_(list(candidates))
).options(selectinload(Card.set).selectinload(Set.serie))
else:
stmt = (
select(Card)
.join(Card.set)
.where(Card.name.ilike(f"%{q}%"))
.options(selectinload(Card.set).selectinload(Set.serie))
.limit(50)
)
result = await db.execute(stmt)
cards = result.scalars().all()
return [{
"id": c.id, "full_code": c.full_code, "name": c.name,
"set_name": c.set.name, "set_code": c.set.code,
"serie_name": c.set.serie.name,
"image_url": c.image_url,
} for c in cards]
+124 -88
View File
@@ -1,110 +1,146 @@
{% extends "base.html" %}
{% block title %}Admin — Kort i {{ set_code }}{% endblock %}
{% block title %}Admin — {{ set.code }} kort{% endblock %}
{% block content %}
<h1>🃏 Kort i <span class="tag">{{ set_code }}</span></h1>
<p style="color:var(--text-muted);margin-bottom:16px;">Tilføj eller fjern kort fra dette set.</p>
<a href="/admin/sets" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Sæt</a>
<h1 style="margin-top:12px;">
<span class="tag">{{ set.code }}</span>
{{ set.name }}
</h1>
<p style="color:var(--text-muted);margin-bottom:16px;">{{ set.serie.name }}</p>
<div class="card" style="margin-bottom:24px;">
<h2>Tilføj nyt kort</h2>
<form id="create-card-form" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;">
<input type="text" name="number" placeholder="Nummer (f.eks. 068)" required>
<input type="text" name="name" placeholder="Navn (f.eks. Garchomp EX)" required>
<input type="text" name="type" placeholder="Type (f.eks. Colorless)">
<input type="text" name="rarity" placeholder="Sjældenhed (f.eks. Rare)">
<input type="number" name="hp" placeholder="HP">
<input type="text" name="stage" placeholder="Stage (f.eks. Basic)">
<input type="text" name="image_url" placeholder="Billede URL" style="grid-column:span 2;">
<textarea name="description" placeholder="Beskrivelse" rows="2" style="grid-column:span 2;"></textarea>
<button type="submit" class="btn btn-primary" style="grid-column:span 2;">Tilføj kort</button>
</form>
<p id="card-msg" style="font-size:0.85rem;margin-top:8px;"></p>
<div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<div class="card" style="flex:1;min-width:280px;">
<h3>Opret kort</h3>
<form onsubmit="createCard(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<input type="text" id="card-number" placeholder="Nummer (f.eks. 050)" required>
<input type="text" id="card-name" placeholder="Navn (f.eks. Raichu)" required>
<input type="text" id="card-image" placeholder="Billede URL (valgfri)">
<button class="btn btn-primary">Opret</button>
</form>
</div>
<div class="card" style="flex:1;min-width:280px;">
<h3>CSV-import</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin:8px 0;">
Kolonner: <code>set_code,number,name,image</code>
<br><a href="/api/admin/templates/cards.csv" style="color:var(--accent);">Download skabelon</a>
</p>
<form onsubmit="importCards(event)" style="display:flex;flex-direction:column;gap:10px;">
<input type="file" id="cards-csv" accept=".csv" required>
<button class="btn btn-primary">Importer</button>
</form>
<div id="cards-import-result" style="margin-top:8px;"></div>
</div>
</div>
<h2>Eksisterende kort</h2>
<div id="card-list">Indlæser...</div>
<a href="/admin/sets" class="btn btn-outline" style="margin-top:16px;">← Tilbage til serier</a>
<h2>Kort i {{ set.code }}</h2>
<table>
<thead>
<tr><th>#</th><th>Navn</th><th>Kode</th><th>Billede</th><th>Upload</th><th>Handling</th></tr>
</thead>
<tbody>
{% for card in set.cards %}
<tr id="card-{{ card.id }}">
<td style="font-weight:600;">{{ card.number }}</td>
<td><a href="/card/{{ set.code }}/{{ card.number }}" style="color:var(--text);">{{ card.name }}</a></td>
<td><span class="tag">{{ card.full_code }}</span></td>
<td>
{% if card.image_url %}
<a href="{{ card.image_url }}" target="_blank">
<img src="{{ card.image_url }}" alt="{{ card.name }}"
style="height:40px;border-radius:4px;">
</a>
{% else %}
<span style="color:var(--text-muted);font-size:0.8rem;"></span>
{% endif %}
</td>
<td>
<form onsubmit="uploadImage(event, {{ card.id }})" style="display:flex;gap:6px;align-items:center;">
<input type="file" accept="image/*" style="width:auto;font-size:0.8rem;padding:4px;">
<button class="btn btn-outline btn-sm"></button>
</form>
</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteCard({{ card.id }})">Slet</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if set.cards|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);margin-top:16px;">
<p>Ingen kort i {{ set.code }} endnu.</p>
</div>
{% endif %}
<script>
const SET_CODE = '{{ set_code }}';
async function loadCards() {
try {
const res = await fetch('/api/admin/sets/' + SET_CODE + '/cards');
const cards = await res.json();
const container = document.getElementById('card-list');
if (cards.length === 0) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen kort i dette set endnu.</p></div>';
return;
}
let html = '<div style="overflow-x:auto;"><table><thead><tr><th>#</th><th>Kode</th><th>Navn</th><th>Type</th><th>Sjældenhed</th><th style="width:60px;"></th></tr></thead><tbody>';
cards.forEach(c => {
html += '<tr>' +
'<td>' + c.number + '</td>' +
'<td><span class="tag">' + c.full_code + '</span></td>' +
'<td>' + c.name + '</td>' +
'<td>' + (c.type || '—') + '</td>' +
'<td>' + (c.rarity || '—') + '</td>' +
'<td><button class="btn btn-danger btn-sm" onclick="deleteCard(' + c.id + ')">✕</button></td>' +
'</tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
} catch(e) {
document.getElementById('card-list').innerHTML = '<p style="color:var(--accent);">Kunne ikke indlæse kort</p>';
async function createCard(e) {
e.preventDefault();
const number = document.getElementById('card-number').value.trim();
const name = document.getElementById('card-name').value.trim();
const image = document.getElementById('card-image').value.trim() || null;
if (!number || !name) return;
const res = await fetch('/api/admin/sets/{{ set.code }}/cards', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number, name, image})
});
const data = await res.json();
if (data.ok) {
location.reload();
} else {
alert(data.detail || 'Fejl');
}
}
async function deleteCard(id) {
if (!confirm('Slet dette kort?')) return;
try {
const res = await fetch('/api/admin/cards/' + id, { method: 'DELETE' });
if (res.ok) {
loadCards();
} else {
alert('Kunne ikke slette');
}
} catch(e) {
const res = await fetch('/api/admin/cards/' + id, {method: 'DELETE'});
const data = await res.json();
if (data.ok) {
document.getElementById('card-' + id).remove();
} else {
alert('Fejl ved sletning');
}
}
document.getElementById('create-card-form').addEventListener('submit', async (e) => {
async function importCards(e) {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('card-msg');
try {
const res = await fetch('/api/admin/sets/' + SET_CODE + '/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
number: form.number.value,
name: form.name.value,
type: form.type.value || null,
rarity: form.rarity.value || null,
hp: form.hp.value ? parseInt(form.hp.value) : null,
stage: form.stage.value || null,
description: form.description.value || null,
image_url: form.image_url.value || null,
}),
});
if (res.ok) {
form.reset();
msg.style.color = 'var(--text)';
msg.textContent = '✅ Kort tilføjet';
loadCards();
} else {
const err = await res.json();
msg.style.color = 'var(--accent)';
msg.textContent = '❌ ' + (err.detail || 'Fejl');
const file = document.getElementById('cards-csv').files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/admin/cards/import', {method: 'POST', body: form});
const data = await res.json();
const el = document.getElementById('cards-import-result');
if (data.ok) {
el.innerHTML = '<p style="color:green;">✓ ' + data.created + ' kort oprettet</p>';
if (data.errors.length) {
el.innerHTML += '<p style="color:var(--accent);">' + data.errors.join('<br>') + '</p>';
}
} catch(e) {
msg.style.color = 'var(--accent)';
msg.textContent = '❌ Kunne ikke forbinde';
setTimeout(() => location.reload(), 1500);
} else {
el.innerHTML = '<p style="color:var(--accent);">Fejl ved import</p>';
}
});
}
loadCards();
async function uploadImage(e, cardId) {
e.preventDefault();
const file = e.target.querySelector('input[type="file"]').files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/admin/cards/' + cardId + '/upload', {
method: 'POST',
body: form
});
const data = await res.json();
if (data.ok) {
location.reload();
} else {
alert(data.detail || 'Fejl ved upload');
}
}
</script>
{% endblock %}
+20 -85
View File
@@ -1,95 +1,30 @@
{% extends "base.html" %}
{% block title %}Admin panel{% endblock %}
{% block title %}Admin{% endblock %}
{% block content %}
<h1>⚙️ Admin panel</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">Velkommen, {{ user.username }}!</p>
<h1>🔧 Adminpanel</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">Velkommen, {{ request.state.user.username }}!</p>
<div class="card-grid">
<a href="/admin/sets" style="text-decoration:none;color:var(--text);">
<div class="card" style="text-align:center;padding:32px;">
<div style="font-size:2rem;margin-bottom:8px;">📦</div>
<h3>Administrér serier</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Opret, redigér og slet serier/sets</p>
<a href="/admin/series" class="card-set">
<div class="card" style="text-align:center;">
<p style="font-size:2rem;margin-bottom:8px;">📦</p>
<h3>Serier</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Administrér serier</p>
</div>
</a>
<a href="/search" style="text-decoration:none;color:var(--text);">
<div class="card" style="text-align:center;padding:32px;">
<div style="font-size:2rem;margin-bottom:8px;">🔍</div>
<h3>Se kort</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Søg og gennemse alle kort</p>
<a href="/admin/sets" class="card-set">
<div class="card" style="text-align:center;">
<p style="font-size:2rem;margin-bottom:8px;">📁</p>
<h3>Sæt</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Administrér sæt</p>
</div>
</a>
<a href="/search" class="card-set">
<div class="card" style="text-align:center;">
<p style="font-size:2rem;margin-bottom:8px;">🔍</p>
<h3>Søg</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Søg efter kort</p>
</div>
</a>
</div>
<div class="card" style="margin-top:24px;">
<h2>👥 Brugere</h2>
<table>
<thead>
<tr><th>Brugernavn</th><th>Rolle</th><th>Oprettet</th></tr>
</thead>
<tbody id="user-list">
<tr><td colspan="3">Indlæser...</td></tr>
</tbody>
</table>
<div style="margin-top:16px;">
<h3>Opret ny bruger</h3>
<form id="create-user-form" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;">
<input type="text" name="username" placeholder="Brugernavn" required style="max-width:200px;">
<input type="password" name="password" placeholder="Adgangskode" required style="max-width:200px;">
<select name="role" style="max-width:120px;">
<option value="user">Bruger</option>
<option value="admin">Admin</option>
</select>
<button type="submit" class="btn btn-primary">Opret</button>
</form>
<p id="user-msg" style="font-size:0.85rem;margin-top:8px;"></p>
</div>
</div>
<script>
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
const users = await res.json();
const tbody = document.getElementById('user-list');
tbody.innerHTML = users.map(u =>
'<tr><td>' + u.username + '</td><td><span class="tag">' + u.role + '</span></td><td>' + (u.created_at || '') + '</td></tr>'
).join('');
} catch(e) {
document.getElementById('user-list').innerHTML = '<tr><td colspan="3">Kunne ikke indlæse brugere</td></tr>';
}
}
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('user-msg');
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
role: form.role.value,
}),
});
if (res.ok) {
form.reset();
msg.style.color = 'var(--text)';
msg.textContent = '✅ Bruger oprettet';
loadUsers();
} else {
const err = await res.json();
msg.style.color = 'var(--accent)';
msg.textContent = '❌ ' + (err.detail || 'Fejl');
}
} catch(e) {
msg.style.color = 'var(--accent)';
msg.textContent = '❌ Kunne ikke forbinde';
}
});
loadUsers();
</script>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Admin — Serier{% endblock %}
{% block content %}
<a href="/admin" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Admin</a>
<h1 style="margin-top:12px;">📦 Serier</h1>
<div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<div class="card" style="flex:1;min-width:280px;">
<h3>Opret serie</h3>
<form onsubmit="createSerie(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<input type="text" id="serie-name" placeholder="Navn (f.eks. Sword & Shield)" required>
<input type="text" id="serie-desc" placeholder="Beskrivelse (valgfri)">
<button class="btn btn-primary">Opret</button>
</form>
</div>
<div class="card" style="flex:1;min-width:280px;">
<h3>CSV-import</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin:8px 0;">
Kolonner: <code>name,description</code>
<br><a href="/api/admin/templates/series.csv" style="color:var(--accent);">Download skabelon</a>
</p>
<form onsubmit="importSeries(event)" style="display:flex;flex-direction:column;gap:10px;">
<input type="file" id="series-csv" accept=".csv" required>
<button class="btn btn-primary">Importer</button>
</form>
<div id="series-import-result" style="margin-top:8px;"></div>
</div>
</div>
<h2>Eksisterende serier</h2>
<table>
<thead>
<tr><th>Navn</th><th>Sæt</th><th>Handling</th></tr>
</thead>
<tbody id="series-list">
{% for s in series %}
<tr id="serie-{{ s.id }}">
<td><strong>{{ s.name }}</strong></td>
<td>{{ s.sets|length }}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteSerie({{ s.id }})">Slet</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if series|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);margin-top:16px;">
<p>Ingen serier endnu.</p>
</div>
{% endif %}
<script>
async function createSerie(e) {
e.preventDefault();
const name = document.getElementById('serie-name').value.trim();
const desc = document.getElementById('serie-desc').value.trim();
if (!name) return;
const res = await fetch('/api/admin/series', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, description: desc || null})
});
const data = await res.json();
if (data.ok) {
location.reload();
} else {
alert(data.detail || 'Fejl');
}
}
async function deleteSerie(id) {
if (!confirm('Slet denne serie og alle dens sæt og kort?')) return;
const res = await fetch('/api/admin/series/' + id, {method: 'DELETE'});
const data = await res.json();
if (data.ok) {
document.getElementById('serie-' + id).remove();
} else {
alert('Fejl ved sletning');
}
}
async function importSeries(e) {
e.preventDefault();
const file = document.getElementById('series-csv').files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/admin/series/import', {method: 'POST', body: form});
const data = await res.json();
const el = document.getElementById('series-import-result');
if (data.ok) {
el.innerHTML = '<p style="color:green;">✓ ' + data.created + ' serie(r) oprettet</p>';
if (data.errors.length) {
el.innerHTML += '<p style="color:var(--accent);">' + data.errors.join('<br>') + '</p>';
}
setTimeout(() => location.reload(), 1500);
} else {
el.innerHTML = '<p style="color:var(--accent);">Fejl ved import</p>';
}
}
</script>
{% endblock %}
+100 -76
View File
@@ -1,95 +1,119 @@
{% extends "base.html" %}
{% block title %}Admin — Serier{% endblock %}
{% block title %}Admin — Sæt{% endblock %}
{% block content %}
<h1>📦 Administrér serier</h1>
<p style="color:var(--text-muted);margin-bottom:16px;">Opret og slet Pokémon TCG serier/sets.</p>
<a href="/admin" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Admin</a>
<h1 style="margin-top:12px;">📁 Sæt</h1>
<div class="card" style="margin-bottom:24px;">
<h2>Opret ny serie</h2>
<form id="create-set-form" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;">
<input type="text" name="code" placeholder="Kode (f.eks. CRI)" required style="max-width:120px;text-transform:uppercase;">
<input type="text" name="name" placeholder="Navn (f.eks. Chaos Rising)" required style="max-width:280px;">
<input type="text" name="release_date" placeholder="Udgivelsesdato" style="max-width:160px;">
<button type="submit" class="btn btn-primary">Opret</button>
</form>
<p id="set-msg" style="font-size:0.85rem;margin-top:8px;"></p>
<div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<div class="card" style="flex:1;min-width:280px;">
<h3>Opret sæt</h3>
<form onsubmit="createSet(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<select id="set-serie" required>
<option value="">— Vælg serie —</option>
{% for s in series %}
<option value="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
<input type="text" id="set-code" placeholder="Kode (f.eks. EVS)" required>
<input type="text" id="set-name" placeholder="Navn (f.eks. Evolving Skies)" required>
<input type="date" id="set-date" placeholder="Udgivelsesdato">
<input type="text" id="set-desc" placeholder="Beskrivelse (valgfri)">
<button class="btn btn-primary">Opret</button>
</form>
</div>
<div class="card" style="flex:1;min-width:280px;">
<h3>CSV-import</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin:8px 0;">
Kolonner: <code>serie,code,name,release_date,description</code>
<br><a href="/api/admin/templates/sets.csv" style="color:var(--accent);">Download skabelon</a>
</p>
<form onsubmit="importSets(event)" style="display:flex;flex-direction:column;gap:10px;">
<input type="file" id="sets-csv" accept=".csv" required>
<button class="btn btn-primary">Importer</button>
</form>
<div id="sets-import-result" style="margin-top:8px;"></div>
</div>
</div>
<h2>Eksisterende serier</h2>
<div id="set-list"></div>
<h2>Eksisterende sæt</h2>
<table>
<thead>
<tr><th>Kode</th><th>Navn</th><th>Serie</th><th>Kort</th><th>Handling</th></tr>
</thead>
<tbody id="sets-list">
{% for s in sets %}
<tr id="set-{{ s.id }}">
<td><span class="tag">{{ s.code }}</span></td>
<td><a href="/admin/cards/{{ s.code }}" style="color:var(--text);">{{ s.name }}</a></td>
<td>{{ s.serie.name }}</td>
<td>{{ s.cards|length }}</td>
<td>
<a href="/admin/cards/{{ s.code }}" class="btn btn-outline btn-sm">Kort</a>
<button class="btn btn-danger btn-sm" onclick="deleteSet({{ s.id }})">Slet</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);margin-top:16px;">
<p>Ingen sæt endnu.</p>
</div>
{% endif %}
<script>
async function loadSets() {
try {
const res = await fetch('/api/admin/sets');
const sets = await res.json();
const container = document.getElementById('set-list');
if (sets.length === 0) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen serier endnu.</p></div>';
return;
}
let html = '<div style="overflow-x:auto;"><table><thead><tr><th>Kode</th><th>Navn</th><th>Udgivet</th><th style="width:100px;"></th></tr></thead><tbody>';
sets.forEach(s => {
html += '<tr>' +
'<td><span class="tag">' + s.code + '</span></td>' +
'<td><a href="/admin/cards/' + s.code + '" style="color:var(--text);">' + s.name + '</a></td>' +
'<td>' + (s.release_date || '—') + '</td>' +
'<td><button class="btn btn-danger btn-sm" onclick="deleteSet(' + s.id + ', \'' + s.code + '\')">Slet</button></td>' +
'</tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
} catch(e) {
document.getElementById('set-list').innerHTML = '<p style="color:var(--accent);">Kunne ikke indlæse serier</p>';
async function createSet(e) {
e.preventDefault();
const serie = document.getElementById('set-serie').value;
const code = document.getElementById('set-code').value.trim().toUpperCase();
const name = document.getElementById('set-name').value.trim();
const date = document.getElementById('set-date').value || null;
const desc = document.getElementById('set-desc').value.trim() || null;
if (!serie || !code || !name) return;
const res = await fetch('/api/admin/series/' + encodeURIComponent(serie) + '/sets', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code, name, release_date: date, description: desc})
});
const data = await res.json();
if (data.ok) {
location.reload();
} else {
alert(data.detail || 'Fejl');
}
}
async function deleteSet(id, code) {
if (!confirm('Slet serien ' + code + '? Alle kort i serien slettes også.')) return;
try {
const res = await fetch('/api/admin/sets/' + id, { method: 'DELETE' });
if (res.ok) {
loadSets();
} else {
alert('Kunne ikke slette');
}
} catch(e) {
async function deleteSet(id) {
if (!confirm('Slet dette sæt og alle dets kort?')) return;
const res = await fetch('/api/admin/sets/' + id, {method: 'DELETE'});
const data = await res.json();
if (data.ok) {
document.getElementById('set-' + id).remove();
} else {
alert('Fejl ved sletning');
}
}
document.getElementById('create-set-form').addEventListener('submit', async (e) => {
async function importSets(e) {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('set-msg');
try {
const res = await fetch('/api/admin/sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: form.code.value,
name: form.name.value,
release_date: form.release_date.value || null,
}),
});
if (res.ok) {
form.reset();
msg.style.color = 'var(--text)';
msg.textContent = '✅ Serie oprettet';
loadSets();
} else {
const err = await res.json();
msg.style.color = 'var(--accent)';
msg.textContent = '❌ ' + (err.detail || 'Fejl');
const file = document.getElementById('sets-csv').files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/admin/sets/import', {method: 'POST', body: form});
const data = await res.json();
const el = document.getElementById('sets-import-result');
if (data.ok) {
el.innerHTML = '<p style="color:green;">✓ ' + data.created + ' sæt oprettet</p>';
if (data.errors.length) {
el.innerHTML += '<p style="color:var(--accent);">' + data.errors.join('<br>') + '</p>';
}
} catch(e) {
msg.style.color = 'var(--accent)';
msg.textContent = '❌ Kunne ikke forbinde';
setTimeout(() => location.reload(), 1500);
} else {
el.innerHTML = '<p style="color:var(--accent);">Fejl ved import</p>';
}
});
loadSets();
}
</script>
{% endblock %}
+2 -1
View File
@@ -239,7 +239,8 @@
<nav>
<a href="/" class="logo">⚡ Pokedexter</a>
<a href="/">Hjem</a>
<a href="/sets">Serier</a>
<a href="/series">Serier</a>
<a href="/sets">Sæt</a>
<a href="/search">Søg</a>
{% if request.state.user and request.state.user.role == "admin" %}
<a href="/admin">Admin</a>
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ card.full_code }} — {{ card.name }}{% endblock %}
{% block content %}
<a href="/sets/{{ card.set.code }}" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">
&larr; {{ card.set.serie.name }} — {{ card.set.name }}
</a>
<div style="display:flex;gap:32px;margin-top:20px;flex-wrap:wrap;">
<div style="flex:0 0 auto;max-width:400px;">
{% if card.image_url %}
<img src="{{ card.image_url }}" alt="{{ card.name }}"
style="width:100%;border-radius:12px;border:2px solid var(--border);box-shadow:0 4px 20px var(--shadow);">
{% else %}
<div class="card" style="width:320px;height:440px;display:flex;align-items:center;justify-content:center;
text-align:center;color:var(--text-muted);">
<div>
<p style="font-size:3rem;margin-bottom:12px;">🃏</p>
<p>Intet billede tilgængeligt</p>
</div>
</div>
{% endif %}
</div>
<div style="flex:1;min-width:200px;">
<h1 style="margin-bottom:4px;">
<span class="tag">{{ card.full_code }}</span>
</h1>
<h2 style="font-size:1.8rem;margin:0 0 20px 0;color:var(--text);">{{ card.name }}</h2>
<div class="card" style="margin-bottom:16px;">
<table style="margin:0;">
<tr>
<td style="font-weight:600;width:100px;">Sæt</td>
<td>
<a href="/sets/{{ card.set.code }}" style="color:var(--text);">
{{ card.set.code }} — {{ card.set.name }}
</a>
</td>
</tr>
<tr>
<td style="font-weight:600;">Serie</td>
<td>
<a href="/series/{{ card.set.serie.name|urlencode }}" style="color:var(--text);">
{{ card.set.serie.name }}
</a>
</td>
</tr>
<tr>
<td style="font-weight:600;">Nummer</td>
<td>{{ card.number }}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}
+13 -12
View File
@@ -1,27 +1,28 @@
{% extends "base.html" %}
{% block title %}Forside{% endblock %}
{% block content %}
<h1>📦 Alle serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} serie(r) i databasen</p>
<h1>⚡ Pokémon TCG serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ series|length }} serie(r) i databasen</p>
<div class="card-grid">
{% for s in sets %}
<a href="/sets/{{ s.code }}" class="card-set">
{% for serie in series %}
<a href="/series/{{ serie.name|urlencode }}" class="card-set">
<div class="card">
<span class="tag">{{ s.code }}</span>
<h3 style="margin-top:10px;">{{ s.name }}</h3>
{% if s.release_date %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">📅 {{ s.release_date }}</p>
<h3 style="margin-bottom:6px;">📦 {{ serie.name }}</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">
{{ serie.sets|length }} sæt
</p>
{% if serie.description %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:6px;">{{ serie.description }}</p>
{% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">{{ s.cards|length }} kort</p>
</div>
</a>
{% endfor %}
</div>
{% if sets|length == 0 %}
{% if series|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen serier endnu. Log ind som admin for at tilføje.</p>
<p>Ingen serier i databasen endnu.</p>
</div>
{% endif %}
{% endblock %}
{% endblock %}
+6 -7
View File
@@ -24,21 +24,20 @@
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = { username: form.username.value, password: form.password.value };
const formData = { username: form.username.value, password: form.password.value };
const errEl = document.getElementById('login-error');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
redirect: 'manual',
body: JSON.stringify(formData),
});
if (res.ok) {
window.location.href = '/';
const json = await res.json();
if (json.ok) {
window.location.href = json.redirect || '/';
} else {
const err = await res.json();
errEl.textContent = err.detail || 'Login fejlede';
errEl.textContent = json.detail || 'Login fejlede';
errEl.style.display = 'block';
}
} catch(e) {
+38 -22
View File
@@ -2,49 +2,65 @@
{% block title %}Søg{% endblock %}
{% block content %}
<h1>🔍 Søg efter kort</h1>
<p style="color:var(--text-muted);margin-bottom:16px;">Søg på kortkode (f.eks. <strong>CRI-068</strong>) eller kortnavn.</p>
<p style="color:var(--text-muted);margin-bottom:16px;">
Søg på kode (f.eks. <strong>CRI-068</strong>) eller kortnavn (f.eks. <strong>Pikachu</strong>)
</p>
<div class="search-box">
<input type="text" id="search-input" placeholder="CRI-068 eller kortnavn..." autofocus>
<button class="btn btn-primary" onclick="doSearch()">Søg</button>
<input type="text" id="search-input" placeholder="Søg på kortkode eller navn..."
onkeydown="if(event.key==='Enter') search()">
<button class="btn btn-primary" onclick="search()">Søg</button>
</div>
<div id="search-results"></div>
<div id="search-results">
<p style="color:var(--text-muted);text-align:center;padding:40px 0;">
Indtast en søgning for at finde kort.
</p>
</div>
<script>
const input = document.getElementById('search-input');
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
async function search() {
const q = document.getElementById('search-input').value.trim();
const el = document.getElementById('search-results');
async function doSearch() {
const q = input.value.trim();
const container = document.getElementById('search-results');
if (!q) { container.innerHTML = ''; return; }
if (!q) {
el.innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:40px 0;">Indtast en søgning.</p>';
return;
}
container.innerHTML = '<p style="color:var(--text-muted);">Søger...</p>';
el.innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:20px 0;">Søger...</p>';
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
const data = await res.json();
container.innerHTML = '';
if (data.length === 0) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen kort fundet for "<strong>' + q + '</strong>"</p></div>';
el.innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:40px 0;">Ingen resultater.</p>';
return;
}
let html = '<div class="card" style="padding:0;"><div style="padding:10px 14px;border-bottom:1px solid var(--border);font-size:0.85rem;color:var(--text-muted);">' + data.length + ' resultat(er)</div>';
let html = '<p style="margin-bottom:12px;">' + data.length + ' resultat(er)</p>';
data.forEach(c => {
const imgHtml = c.image_url
? '<img src="' + c.image_url + '" alt="' + c.name + '" style="height:50px;border-radius:4px;">'
: '<span style="color:var(--text-muted);font-size:0.8rem;">Intet billede</span>';
html += '<div class="result-row">' +
'<div><span class="tag">' + c.full_code + '</span> <strong>' + c.name + '</strong>' +
' <span style="color:var(--text-muted);font-size:0.85rem;">' + c.set_name + '</span></div>' +
'<div style="font-size:0.85rem;color:var(--text-muted);">' + c.type + ' · ' + c.rarity + '</div>' +
'</div>';
'<div>' +
'<strong>' + c.full_code + '</strong> ' + c.name +
'<br><span style="color:var(--text-muted);font-size:0.8rem;">' +
c.serie_name + ' &middot; ' + c.set_name +
'</span>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:10px;">' +
imgHtml +
'<a href="/card/' + c.set_code + '/' + c.full_code.split('-')[1] + '" class="btn btn-outline btn-sm">Se kort</a>' +
'</div>' +
'</div>';
});
html += '</div>';
container.innerHTML = html;
el.innerHTML = html;
} catch(e) {
container.innerHTML = '<p style="color:var(--accent);">Fejl: kunne ikke søge</p>';
el.innerHTML = '<p style="color:var(--accent);text-align:center;">Fejl ved søgning.</p>';
}
}
</script>
{% endblock %}
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}{{ serie.name }}{% endblock %}
{% block content %}
<a href="/series" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Tilbage til serier</a>
<h1 style="margin-top:12px;">📦 {{ serie.name }}</h1>
{% if serie.description %}
<p style="color:var(--text-muted);margin-bottom:16px;">{{ serie.description }}</p>
{% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:20px;">{{ serie.sets|length }} sæt</p>
<div class="card-grid">
{% for s in serie.sets %}
<a href="/sets/{{ s.code }}" class="card-set">
<div class="card">
<h3 style="margin-bottom:6px;">
<span class="tag">{{ s.code }}</span>
{{ s.name }}
</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">
{{ s.cards|length }} kort
</p>
{% if s.release_date %}
<p style="font-size:0.8rem;color:var(--text-muted);margin-top:4px;">
Udgivet: {{ s.release_date }}
</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if serie.sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen sæt i denne serie endnu.</p>
</div>
{% endif %}
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Serier{% endblock %}
{% block content %}
<h1>📦 Pokémon TCG serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ series|length }} serie(r)</p>
<div class="card-grid">
{% for serie in series %}
<a href="/series/{{ serie.name|urlencode }}" class="card-set">
<div class="card">
<h3 style="margin-bottom:6px;">{{ serie.name }}</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">
{{ serie.sets|length }} sæt ·
{% set ns = namespace(total=0) %}
{% for s in serie.sets %}{% set ns.total = ns.total + s.cards|length %}{% endfor %}
{{ ns.total }} kort
</p>
{% if serie.description %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:6px;">{{ serie.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if series|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen serier endnu.</p>
</div>
{% endif %}
{% endblock %}
+48 -45
View File
@@ -1,54 +1,57 @@
{% extends "base.html" %}
{% block title %}{{ set.code }} — {{ set.name }}{% endblock %}
{% block content %}
<div style="margin-bottom:20px;">
<a href="/sets" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">← Tilbage til serier</a>
</div>
<a href="/series/{{ set.serie.name|urlencode }}" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">
&larr; {{ set.serie.name }}
</a>
<div class="card" style="margin-bottom:24px;">
<span class="tag" style="font-size:1rem;">{{ set.code }}</span>
<h1 style="margin-top:8px;">{{ set.name }}</h1>
{% if set.release_date %}
<p style="color:var(--text-muted);">📅 Udgivet: {{ set.release_date }}</p>
{% endif %}
{% if set.description %}
<p style="margin-top:8px;">{{ set.description }}</p>
{% endif %}
<p style="font-size:0.9rem;color:var(--text-muted);margin-top:8px;">{{ set.cards|length }} kort i dette set</p>
</div>
<h1 style="margin-top:12px;">
<span class="tag">{{ set.code }}</span>
{{ set.name }}
</h1>
<h2>Kort i {{ set.code }}</h2>
{% if set.release_date %}
<p style="color:var(--text-muted);margin-bottom:6px;">Udgivet: {{ set.release_date }}</p>
{% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:20px;">{{ set.cards|length }} kort</p>
{% if set.cards|length > 0 %}
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>#</th>
<th>Kode</th>
<th>Navn</th>
<th>Type</th>
<th>Sjældenhed</th>
{% if set.cards[0].hp %}<th>HP</th>{% endif %}
</tr>
</thead>
<tbody>
{% for card in set.cards %}
<tr>
<td>{{ card.number }}</td>
<td><span class="tag">{{ card.full_code }}</span></td>
<td>{{ card.name }}</td>
<td>{{ card.type or "—" }}</td>
<td>{{ card.rarity or "—" }}</td>
{% if card.hp %}<td>{{ card.hp }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<table>
<thead>
<tr>
<th>#</th>
<th>Navn</th>
<th>Kode</th>
<th>Billede</th>
</tr>
</thead>
<tbody>
{% for card in set.cards %}
<tr>
<td style="font-weight:600;">{{ card.number }}</td>
<td>
<a href="/card/{{ set.code }}/{{ card.number }}" style="color:var(--text);text-decoration:none;">
{{ card.name }}
</a>
</td>
<td><span class="tag">{{ card.full_code }}</span></td>
<td>
{% if card.image_url %}
<a href="/card/{{ set.code }}/{{ card.number }}">
<img src="{{ card.image_url }}" alt="{{ card.name }}"
style="height:60px;border-radius:6px;object-fit:contain;">
</a>
{% else %}
<span style="color:var(--text-muted);font-size:0.8rem;">Intet billede</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if set.cards|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen kort i dette set endnu.</p>
<p>Ingen kort i dette sæt endnu.</p>
</div>
{% endif %}
{% endblock %}
{% endblock %}
+15 -9
View File
@@ -1,19 +1,25 @@
{% extends "base.html" %}
{% block title %}Alle serier{% endblock %}
{% block title %}Alle sæt{% endblock %}
{% block content %}
<h1>📦 Pokémon TCG serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} serie(r) i databasen</p>
<h1>📦 Alle sæt</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} sæt i databasen</p>
<div class="card-grid">
{% for s in sets %}
<a href="/sets/{{ s.code }}" class="card-set">
<div class="card">
<span class="tag">{{ s.code }}</span>
<h3 style="margin-top:10px;">{{ s.name }}</h3>
<h3 style="margin-bottom:6px;">
<span class="tag">{{ s.code }}</span>
{{ s.name }}
</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">
{{ s.serie.name }} · {{ s.cards|length }} kort
</p>
{% if s.release_date %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">📅 {{ s.release_date }}</p>
<p style="font-size:0.8rem;color:var(--text-muted);margin-top:4px;">
Udgivet: {{ s.release_date }}
</p>
{% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">{{ s.cards|length }} kort</p>
</div>
</a>
{% endfor %}
@@ -21,7 +27,7 @@
{% if sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen serier i databasen endnu.</p>
<p>Ingen sæt endnu.</p>
</div>
{% endif %}
{% endblock %}
{% endblock %}
+6 -6
View File
@@ -7,7 +7,7 @@ services:
POSTGRES_USER: pokedexter
POSTGRES_PASSWORD: pokedexter_secret
volumes:
- pgdata:/var/lib/postgresql/data
- pokedexter_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pokedexter"]
interval: 5s
@@ -15,8 +15,8 @@ services:
retries: 5
backend:
image: pokedexter-backend
build: ./backend
image: pokedexter-backend:latest
# build: ./backend
container_name: pokedexter-backend
environment:
DATABASE_URL: "postgresql+asyncpg://pokedexter:pokedexter_secret@db/pokedexter"
@@ -26,8 +26,8 @@ services:
condition: service_healthy
nginx:
image: pokedexter-nginx
build: ./nginx
image: pokedexter-nginx:latest
# build: ./nginx
container_name: pokedexter-nginx
ports:
- "8080:80"
@@ -35,4 +35,4 @@ services:
- backend
volumes:
pgdata:
pokedexter_pgdata: