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
+2
View File
@@ -4,3 +4,5 @@ __pycache__/
.env .env
.venv/ .venv/
venv/ venv/
dev/
*.tar.gz
+4
View File
@@ -6,6 +6,7 @@ from fastapi.templating import Jinja2Templates
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from sqlalchemy import select from sqlalchemy import select
from pathlib import Path
from .database import engine, async_session, Base from .database import engine, async_session, Base
from .routers import auth, admin, cards from .routers import auth, admin, cards
from .auth import get_current_user, hash_password from .auth import get_current_user, hash_password
@@ -27,6 +28,9 @@ async def lifespan(_app: FastAPI):
role="admin", role="admin",
)) ))
await session.commit() await session.commit()
# Ensure image upload directory exists
Path("app/static/images").mkdir(parents=True, exist_ok=True)
yield yield
+29 -11
View File
@@ -9,20 +9,34 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True) username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False) 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()) 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): class Set(Base):
__tablename__ = "sets" __tablename__ = "sets"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), unique=True, nullable=False, index=True) # e.g. "CRI" serie_id = Column(Integer, ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(200), nullable=False) # e.g. "Chaos Rising" 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) release_date = Column(String(20), nullable=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) 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", cards = relationship("Card", back_populates="set", cascade="all, delete-orphan",
order_by="Card.number") order_by="Card.number")
@@ -32,17 +46,21 @@ class Card(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True) set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True)
number = Column(String(10), nullable=False) # e.g. "068" number = Column(String(10), nullable=False) # "050"
name = Column(String(200), nullable=False) # e.g. "Garchomp EX" name = Column(String(200), nullable=False) # "Raichu"
type = Column(String(50), nullable=True) # e.g. "Colorless", "Fire" image_path = Column(String(500), nullable=True) # URL eller sti "images/.../050.jpg"
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)
set = relationship("Set", back_populates="cards") set = relationship("Set", back_populates="cards")
@property @property
def full_code(self): def full_code(self):
return f"{self.set.code}-{self.number}" 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 import csv
from fastapi.responses import HTMLResponse 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 fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from pydantic import BaseModel from pydantic import BaseModel
from ..database import get_db from ..database import get_db
from ..models import Set, Card, User from ..models import User, Serie, Set, Card
from ..auth import require_admin from ..auth import require_admin, hash_password
router = APIRouter() 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") 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 code: str
name: str name: str
release_date: str | None = None release_date: str | None = None
description: str | None = None description: str | None = None
class CardCreate(BaseModel):
class CardForm(BaseModel):
number: str number: str
name: str name: str
type: str | None = None image: str | None = None # URL
rarity: str | None = None
hp: int | None = None class NewUserForm(BaseModel):
stage: str | None = None username: str
description: str | None = None password: str
image_url: str | None = None role: str = "user"
# --- Admin pages --- # ─── Admin HTML pages ───────────────────────────────────────────────
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, user: User = Depends(require_admin_html)): async def admin_dashboard(request: Request, user: User = Depends(require_admin)):
return templates.TemplateResponse("admin/dashboard.html", {"request": request, "user": user}) 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) @router.get("/admin/sets", response_class=HTMLResponse)
async def admin_sets(request: Request, user: User = Depends(require_admin_html)): async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db),
return templates.TemplateResponse("admin/sets.html", {"request": request, "user": user}) 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) @router.get("/admin/cards/{set_code}", response_class=HTMLResponse)
async def admin_cards(request: Request, set_code: str, user: User = Depends(require_admin_html)): async def admin_cards_page(request: Request, set_code: str, db: AsyncSession = Depends(get_db),
return templates.TemplateResponse("admin/cards.html", {"request": request, "user": user, "set_code": set_code}) 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") @router.post("/api/admin/series")
async def create_set( async def create_serie(data: SerieCreate, db: AsyncSession = Depends(get_db),
data: SetForm, _: User = Depends(require_admin)):
db: AsyncSession = Depends(get_db), result = await db.execute(select(Serie).where(Serie.name == data.name))
_: User = Depends(require_admin), 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())) exists = await db.execute(select(Set).where(Set.code == data.code.upper()))
if exists.scalar_one_or_none(): if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Set '{data.code.upper()}' findes allerede") raise HTTPException(status_code=400, detail="Set-kode findes allerede")
s = Set(serie_id=serie.id, code=data.code.upper(), name=data.name,
s = Set(code=data.code.upper(), name=data.name, release_date=data.release_date, description=data.description) release_date=data.release_date, description=data.description)
db.add(s) db.add(s)
await db.commit() await db.commit()
await db.refresh(s) return {"ok": True, "id": s.id, "code": s.code, "name": s.name}
return {"ok": True, "id": s.id, "code": s.code}
@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") @router.get("/api/admin/sets")
async def list_sets_admin( async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
db: AsyncSession = Depends(get_db), result = await db.execute(
_: User = Depends(require_admin), select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
): )
result = await db.execute(select(Set).order_by(Set.code))
sets = result.scalars().all() 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}") @router.delete("/api/admin/sets/{set_id}")
async def delete_set( async def delete_set(set_id: int, db: AsyncSession = Depends(get_db),
set_id: int, _: User = Depends(require_admin)):
db: AsyncSession = Depends(get_db), result = await db.execute(select(Set).where(Set.id == set_id))
_: User = Depends(require_admin), s = result.scalar_one_or_none()
):
s = await db.get(Set, set_id)
if not s: if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet") raise HTTPException(status_code=404, detail="Set ikke fundet")
await db.delete(s) await db.delete(s)
await db.commit() await db.commit()
return {"ok": True} 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") @router.post("/api/admin/sets/{set_code}/cards")
async def create_card( async def create_card(set_code: str, data: CardCreate, db: AsyncSession = Depends(get_db),
set_code: str, _: User = Depends(require_admin)):
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),
):
result = await db.execute(select(Set).where(Set.code == set_code.upper())) result = await db.execute(select(Set).where(Set.code == set_code.upper()))
s = result.scalar_one_or_none() s = result.scalar_one_or_none()
if not s: if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet") raise HTTPException(status_code=404, detail="Set ikke fundet")
return [ card = Card(set_id=s.id, number=data.number, name=data.name)
{"id": c.id, "number": c.number, "name": c.name, "type": c.type or "", if data.image:
"rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code} card.image_path = data.image
for c in s.cards 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}") @router.delete("/api/admin/cards/{card_id}")
async def delete_card( async def delete_card(card_id: int, db: AsyncSession = Depends(get_db),
card_id: int, _: User = Depends(require_admin)):
db: AsyncSession = Depends(get_db), result = await db.execute(select(Card).where(Card.id == card_id))
_: User = Depends(require_admin), card = result.scalar_one_or_none()
):
card = await db.get(Card, card_id)
if not card: if not card:
raise HTTPException(status_code=404, detail="Kort ikke fundet") 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.delete(card)
await db.commit() await db.commit()
return {"ok": True} 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 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 fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,7 +7,7 @@ from pydantic import BaseModel
from ..database import get_db from ..database import get_db
from ..models import User 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() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") 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") raise HTTPException(status_code=401, detail="Forkert brugernavn eller adgangskode")
token = create_token(user.username, user.role) token = create_token(user.username, user.role)
resp = RedirectResponse(url="/", status_code=302) resp = JSONResponse(content={"ok": True, "redirect": "/"})
resp.set_cookie( resp.set_cookie(
key="token", value=token, httponly=True, max_age=30 * 24 * 3600, key="token", value=token, httponly=True, max_age=30 * 24 * 3600,
samesite="lax" samesite="lax"
@@ -47,41 +47,6 @@ async def login(data: LoginForm, db: AsyncSession = Depends(get_db)):
@router.post("/api/logout") @router.post("/api/logout")
async def logout(): async def logout():
resp = RedirectResponse(url="/login", status_code=302) resp = JSONResponse(content={"ok": True, "redirect": "/login"})
resp.delete_cookie("token") resp.delete_cookie("token")
return resp 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 sqlalchemy.orm import selectinload
from ..database import get_db from ..database import get_db
from ..models import Set, Card from ..models import Serie, Set, Card
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -14,14 +14,40 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def index(request: Request, db: AsyncSession = Depends(get_db)): async def index(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code)) result = await db.execute(
sets = result.scalars().all() select(Serie).options(selectinload(Serie.sets).selectinload(Set.cards)).order_by(Serie.name)
return templates.TemplateResponse("index.html", {"request": request, "sets": sets}) )
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) @router.get("/sets", response_class=HTMLResponse)
async def list_sets(request: Request, db: AsyncSession = Depends(get_db)): 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() sets = result.scalars().all()
return templates.TemplateResponse("sets.html", {"request": request, "sets": sets}) 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) @router.get("/sets/{set_code}", response_class=HTMLResponse)
async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends(get_db)): async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends(get_db)):
result = await db.execute( 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() s = result.scalar_one_or_none()
if not s: 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}) 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) @router.get("/search", response_class=HTMLResponse)
async def search_page(request: Request): async def search_page(request: Request):
return templates.TemplateResponse("search.html", {"request": request}) return templates.TemplateResponse("search.html", {"request": request})
@router.get("/api/search") # ─── JSON API ───────────────────────────────────────────────────────
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()
# Try full-code search: CODE-NUMBER @router.get("/api/series")
if "-" in q: async def api_list_series(db: AsyncSession = Depends(get_db)):
parts = q.split("-", 1) result = await db.execute(
code_part = parts[0].strip().upper() select(Serie).options(selectinload(Serie.sets)).order_by(Serie.name)
num_part = parts[1].strip().lstrip("0") # remove leading zeros )
stmt = ( return [{"id": s.id, "name": s.name, "description": s.description or "",
select(Card) "set_count": len(s.sets)} for s in result.scalars().all()]
.join(Card.set)
.where(Set.code == code_part, Card.number == num_part)
.options(selectinload(Card.set)) @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 serie = result.scalar_one_or_none()
stmt = ( if not serie:
select(Card) return {"error": "Serie ikke fundet"}
.join(Card.set) return [{"code": s.code, "name": s.name, "card_count": len(s.cards)} for s in serie.sets]
.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
]
@router.get("/api/sets") @router.get("/api/sets")
async def api_list_sets(db: AsyncSession = Depends(get_db)): async def api_list_sets(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code)) result = await db.execute(
sets = result.scalars().all() select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
return [ )
{"code": s.code, "name": s.name, "release_date": s.release_date or "", return [{"code": s.code, "name": s.name, "serie": s.serie.name,
"card_count": len(s.cards)} "card_count": len(s.cards)} for s in result.scalars().all()]
for s in sets
]
@router.get("/api/sets/{set_code}/cards") @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() s = result.scalar_one_or_none()
if not s: if not s:
return [] return []
return [ return [{"id": c.id, "number": c.number, "name": c.name,
{"id": c.id, "number": c.number, "name": c.name, "type": c.type or "", "full_code": c.full_code, "image_url": c.image_url} for c in s.cards]
"rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code}
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" %} {% extends "base.html" %}
{% block title %}Admin — Kort i {{ set_code }}{% endblock %} {% block title %}Admin — {{ set.code }} kort{% endblock %}
{% block content %} {% block content %}
<h1>🃏 Kort i <span class="tag">{{ set_code }}</span></h1> <a href="/admin/sets" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Sæt</a>
<p style="color:var(--text-muted);margin-bottom:16px;">Tilføj eller fjern kort fra dette set.</p> <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;"> <div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<h2>Tilføj nyt kort</h2> <div class="card" style="flex:1;min-width:280px;">
<form id="create-card-form" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;"> <h3>Opret kort</h3>
<input type="text" name="number" placeholder="Nummer (f.eks. 068)" required> <form onsubmit="createCard(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<input type="text" name="name" placeholder="Navn (f.eks. Garchomp EX)" required> <input type="text" id="card-number" placeholder="Nummer (f.eks. 050)" required>
<input type="text" name="type" placeholder="Type (f.eks. Colorless)"> <input type="text" id="card-name" placeholder="Navn (f.eks. Raichu)" required>
<input type="text" name="rarity" placeholder="Sjældenhed (f.eks. Rare)"> <input type="text" id="card-image" placeholder="Billede URL (valgfri)">
<input type="number" name="hp" placeholder="HP"> <button class="btn btn-primary">Opret</button>
<input type="text" name="stage" placeholder="Stage (f.eks. Basic)"> </form>
<input type="text" name="image_url" placeholder="Billede URL" style="grid-column:span 2;"> </div>
<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> <div class="card" style="flex:1;min-width:280px;">
</form> <h3>CSV-import</h3>
<p id="card-msg" style="font-size:0.85rem;margin-top:8px;"></p> <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> </div>
<h2>Eksisterende kort</h2> <h2>Kort i {{ set.code }}</h2>
<div id="card-list">Indlæser...</div> <table>
<a href="/admin/sets" class="btn btn-outline" style="margin-top:16px;">← Tilbage til serier</a> <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> <script>
const SET_CODE = '{{ set_code }}'; async function createCard(e) {
e.preventDefault();
async function loadCards() { const number = document.getElementById('card-number').value.trim();
try { const name = document.getElementById('card-name').value.trim();
const res = await fetch('/api/admin/sets/' + SET_CODE + '/cards'); const image = document.getElementById('card-image').value.trim() || null;
const cards = await res.json(); if (!number || !name) return;
const container = document.getElementById('card-list'); const res = await fetch('/api/admin/sets/{{ set.code }}/cards', {
method: 'POST',
if (cards.length === 0) { headers: {'Content-Type': 'application/json'},
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen kort i dette set endnu.</p></div>'; body: JSON.stringify({number, name, image})
return; });
} const data = await res.json();
if (data.ok) {
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>'; location.reload();
cards.forEach(c => { } else {
html += '<tr>' + alert(data.detail || 'Fejl');
'<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 deleteCard(id) { async function deleteCard(id) {
if (!confirm('Slet dette kort?')) return; if (!confirm('Slet dette kort?')) return;
try { const res = await fetch('/api/admin/cards/' + id, {method: 'DELETE'});
const res = await fetch('/api/admin/cards/' + id, { method: 'DELETE' }); const data = await res.json();
if (res.ok) { if (data.ok) {
loadCards(); document.getElementById('card-' + id).remove();
} else { } else {
alert('Kunne ikke slette');
}
} catch(e) {
alert('Fejl ved sletning'); alert('Fejl ved sletning');
} }
} }
document.getElementById('create-card-form').addEventListener('submit', async (e) => { async function importCards(e) {
e.preventDefault(); e.preventDefault();
const form = e.target; const file = document.getElementById('cards-csv').files[0];
const msg = document.getElementById('card-msg'); if (!file) return;
try { const form = new FormData();
const res = await fetch('/api/admin/sets/' + SET_CODE + '/cards', { form.append('file', file);
method: 'POST', const res = await fetch('/api/admin/cards/import', {method: 'POST', body: form});
headers: { 'Content-Type': 'application/json' }, const data = await res.json();
body: JSON.stringify({ const el = document.getElementById('cards-import-result');
number: form.number.value, if (data.ok) {
name: form.name.value, el.innerHTML = '<p style="color:green;">✓ ' + data.created + ' kort oprettet</p>';
type: form.type.value || null, if (data.errors.length) {
rarity: form.rarity.value || null, el.innerHTML += '<p style="color:var(--accent);">' + data.errors.join('<br>') + '</p>';
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');
} }
} catch(e) { setTimeout(() => location.reload(), 1500);
msg.style.color = 'var(--accent)'; } else {
msg.textContent = '❌ Kunne ikke forbinde'; 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> </script>
{% endblock %} {% endblock %}
+20 -85
View File
@@ -1,95 +1,30 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Admin panel{% endblock %} {% block title %}Admin{% endblock %}
{% block content %} {% block content %}
<h1>⚙️ Admin panel</h1> <h1>🔧 Adminpanel</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">Velkommen, {{ user.username }}!</p> <p style="color:var(--text-muted);margin-bottom:24px;">Velkommen, {{ request.state.user.username }}!</p>
<div class="card-grid"> <div class="card-grid">
<a href="/admin/sets" style="text-decoration:none;color:var(--text);"> <a href="/admin/series" class="card-set">
<div class="card" style="text-align:center;padding:32px;"> <div class="card" style="text-align:center;">
<div style="font-size:2rem;margin-bottom:8px;">📦</div> <p style="font-size:2rem;margin-bottom:8px;">📦</p>
<h3>Administrér serier</h3> <h3>Serier</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Opret, redigér og slet serier/sets</p> <p style="font-size:0.85rem;color:var(--text-muted);">Administrér serier</p>
</div> </div>
</a> </a>
<a href="/search" style="text-decoration:none;color:var(--text);"> <a href="/admin/sets" class="card-set">
<div class="card" style="text-align:center;padding:32px;"> <div class="card" style="text-align:center;">
<div style="font-size:2rem;margin-bottom:8px;">🔍</div> <p style="font-size:2rem;margin-bottom:8px;">📁</p>
<h3>Se kort</h3> <h3>Sæt</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Søg og gennemse alle kort</p> <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> </div>
</a> </a>
</div> </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 %} {% 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" %} {% extends "base.html" %}
{% block title %}Admin — Serier{% endblock %} {% block title %}Admin — Sæt{% endblock %}
{% block content %} {% block content %}
<h1>📦 Administrér serier</h1> <a href="/admin" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Admin</a>
<p style="color:var(--text-muted);margin-bottom:16px;">Opret og slet Pokémon TCG serier/sets.</p> <h1 style="margin-top:12px;">📁 Sæt</h1>
<div class="card" style="margin-bottom:24px;"> <div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<h2>Opret ny serie</h2> <div class="card" style="flex:1;min-width:280px;">
<form id="create-set-form" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;"> <h3>Opret sæt</h3>
<input type="text" name="code" placeholder="Kode (f.eks. CRI)" required style="max-width:120px;text-transform:uppercase;"> <form onsubmit="createSet(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<input type="text" name="name" placeholder="Navn (f.eks. Chaos Rising)" required style="max-width:280px;"> <select id="set-serie" required>
<input type="text" name="release_date" placeholder="Udgivelsesdato" style="max-width:160px;"> <option value="">— Vælg serie —</option>
<button type="submit" class="btn btn-primary">Opret</button> {% for s in series %}
</form> <option value="{{ s.name }}">{{ s.name }}</option>
<p id="set-msg" style="font-size:0.85rem;margin-top:8px;"></p> {% 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> </div>
<h2>Eksisterende serier</h2> <h2>Eksisterende sæt</h2>
<div id="set-list"></div> <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> <script>
async function loadSets() { async function createSet(e) {
try { e.preventDefault();
const res = await fetch('/api/admin/sets'); const serie = document.getElementById('set-serie').value;
const sets = await res.json(); const code = document.getElementById('set-code').value.trim().toUpperCase();
const container = document.getElementById('set-list'); const name = document.getElementById('set-name').value.trim();
const date = document.getElementById('set-date').value || null;
if (sets.length === 0) { const desc = document.getElementById('set-desc').value.trim() || null;
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen serier endnu.</p></div>'; if (!serie || !code || !name) return;
return; const res = await fetch('/api/admin/series/' + encodeURIComponent(serie) + '/sets', {
} method: 'POST',
headers: {'Content-Type': 'application/json'},
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>'; body: JSON.stringify({code, name, release_date: date, description: desc})
sets.forEach(s => { });
html += '<tr>' + const data = await res.json();
'<td><span class="tag">' + s.code + '</span></td>' + if (data.ok) {
'<td><a href="/admin/cards/' + s.code + '" style="color:var(--text);">' + s.name + '</a></td>' + location.reload();
'<td>' + (s.release_date || '—') + '</td>' + } else {
'<td><button class="btn btn-danger btn-sm" onclick="deleteSet(' + s.id + ', \'' + s.code + '\')">Slet</button></td>' + alert(data.detail || 'Fejl');
'</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 deleteSet(id, code) { async function deleteSet(id) {
if (!confirm('Slet serien ' + code + '? Alle kort i serien slettes også.')) return; if (!confirm('Slet dette sæt og alle dets kort?')) return;
try { const res = await fetch('/api/admin/sets/' + id, {method: 'DELETE'});
const res = await fetch('/api/admin/sets/' + id, { method: 'DELETE' }); const data = await res.json();
if (res.ok) { if (data.ok) {
loadSets(); document.getElementById('set-' + id).remove();
} else { } else {
alert('Kunne ikke slette');
}
} catch(e) {
alert('Fejl ved sletning'); alert('Fejl ved sletning');
} }
} }
document.getElementById('create-set-form').addEventListener('submit', async (e) => { async function importSets(e) {
e.preventDefault(); e.preventDefault();
const form = e.target; const file = document.getElementById('sets-csv').files[0];
const msg = document.getElementById('set-msg'); if (!file) return;
try { const form = new FormData();
const res = await fetch('/api/admin/sets', { form.append('file', file);
method: 'POST', const res = await fetch('/api/admin/sets/import', {method: 'POST', body: form});
headers: { 'Content-Type': 'application/json' }, const data = await res.json();
body: JSON.stringify({ const el = document.getElementById('sets-import-result');
code: form.code.value, if (data.ok) {
name: form.name.value, el.innerHTML = '<p style="color:green;">✓ ' + data.created + ' sæt oprettet</p>';
release_date: form.release_date.value || null, if (data.errors.length) {
}), el.innerHTML += '<p style="color:var(--accent);">' + data.errors.join('<br>') + '</p>';
});
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');
} }
} catch(e) { setTimeout(() => location.reload(), 1500);
msg.style.color = 'var(--accent)'; } else {
msg.textContent = '❌ Kunne ikke forbinde'; el.innerHTML = '<p style="color:var(--accent);">Fejl ved import</p>';
} }
}); }
loadSets();
</script> </script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -239,7 +239,8 @@
<nav> <nav>
<a href="/" class="logo">⚡ Pokedexter</a> <a href="/" class="logo">⚡ Pokedexter</a>
<a href="/">Hjem</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> <a href="/search">Søg</a>
{% if request.state.user and request.state.user.role == "admin" %} {% if request.state.user and request.state.user.role == "admin" %}
<a href="/admin">Admin</a> <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 %}
+12 -11
View File
@@ -1,27 +1,28 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Forside{% endblock %} {% block title %}Forside{% endblock %}
{% block content %} {% block content %}
<h1>📦 Alle serier</h1> <h1>⚡ Pokémon TCG serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} serie(r) i databasen</p> <p style="color:var(--text-muted);margin-bottom:24px;">{{ series|length }} serie(r) i databasen</p>
<div class="card-grid"> <div class="card-grid">
{% for s in sets %} {% for serie in series %}
<a href="/sets/{{ s.code }}" class="card-set"> <a href="/series/{{ serie.name|urlencode }}" class="card-set">
<div class="card"> <div class="card">
<span class="tag">{{ s.code }}</span> <h3 style="margin-bottom:6px;">📦 {{ serie.name }}</h3>
<h3 style="margin-top:10px;">{{ s.name }}</h3> <p style="font-size:0.85rem;color:var(--text-muted);">
{% if s.release_date %} {{ serie.sets|length }} sæt
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">📅 {{ s.release_date }}</p> </p>
{% if serie.description %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:6px;">{{ serie.description }}</p>
{% endif %} {% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">{{ s.cards|length }} kort</p>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if sets|length == 0 %} {% if series|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);"> <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> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+6 -7
View File
@@ -24,21 +24,20 @@
document.getElementById('login-form').addEventListener('submit', async (e) => { document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const form = e.target; 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'); const errEl = document.getElementById('login-error');
try { try {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(formData),
redirect: 'manual',
}); });
if (res.ok) { const json = await res.json();
window.location.href = '/'; if (json.ok) {
window.location.href = json.redirect || '/';
} else { } else {
const err = await res.json(); errEl.textContent = json.detail || 'Login fejlede';
errEl.textContent = err.detail || 'Login fejlede';
errEl.style.display = 'block'; errEl.style.display = 'block';
} }
} catch(e) { } catch(e) {
+37 -21
View File
@@ -2,48 +2,64 @@
{% block title %}Søg{% endblock %} {% block title %}Søg{% endblock %}
{% block content %} {% block content %}
<h1>🔍 Søg efter kort</h1> <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"> <div class="search-box">
<input type="text" id="search-input" placeholder="CRI-068 eller kortnavn..." autofocus> <input type="text" id="search-input" placeholder="Søg på kortkode eller navn..."
<button class="btn btn-primary" onclick="doSearch()">Søg</button> onkeydown="if(event.key==='Enter') search()">
<button class="btn btn-primary" onclick="search()">Søg</button>
</div> </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> <script>
const input = document.getElementById('search-input'); async function search() {
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); }); const q = document.getElementById('search-input').value.trim();
const el = document.getElementById('search-results');
async function doSearch() { if (!q) {
const q = input.value.trim(); el.innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:40px 0;">Indtast en søgning.</p>';
const container = document.getElementById('search-results'); return;
if (!q) { container.innerHTML = ''; 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 { try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q)); const res = await fetch('/api/search?q=' + encodeURIComponent(q));
const data = await res.json(); const data = await res.json();
container.innerHTML = '';
if (data.length === 0) { 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; 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 => { 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">' + html += '<div class="result-row">' +
'<div><span class="tag">' + c.full_code + '</span> <strong>' + c.name + '</strong>' + '<div>' +
' <span style="color:var(--text-muted);font-size:0.85rem;">' + c.set_name + '</span></div>' + '<strong>' + c.full_code + '</strong> ' + c.name +
'<div style="font-size:0.85rem;color:var(--text-muted);">' + c.type + ' · ' + c.rarity + '</div>' + '<br><span style="color:var(--text-muted);font-size:0.8rem;">' +
'</div>'; 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>'; el.innerHTML = html;
container.innerHTML = html;
} catch(e) { } 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> </script>
+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 %}
+47 -44
View File
@@ -1,54 +1,57 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ set.code }} — {{ set.name }}{% endblock %} {% block title %}{{ set.code }} — {{ set.name }}{% endblock %}
{% block content %} {% block content %}
<div style="margin-bottom:20px;"> <a href="/series/{{ set.serie.name|urlencode }}" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">
<a href="/sets" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">← Tilbage til serier</a> &larr; {{ set.serie.name }}
</div> </a>
<div class="card" style="margin-bottom:24px;"> <h1 style="margin-top:12px;">
<span class="tag" style="font-size:1rem;">{{ set.code }}</span> <span class="tag">{{ set.code }}</span>
<h1 style="margin-top:8px;">{{ set.name }}</h1> {{ set.name }}
{% if set.release_date %} </h1>
<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>
<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 %} <table>
<div style="overflow-x:auto;"> <thead>
<table> <tr>
<thead> <th>#</th>
<tr> <th>Navn</th>
<th>#</th> <th>Kode</th>
<th>Kode</th> <th>Billede</th>
<th>Navn</th> </tr>
<th>Type</th> </thead>
<th>Sjældenhed</th> <tbody>
{% if set.cards[0].hp %}<th>HP</th>{% endif %} {% for card in set.cards %}
</tr> <tr>
</thead> <td style="font-weight:600;">{{ card.number }}</td>
<tbody> <td>
{% for card in set.cards %} <a href="/card/{{ set.code }}/{{ card.number }}" style="color:var(--text);text-decoration:none;">
<tr> {{ card.name }}
<td>{{ card.number }}</td> </a>
<td><span class="tag">{{ card.full_code }}</span></td> </td>
<td>{{ card.name }}</td> <td><span class="tag">{{ card.full_code }}</span></td>
<td>{{ card.type or "—" }}</td> <td>
<td>{{ card.rarity or "—" }}</td> {% if card.image_url %}
{% if card.hp %}<td>{{ card.hp }}</td>{% endif %} <a href="/card/{{ set.code }}/{{ card.number }}">
</tr> <img src="{{ card.image_url }}" alt="{{ card.name }}"
{% endfor %} style="height:60px;border-radius:6px;object-fit:contain;">
</tbody> </a>
</table> {% else %}
</div> <span style="color:var(--text-muted);font-size:0.8rem;">Intet billede</span>
{% else %} {% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if set.cards|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);"> <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> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+14 -8
View File
@@ -1,19 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Alle serier{% endblock %} {% block title %}Alle sæt{% endblock %}
{% block content %} {% block content %}
<h1>📦 Pokémon TCG serier</h1> <h1>📦 Alle sæt</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} serie(r) i databasen</p> <p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} sæt i databasen</p>
<div class="card-grid"> <div class="card-grid">
{% for s in sets %} {% for s in sets %}
<a href="/sets/{{ s.code }}" class="card-set"> <a href="/sets/{{ s.code }}" class="card-set">
<div class="card"> <div class="card">
<span class="tag">{{ s.code }}</span> <h3 style="margin-bottom:6px;">
<h3 style="margin-top:10px;">{{ s.name }}</h3> <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 %} {% 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 %} {% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">{{ s.cards|length }} kort</p>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
@@ -21,7 +27,7 @@
{% if sets|length == 0 %} {% if sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);"> <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> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+6 -6
View File
@@ -7,7 +7,7 @@ services:
POSTGRES_USER: pokedexter POSTGRES_USER: pokedexter
POSTGRES_PASSWORD: pokedexter_secret POSTGRES_PASSWORD: pokedexter_secret
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pokedexter_pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U pokedexter"] test: ["CMD-SHELL", "pg_isready -U pokedexter"]
interval: 5s interval: 5s
@@ -15,8 +15,8 @@ services:
retries: 5 retries: 5
backend: backend:
image: pokedexter-backend image: pokedexter-backend:latest
build: ./backend # build: ./backend
container_name: pokedexter-backend container_name: pokedexter-backend
environment: environment:
DATABASE_URL: "postgresql+asyncpg://pokedexter:pokedexter_secret@db/pokedexter" DATABASE_URL: "postgresql+asyncpg://pokedexter:pokedexter_secret@db/pokedexter"
@@ -26,8 +26,8 @@ services:
condition: service_healthy condition: service_healthy
nginx: nginx:
image: pokedexter-nginx image: pokedexter-nginx:latest
build: ./nginx # build: ./nginx
container_name: pokedexter-nginx container_name: pokedexter-nginx
ports: ports:
- "8080:80" - "8080:80"
@@ -35,4 +35,4 @@ services:
- backend - backend
volumes: volumes:
pgdata: pokedexter_pgdata: