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
+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');