Admin: serie-detail, billedhåndtering, dark mode fix, source_url kolonne + verify

- Serie-detail side: sæt under serie + enkelt/bulk opret
- Billedhentning: URL→lokal download med source_url til re-sync
- Advarselsbadge (⚠) på kort med eksterne URLs (ingen streaming)
- Sync/Verify endpoints + knapper i admin UI
- Brugerstyring: opret/slet (self-delete beskyttet)
- Dark mode flash fix: render-blokerende script i <head>
- Persistent volume til billeder (pokedexter_images)
This commit is contained in:
2026-07-01 23:11:55 +02:00
parent 8361066f2f
commit 54465f751c
10 changed files with 678 additions and 113 deletions
+151 -94
View File
@@ -1,47 +1,51 @@
# Pokedexter ⚡
Pokémon TCG-samlerwebapp — bygget med **FastAPI**, **PostgreSQL**, **Nginx** og **Docker**.
Pokémon TCG-samlerwebapp — bygget med **FastAPI**, **PostgreSQL 16**, **Nginx** og **Podman/Docker**.
Administratorer opretter serier, kort og brugere. Brugere kan logge ind, browse og søge. Ingen åben registrering.
Administratorer opretter serier, t og kort. Brugere kan logge ind, browse og søge. Ingen åben registrering.
---
## Arkitektur
```
Browser ──► Nginx (:8080) ──► FastAPI (:8000) ──► PostgreSQL (:5432)
Browser ──► Nginx (:8085) ──► FastAPI (:8000) ──► PostgreSQL (:5432)
▲ │
└─── static/ ───────┘
```
- **Nginx** — reverse proxy, serverer statiske filer
- **Nginx** — reverse proxy, serverer statiske filer på port 8085
- **FastAPI** — async Python backend, Jinja2 templates
- **PostgreSQL 16** — database med SQLAlchemy (async)
- **PostgreSQL 16** — database med SQLAlchemy (async/asyncpg)
---
## Kom i gang
### Docker
Billeder bygges lokalt først, deploy sker via Portainer.
### Byg lokalt (podman)
```bash
docker compose up -d --build
podman build -t pokedexter-backend:latest -f backend/Dockerfile backend/
podman build -t pokedexter-nginx:latest -f nginx/Dockerfile nginx/
```
### Podman
### Start lokalt
```bash
./start.sh
podman-compose up -d # podman
docker compose up -d # docker
```
### Stop
```bash
./stop.sh # podman
docker compose down # docker
podman-compose down
docker compose down
```
Data i `pgdata`-volumen bevares mellem genstarter.
Data i volumes (`pokedexter_pgdata`, `pokedexter_images`) bevares mellem genstarter.
---
@@ -57,54 +61,77 @@ Brugeren oprettes automatisk ved første opstart. Skift adgangskoden efter førs
## Sådan bruges det
### Opret en serie (set)
### Opret en serie
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`)
3. Indtast **navn** (f.eks. `Sword & Shield`) og evt. beskrivelse
4. Tryk **Opret**
### Tilføj kort til en serie
### Opret sæt under en serie
1. Gå til **Admin → Serier**
2. Klik på serienavnet (link)
3. Indtast **kode** (f.eks. `EVS`), **navn** (`Evolving Skies`) og evt. **release_date**
4. Tryk **Opret sæt**
Eller **bulk import**: indsæt CSV direkte i tekstfeltet:
```
Stellar Crown,SCR,Stellar Crown,2024-09-13,
Surging Sparks,SSP,Surging Sparks,2024-11-08,
```
### Tilføj kort
1. Gå til **Admin → Sæt**
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**
3. Udfyld **nummer**, **navn** og evt. **image URL**
4. Tryk **Opret kort**
Eksempel:
Eller **bulk import** via CSV:
| Felt | Værdi |
|------------|-----------------|
| Nummer | `068` |
| Navn | `Chaos Dragon` |
| Type | `Dragon` |
| Rarity | `Rare` |
| HP | `120` |
| Stadie | `Basic` |
```
SCR,001,Caterpie,https://images.pokemontcg.io/scarlet_ivy/1.png
SCR,002,Metapod,
```
Kortet får automatisk koden `CRI-068`.
Kortet får automatisk koden `SCR-001`.
### Billedhåndtering
Når en URL angives ved kortoprettelse:
- Billedet **downloades automatisk** og gemmes lokalt i volume'et
- Hvis download fejler, bevares URL'en som `image_path` og et ⚠-badge vises
- `Card.image_url` returnerer **aldrig eksterne URLs** — ingen streaming
#### Synkronisér billeder
**Admin → Sæt → Vis kort** findes en **Synkronisér billeder**-knap, der downloader alle eksterne billeder for sættet til lokal lagring.
#### Verificér & genopret
**Admin**-dashboardet findes:
- **Verificér & genopret** — tjekker om alle lokale billedfiler findes på disken. Manglende filer re-downloades fra `source_url`
- **Force-genhent alle** — genhenter ALLE billeder fra deres originale kilder (uanset om de findes)
Dette er nyttigt hvis volume'et afmonteres ved en fejl, eller billeder tabes ved genstart.
### 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"}'
```
**Admin**-dashboardet under "Brugere" kan du oprette og slette brugere. Du kan ikke slette dig selv.
### Browse kort
- `/sets` — liste alle serier
- `/sets/CRI` — se alle kort i serie `CRI`
- `/card/CRI-068` — detaljer om et specifikt kort
- `/sets` — liste alle serier med sæt
- `/sets/{code}` — se alle kort i sæt `EVS`
- `/card/{full_code}` — detaljer om et specifikt kort
### Søg
- `/search?q=CRI-068` — søg på fuld kortkode
- `/search?q=dragon` — søg på kortnavn
- `/search?q=SCR-001` — søg på fuld kortkode
- `/search?q=pikachu` — søg på kortnavn
---
@@ -112,13 +139,13 @@ curl -X POST http://localhost:8080/api/admin/users \
### 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) |
| Metode | Sti | Beskrivelse |
|--------|----------------------|------------------------|
| GET | `/` | Forside |
| GET | `/sets` | Liste alle serier |
| GET | `/sets/{code}` | Kort i et sæt |
| GET | `/search` | Søgeside |
| GET | `/api/search` | Søg (JSON) |
| GET | `/api/cards/{full_code}` | Enkelt kort (JSON) |
### Auth endpoints
@@ -131,19 +158,28 @@ curl -X POST http://localhost:8080/api/admin/users \
### 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 |
| Metode | Sti | Beskrivelse |
|--------|--------------------------------------------|-------------------------------------|
| GET | `/admin` | Admin dashboard |
| GET | `/admin/series` | Administrér serier |
| GET | `/admin/series/{name}` | Serie-detail med sæt |
| GET | `/admin/sets` | Administrér sæt |
| GET | `/admin/sets/{code}` | Administrér kort i sæt |
| POST | `/api/admin/users` | Opret bruger |
| DELETE | `/api/admin/users/{id}` | Slet bruger (ikke dig selv) |
| POST | `/api/admin/series` | Opret serie |
| POST | `/api/admin/series/csv` | Bulk import af serier (CSV) |
| DELETE | `/api/admin/series/{id}` | Slet serie |
| POST | `/api/admin/series/{name}/sets` | Opret sæt under serie |
| POST | `/api/admin/series/{name}/sets/csv` | Bulk import af sæt (CSV) |
| POST | `/api/admin/sets/{code}/cards` | Opret kort |
| POST | `/api/admin/sets/{code}/cards/csv` | Bulk import af kort (CSV) |
| DELETE | `/api/admin/cards/{id}` | Slet kort |
| POST | `/api/admin/cards/{id}/upload` | Upload billede (fil) |
| POST | `/api/admin/sets/{code}/sync-images` | Synkronisér billeder for sæt |
| POST | `/api/admin/sync-all-images` | Synkronisér alle billeder |
| POST | `/api/admin/verify-images` | Verificér og genopret billeder |
| POST | `/api/admin/verify-images?force=true` | Force-genhent alle billeder |
---
@@ -157,38 +193,44 @@ User
role String ("admin" | "user")
created_at DateTime
Serie
id Integer (PK)
name String (unique) — "Sword & Shield"
description Text (optional)
created_at DateTime
sets ► Set[]
Set
id Integer (PK)
code String (unique) — "CRI"
name String — "Chaos Rising"
serie_id Integer (FK → series.id)
code String (unique) — "EVS"
name String — "Evolving Skies"
release_date String (optional)
description Text (optional)
created_at DateTime
serie ► Serie
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)
number String — "050"
name String — "Raichu"
image_path String (optional) — lokal sti eller URL
source_url String (optional) — original ekstern URL til re-sync
set ► Set
full_code [property] — "CRI-068"
full_code [property] — "EVS-050"
image_url [property] — "/static/..." eller None (ingen eksterne URLs)
```
---
## Miljøvariable
| Variabel | Standard | Beskrivelse |
|---------------|----------------------------------|----------------------|
| Variabel | Standard | Beskrivelse |
|----------------|----------------------------------|----------------------|
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL forbindelse |
| `SECRET_KEY` | `skift-mig-til-en-sikker-noegle` | JWT signeringsnøgle |
| `SECRET_KEY` | `skift-mig-til-en-sikker-noegle` | JWT signeringsnøgle |
---
@@ -197,22 +239,26 @@ Card
```
pokedexter/
├── docker-compose.yml
├── start.sh # podman start
├── stop.sh # podman stop
├── start.sh # podman start
├── stop.sh # podman stop
├── README.md
├── 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
│ ├── main.py # FastAPI app, middleware
│ ├── database.py # async engine + session
│ ├── models.py # SQLAlchemy modeller
│ ├── auth.py # bcrypt + JWT
│ ├── static/
│ │ ── images/ # uploadet/downloadede billeder (volume)
├── templates/ # Jinja2 HTML templates
│ │ ── base.html # layout m. dark mode toggle
│ └── admin/ # admin UI templates
│ └── routers/
│ ├── auth.py # login/logout + admin user mgmt
│ ├── admin.py # admin CRUD for series/sets/cards
│ └── cards.py # browse + search
├── nginx/
│ ├── Dockerfile
│ └── nginx.conf
@@ -224,22 +270,33 @@ pokedexter/
## Udvikling
Genstart backend efter kodeændringer:
### Genstart backend efter kodeændringer
```bash
docker compose up -d --build backend
# eller med podman:
# Byg nyt image
podman build -t pokedexter-backend:latest -f backend/Dockerfile backend/
# Genstart via Portainer, eller:
podman stop pokedexter-backend && podman rm pokedexter-backend
podman build -t pokedexter-backend ./backend
# ... og kør igen med podman run ...
podman-compose up -d backend
```
### Dark mode
Dark mode gemmes i `localStorage` og sættes via render-blokerende script i `<head>` — ingen flash ved side-skift.
### Billeder og volumes
Billeder gemmes i volume `pokedexter_images` mountet på `/app/static/images`.
Volume'et skal være mountet før første hentning — ellers går billeder tabt ved container-genstart.
Hvis billeder mangler: brug **Verificér & genopret** på admin-dashboardet.
---
## 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)*
- [ ] Kort-detalje side med artwork
- [ ] Eksport af samling til CSV
+4 -3
View File
@@ -48,7 +48,8 @@ class Card(Base):
set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True)
number = Column(String(10), nullable=False) # "050"
name = Column(String(200), nullable=False) # "Raichu"
image_path = Column(String(500), nullable=True) # URL eller sti "images/.../050.jpg"
image_path = Column(String(500), nullable=True) # Sti: "images/.../050.jpg"
source_url = Column(String(2000), nullable=True) # Original ekstern URL (til re-sync)
set = relationship("Set", back_populates="cards")
@@ -58,9 +59,9 @@ class Card(Base):
@property
def image_url(self):
"""Returner URL'en for visning — absolut URL eller /static/ relativ sti."""
"""Returner URL'en for visning — kun lokale stier. Ingen eksterne URLs."""
if not self.image_path:
return None
if self.image_path.startswith(("http://", "https://")):
return self.image_path
return None # Må ikke streames fra ekstern server
return f"/static/{self.image_path}"
+244 -8
View File
@@ -4,6 +4,7 @@ import os
import uuid
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
@@ -23,6 +24,38 @@ UPLOAD_DIR = Path("app/static/images")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# ─── Hjælpefunktioner ───────────────────────────────────────────────
async def download_image_from_url(url: str, serie_name: str, set_code: str, number: str) -> str | None:
"""Download billede fra URL og gem lokalt. Returnerer relativ sti eller None ved fejl."""
if not url or not url.startswith(("http://", "https://")):
return url # Allerede lokal sti eller None
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
# Bestem filtype ud fra content-type
ct = resp.headers.get("content-type", "")
ext_map = {"image/jpeg": ".jpg", "image/png": ".png",
"image/gif": ".gif", "image/webp": ".webp"}
ext = ext_map.get(ct.split(";")[0].strip(), ".jpg")
serie_folder = serie_name.replace(" ", "_").replace("/", "_")
folder = UPLOAD_DIR / serie_folder / set_code
folder.mkdir(parents=True, exist_ok=True)
filename = f"{number}{ext}"
filepath = folder / filename
filepath.write_bytes(resp.content)
return f"images/{serie_folder}/{set_code}/{filename}"
except Exception as e:
print(f"Kunne ikke downloade billede {url}: {e}")
return None # Ingen fallback til URL — offline only
# ─── Pydantic schemas ───────────────────────────────────────────────
class SerieCreate(BaseModel):
@@ -49,8 +82,13 @@ class NewUserForm(BaseModel):
# ─── Admin HTML pages ───────────────────────────────────────────────
@router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, user: User = Depends(require_admin)):
return templates.TemplateResponse("admin/dashboard.html", {"request": request})
async def admin_dashboard(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
result = await db.execute(select(User).order_by(User.username))
users = result.scalars().all()
return templates.TemplateResponse("admin/dashboard.html", {
"request": request, "users": users
})
@router.get("/admin/series", response_class=HTMLResponse)
@@ -61,6 +99,28 @@ async def admin_series(request: Request, db: AsyncSession = Depends(get_db),
return templates.TemplateResponse("admin/series.html", {"request": request, "series": series})
@router.get("/admin/series/{serie_name}", response_class=HTMLResponse)
async def admin_series_detail(request: Request, serie_name: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
result = await db.execute(
select(Serie).where(Serie.name == serie_name).options(
selectinload(Serie.sets).selectinload(Set.cards)
)
)
serie = result.scalar_one_or_none()
if not serie:
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
# Hent serier til dropdown (til CSV import)
series_result = await db.execute(select(Serie).order_by(Serie.name))
all_series = series_result.scalars().all()
return templates.TemplateResponse("admin/serie_detail.html", {
"request": request, "serie": serie, "all_series": all_series
})
@router.get("/admin/sets", response_class=HTMLResponse)
async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
@@ -105,6 +165,19 @@ async def list_users(db: AsyncSession = Depends(get_db), _: User = Depends(requi
return [{"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)}
for u in result.scalars().all()]
@router.delete("/api/admin/users/{user_id}")
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Du kan ikke slette dig selv")
await db.delete(user)
await db.commit()
return {"ok": True}
# ─── API: Series ────────────────────────────────────────────────────
@@ -193,6 +266,42 @@ async def list_sets(serie_name: str, db: AsyncSession = Depends(get_db),
return [{"id": s.id, "code": s.code, "name": s.name, "card_count": len(s.cards)}
for s in serie.sets]
@router.post("/api/admin/series/{serie_name}/sets/import")
async def import_sets_for_serie(serie_name: str, file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""CSV-import af sets til en specifik serie — 'serie'-kolonne ignoreres/overskrives."""
result = await db.execute(select(Serie).where(Serie.name == serie_name))
serie = result.scalar_one_or_none()
if not serie:
raise HTTPException(status_code=404, detail="Serie ikke fundet")
content = await file.read()
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
created = 0
errors = []
for i, row in enumerate(reader, start=2):
code = row.get("code", "").strip().upper()
name = row.get("name", "").strip()
if not code or not name:
errors.append(f"Linje {i}: mangler kode eller navn")
continue
try:
exists = await db.execute(select(Set).where(Set.code == code))
if exists.scalar_one_or_none():
errors.append(f"Linje {i}: kode '{code}' findes allerede")
continue
s = Set(serie_id=serie.id, code=code, name=name,
release_date=row.get("release_date", "").strip() or None,
description=row.get("description", "").strip() or None)
db.add(s)
created += 1
except Exception as e:
errors.append(f"Linje {i}: {str(e)}")
await db.commit()
return {"ok": True, "created": created, "errors": errors}
@router.get("/api/admin/sets")
async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
result = await db.execute(
@@ -255,13 +364,18 @@ async def import_sets(file: UploadFile = File(...), db: AsyncSession = Depends(g
@router.post("/api/admin/sets/{set_code}/cards")
async def create_card(set_code: str, data: CardCreate, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
result = await db.execute(select(Set).where(Set.code == set_code.upper()))
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.serie))
)
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
card = Card(set_id=s.id, number=data.number, name=data.name)
if data.image:
card.image_path = data.image
card.source_url = data.image # Gem original URL til evt. re-sync
local_path = await download_image_from_url(data.image, s.serie.name, s.code, data.number)
card.image_path = local_path or data.image # Behold URL hvis download fejler
db.add(card)
await db.commit()
return {"ok": True, "id": card.id, "full_code": card.full_code, "name": card.name}
@@ -312,13 +426,20 @@ async def import_cards(file: UploadFile = File(...), db: AsyncSession = Depends(
errors.append(f"Linje {i}: mangler set_code, number eller name")
continue
try:
result = await db.execute(select(Set).where(Set.code == set_code))
result = await db.execute(
select(Set).where(Set.code == set_code).options(selectinload(Set.serie))
)
s = result.scalar_one_or_none()
if not s:
errors.append(f"Linje {i}: set '{set_code}' ikke fundet")
continue
image = row.get("image", "").strip() or None
card = Card(set_id=s.id, number=number, name=name, image_path=image)
raw_url = row.get("image", "").strip() or None
if raw_url:
local_path = await download_image_from_url(raw_url, s.serie.name, s.code, number)
image = local_path or raw_url # Behold URL hvis download fejler
else:
image = None
card = Card(set_id=s.id, number=number, name=name, image_path=image, source_url=raw_url)
db.add(card)
created += 1
except Exception as e:
@@ -363,6 +484,121 @@ async def upload_card_image(card_id: int, file: UploadFile = File(...),
return {"ok": True, "image_url": f"/static/{rel_path}", "path": rel_path}
# ─── Synkronisér billeder ──────────────────────────────────────────
@router.post("/api/admin/sets/{set_code}/sync-images")
async def sync_set_images(set_code: str, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""Download alle eksterne billeder for et bestemt sæt til lokal lagring."""
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(
selectinload(Set.cards), selectinload(Set.serie)
)
)
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
downloaded = 0
skipped = 0
errors = []
for card in s.cards:
if not card.image_path or not card.image_path.startswith(("http://", "https://")):
skipped += 1
continue
try:
if not card.source_url:
card.source_url = card.image_path # Gem URL hvis ikke allerede gemt
rel_path = await download_image_from_url(card.image_path, s.serie.name, s.code, card.number)
if rel_path:
card.image_path = rel_path
downloaded += 1
else:
errors.append(f"{card.full_code}: download fejlede")
except Exception as e:
errors.append(f"{card.full_code}: {str(e)}")
await db.commit()
return {"ok": True, "set": s.code, "downloaded": downloaded, "skipped": skipped, "errors": errors}
@router.post("/api/admin/sync-all-images")
async def sync_all_images(db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""Download ALLE eksterne billeder på tværs af alle sæt."""
result = await db.execute(
select(Card).options(selectinload(Card.set).selectinload(Set.serie))
)
cards = result.scalars().all()
downloaded = 0
skipped = 0
errors = []
for card in cards:
if not card.image_path or not card.image_path.startswith(("http://", "https://")):
skipped += 1
continue
try:
serie_name = card.set.serie.name
set_code = card.set.code
if not card.source_url:
card.source_url = card.image_path # Gem URL hvis ikke allerede gemt
rel_path = await download_image_from_url(card.image_path, serie_name, set_code, card.number)
if rel_path:
card.image_path = rel_path
downloaded += 1
else:
errors.append(f"{card.full_code}: download fejlede")
except Exception as e:
errors.append(f"{card.full_code}: {str(e)}")
await db.commit()
return {"ok": True, "downloaded": downloaded, "skipped": skipped, "errors": errors}
@router.post("/api/admin/verify-images")
async def verify_images(force: bool = False, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""Tjek at alle billedfiler findes på disken. Re-download fra source_url hvis mangler."""
result = await db.execute(
select(Card).where(Card.source_url.isnot(None)).options(
selectinload(Card.set).selectinload(Set.serie)
)
)
cards = result.scalars().all()
restored = 0
skipped = 0
errors = []
for card in cards:
# Bestem om filen findes lokalt
is_local = card.image_path and not card.image_path.startswith(("http://", "https://"))
file_exists = False
if is_local:
fp = Path("app/static") / card.image_path
file_exists = fp.exists()
if not force and is_local and file_exists:
skipped += 1
continue
# Mangler eller force — prøv at downloade fra source_url
try:
serie_name = card.set.serie.name
set_code = card.set.code
rel_path = await download_image_from_url(card.source_url, serie_name, set_code, card.number)
if rel_path:
card.image_path = rel_path
restored += 1
else:
errors.append(f"{card.full_code}: download fra source_url fejlede")
except Exception as e:
errors.append(f"{card.full_code}: {str(e)}")
await db.commit()
return {"ok": True, "restored": restored, "skipped": skipped, "errors": errors}
# ─── CSV Templates ──────────────────────────────────────────────────
@router.get("/api/admin/templates/series.csv", response_class=PlainTextResponse)
@@ -375,4 +611,4 @@ async def sets_csv_template(_: User = Depends(require_admin)):
@router.get("/api/admin/templates/cards.csv", response_class=PlainTextResponse)
async def cards_csv_template(_: User = Depends(require_admin)):
return "set_code,number,name,image\nEVS,050,Raichu,\n"
return "set_code,number,name,image\nEVS,050,Raichu,\n"
+34 -1
View File
@@ -31,6 +31,15 @@
</form>
<div id="cards-import-result" style="margin-top:8px;"></div>
</div>
<div class="card" style="flex:1;min-width:280px;">
<h3>Synkronisér billeder</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin:8px 0;">
Hent alle eksterne billeder for {{ set.code }} og gem lokalt.
</p>
<button class="btn btn-primary" onclick="syncImages('{{ set.code }}')">📥 Synkronisér</button>
<div id="sync-result" style="margin-top:8px;"></div>
</div>
</div>
<h2>Kort i {{ set.code }}</h2>
@@ -45,7 +54,11 @@
<td><a href="/card/{{ set.code }}/{{ card.number }}" style="color:var(--text);">{{ card.name }}</a></td>
<td><span class="tag">{{ card.full_code }}</span></td>
<td>
{% if card.image_url %}
{% if card.image_path and (card.image_path.startswith('http://') or card.image_path.startswith('https://')) %}
<span class="warning-badge" title="Billede ikke synkroniseret — eksternt URL: {{ card.image_path }}">⚠️</span>
<span style="color:var(--accent);font-size:0.75rem;cursor:help;"
onclick="alert('{{ card.image_path }}')">eksternt</span>
{% elif card.image_url %}
<a href="{{ card.image_url }}" target="_blank">
<img src="{{ card.image_url }}" alt="{{ card.name }}"
style="height:40px;border-radius:4px;">
@@ -142,5 +155,25 @@ async function uploadImage(e, cardId) {
alert(data.detail || 'Fejl ved upload');
}
}
async function syncImages(setCode) {
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Synkroniserer...';
const res = await fetch('/api/admin/sets/' + setCode + '/sync-images', {method: 'POST'});
const data = await res.json();
const el = document.getElementById('sync-result');
if (data.ok) {
let msg = '\u2713 ' + data.downloaded + ' billed(er) downloadet';
if (data.skipped > 0) msg += ', ' + data.skipped + ' sprunget over (allerede lokale)';
if (data.errors.length) msg += '<br><span style="color:var(--accent);">' + data.errors.join('<br>') + '</span>';
el.innerHTML = '<p style="color:green;">' + msg + '</p>';
setTimeout(() => location.reload(), 1500);
} else {
el.innerHTML = '<p style="color:var(--accent);">Fejl: ' + (data.detail || 'Ukendt') + '</p>';
}
btn.disabled = false;
btn.textContent = '\ud83d\udce5 Synkronis\u00e9r';
}
</script>
{% endblock %}
+110
View File
@@ -27,4 +27,114 @@
</div>
</a>
</div>
<h2 style="margin-top:36px;">🖼️ Billedvedligeholdelse</h2>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:16px 0;">
<button class="btn btn-outline" onclick="verifyImages(false)">
🔍 Verificér & genopret manglende billeder
</button>
<button class="btn btn-outline" onclick="verifyImages(true)">
📥 Force-genhent alle billeder
</button>
</div>
<div id="verify-result" style="margin-top:8px;font-size:0.9rem;"></div>
<h2 style="margin-top:36px;">👥 Brugere</h2>
<div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<div class="card" style="flex:1;min-width:280px;">
<h3>Opret bruger</h3>
<form onsubmit="createUser(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<input type="text" id="new-username" placeholder="Brugernavn" required>
<input type="password" id="new-password" placeholder="Adgangskode" required>
<select id="new-role">
<option value="user">Bruger</option>
<option value="admin">Administrator</option>
</select>
<button class="btn btn-primary">Opret</button>
</form>
<div id="user-creation-result" style="margin-top:8px;"></div>
</div>
</div>
<table>
<thead>
<tr><th>Brugernavn</th><th>Rolle</th><th>Oprettet</th><th>Handling</th></tr>
</thead>
<tbody id="users-list">
{% for u in users %}
<tr id="user-{{ u.id }}">
<td><strong>{{ u.username }}</strong></td>
<td><span class="tag">{{ u.role }}</span></td>
<td>{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '—' }}</td>
<td>
{% if u.username != request.state.user.username %}
<button class="btn btn-danger btn-sm" onclick="deleteUser({{ u.id }}, '{{ u.username }}')">Slet</button>
{% else %}
<span style="color:var(--text-muted);font-size:0.85rem;">dig</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
async function verifyImages(force) {
const btn = event.target;
const resultEl = document.getElementById('verify-result');
btn.disabled = true;
btn.textContent = force ? '📥 Genhenter...' : '🔍 Verificerer...';
resultEl.innerHTML = '<p style="color:var(--text-muted);">Arbejder...</p>';
const res = await fetch('/api/admin/verify-images' + (force ? '?force=true' : ''), {method: 'POST'});
const data = await res.json();
btn.disabled = false;
btn.textContent = force ? '📥 Force-genhent alle billeder' : '🔍 Verificér & genopret manglende billeder';
if (data.ok) {
let msg = `${data.restored} billeder genhentet, ${data.skipped} sprunget over.`;
if (data.errors.length) {
msg += `<br><span style="color:var(--accent);">⚠️ ${data.errors.length} fejl:</span><ul>`;
data.errors.forEach(e => { msg += `<li>${e}</li>`; });
msg += '</ul>';
}
resultEl.innerHTML = '<p>' + msg + '</p>';
} else {
resultEl.innerHTML = '<p style="color:var(--accent);">❌ ' + (data.detail || 'Ukendt fejl') + '</p>';
}
}
async function createUser(e) {
e.preventDefault();
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) return;
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password, role})
});
const data = await res.json();
const el = document.getElementById('user-creation-result');
if (data.ok) {
el.innerHTML = '<p style="color:green;">✓ Bruger oprettet</p>';
setTimeout(() => location.reload(), 1000);
} else {
el.innerHTML = '<p style="color:var(--accent);">' + (data.detail || 'Fejl') + '</p>';
}
}
async function deleteUser(id, username) {
if (!confirm('Slet bruger "' + username + '"?')) return;
const res = await fetch('/api/admin/users/' + id, {method: 'DELETE'});
const data = await res.json();
if (data.ok) {
document.getElementById('user-' + id).remove();
} else {
alert(data.detail || 'Fejl ved sletning');
}
}
</script>
{% endblock %}
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Admin — {{ serie.name }}{% endblock %}
{% block content %}
<a href="/admin/series" style="color:var(--text-muted);text-decoration:none;font-size:0.9rem;">&larr; Serier</a>
<h1 style="margin-top:12px;">📦 {{ serie.name }}</h1>
{% if serie.description %}
<p style="color:var(--text-muted);margin-bottom:16px;">{{ serie.description }}</p>
{% endif %}
<div style="display:flex;gap:24px;flex-wrap:wrap;margin:20px 0;">
<div class="card" style="flex:1;min-width:280px;">
<h3>Opret sæt under {{ serie.name }}</h3>
<form onsubmit="createSet(event)" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
<input type="hidden" id="set-serie-name" value="{{ serie.name }}">
<input type="text" id="set-code" placeholder="Kode (f.eks. EVS)" required>
<input type="text" id="set-name" placeholder="Navn (f.eks. Evolving Skies)" required>
<input type="date" id="set-date" placeholder="Udgivelsesdato">
<input type="text" id="set-desc" placeholder="Beskrivelse (valgfri)">
<button class="btn btn-primary">Opret</button>
</form>
</div>
<div class="card" style="flex:1;min-width:280px;">
<h3>CSV-import af sæt</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin:8px 0;">
Kolonner: <code>code,name,release_date,description</code>
&mdash; <strong>{{ serie.name }}</strong> udfyldes automatisk.
<br><a href="/api/admin/templates/sets.csv" style="color:var(--accent);">Download skabelon</a>
</p>
<form onsubmit="importSets(event)" style="display:flex;flex-direction:column;gap:10px;">
<input type="file" id="sets-csv" accept=".csv" required>
<button class="btn btn-primary">Importer</button>
</form>
<div id="sets-import-result" style="margin-top:8px;"></div>
</div>
</div>
<h2>Sæt i {{ serie.name }}</h2>
<table>
<thead>
<tr><th>Kode</th><th>Navn</th><th>Kort</th><th>Udgivet</th><th>Handling</th></tr>
</thead>
<tbody id="sets-list">
{% for set in serie.sets %}
<tr id="set-{{ set.id }}">
<td><span class="tag">{{ set.code }}</span></td>
<td><a href="/admin/cards/{{ set.code }}" style="color:var(--text);">{{ set.name }}</a></td>
<td>{{ set.cards|length }}</td>
<td>{{ set.release_date or '—' }}</td>
<td>
<button class="btn btn-outline btn-sm" onclick="location.href='/admin/cards/{{ set.code }}'">Kort</button>
<button class="btn btn-danger btn-sm" onclick="deleteSet({{ set.id }})">Slet</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if serie.sets|length == 0 %}
<div class="card" style="text-align:center;color:var(--text-muted);margin-top:16px;">
<p>Ingen sæt i {{ serie.name }} endnu.</p>
</div>
{% endif %}
<script>
async function createSet(e) {
e.preventDefault();
const serieName = document.getElementById('set-serie-name').value;
const code = document.getElementById('set-code').value.trim();
const name = document.getElementById('set-name').value.trim();
if (!code || !name) return;
const release_date = document.getElementById('set-date').value || null;
const description = document.getElementById('set-desc').value.trim() || null;
const res = await fetch('/api/admin/series/' + encodeURIComponent(serieName) + '/sets', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code, name, release_date, description})
});
const data = await res.json();
if (data.ok) {
location.reload();
} else {
alert(data.detail || 'Fejl');
}
}
async function deleteSet(id) {
if (!confirm('Slet dette sæt og alle dets kort?')) return;
const res = await fetch('/api/admin/sets/' + id, {method: 'DELETE'});
const data = await res.json();
if (data.ok) {
document.getElementById('set-' + id).remove();
} else {
alert('Fejl ved sletning');
}
}
async function importSets(e) {
e.preventDefault();
const file = document.getElementById('sets-csv').files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
const serieName = document.getElementById('set-serie-name').value;
const res = await fetch('/api/admin/series/' + encodeURIComponent(serieName) + '/sets/import', {
method: 'POST', body: form
});
const data = await res.json();
const el = document.getElementById('sets-import-result');
if (data.ok) {
el.innerHTML = '<p style="color:green;">\u2713 ' + data.created + ' s\u00e6t oprettet</p>';
if (data.errors.length) {
el.innerHTML += '<p style="color:var(--accent);">' + data.errors.join('<br>') + '</p>';
}
setTimeout(() => location.reload(), 1500);
} else {
el.innerHTML = '<p style="color:var(--accent);">Fejl ved import</p>';
}
}
</script>
{% endblock %}
+2 -1
View File
@@ -36,9 +36,10 @@
<tbody id="series-list">
{% for s in series %}
<tr id="serie-{{ s.id }}">
<td><strong>{{ s.name }}</strong></td>
<td><a href="/admin/series/{{ s.name|urlencode }}" style="color:var(--text);font-weight:600;">{{ s.name }}</a></td>
<td>{{ s.sets|length }}</td>
<td>
<a href="/admin/series/{{ s.name|urlencode }}" class="btn btn-outline btn-sm">Se sæt</a>
<button class="btn btn-danger btn-sm" onclick="deleteSerie({{ s.id }})">Slet</button>
</td>
</tr>
+7 -5
View File
@@ -1,10 +1,16 @@
<!DOCTYPE html>
<html lang="da" data-theme="light">
<html lang="da">
<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>">
<script>
(function(){
var t = localStorage.getItem('pokedexter-theme');
if (t) document.documentElement.setAttribute('data-theme', t);
})();
</script>
<style>
:root {
--bg: #f5f0e5;
@@ -259,10 +265,6 @@
</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');
+1
View File
@@ -6,3 +6,4 @@ jinja2==3.1.4
python-multipart==0.0.12
bcrypt==5.0.0
python-jose[cryptography]==3.3.0
httpx==0.27.0
+4 -1
View File
@@ -24,15 +24,18 @@ services:
depends_on:
db:
condition: service_healthy
volumes:
- pokedexter_images:/app/static/images
nginx:
image: pokedexter-nginx:latest
# build: ./nginx
container_name: pokedexter-nginx
ports:
- "8080:80"
- "8085:80"
depends_on:
- backend
volumes:
pokedexter_pgdata:
pokedexter_images: