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 ⚡ # 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 ## Arkitektur
``` ```
Browser ──► Nginx (:8080) ──► FastAPI (:8000) ──► PostgreSQL (:5432) Browser ──► Nginx (:8085) ──► FastAPI (:8000) ──► PostgreSQL (:5432)
▲ │ ▲ │
└─── static/ ───────┘ └─── static/ ───────┘
``` ```
- **Nginx** — reverse proxy, serverer statiske filer - **Nginx** — reverse proxy, serverer statiske filer på port 8085
- **FastAPI** — async Python backend, Jinja2 templates - **FastAPI** — async Python backend, Jinja2 templates
- **PostgreSQL 16** — database med SQLAlchemy (async) - **PostgreSQL 16** — database med SQLAlchemy (async/asyncpg)
--- ---
## Kom i gang ## Kom i gang
### Docker Billeder bygges lokalt først, deploy sker via Portainer.
### Byg lokalt (podman)
```bash ```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 ```bash
./start.sh podman-compose up -d # podman
docker compose up -d # docker
``` ```
### Stop ### Stop
```bash ```bash
./stop.sh # podman podman-compose down
docker compose down # docker 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 ## Sådan bruges det
### Opret en serie (set) ### Opret en serie
1. Log ind på `/login` med `admin` / `admin123` 1. Log ind på `/login` med `admin` / `admin123`
2. Gå til **Admin → Serier** 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** 4. Tryk **Opret**
### Tilføj kort til en serie ### Opret sæt under en serie
1. Gå til **Admin → Serier** 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 2. Klik **Vis kort** på det ønskede sæt
3. Udfyld kortets detaljer (nummer, navn, type, sjældenhed, HP, stadie) 3. Udfyld **nummer**, **navn** og evt. **image URL**
4. Tryk **Tilføj kort** 4. Tryk **Opret kort**
Eksempel: Eller **bulk import** via CSV:
| Felt | Værdi | ```
|------------|-----------------| SCR,001,Caterpie,https://images.pokemontcg.io/scarlet_ivy/1.png
| Nummer | `068` | SCR,002,Metapod,
| Navn | `Chaos Dragon` | ```
| Type | `Dragon` |
| Rarity | `Rare` |
| HP | `120` |
| Stadie | `Basic` |
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) ### Opret brugere (admin)
API — ingen HTML-side endnu: **Admin**-dashboardet under "Brugere" kan du oprette og slette brugere. Du kan ikke slette dig selv.
```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 ### Browse kort
- `/sets` — liste alle serier - `/sets` — liste alle serier med sæt
- `/sets/CRI` — se alle kort i serie `CRI` - `/sets/{code}` — se alle kort i sæt `EVS`
- `/card/CRI-068` — detaljer om et specifikt kort - `/card/{full_code}` — detaljer om et specifikt kort
### Søg ### Søg
- `/search?q=CRI-068` — søg på fuld kortkode - `/search?q=SCR-001` — søg på fuld kortkode
- `/search?q=dragon` — søg på kortnavn - `/search?q=pikachu` — søg på kortnavn
--- ---
@@ -112,13 +139,13 @@ curl -X POST http://localhost:8080/api/admin/users \
### Offentlige endpoints ### Offentlige endpoints
| Metode | Sti | Beskrivelse | | Metode | Sti | Beskrivelse |
|--------|----------------|------------------------| |--------|----------------------|------------------------|
| GET | `/` | Forside | | GET | `/` | Forside |
| GET | `/sets` | Liste alle serier | | GET | `/sets` | Liste alle serier |
| GET | `/sets/{code}` | Kort i en serie | | GET | `/sets/{code}` | Kort i et sæt |
| GET | `/search` | Søgeside | | GET | `/search` | Søgeside |
| GET | `/api/search` | Søg (JSON) | | GET | `/api/search` | Søg (JSON) |
| GET | `/api/cards/{full_code}` | Enkelt kort (JSON) | | GET | `/api/cards/{full_code}` | Enkelt kort (JSON) |
### Auth endpoints ### Auth endpoints
@@ -131,19 +158,28 @@ curl -X POST http://localhost:8080/api/admin/users \
### Admin endpoints (kræver admin) ### Admin endpoints (kræver admin)
| Metode | Sti | Beskrivelse | | Metode | Sti | Beskrivelse |
|--------|-------------------------------------|--------------------------------| |--------|--------------------------------------------|-------------------------------------|
| GET | `/admin` | Admin dashboard | | GET | `/admin` | Admin dashboard |
| GET | `/admin/sets` | Administrér serier | | GET | `/admin/series` | Administrér serier |
| GET | `/admin/cards/{code}` | Administrér kort i serie | | GET | `/admin/series/{name}` | Serie-detail med sæt |
| POST | `/api/admin/users` | Opret bruger | | GET | `/admin/sets` | Administrér sæt |
| GET | `/api/admin/users` | List brugere | | GET | `/admin/sets/{code}` | Administrér kort i sæt |
| POST | `/api/admin/sets` | Opret serie | | POST | `/api/admin/users` | Opret bruger |
| GET | `/api/admin/sets` | List serier | | DELETE | `/api/admin/users/{id}` | Slet bruger (ikke dig selv) |
| DELETE | `/api/admin/sets/{id}` | Slet serie | | POST | `/api/admin/series` | Opret serie |
| POST | `/api/admin/sets/{code}/cards` | Opret kort | | POST | `/api/admin/series/csv` | Bulk import af serier (CSV) |
| GET | `/api/admin/sets/{code}/cards` | List kort i serie | | DELETE | `/api/admin/series/{id}` | Slet serie |
| DELETE | `/api/admin/cards/{id}` | Slet kort | | 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") role String ("admin" | "user")
created_at DateTime created_at DateTime
Serie
id Integer (PK)
name String (unique) — "Sword & Shield"
description Text (optional)
created_at DateTime
sets ► Set[]
Set Set
id Integer (PK) id Integer (PK)
code String (unique) — "CRI" serie_id Integer (FK → series.id)
name String — "Chaos Rising" code String (unique) — "EVS"
name String — "Evolving Skies"
release_date String (optional) release_date String (optional)
description Text (optional) description Text (optional)
created_at DateTime created_at DateTime
serie ► Serie
cards ► Card[] cards ► Card[]
Card Card
id Integer (PK) id Integer (PK)
set_id Integer (FK → sets.id) set_id Integer (FK → sets.id)
number String — "068" number String — "050"
name String — "Chaos Dragon" name String — "Raichu"
type String — "Dragon", "Fire", ... image_path String (optional) — lokal sti eller URL
rarity String — "Common", "Uncommon", "Rare", ... source_url String (optional) — original ekstern URL til re-sync
hp Integer
stage String — "Basic", "Stage 1", "Stage 2"
description Text
image_url String (optional)
set ► Set set ► Set
full_code [property] — "CRI-068" full_code [property] — "EVS-050"
image_url [property] — "/static/..." eller None (ingen eksterne URLs)
``` ```
--- ---
## Miljøvariable ## Miljøvariable
| Variabel | Standard | Beskrivelse | | Variabel | Standard | Beskrivelse |
|---------------|----------------------------------|----------------------| |----------------|----------------------------------|----------------------|
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL forbindelse | | `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/ pokedexter/
├── docker-compose.yml ├── docker-compose.yml
├── start.sh # podman start ├── start.sh # podman start
├── stop.sh # podman stop ├── stop.sh # podman stop
├── README.md
├── backend/ ├── backend/
│ ├── Dockerfile │ ├── Dockerfile
│ ├── requirements.txt │ ├── requirements.txt
│ └── app/ │ └── app/
│ ├── main.py # FastAPI app, middleware │ ├── main.py # FastAPI app, middleware
│ ├── database.py # async engine + session │ ├── database.py # async engine + session
│ ├── models.py # SQLAlchemy modeller │ ├── models.py # SQLAlchemy modeller
│ ├── auth.py # bcrypt + JWT │ ├── auth.py # bcrypt + JWT
│ ├── routers/ │ ├── static/
│ │ ── auth.py # login/logout + admin user mgmt │ │ ── images/ # uploadet/downloadede billeder (volume)
│ ├── admin.py # admin CRUD for sets/cards ├── templates/ # Jinja2 HTML templates
│ │ ── cards.py # browse + search │ │ ── base.html # layout m. dark mode toggle
├── templates/ # Jinja2 HTML templates │ └── admin/ # admin UI templates
│ └── static/ # CSS, JS, billeder │ └── routers/
│ ├── auth.py # login/logout + admin user mgmt
│ ├── admin.py # admin CRUD for series/sets/cards
│ └── cards.py # browse + search
├── nginx/ ├── nginx/
│ ├── Dockerfile │ ├── Dockerfile
│ └── nginx.conf │ └── nginx.conf
@@ -224,22 +270,33 @@ pokedexter/
## Udvikling ## Udvikling
Genstart backend efter kodeændringer: ### Genstart backend efter kodeændringer
```bash ```bash
docker compose up -d --build backend # Byg nyt image
# eller med podman: podman build -t pokedexter-backend:latest -f backend/Dockerfile backend/
# Genstart via Portainer, eller:
podman stop pokedexter-backend && podman rm pokedexter-backend podman stop pokedexter-backend && podman rm pokedexter-backend
podman build -t pokedexter-backend ./backend podman-compose up -d backend
# ... og kør igen med podman run ...
``` ```
### 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 ## Planlagte features
- [ ] Personlig mappe — brugere kan markere kort de ejer/samler - [ ] Personlig mappe — brugere kan markere kort de ejer/samler
- [ ] Kortbilleder (upload eller URL)
- [ ] Filtrering på type, sjældenhed, HP - [ ] Filtrering på type, sjældenhed, HP
- [ ] Dark mode toggle gemt i localStorage *(done)* - [ ] Kort-detalje side med artwork
- [ ] Mobilvenlig navigation *(done)* - [ ] 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) set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True)
number = Column(String(10), nullable=False) # "050" number = Column(String(10), nullable=False) # "050"
name = Column(String(200), nullable=False) # "Raichu" 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") set = relationship("Set", back_populates="cards")
@@ -58,9 +59,9 @@ class Card(Base):
@property @property
def image_url(self): 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: if not self.image_path:
return None return None
if self.image_path.startswith(("http://", "https://")): 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}" return f"/static/{self.image_path}"
+244 -8
View File
@@ -4,6 +4,7 @@ import os
import uuid import uuid
from pathlib import Path from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -23,6 +24,38 @@ UPLOAD_DIR = Path("app/static/images")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) 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 ─────────────────────────────────────────────── # ─── Pydantic schemas ───────────────────────────────────────────────
class SerieCreate(BaseModel): class SerieCreate(BaseModel):
@@ -49,8 +82,13 @@ class NewUserForm(BaseModel):
# ─── Admin HTML pages ─────────────────────────────────────────────── # ─── Admin HTML pages ───────────────────────────────────────────────
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, user: User = Depends(require_admin)): async def admin_dashboard(request: Request, db: AsyncSession = Depends(get_db),
return templates.TemplateResponse("admin/dashboard.html", {"request": request}) 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) @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}) 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) @router.get("/admin/sets", response_class=HTMLResponse)
async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db), async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)): 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)} return [{"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)}
for u in result.scalars().all()] 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 ──────────────────────────────────────────────────── # ─── 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)} return [{"id": s.id, "code": s.code, "name": s.name, "card_count": len(s.cards)}
for s in serie.sets] 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") @router.get("/api/admin/sets")
async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)): async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
result = await db.execute( 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") @router.post("/api/admin/sets/{set_code}/cards")
async def create_card(set_code: str, data: CardCreate, db: AsyncSession = Depends(get_db), async def create_card(set_code: str, data: CardCreate, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)): _: 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() s = result.scalar_one_or_none()
if not s: if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet") raise HTTPException(status_code=404, detail="Set ikke fundet")
card = Card(set_id=s.id, number=data.number, name=data.name) card = Card(set_id=s.id, number=data.number, name=data.name)
if data.image: 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) db.add(card)
await db.commit() await db.commit()
return {"ok": True, "id": card.id, "full_code": card.full_code, "name": card.name} 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") errors.append(f"Linje {i}: mangler set_code, number eller name")
continue continue
try: 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() s = result.scalar_one_or_none()
if not s: if not s:
errors.append(f"Linje {i}: set '{set_code}' ikke fundet") errors.append(f"Linje {i}: set '{set_code}' ikke fundet")
continue continue
image = row.get("image", "").strip() or None raw_url = row.get("image", "").strip() or None
card = Card(set_id=s.id, number=number, name=name, image_path=image) 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) db.add(card)
created += 1 created += 1
except Exception as e: 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} 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 ────────────────────────────────────────────────── # ─── CSV Templates ──────────────────────────────────────────────────
@router.get("/api/admin/templates/series.csv", response_class=PlainTextResponse) @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) @router.get("/api/admin/templates/cards.csv", response_class=PlainTextResponse)
async def cards_csv_template(_: User = Depends(require_admin)): 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> </form>
<div id="cards-import-result" style="margin-top:8px;"></div> <div id="cards-import-result" style="margin-top:8px;"></div>
</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> </div>
<h2>Kort i {{ set.code }}</h2> <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><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><span class="tag">{{ card.full_code }}</span></td>
<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"> <a href="{{ card.image_url }}" target="_blank">
<img src="{{ card.image_url }}" alt="{{ card.name }}" <img src="{{ card.image_url }}" alt="{{ card.name }}"
style="height:40px;border-radius:4px;"> style="height:40px;border-radius:4px;">
@@ -142,5 +155,25 @@ async function uploadImage(e, cardId) {
alert(data.detail || 'Fejl ved upload'); 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> </script>
{% endblock %} {% endblock %}
+110
View File
@@ -27,4 +27,114 @@
</div> </div>
</a> </a>
</div> </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 %} {% 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"> <tbody id="series-list">
{% for s in series %} {% for s in series %}
<tr id="serie-{{ s.id }}"> <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>{{ s.sets|length }}</td>
<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> <button class="btn btn-danger btn-sm" onclick="deleteSerie({{ s.id }})">Slet</button>
</td> </td>
</tr> </tr>
+7 -5
View File
@@ -1,10 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="da" data-theme="light"> <html lang="da">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pokedexter — {% block title %}Pokémon TCG samling{% endblock %}</title> <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>"> <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> <style>
:root { :root {
--bg: #f5f0e5; --bg: #f5f0e5;
@@ -259,10 +265,6 @@
</main> </main>
<script> <script>
// Load saved theme
const saved = localStorage.getItem('pokedexter-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
function toggleTheme() { function toggleTheme() {
const html = document.documentElement; const html = document.documentElement;
const cur = html.getAttribute('data-theme'); const cur = html.getAttribute('data-theme');
+1
View File
@@ -6,3 +6,4 @@ jinja2==3.1.4
python-multipart==0.0.12 python-multipart==0.0.12
bcrypt==5.0.0 bcrypt==5.0.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
httpx==0.27.0
+4 -1
View File
@@ -24,15 +24,18 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes:
- pokedexter_images:/app/static/images
nginx: nginx:
image: pokedexter-nginx:latest image: pokedexter-nginx:latest
# build: ./nginx # build: ./nginx
container_name: pokedexter-nginx container_name: pokedexter-nginx
ports: ports:
- "8080:80" - "8085:80"
depends_on: depends_on:
- backend - backend
volumes: volumes:
pokedexter_pgdata: pokedexter_pgdata:
pokedexter_images: