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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user