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
+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