Fix: eagerload series.sets i admin og API (MissingGreenlet i async mode)
This commit is contained in:
@@ -4,3 +4,5 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
dev/
|
||||||
|
*.tar.gz
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
@@ -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
@@ -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]
|
||||||
@@ -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;">← 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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;">← 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 %}
|
||||||
@@ -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;">← 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
← {{ 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 + ' · ' + 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>
|
||||||
|
|||||||
@@ -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;">← 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
← {{ 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 %}
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user