From c0952a3bd3e8574f9b2ed9b6d3f85f24d6f38500 Mon Sep 17 00:00:00 2001 From: GlennIgen Date: Wed, 1 Jul 2026 21:10:06 +0200 Subject: [PATCH] Initial: Pokedexter TCG-Collector webapp --- .gitignore | 6 + README.md | 245 ++++++++++++++++++ backend/Dockerfile | 10 + backend/app/__init__.py | 0 backend/app/auth.py | 67 +++++ backend/app/database.py | 17 ++ backend/app/main.py | 74 ++++++ backend/app/models.py | 48 ++++ backend/app/routers/__init__.py | 0 backend/app/routers/admin.py | 155 ++++++++++++ backend/app/routers/auth.py | 87 +++++++ backend/app/routers/cards.py | 113 +++++++++ backend/app/templates/404.html | 9 + backend/app/templates/admin/cards.html | 110 +++++++++ backend/app/templates/admin/dashboard.html | 95 +++++++ backend/app/templates/admin/sets.html | 95 +++++++ backend/app/templates/base.html | 275 +++++++++++++++++++++ backend/app/templates/index.html | 27 ++ backend/app/templates/login.html | 50 ++++ backend/app/templates/search.html | 50 ++++ backend/app/templates/set_detail.html | 54 ++++ backend/app/templates/sets.html | 27 ++ backend/requirements.txt | 8 + db/init.sql | 2 + docker-compose.yml | 38 +++ nginx/Dockerfile | 2 + nginx/nginx.conf | 20 ++ 27 files changed, 1684 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/cards.py create mode 100644 backend/app/templates/404.html create mode 100644 backend/app/templates/admin/cards.html create mode 100644 backend/app/templates/admin/dashboard.html create mode 100644 backend/app/templates/admin/sets.html create mode 100644 backend/app/templates/base.html create mode 100644 backend/app/templates/index.html create mode 100644 backend/app/templates/login.html create mode 100644 backend/app/templates/search.html create mode 100644 backend/app/templates/set_detail.html create mode 100644 backend/app/templates/sets.html create mode 100644 backend/requirements.txt create mode 100644 db/init.sql create mode 100644 docker-compose.yml create mode 100644 nginx/Dockerfile create mode 100644 nginx/nginx.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a62c2b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.venv/ +venv/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b15f4d0 --- /dev/null +++ b/README.md @@ -0,0 +1,245 @@ +# Pokedexter ⚡ + +Pokémon TCG-samlerwebapp — bygget med **FastAPI**, **PostgreSQL**, **Nginx** og **Docker**. + +Administratorer opretter serier, kort og brugere. Brugere kan logge ind, browse og søge. Ingen åben registrering. + +--- + +## Arkitektur + +``` +Browser ──► Nginx (:8080) ──► FastAPI (:8000) ──► PostgreSQL (:5432) + ▲ │ + └─── static/ ───────┘ +``` + +- **Nginx** — reverse proxy, serverer statiske filer +- **FastAPI** — async Python backend, Jinja2 templates +- **PostgreSQL 16** — database med SQLAlchemy (async) + +--- + +## Kom i gang + +### Docker + +```bash +docker compose up -d --build +``` + +### Podman + +```bash +./start.sh +``` + +### Stop + +```bash +./stop.sh # podman +docker compose down # docker +``` + +Data i `pgdata`-volumen bevares mellem genstarter. + +--- + +## Login + +| Brugernavn | Adgangskode | Rolle | +|------------|-------------|-------| +| `admin` | `admin123` | Admin | + +Brugeren oprettes automatisk ved første opstart. Skift adgangskoden efter første login. + +--- + +## Sådan bruges det + +### Opret en serie (set) + +1. Log ind på `/login` med `admin` / `admin123` +2. Gå til **Admin → Serier** +3. Indtast **kode** (f.eks. `CRI`) og **navn** (f.eks. `Chaos Rising`) +4. Tryk **Opret** + +### Tilføj kort til en serie + +1. Gå til **Admin → Serier** +2. Klik **Vis kort** på det ønskede sæt +3. Udfyld kortets detaljer (nummer, navn, type, sjældenhed, HP, stadie) +4. Tryk **Tilføj kort** + +Eksempel: + +| Felt | Værdi | +|------------|-----------------| +| Nummer | `068` | +| Navn | `Chaos Dragon` | +| Type | `Dragon` | +| Rarity | `Rare` | +| HP | `120` | +| Stadie | `Basic` | + +Kortet får automatisk koden `CRI-068`. + +### Opret brugere (admin) + +API — ingen HTML-side endnu: + +```bash +curl -X POST http://localhost:8080/api/admin/users \ + -H "Content-Type: application/json" \ + -b "token=DIN_JWT_TOKEN" \ + -d '{"username":"glenn","password":"hemmeligt","role":"user"}' +``` + +### Browse kort + +- `/sets` — liste alle serier +- `/sets/CRI` — se alle kort i serie `CRI` +- `/card/CRI-068` — detaljer om et specifikt kort + +### Søg + +- `/search?q=CRI-068` — søg på fuld kortkode +- `/search?q=dragon` — søg på kortnavn + +--- + +## API-oversigt + +### Offentlige endpoints + +| Metode | Sti | Beskrivelse | +|--------|----------------|------------------------| +| GET | `/` | Forside | +| GET | `/sets` | Liste alle serier | +| GET | `/sets/{code}` | Kort i en serie | +| GET | `/search` | Søgeside | +| GET | `/api/search` | Søg (JSON) | +| GET | `/api/cards/{full_code}` | Enkelt kort (JSON) | + +### Auth endpoints + +| Metode | Sti | Beskrivelse | +|--------|----------------|------------------------------| +| GET | `/login` | Loginside | +| POST | `/api/login` | Login (sætter cookie) | +| GET | `/logout` | Logout | + +### Admin endpoints (kræver admin) + +| Metode | Sti | Beskrivelse | +|--------|-------------------------------------|--------------------------------| +| GET | `/admin` | Admin dashboard | +| GET | `/admin/sets` | Administrér serier | +| GET | `/admin/cards/{code}` | Administrér kort i serie | +| POST | `/api/admin/users` | Opret bruger | +| GET | `/api/admin/users` | List brugere | +| POST | `/api/admin/sets` | Opret serie | +| GET | `/api/admin/sets` | List serier | +| DELETE | `/api/admin/sets/{id}` | Slet serie | +| POST | `/api/admin/sets/{code}/cards` | Opret kort | +| GET | `/api/admin/sets/{code}/cards` | List kort i serie | +| DELETE | `/api/admin/cards/{id}` | Slet kort | + +--- + +## Datamodel + +``` +User + id Integer (PK) + username String (unique) + password_hash String (bcrypt) + role String ("admin" | "user") + created_at DateTime + +Set + id Integer (PK) + code String (unique) — "CRI" + name String — "Chaos Rising" + release_date String (optional) + description Text (optional) + created_at DateTime + cards ► Card[] + +Card + id Integer (PK) + set_id Integer (FK → sets.id) + number String — "068" + name String — "Chaos Dragon" + type String — "Dragon", "Fire", ... + rarity String — "Common", "Uncommon", "Rare", ... + hp Integer + stage String — "Basic", "Stage 1", "Stage 2" + description Text + image_url String (optional) + set ► Set + full_code [property] — "CRI-068" +``` + +--- + +## Miljøvariable + +| Variabel | Standard | Beskrivelse | +|---------------|----------------------------------|----------------------| +| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL forbindelse | +| `SECRET_KEY` | `skift-mig-til-en-sikker-noegle` | JWT signeringsnøgle | + +--- + +## Projektstruktur + +``` +pokedexter/ +├── docker-compose.yml +├── start.sh # podman start +├── stop.sh # podman stop +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app/ +│ ├── main.py # FastAPI app, middleware +│ ├── database.py # async engine + session +│ ├── models.py # SQLAlchemy modeller +│ ├── auth.py # bcrypt + JWT +│ ├── routers/ +│ │ ├── auth.py # login/logout + admin user mgmt +│ │ ├── admin.py # admin CRUD for sets/cards +│ │ └── cards.py # browse + search +│ ├── templates/ # Jinja2 HTML templates +│ └── static/ # CSS, JS, billeder +├── nginx/ +│ ├── Dockerfile +│ └── nginx.conf +└── db/ + └── init.sql +``` + +--- + +## Udvikling + +Genstart backend efter kodeændringer: + +```bash +docker compose up -d --build backend +# eller med podman: +podman stop pokedexter-backend && podman rm pokedexter-backend +podman build -t pokedexter-backend ./backend +# ... og kør igen med podman run ... +``` + +--- + +## Planlagte features + +- [ ] Personlig mappe — brugere kan markere kort de ejer/samler +- [ ] Kortbilleder (upload eller URL) +- [ ] Filtrering på type, sjældenhed, HP +- [ ] Dark mode toggle gemt i localStorage *(done)* +- [ ] Mobilvenlig navigation *(done)* \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0f40ae1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..0dbf8f6 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,67 @@ +import os +import bcrypt +from datetime import datetime, timedelta, timezone +from jose import JWTError, jwt +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from .database import get_db +from .models import User + +SECRET_KEY = os.getenv("SECRET_KEY", "skift-mig-til-en-sikker-noegle") +ALGORITHM = "HS256" +TOKEN_EXPIRE_DAYS = 30 + +bearer_scheme = HTTPBearer(auto_error=False) + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + + +def create_token(username: str, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=TOKEN_EXPIRE_DAYS) + payload = {"sub": username, "role": role, "exp": expire} + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict | None: + try: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + return None + + +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: AsyncSession = Depends(get_db), +) -> User | None: + """Return the current user, or None if not authenticated. + Checks both Authorization header and cookie.""" + token = None + if credentials: + token = credentials.credentials + if not token: + token = request.cookies.get("token") + if not token: + return None + payload = decode_token(token) + if not payload: + return None + result = await db.execute(select(User).where(User.username == payload["sub"])) + return result.scalar_one_or_none() + + +async def require_admin(user: User | None = Depends(get_current_user)) -> User: + if not user or user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Admin login required", + ) + return user \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..c53bed8 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://pokedexter:pokedexter_secret@db/pokedexter") + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with async_session() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..ac41c47 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,74 @@ +import os +from fastapi import FastAPI, Depends, Request +from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from contextlib import asynccontextmanager +from sqlalchemy import select + +from .database import engine, async_session, Base +from .routers import auth, admin, cards +from .auth import get_current_user, hash_password +from .models import User + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Create default admin user if not exists + async with async_session() as session: + result = await session.execute(select(User).where(User.username == "admin")) + if not result.scalar_one_or_none(): + session.add(User( + username="admin", + password_hash=hash_password("admin123"), + role="admin", + )) + await session.commit() + yield + + +app = FastAPI(lifespan=lifespan) + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +app.include_router(cards.router) +app.include_router(auth.router) +app.include_router(admin.router) + +templates = Jinja2Templates(directory="app/templates") + + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + """Attach user to request for templates.""" + token = request.cookies.get("token") + request.state.user = None + if token: + from .auth import decode_token + from sqlalchemy import select + from .database import async_session + payload = decode_token(token) + if payload: + async with async_session() as db: + result = await db.execute( + select(User).where(User.username == payload["sub"]) + ) + request.state.user = result.scalar_one_or_none() + response = await call_next(request) + return response + + +@app.get("/", response_class=HTMLResponse) +async def root(request: Request): + if not request.state.user: + return RedirectResponse(url="/login") + return await cards.index(request) + + +@app.get("/logout") +async def logout_redirect(): + resp = RedirectResponse(url="/login", status_code=302) + resp.delete_cookie("token") + return resp \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..02687a6 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, func +from sqlalchemy.orm import relationship +from .database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + role = Column(String(20), nullable=False, default="user") # "admin" or "user" + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class Set(Base): + __tablename__ = "sets" + + id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String(10), unique=True, nullable=False, index=True) # e.g. "CRI" + name = Column(String(200), nullable=False) # e.g. "Chaos Rising" + release_date = Column(String(20), nullable=True) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + cards = relationship("Card", back_populates="set", cascade="all, delete-orphan", + order_by="Card.number") + + +class Card(Base): + __tablename__ = "cards" + + id = Column(Integer, primary_key=True, autoincrement=True) + set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True) + number = Column(String(10), nullable=False) # e.g. "068" + name = Column(String(200), nullable=False) # e.g. "Garchomp EX" + type = Column(String(50), nullable=True) # e.g. "Colorless", "Fire" + rarity = Column(String(50), nullable=True) # e.g. "Rare", "Uncommon" + hp = Column(Integer, nullable=True) + stage = Column(String(50), nullable=True) # e.g. "Basic", "Stage 1", "Stage 2" + description = Column(Text, nullable=True) + image_url = Column(String(500), nullable=True) + + set = relationship("Set", back_populates="cards") + + @property + def full_code(self): + return f"{self.set.code}-{self.number}" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..eee2465 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from ..database import get_db +from ..models import Set, Card, User +from ..auth import require_admin + +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") + + +class SetForm(BaseModel): + code: str + name: str + release_date: str | None = None + description: str | None = None + + +class CardForm(BaseModel): + number: str + name: str + type: str | None = None + rarity: str | None = None + hp: int | None = None + stage: str | None = None + description: str | None = None + image_url: str | None = None + + +# --- Admin pages --- + +@router.get("/admin", response_class=HTMLResponse) +async def admin_dashboard(request: Request, user: User = Depends(require_admin_html)): + return templates.TemplateResponse("admin/dashboard.html", {"request": request, "user": user}) + + +@router.get("/admin/sets", response_class=HTMLResponse) +async def admin_sets(request: Request, user: User = Depends(require_admin_html)): + return templates.TemplateResponse("admin/sets.html", {"request": request, "user": user}) + + +@router.get("/admin/cards/{set_code}", response_class=HTMLResponse) +async def admin_cards(request: Request, set_code: str, user: User = Depends(require_admin_html)): + return templates.TemplateResponse("admin/cards.html", {"request": request, "user": user, "set_code": set_code}) + + +# --- CRUD: Sets --- + +@router.post("/api/admin/sets") +async def create_set( + data: SetForm, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + exists = await db.execute(select(Set).where(Set.code == data.code.upper())) + if exists.scalar_one_or_none(): + raise HTTPException(status_code=400, detail=f"Set '{data.code.upper()}' findes allerede") + + s = Set(code=data.code.upper(), name=data.name, release_date=data.release_date, description=data.description) + db.add(s) + await db.commit() + await db.refresh(s) + return {"ok": True, "id": s.id, "code": s.code} + + +@router.get("/api/admin/sets") +async def list_sets_admin( + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + result = await db.execute(select(Set).order_by(Set.code)) + sets = result.scalars().all() + return [{"id": s.id, "code": s.code, "name": s.name, "release_date": s.release_date or ""} for s in sets] + + +@router.delete("/api/admin/sets/{set_id}") +async def delete_set( + set_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + s = await db.get(Set, set_id) + if not s: + raise HTTPException(status_code=404, detail="Set ikke fundet") + await db.delete(s) + await db.commit() + return {"ok": True} + + +# --- CRUD: Cards --- + +@router.post("/api/admin/sets/{set_code}/cards") +async def create_card( + set_code: str, + data: CardForm, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + result = await db.execute(select(Set).where(Set.code == set_code.upper())) + s = result.scalar_one_or_none() + if not s: + raise HTTPException(status_code=404, detail=f"Set '{set_code.upper()}' ikke fundet") + + card = Card( + set_id=s.id, number=data.number, name=data.name, + type=data.type, rarity=data.rarity, hp=data.hp, + stage=data.stage, description=data.description, image_url=data.image_url, + ) + db.add(card) + await db.commit() + await db.refresh(card) + return {"ok": True, "id": card.id, "full_code": card.full_code} + + +@router.get("/api/admin/sets/{set_code}/cards") +async def list_cards_admin( + set_code: str, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + result = await db.execute(select(Set).where(Set.code == set_code.upper())) + s = result.scalar_one_or_none() + if not s: + raise HTTPException(status_code=404, detail="Set ikke fundet") + return [ + {"id": c.id, "number": c.number, "name": c.name, "type": c.type or "", + "rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code} + for c in s.cards + ] + + +@router.delete("/api/admin/cards/{card_id}") +async def delete_card( + card_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + card = await db.get(Card, card_id) + if not card: + raise HTTPException(status_code=404, detail="Kort ikke fundet") + await db.delete(card) + await db.commit() + return {"ok": True} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..58fa628 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,87 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from ..database import get_db +from ..models import User +from ..auth import verify_password, create_token, hash_password, require_admin + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +class LoginForm(BaseModel): + username: str + password: str + + +class CreateUserForm(BaseModel): + username: str + password: str + role: str = "user" + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + + +@router.post("/api/login") +async def login(data: LoginForm, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.username == data.username)) + user = result.scalar_one_or_none() + if not user or not verify_password(data.password, user.password_hash): + raise HTTPException(status_code=401, detail="Forkert brugernavn eller adgangskode") + + token = create_token(user.username, user.role) + resp = RedirectResponse(url="/", status_code=302) + resp.set_cookie( + key="token", value=token, httponly=True, max_age=30 * 24 * 3600, + samesite="lax" + ) + return resp + + +@router.post("/api/logout") +async def logout(): + resp = RedirectResponse(url="/login", status_code=302) + resp.delete_cookie("token") + return resp + + +# --- Admin-only: user management --- + +@router.post("/api/admin/users") +async def create_user( + data: CreateUserForm, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + exists = await db.execute(select(User).where(User.username == data.username)) + if exists.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Bruger findes allerede") + + user = User( + username=data.username, + password_hash=hash_password(data.password), + role=data.role, + ) + db.add(user) + await db.commit() + return {"ok": True, "username": user.username, "role": user.role} + + +@router.get("/api/admin/users") +async def list_users( + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + result = await db.execute(select(User).order_by(User.username)) + users = result.scalars().all() + return [ + {"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)} + for u in users + ] diff --git a/backend/app/routers/cards.py b/backend/app/routers/cards.py new file mode 100644 index 0000000..6065c43 --- /dev/null +++ b/backend/app/routers/cards.py @@ -0,0 +1,113 @@ +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ..database import get_db +from ..models import Set, Card + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/", response_class=HTMLResponse) +async def index(request: Request, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Set).order_by(Set.code)) + sets = result.scalars().all() + return templates.TemplateResponse("index.html", {"request": request, "sets": sets}) + + +@router.get("/sets", response_class=HTMLResponse) +async def list_sets(request: Request, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Set).order_by(Set.code)) + sets = result.scalars().all() + return templates.TemplateResponse("sets.html", {"request": request, "sets": sets}) + + +@router.get("/sets/{set_code}", response_class=HTMLResponse) +async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.cards)) + ) + s = result.scalar_one_or_none() + if not s: + return templates.TemplateResponse("404.html", {"request": request}, status_code=404) + return templates.TemplateResponse("set_detail.html", {"request": request, "set": s}) + + +@router.get("/search", response_class=HTMLResponse) +async def search_page(request: Request): + return templates.TemplateResponse("search.html", {"request": request}) + + +@router.get("/api/search") +async def search_cards( + q: str = Query(""), + db: AsyncSession = Depends(get_db), +): + """Search cards by full code (e.g. CRI-068) or by name.""" + q = q.strip() + + # Try full-code search: CODE-NUMBER + if "-" in q: + parts = q.split("-", 1) + code_part = parts[0].strip().upper() + num_part = parts[1].strip().lstrip("0") # remove leading zeros + stmt = ( + select(Card) + .join(Card.set) + .where(Set.code == code_part, Card.number == num_part) + .options(selectinload(Card.set)) + ) + else: + # Search by name + stmt = ( + select(Card) + .join(Card.set) + .where(Card.name.ilike(f"%{q}%")) + .options(selectinload(Card.set)) + .limit(50) + ) + + result = await db.execute(stmt) + cards = result.scalars().all() + + return [ + { + "id": c.id, + "full_code": c.full_code, + "name": c.name, + "type": c.type or "", + "rarity": c.rarity or "", + "set_name": c.set.name, + } + for c in cards + ] + + +@router.get("/api/sets") +async def api_list_sets(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Set).order_by(Set.code)) + sets = result.scalars().all() + return [ + {"code": s.code, "name": s.name, "release_date": s.release_date or "", + "card_count": len(s.cards)} + for s in sets + ] + + +@router.get("/api/sets/{set_code}/cards") +async def api_set_cards(set_code: str, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.cards)) + ) + s = result.scalar_one_or_none() + if not s: + return [] + return [ + {"id": c.id, "number": c.number, "name": c.name, "type": c.type or "", + "rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code} + for c in s.cards + ] diff --git a/backend/app/templates/404.html b/backend/app/templates/404.html new file mode 100644 index 0000000..6fd87b0 --- /dev/null +++ b/backend/app/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}404 — Ikke fundet{% endblock %} +{% block content %} +
+

404

+

Siden blev ikke fundet.

+ Gå til forsiden +
+{% endblock %} diff --git a/backend/app/templates/admin/cards.html b/backend/app/templates/admin/cards.html new file mode 100644 index 0000000..bc2a68e --- /dev/null +++ b/backend/app/templates/admin/cards.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}Admin — Kort i {{ set_code }}{% endblock %} +{% block content %} +

🃏 Kort i {{ set_code }}

+

Tilføj eller fjern kort fra dette set.

+ +
+

Tilføj nyt kort

+
+ + + + + + + + + +
+

+
+ +

Eksisterende kort

+
Indlæser...
+← Tilbage til serier + + +{% endblock %} \ No newline at end of file diff --git a/backend/app/templates/admin/dashboard.html b/backend/app/templates/admin/dashboard.html new file mode 100644 index 0000000..49fc764 --- /dev/null +++ b/backend/app/templates/admin/dashboard.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}Admin panel{% endblock %} +{% block content %} +

⚙️ Admin panel

+

Velkommen, {{ user.username }}!

+ +
+ +
+
📦
+

Administrér serier

+

Opret, redigér og slet serier/sets

+
+
+ +
+
🔍
+

Se kort

+

Søg og gennemse alle kort

+
+
+
+ +
+

👥 Brugere

+ + + + + + + +
BrugernavnRolleOprettet
Indlæser...
+
+

Opret ny bruger

+
+ + + + +
+

+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/backend/app/templates/admin/sets.html b/backend/app/templates/admin/sets.html new file mode 100644 index 0000000..e280c1e --- /dev/null +++ b/backend/app/templates/admin/sets.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}Admin — Serier{% endblock %} +{% block content %} +

📦 Administrér serier

+

Opret og slet Pokémon TCG serier/sets.

+ +
+

Opret ny serie

+
+ + + + +
+

+
+ +

Eksisterende serier

+
+ + +{% endblock %} \ No newline at end of file diff --git a/backend/app/templates/base.html b/backend/app/templates/base.html new file mode 100644 index 0000000..bf4a7fa --- /dev/null +++ b/backend/app/templates/base.html @@ -0,0 +1,275 @@ + + + + + + Pokedexter — {% block title %}Pokémon TCG samling{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/backend/app/templates/index.html b/backend/app/templates/index.html new file mode 100644 index 0000000..7437672 --- /dev/null +++ b/backend/app/templates/index.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Forside{% endblock %} +{% block content %} +

📦 Alle serier

+

{{ sets|length }} serie(r) i databasen

+ +
+ {% for s in sets %} + +
+ {{ s.code }} +

{{ s.name }}

+ {% if s.release_date %} +

📅 {{ s.release_date }}

+ {% endif %} +

{{ s.cards|length }} kort

+
+
+ {% endfor %} +
+ +{% if sets|length == 0 %} +
+

Ingen serier endnu. Log ind som admin for at tilføje.

+
+{% endif %} +{% endblock %} diff --git a/backend/app/templates/login.html b/backend/app/templates/login.html new file mode 100644 index 0000000..a9558d8 --- /dev/null +++ b/backend/app/templates/login.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Log ind{% endblock %} +{% block content %} +
+
+

⚡ Pokedexter

+

Log ind for at fortsætte

+
+
+ + +
+
+ + +
+ + +
+
+
+ + +{% endblock %} diff --git a/backend/app/templates/search.html b/backend/app/templates/search.html new file mode 100644 index 0000000..5c6c0a8 --- /dev/null +++ b/backend/app/templates/search.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Søg{% endblock %} +{% block content %} +

🔍 Søg efter kort

+

Søg på kortkode (f.eks. CRI-068) eller kortnavn.

+ + + +
+ + +{% endblock %} diff --git a/backend/app/templates/set_detail.html b/backend/app/templates/set_detail.html new file mode 100644 index 0000000..6185723 --- /dev/null +++ b/backend/app/templates/set_detail.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{{ set.code }} — {{ set.name }}{% endblock %} +{% block content %} +
+ ← Tilbage til serier +
+ +
+ {{ set.code }} +

{{ set.name }}

+ {% if set.release_date %} +

📅 Udgivet: {{ set.release_date }}

+ {% endif %} + {% if set.description %} +

{{ set.description }}

+ {% endif %} +

{{ set.cards|length }} kort i dette set

+
+ +

Kort i {{ set.code }}

+ +{% if set.cards|length > 0 %} +
+ + + + + + + + + {% if set.cards[0].hp %}{% endif %} + + + + {% for card in set.cards %} + + + + + + + {% if card.hp %}{% endif %} + + {% endfor %} + +
#KodeNavnTypeSjældenhedHP
{{ card.number }}{{ card.full_code }}{{ card.name }}{{ card.type or "—" }}{{ card.rarity or "—" }}{{ card.hp }}
+
+{% else %} +
+

Ingen kort i dette set endnu.

+
+{% endif %} +{% endblock %} diff --git a/backend/app/templates/sets.html b/backend/app/templates/sets.html new file mode 100644 index 0000000..ea8483c --- /dev/null +++ b/backend/app/templates/sets.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Alle serier{% endblock %} +{% block content %} +

📦 Pokémon TCG serier

+

{{ sets|length }} serie(r) i databasen

+ +
+ {% for s in sets %} + +
+ {{ s.code }} +

{{ s.name }}

+ {% if s.release_date %} +

📅 {{ s.release_date }}

+ {% endif %} +

{{ s.cards|length }} kort

+
+
+ {% endfor %} +
+ +{% if sets|length == 0 %} +
+

Ingen serier i databasen endnu.

+
+{% endif %} +{% endblock %} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0c96b30 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy[asyncio]==2.0.35 +asyncpg==0.30.0 +jinja2==3.1.4 +python-multipart==0.0.12 +bcrypt==5.0.0 +python-jose[cryptography]==3.3.0 diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..a2fbea1 --- /dev/null +++ b/db/init.sql @@ -0,0 +1,2 @@ +-- Admin-bruger oprettes automatisk af app'en ved første opstart. +-- Standard: username=admin, password=admin123 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0dde3e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + db: + image: postgres:16-alpine + container_name: pokedexter-db + environment: + POSTGRES_DB: pokedexter + POSTGRES_USER: pokedexter + POSTGRES_PASSWORD: pokedexter_secret + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pokedexter"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + image: pokedexter-backend + build: ./backend + container_name: pokedexter-backend + environment: + DATABASE_URL: "postgresql+asyncpg://pokedexter:pokedexter_secret@db/pokedexter" + SECRET_KEY: "${SECRET_KEY:-skift-mig-til-en-sikker-noegle}" + depends_on: + db: + condition: service_healthy + + nginx: + image: pokedexter-nginx + build: ./nginx + container_name: pokedexter-nginx + ports: + - "8080:80" + depends_on: + - backend + +volumes: + pgdata: diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..afc6a5d --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..6862a3b --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + client_max_body_size 10M; + + location /static/ { + proxy_pass http://pokedexter-backend:8000/static/; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://pokedexter-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } +} \ No newline at end of file