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:
@@ -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, sæ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
|
||||
|
||||
På **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
|
||||
|
||||
På **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"}'
|
||||
```
|
||||
På **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
|
||||
|
||||
---
|
||||
|
||||
@@ -113,10 +140,10 @@ 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 | `/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) |
|
||||
@@ -132,18 +159,27 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| GET | `/api/admin/sets/{code}/cards` | List kort i serie |
|
||||
| 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,28 +193,34 @@ 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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -186,7 +228,7 @@ Card
|
||||
## Miljøvariable
|
||||
|
||||
| Variabel | Standard | Beskrivelse |
|
||||
|---------------|----------------------------------|----------------------|
|
||||
|----------------|----------------------------------|----------------------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL forbindelse |
|
||||
| `SECRET_KEY` | `skift-mig-til-en-sikker-noegle` | JWT signeringsnøgle |
|
||||
|
||||
@@ -199,6 +241,7 @@ pokedexter/
|
||||
├── docker-compose.yml
|
||||
├── start.sh # podman start
|
||||
├── stop.sh # podman stop
|
||||
├── README.md
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
@@ -207,12 +250,15 @@ pokedexter/
|
||||
│ ├── 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
|
||||
│ ├── static/
|
||||
│ │ └── images/ # uploadet/downloadede billeder (volume)
|
||||
│ ├── templates/ # Jinja2 HTML templates
|
||||
│ └── static/ # CSS, JS, billeder
|
||||
│ │ ├── 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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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;">← 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>
|
||||
— <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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user