Initial: Pokedexter TCG-Collector webapp

This commit is contained in:
2026-07-01 21:10:06 +02:00
commit c0952a3bd3
27 changed files with 1684 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*.egg-info/
.env
.venv/
venv/
+245
View File
@@ -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)*
+10
View File
@@ -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"]
View File
+67
View File
@@ -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
+17
View File
@@ -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
+74
View File
@@ -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
+48
View File
@@ -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}"
View File
+155
View File
@@ -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}
+87
View File
@@ -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
]
+113
View File
@@ -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
]
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}404 — Ikke fundet{% endblock %}
{% block content %}
<div style="text-align:center;padding:60px 0;">
<h1 style="font-size:3rem;">404</h1>
<p style="color:var(--text-muted);margin:16px 0;">Siden blev ikke fundet.</p>
<a href="/" class="btn btn-primary">Gå til forsiden</a>
</div>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Admin — Kort i {{ set_code }}{% endblock %}
{% block content %}
<h1>🃏 Kort i <span class="tag">{{ set_code }}</span></h1>
<p style="color:var(--text-muted);margin-bottom:16px;">Tilføj eller fjern kort fra dette set.</p>
<div class="card" style="margin-bottom:24px;">
<h2>Tilføj nyt kort</h2>
<form id="create-card-form" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;">
<input type="text" name="number" placeholder="Nummer (f.eks. 068)" required>
<input type="text" name="name" placeholder="Navn (f.eks. Garchomp EX)" required>
<input type="text" name="type" placeholder="Type (f.eks. Colorless)">
<input type="text" name="rarity" placeholder="Sjældenhed (f.eks. Rare)">
<input type="number" name="hp" placeholder="HP">
<input type="text" name="stage" placeholder="Stage (f.eks. Basic)">
<input type="text" name="image_url" placeholder="Billede URL" style="grid-column:span 2;">
<textarea name="description" placeholder="Beskrivelse" rows="2" style="grid-column:span 2;"></textarea>
<button type="submit" class="btn btn-primary" style="grid-column:span 2;">Tilføj kort</button>
</form>
<p id="card-msg" style="font-size:0.85rem;margin-top:8px;"></p>
</div>
<h2>Eksisterende kort</h2>
<div id="card-list">Indlæser...</div>
<a href="/admin/sets" class="btn btn-outline" style="margin-top:16px;">← Tilbage til serier</a>
<script>
const SET_CODE = '{{ set_code }}';
async function loadCards() {
try {
const res = await fetch('/api/admin/sets/' + SET_CODE + '/cards');
const cards = await res.json();
const container = document.getElementById('card-list');
if (cards.length === 0) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen kort i dette set endnu.</p></div>';
return;
}
let html = '<div style="overflow-x:auto;"><table><thead><tr><th>#</th><th>Kode</th><th>Navn</th><th>Type</th><th>Sjældenhed</th><th style="width:60px;"></th></tr></thead><tbody>';
cards.forEach(c => {
html += '<tr>' +
'<td>' + c.number + '</td>' +
'<td><span class="tag">' + c.full_code + '</span></td>' +
'<td>' + c.name + '</td>' +
'<td>' + (c.type || '—') + '</td>' +
'<td>' + (c.rarity || '—') + '</td>' +
'<td><button class="btn btn-danger btn-sm" onclick="deleteCard(' + c.id + ')">✕</button></td>' +
'</tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
} catch(e) {
document.getElementById('card-list').innerHTML = '<p style="color:var(--accent);">Kunne ikke indlæse kort</p>';
}
}
async function deleteCard(id) {
if (!confirm('Slet dette kort?')) return;
try {
const res = await fetch('/api/admin/cards/' + id, { method: 'DELETE' });
if (res.ok) {
loadCards();
} else {
alert('Kunne ikke slette');
}
} catch(e) {
alert('Fejl ved sletning');
}
}
document.getElementById('create-card-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('card-msg');
try {
const res = await fetch('/api/admin/sets/' + SET_CODE + '/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
number: form.number.value,
name: form.name.value,
type: form.type.value || null,
rarity: form.rarity.value || null,
hp: form.hp.value ? parseInt(form.hp.value) : null,
stage: form.stage.value || null,
description: form.description.value || null,
image_url: form.image_url.value || null,
}),
});
if (res.ok) {
form.reset();
msg.style.color = 'var(--text)';
msg.textContent = '✅ Kort tilføjet';
loadCards();
} else {
const err = await res.json();
msg.style.color = 'var(--accent)';
msg.textContent = '❌ ' + (err.detail || 'Fejl');
}
} catch(e) {
msg.style.color = 'var(--accent)';
msg.textContent = '❌ Kunne ikke forbinde';
}
});
loadCards();
</script>
{% endblock %}
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Admin panel{% endblock %}
{% block content %}
<h1>⚙️ Admin panel</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">Velkommen, {{ user.username }}!</p>
<div class="card-grid">
<a href="/admin/sets" style="text-decoration:none;color:var(--text);">
<div class="card" style="text-align:center;padding:32px;">
<div style="font-size:2rem;margin-bottom:8px;">📦</div>
<h3>Administrér serier</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Opret, redigér og slet serier/sets</p>
</div>
</a>
<a href="/search" style="text-decoration:none;color:var(--text);">
<div class="card" style="text-align:center;padding:32px;">
<div style="font-size:2rem;margin-bottom:8px;">🔍</div>
<h3>Se kort</h3>
<p style="font-size:0.85rem;color:var(--text-muted);">Søg og gennemse alle kort</p>
</div>
</a>
</div>
<div class="card" style="margin-top:24px;">
<h2>👥 Brugere</h2>
<table>
<thead>
<tr><th>Brugernavn</th><th>Rolle</th><th>Oprettet</th></tr>
</thead>
<tbody id="user-list">
<tr><td colspan="3">Indlæser...</td></tr>
</tbody>
</table>
<div style="margin-top:16px;">
<h3>Opret ny bruger</h3>
<form id="create-user-form" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;">
<input type="text" name="username" placeholder="Brugernavn" required style="max-width:200px;">
<input type="password" name="password" placeholder="Adgangskode" required style="max-width:200px;">
<select name="role" style="max-width:120px;">
<option value="user">Bruger</option>
<option value="admin">Admin</option>
</select>
<button type="submit" class="btn btn-primary">Opret</button>
</form>
<p id="user-msg" style="font-size:0.85rem;margin-top:8px;"></p>
</div>
</div>
<script>
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
const users = await res.json();
const tbody = document.getElementById('user-list');
tbody.innerHTML = users.map(u =>
'<tr><td>' + u.username + '</td><td><span class="tag">' + u.role + '</span></td><td>' + (u.created_at || '') + '</td></tr>'
).join('');
} catch(e) {
document.getElementById('user-list').innerHTML = '<tr><td colspan="3">Kunne ikke indlæse brugere</td></tr>';
}
}
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('user-msg');
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
role: form.role.value,
}),
});
if (res.ok) {
form.reset();
msg.style.color = 'var(--text)';
msg.textContent = '✅ Bruger oprettet';
loadUsers();
} else {
const err = await res.json();
msg.style.color = 'var(--accent)';
msg.textContent = '❌ ' + (err.detail || 'Fejl');
}
} catch(e) {
msg.style.color = 'var(--accent)';
msg.textContent = '❌ Kunne ikke forbinde';
}
});
loadUsers();
</script>
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Admin — Serier{% endblock %}
{% block content %}
<h1>📦 Administrér serier</h1>
<p style="color:var(--text-muted);margin-bottom:16px;">Opret og slet Pokémon TCG serier/sets.</p>
<div class="card" style="margin-bottom:24px;">
<h2>Opret ny serie</h2>
<form id="create-set-form" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;">
<input type="text" name="code" placeholder="Kode (f.eks. CRI)" required style="max-width:120px;text-transform:uppercase;">
<input type="text" name="name" placeholder="Navn (f.eks. Chaos Rising)" required style="max-width:280px;">
<input type="text" name="release_date" placeholder="Udgivelsesdato" style="max-width:160px;">
<button type="submit" class="btn btn-primary">Opret</button>
</form>
<p id="set-msg" style="font-size:0.85rem;margin-top:8px;"></p>
</div>
<h2>Eksisterende serier</h2>
<div id="set-list"></div>
<script>
async function loadSets() {
try {
const res = await fetch('/api/admin/sets');
const sets = await res.json();
const container = document.getElementById('set-list');
if (sets.length === 0) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen serier endnu.</p></div>';
return;
}
let html = '<div style="overflow-x:auto;"><table><thead><tr><th>Kode</th><th>Navn</th><th>Udgivet</th><th style="width:100px;"></th></tr></thead><tbody>';
sets.forEach(s => {
html += '<tr>' +
'<td><span class="tag">' + s.code + '</span></td>' +
'<td><a href="/admin/cards/' + s.code + '" style="color:var(--text);">' + s.name + '</a></td>' +
'<td>' + (s.release_date || '—') + '</td>' +
'<td><button class="btn btn-danger btn-sm" onclick="deleteSet(' + s.id + ', \'' + s.code + '\')">Slet</button></td>' +
'</tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
} catch(e) {
document.getElementById('set-list').innerHTML = '<p style="color:var(--accent);">Kunne ikke indlæse serier</p>';
}
}
async function deleteSet(id, code) {
if (!confirm('Slet serien ' + code + '? Alle kort i serien slettes også.')) return;
try {
const res = await fetch('/api/admin/sets/' + id, { method: 'DELETE' });
if (res.ok) {
loadSets();
} else {
alert('Kunne ikke slette');
}
} catch(e) {
alert('Fejl ved sletning');
}
}
document.getElementById('create-set-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('set-msg');
try {
const res = await fetch('/api/admin/sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: form.code.value,
name: form.name.value,
release_date: form.release_date.value || null,
}),
});
if (res.ok) {
form.reset();
msg.style.color = 'var(--text)';
msg.textContent = '✅ Serie oprettet';
loadSets();
} else {
const err = await res.json();
msg.style.color = 'var(--accent)';
msg.textContent = '❌ ' + (err.detail || 'Fejl');
}
} catch(e) {
msg.style.color = 'var(--accent)';
msg.textContent = '❌ Kunne ikke forbinde';
}
});
loadSets();
</script>
{% endblock %}
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="da" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pokedexter — {% block title %}Pokémon TCG samling{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<style>
:root {
--bg: #f5f0e5;
--bg-card: #faf7f0;
--bg-nav: #e8e0d0;
--text: #3a3228;
--text-muted: #7a7060;
--accent: #c0392b;
--accent-hover: #a93226;
--border: #ddd5c5;
--input-bg: #fcf9f4;
--shadow: rgba(0,0,0,0.08);
--tag-bg: #e8e0d0;
}
[data-theme="dark"] {
--bg: #1a1816;
--bg-card: #252220;
--bg-nav: #201e1b;
--text: #e8e0d5;
--text-muted: #8a8070;
--accent: #e65c4a;
--accent-hover: #ff6b58;
--border: #3a3530;
--input-bg: #2a2724;
--shadow: rgba(0,0,0,0.3);
--tag-bg: #35302a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: background 0.3s, color 0.3s;
}
nav {
background: var(--bg-nav);
border-bottom: 1px solid var(--border);
padding: 0 24px;
display: flex;
align-items: center;
height: 56px;
gap: 24px;
position: sticky;
top: 0;
z-index: 100;
transition: background 0.3s;
}
nav a {
color: var(--text);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
padding: 6px 0;
}
nav a:hover { color: var(--accent); }
nav .logo {
font-size: 1.2rem;
font-weight: 700;
color: var(--accent);
margin-right: auto;
}
.nav-right {
display: flex;
align-items: center;
gap: 16px;
}
.theme-toggle {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 10px;
cursor: pointer;
font-size: 1rem;
color: var(--text);
transition: background 0.2s;
}
.theme-toggle:hover { background: var(--tag-bg); }
.container {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
}
h1 { font-size: 1.6rem; margin-bottom: 16px; }
h2 { font-size: 1.2rem; margin-bottom: 12px; color: var(--text-muted); }
.btn {
display: inline-block;
padding: 8px 18px;
border-radius: 8px;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { background: var(--tag-bg); }
.btn-danger {
background: #c0392b;
color: #fff;
}
.btn-danger:hover { background: #e65c4a; }
.btn-sm { padding: 4px 12px; font-size: 0.8rem; }
input, select, textarea {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 12px;
border-radius: 8px;
font-size: 0.95rem;
width: 100%;
transition: border 0.2s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px var(--shadow);
transition: background 0.3s, box-shadow 0.3s;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin: 20px 0;
}
.card-set {
text-decoration: none;
color: var(--text);
display: block;
}
.card-set:hover .card {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 16px var(--shadow);
}
.tag {
display: inline-block;
background: var(--tag-bg);
padding: 2px 10px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
font-family: monospace;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
th {
font-weight: 600;
color: var(--text-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:hover td { background: var(--tag-bg); }
.search-box {
display: flex;
gap: 8px;
margin: 20px 0;
}
.search-box input { flex: 1; }
#search-results {
margin-top: 16px;
}
.result-row {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.result-row:last-child { border-bottom: none; }
.result-row:hover { background: var(--tag-bg); }
@media (max-width: 600px) {
.container { padding: 16px; }
nav { padding: 0 12px; gap: 12px; }
.card-grid { grid-template-columns: 1fr; }
.nav-right { gap: 8px; }
}
</style>
</head>
<body>
<nav>
<a href="/" class="logo">⚡ Pokedexter</a>
<a href="/">Hjem</a>
<a href="/sets">Serier</a>
<a href="/search">Søg</a>
{% if request.state.user and request.state.user.role == "admin" %}
<a href="/admin">Admin</a>
{% endif %}
<div class="nav-right">
<button class="theme-toggle" onclick="toggleTheme()" title="Skift farvetema">🌓</button>
{% if request.state.user %}
<span style="font-size:0.85rem;color:var(--text-muted)">{{ request.state.user.username }}</span>
<a href="/logout" class="btn btn-outline btn-sm">Log ud</a>
{% endif %}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<script>
// Load saved theme
const saved = localStorage.getItem('pokedexter-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
function toggleTheme() {
const html = document.documentElement;
const cur = html.getAttribute('data-theme');
const next = cur === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('pokedexter-theme', next);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Forside{% endblock %}
{% block content %}
<h1>📦 Alle serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} serie(r) i databasen</p>
<div class="card-grid">
{% for s in sets %}
<a href="/sets/{{ s.code }}" class="card-set">
<div class="card">
<span class="tag">{{ s.code }}</span>
<h3 style="margin-top:10px;">{{ s.name }}</h3>
{% if s.release_date %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">📅 {{ s.release_date }}</p>
{% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">{{ s.cards|length }} kort</p>
</div>
</a>
{% endfor %}
</div>
{% if sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen serier endnu. Log ind som admin for at tilføje.</p>
</div>
{% endif %}
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Log ind{% endblock %}
{% block content %}
<div style="max-width:400px;margin:60px auto;">
<div class="card">
<h1 style="text-align:center;">⚡ Pokedexter</h1>
<p style="text-align:center;color:var(--text-muted);margin-bottom:20px;">Log ind for at fortsætte</p>
<form id="login-form">
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:0.9rem;">Brugernavn</label>
<input type="text" name="username" required autocomplete="username">
</div>
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:4px;font-size:0.9rem;">Adgangskode</label>
<input type="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Log ind</button>
<p id="login-error" style="color:var(--accent);margin-top:12px;text-align:center;display:none;"></p>
</form>
</div>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = { username: form.username.value, password: form.password.value };
const errEl = document.getElementById('login-error');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
redirect: 'manual',
});
if (res.ok) {
window.location.href = '/';
} else {
const err = await res.json();
errEl.textContent = err.detail || 'Login fejlede';
errEl.style.display = 'block';
}
} catch(e) {
errEl.textContent = 'Kunne ikke forbinde til serveren';
errEl.style.display = 'block';
}
});
</script>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Søg{% endblock %}
{% block content %}
<h1>🔍 Søg efter kort</h1>
<p style="color:var(--text-muted);margin-bottom:16px;">Søg på kortkode (f.eks. <strong>CRI-068</strong>) eller kortnavn.</p>
<div class="search-box">
<input type="text" id="search-input" placeholder="CRI-068 eller kortnavn..." autofocus>
<button class="btn btn-primary" onclick="doSearch()">Søg</button>
</div>
<div id="search-results"></div>
<script>
const input = document.getElementById('search-input');
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
async function doSearch() {
const q = input.value.trim();
const container = document.getElementById('search-results');
if (!q) { container.innerHTML = ''; return; }
container.innerHTML = '<p style="color:var(--text-muted);">Søger...</p>';
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
const data = await res.json();
container.innerHTML = '';
if (data.length === 0) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted);">Ingen kort fundet for "<strong>' + q + '</strong>"</p></div>';
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>';
data.forEach(c => {
html += '<div class="result-row">' +
'<div><span class="tag">' + c.full_code + '</span> <strong>' + c.name + '</strong>' +
' <span style="color:var(--text-muted);font-size:0.85rem;">' + c.set_name + '</span></div>' +
'<div style="font-size:0.85rem;color:var(--text-muted);">' + c.type + ' · ' + c.rarity + '</div>' +
'</div>';
});
html += '</div>';
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<p style="color:var(--accent);">Fejl: kunne ikke søge</p>';
}
}
</script>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}{{ set.code }} — {{ set.name }}{% endblock %}
{% block content %}
<div style="margin-bottom:20px;">
<a href="/sets" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">← Tilbage til serier</a>
</div>
<div class="card" style="margin-bottom:24px;">
<span class="tag" style="font-size:1rem;">{{ set.code }}</span>
<h1 style="margin-top:8px;">{{ set.name }}</h1>
{% if set.release_date %}
<p style="color:var(--text-muted);">📅 Udgivet: {{ set.release_date }}</p>
{% endif %}
{% if set.description %}
<p style="margin-top:8px;">{{ set.description }}</p>
{% endif %}
<p style="font-size:0.9rem;color:var(--text-muted);margin-top:8px;">{{ set.cards|length }} kort i dette set</p>
</div>
<h2>Kort i {{ set.code }}</h2>
{% if set.cards|length > 0 %}
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>#</th>
<th>Kode</th>
<th>Navn</th>
<th>Type</th>
<th>Sjældenhed</th>
{% if set.cards[0].hp %}<th>HP</th>{% endif %}
</tr>
</thead>
<tbody>
{% for card in set.cards %}
<tr>
<td>{{ card.number }}</td>
<td><span class="tag">{{ card.full_code }}</span></td>
<td>{{ card.name }}</td>
<td>{{ card.type or "—" }}</td>
<td>{{ card.rarity or "—" }}</td>
{% if card.hp %}<td>{{ card.hp }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen kort i dette set endnu.</p>
</div>
{% endif %}
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Alle serier{% endblock %}
{% block content %}
<h1>📦 Pokémon TCG serier</h1>
<p style="color:var(--text-muted);margin-bottom:24px;">{{ sets|length }} serie(r) i databasen</p>
<div class="card-grid">
{% for s in sets %}
<a href="/sets/{{ s.code }}" class="card-set">
<div class="card">
<span class="tag">{{ s.code }}</span>
<h3 style="margin-top:10px;">{{ s.name }}</h3>
{% if s.release_date %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">📅 {{ s.release_date }}</p>
{% endif %}
<p style="font-size:0.85rem;color:var(--text-muted);margin-top:4px;">{{ s.cards|length }} kort</p>
</div>
</a>
{% endfor %}
</div>
{% if sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);">
<p>Ingen serier i databasen endnu.</p>
</div>
{% endif %}
{% endblock %}
+8
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
-- Admin-bruger oprettes automatisk af app'en ved første opstart.
-- Standard: username=admin, password=admin123
+38
View File
@@ -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:
+2
View File
@@ -0,0 +1,2 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
+20
View File
@@ -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;
}
}