bollwerk/server/src/main/resources/static/admin/index.html
Jens Reinemann e88e2d04c0 feat(server): add D&D resource upload with metadata extraction and tag suggestions
- Add ResourceAnalyzer: PDF (PDFBox), EPUB, image (EXIF/IPTC) metadata extraction
- Add POST /api/admin/resources/analyze + /confirm endpoints
- Add GET /api/admin/resources/tags with default tag seeding
- Admin UI: D&D zone, review panel with textarea description (4096 chars, MD), tag chips
- Dependencies: PDFBox 3.0.4, Commons Compress 1.27.1, metadata-extractor 2.19.0
2026-05-18 23:13:17 +02:00

1177 lines
No EOL
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bollwerk Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #16140F; color: #E8D5B0; position: relative; }
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.04;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 200px 200px;
}
body > * { position: relative; z-index: 1; }
h1, h2, h3, label, .stat-label, .inv-stats-toggle { font-family: 'Share Tech Mono', 'Courier New', monospace; }
h1, h2 { letter-spacing: 0.05em; }
h2::before { content: '▣ '; color: #C1440E; }
header { background: #1A1815; color: #E8D5B0; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #C1440E; }
header h1 { font-size: 1.2rem; text-transform: uppercase; }
#logout-btn { background: transparent; border: 1px solid #4A3F2F; color: #A89070; padding: 6px 14px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
#logout-btn:hover { border-color: #C1440E; color: #E8D5B0; }
main { max-width: 960px; margin: 32px auto; padding: 0 16px; }
.card { background: #242119; border: 1px solid #4A3F2F; border-left: 3px solid #8B3A0F; border-radius: 3px; padding: 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.6); margin-bottom: 24px; }
h2 { margin-bottom: 20px; font-size: 1.1rem; color: #E8D5B0; }
label { display: block; margin-bottom: 4px; font-size: .875rem; color: #A89070; text-transform: uppercase; }
input, select { width: 100%; padding: 8px 12px; border: 1px solid #4A3F2F; border-radius: 3px; margin-bottom: 14px; font-size: 1rem; background: #1C1A14; color: #E8D5B0; }
input:focus, select:focus { outline: none; border-color: #C1440E; box-shadow: 0 0 0 3px rgba(193,68,14,0.4); }
button.primary { background: #C1440E; color: #E8D5B0; border: none; padding: 10px 20px; border-radius: 3px; cursor: pointer; font-size: .9rem; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; text-shadow: 0 1px 2px rgba(0,0,0,0.4); }
button.primary:hover { background: #D95A20; }
button.danger { background: #8B1A1A; color: #E8D5B0; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
button.danger:hover { background: #A52020; }
button.secondary { background: transparent; color: #A89070; border: 1px solid #4A3F2F; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
button.secondary:hover { border-color: #A89070; color: #E8D5B0; }
button.info { background: #C1440E; color: #E8D5B0; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
button.info:hover { background: #D95A20; }
.error { color: #D95A20; margin-bottom: 12px; font-size: .875rem; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #4A3F2F; font-size: .9rem; color: #E8D5B0; }
th { background: #2E2B22; font-weight: 600; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .8rem; color: #A89070; }
tr:nth-child(even) { background: #1E1C17; }
tr:nth-child(odd) { background: #242119; }
tr:hover { background: #2E2B22; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.inv-badge { display: inline-block; font-size: .75rem; background: #3A2E10; color: #C8A840; border: 1px solid #8B6914; border-radius: 3px; padding: 2px 7px; font-family: monospace; cursor: default; }
.inv-group { background: #1E1C17; border: 1px solid #4A3F2F; border-radius: 3px; padding: 14px 18px; margin-bottom: 12px; }
.inv-group h3 { font-size: .85rem; color: #A89070; margin-bottom: 8px; font-weight: 600; }
.inv-group ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.inv-group ul li { background: #242119; border: 1px solid #4A3F2F; border-radius: 3px; padding: 4px 10px; font-size: .875rem; color: #E8D5B0; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 8px; }
.stat-card { background: #1E1C17; border: 1px solid #4A3F2F; border-radius: 3px; padding: 16px; text-align: center; }
.stat-card .stat-value { font-size: 1.8rem; font-weight: 700; color: #C1440E; margin-bottom: 4px; }
.stat-card .stat-label { font-size: .8rem; color: #A89070; text-transform: uppercase; letter-spacing: .5px; }
#add-user-form { display: none; background: #1E1C17; border: 1px solid #4A3F2F; border-radius: 3px; padding: 20px; margin-top: 16px; }
#add-user-form.open { display: block; }
#add-user-form h3 { margin-bottom: 14px; font-size: 1rem; color: #E8D5B0; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: #242119; border: 2px solid #C1440E; border-radius: 3px; padding: 28px; width: 400px; max-width: 95vw; }
.modal h3 { margin-bottom: 16px; color: #E8D5B0; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
/* Per-inventory stats */
.inv-stats-toggle { background: none; border: none; cursor: pointer; font-size: .85rem; color: #C1440E; padding: 0; margin-top: 12px; text-decoration: underline; }
.inv-stats-toggle:hover { color: #D95A20; }
#inv-stats-section { display: none; margin-top: 16px; }
#inv-stats-section.open { display: block; }
#inv-stats-table th { cursor: pointer; user-select: none; white-space: nowrap; }
#inv-stats-table th:hover { background: #3A3428; }
#inv-stats-table th .sort-arrow { margin-left: 4px; color: #6B5B3E; font-size: .8rem; }
/* Inventory list table */
.table-controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
.table-controls input[type=text] { flex: 1; min-width: 180px; margin: 0; }
.table-controls select { width: auto; margin: 0; }
#inv-table th { cursor: pointer; user-select: none; white-space: nowrap; }
#inv-table th:hover { background: #3A3428; }
#inv-table th .sort-arrow { margin-left: 4px; color: #6B5B3E; font-size: .8rem; }
.paging-bar { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
.paging-bar button { padding: 4px 10px; font-size: .85rem; }
.paging-bar span { font-size: .85rem; color: #A89070; }
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #6B5B3E; border-top: 1px solid #4A3F2F; }
/* Tab navigation */
.tab-bar { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid #4A3F2F; }
.tab-btn { background: transparent; border: none; border-bottom: 3px solid transparent; color: #6B5B3E; padding: 12px 24px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .95rem; letter-spacing: .05em; transition: color .2s, border-color .2s; }
.tab-btn:hover { color: #E8D5B0; }
.tab-btn.active { color: #E8D5B0; border-bottom-color: #C1440E; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Login */
#login-section { max-width: 360px; margin-left: auto; margin-right: auto; }
/* Scrollbar styling */
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: #1A1815; }
::-webkit-scrollbar-thumb { background: #4A3F2F; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #C1440E; }
</style>
</head>
<body>
<header>
<h1>Bollwerk Admin</h1>
<button id="logout-btn" style="display:none" onclick="logout()">Abmelden</button>
</header>
<main>
<section id="login-section" class="card">
<h2>Anmelden</h2>
<div id="login-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="login-username" type="text" autocomplete="username">
<label>Passwort</label>
<input id="login-password" type="password" autocomplete="current-password">
<button class="primary" onclick="login()">Anmelden</button>
</section>
<div id="admin-section" style="display:none">
<!-- Tab Navigation -->
<nav class="tab-bar">
<button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button>
<button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button>
<button class="tab-btn" data-tab="tab-resources" onclick="switchTab('tab-resources')">Ressourcen</button>
<button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button>
</nav>
<!-- Tab: User -->
<div id="tab-users" class="tab-content active">
<!-- Stats Dashboard -->
<section class="card">
<h2>Statistiken</h2>
<div id="stats-grid" class="stats-grid">
<div class="stat-card"><div class="stat-value" id="stat-items"></div><div class="stat-label">Artikel</div></div>
<div class="stat-card"><div class="stat-value" id="stat-locations"></div><div class="stat-label">Lagerorte</div></div>
<div class="stat-card"><div class="stat-value" id="stat-categories"></div><div class="stat-label">Kategorien</div></div>
<div class="stat-card"><div class="stat-value" id="stat-recent"></div><div class="stat-label">Änderungen (30 Tage)</div></div>
<div class="stat-card"><div class="stat-value" id="stat-last-updated" style="font-size:1rem"></div><div class="stat-label">Letzte Änderung</div></div>
</div>
<button class="inv-stats-toggle" onclick="toggleInvStats()">▶ Statistiken pro Inventar anzeigen</button>
<div id="inv-stats-section">
<table id="inv-stats-table">
<thead>
<tr>
<th onclick="sortInvStats('inventoryName')">Name <span class="sort-arrow" id="isort-inventoryName"></span></th>
<th onclick="sortInvStats('totalItems')">Artikel <span class="sort-arrow" id="isort-totalItems"></span></th>
<th onclick="sortInvStats('totalLocations')">Lagerorte <span class="sort-arrow" id="isort-totalLocations"></span></th>
<th onclick="sortInvStats('totalCategories')">Kategorien <span class="sort-arrow" id="isort-totalCategories"></span></th>
<th onclick="sortInvStats('recentTransactions')">Änd. 30T <span class="sort-arrow" id="isort-recentTransactions"></span></th>
<th onclick="sortInvStats('lastUpdated')">Letzte Änd. <span class="sort-arrow" id="isort-lastUpdated"></span></th>
<th onclick="sortInvStats('userCount')">Benutzer <span class="sort-arrow" id="isort-userCount"></span></th>
</tr>
</thead>
<tbody id="inv-stats-body"></tbody>
</table>
</div>
</section>
<!-- User Management -->
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Benutzerverwaltung</h2>
<button class="primary" onclick="toggleAddForm()">+ Benutzer anlegen</button>
</div>
<div id="add-user-form">
<h3>Neuen Benutzer anlegen</h3>
<div id="add-user-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="new-username" type="text">
<label>Passwort</label>
<input id="new-password" type="password">
<div style="display:flex; gap:10px">
<button class="primary" onclick="createUser()">Anlegen</button>
<button class="secondary" onclick="toggleAddForm()">Abbrechen</button>
</div>
</div>
<table id="users-table">
<thead><tr><th>Benutzername</th><th>Erstellt</th><th>Admin</th><th>Inventar</th><th>Aktionen</th></tr></thead>
<tbody id="users-body"></tbody>
</table>
</section>
</div><!-- /tab-users -->
<!-- Tab: Inventare -->
<div id="tab-inventories" class="tab-content">
<!-- Inventory Overview -->
<section class="card">
<h2>Inventar-Übersicht</h2>
<div class="table-controls">
<input type="text" id="inv-search" placeholder="Suche nach Name oder ID…" oninput="applyInvFilter()">
<select id="inv-filter" onchange="applyInvFilter()">
<option value="all">Alle Inventare</option>
<option value="with-users">Mit Benutzern</option>
<option value="without-users">Ohne Benutzer</option>
</select>
<select id="inv-page-size" onchange="invPageSize=+this.value; invPage=0; renderInvTable()">
<option value="10">10 / Seite</option>
<option value="25">25 / Seite</option>
<option value="50">50 / Seite</option>
</select>
</div>
<table id="inv-table">
<thead>
<tr>
<th onclick="sortInv('inventoryName')">Name <span class="sort-arrow" id="sort-inventoryName"></span></th>
<th onclick="sortInv('totalItems')">Artikel <span class="sort-arrow" id="sort-totalItems"></span></th>
<th onclick="sortInv('totalLocations')">Lagerorte <span class="sort-arrow" id="sort-totalLocations"></span></th>
<th onclick="sortInv('totalCategories')">Kategorien <span class="sort-arrow" id="sort-totalCategories"></span></th>
<th onclick="sortInv('userCount')">Benutzer <span class="sort-arrow" id="sort-userCount"></span></th>
<th onclick="sortInv('lastUpdated')">Letzte Änderung <span class="sort-arrow" id="sort-lastUpdated"></span></th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="inv-table-body"></tbody>
</table>
<div class="paging-bar">
<button class="secondary" onclick="invPage--; renderInvTable()" id="inv-prev"> Zurück</button>
<span id="inv-page-info"></span>
<button class="secondary" onclick="invPage++; renderInvTable()" id="inv-next">Weiter </button>
</div>
</section>
</div><!-- /tab-inventories -->
<!-- Tab: Ressourcen -->
<div id="tab-resources" class="tab-content">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Ressourcen-Katalog</h2>
<div style="display:flex; gap:8px">
<button class="secondary" onclick="loadResources()">↻ Aktualisieren</button>
<button class="primary" onclick="toggleResourceForm()">+ Manuell hochladen</button>
<button class="primary" onclick="toggleDndZone()">⇪ Drag &amp; Drop</button>
</div>
</div>
<!-- Drag & Drop Zone -->
<div id="dnd-zone" style="display:none; margin-bottom:16px;">
<div id="dnd-area" style="border:2px dashed #4A3F2F; border-radius:3px; padding:48px 24px; text-align:center; background:#1E1C17; cursor:pointer; transition: border-color .2s, background .2s;">
<p style="font-size:1.1rem; color:#A89070; margin-bottom:8px;">Datei hierher ziehen</p>
<p style="font-size:.85rem; color:#6B5B3E;">PDF, EPUB, Bilder, ZIP, 7z max 25 MB</p>
<p style="font-size:.85rem; color:#6B5B3E; margin-top:4px;">Metadaten werden automatisch extrahiert</p>
</div>
<div id="dnd-progress" style="display:none; text-align:center; padding:20px; color:#A89070;">
Wird analysiert…
</div>
</div>
<!-- Review Panel for analyzed resources -->
<div id="dnd-review" style="display:none; margin-bottom:16px;"></div>
<div id="add-resource-form" style="display:none; background:#1E1C17; border:1px solid #4A3F2F; border-radius:3px; padding:20px; margin-bottom:16px;">
<h3 style="margin-bottom:14px; color:#E8D5B0;">Neue Ressource hochladen</h3>
<div id="resource-error" class="error" style="display:none"></div>
<label>Titel *</label>
<input id="res-title" type="text" placeholder="z.B. GURPS Basic Set">
<label>Beschreibung (Markdown, max 4096 Zeichen)</label>
<textarea id="res-description" maxlength="4096" rows="4" placeholder="Freitext-Beschreibung…" style="width:100%; resize:vertical; background:#1C1A14; color:#E8D5B0; border:1px solid #4A3F2F; border-radius:3px; padding:8px 12px; font-size:1rem; font-family:inherit; margin-bottom:14px;"></textarea>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
<div><label>Autor</label><input id="res-author" type="text"></div>
<div><label>Sprache</label><input id="res-language" type="text" placeholder="de, en, …"></div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
<div><label>Edition</label><input id="res-edition" type="text" placeholder="z.B. 4th Edition"></div>
<div><label>Erscheinungsdatum</label><input id="res-releaseDate" type="date"></div>
</div>
<label>Tags (kommagetrennt)</label>
<input id="res-tags" type="text" placeholder="gurps, rulebook, deutsch">
<label>Datei * (max 25 MB)</label>
<input id="res-file" type="file" accept=".epub,.pdf,.zip,.7z" style="padding:6px">
<div style="display:flex; gap:10px; margin-top:10px">
<button class="primary" onclick="uploadResource()">Hochladen</button>
<button class="secondary" onclick="toggleResourceForm()">Abbrechen</button>
</div>
</div>
<table id="resources-table">
<thead>
<tr>
<th>Titel</th>
<th>Format</th>
<th>Größe</th>
<th>Tags</th>
<th>Autor</th>
<th>Hochgeladen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="resources-body"><tr><td colspan="7" style="text-align:center;color:#6B5B3E">Tab auswählen um zu laden…</td></tr></tbody>
</table>
</section>
</div><!-- /tab-resources -->
<!-- Tab: Backups -->
<div id="tab-backups" class="tab-content">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Backups</h2>
<button class="secondary" onclick="loadBackups()">↻ Aktualisieren</button>
</div>
<table id="backups-table">
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th></tr></thead>
<tbody id="backups-body"><tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr></tbody>
</table>
</section>
</div><!-- /tab-backups -->
</div>
</main>
<!-- Change Password Modal -->
<div id="pw-modal" class="modal-overlay">
<div class="modal">
<h3>Passwort ändern</h3>
<div id="pw-error" class="error" style="display:none"></div>
<label>Neues Passwort</label>
<input id="pw-input" type="password">
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmPasswordChange()">Speichern</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="del-modal" class="modal-overlay">
<div class="modal">
<h3>Benutzer löschen?</h3>
<p id="del-confirm-text" style="margin-bottom:16px; font-size:.9rem; color:#A89070"></p>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="danger" onclick="confirmDelete()">Löschen</button>
</div>
</div>
</div>
<!-- Assign Inventory Modal -->
<div id="assign-modal" class="modal-overlay">
<div class="modal">
<h3>Inventar zuweisen</h3>
<p style="margin-bottom:14px; font-size:.875rem; color:#A89070">Wähle ein bestehendes Inventar für diesen Benutzer:</p>
<div id="assign-error" class="error" style="display:none"></div>
<label>Inventar</label>
<select id="assign-inventory-select"></select>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmAssign()">Zuweisen</button>
</div>
</div>
</div>
<script>
let accessToken = sessionStorage.getItem('accessToken') || '';
let pendingUserId = null;
let allInventories = [];
let allInvStats = [];
// Inventory table state
let invData = [];
let invFiltered = [];
let invPage = 0;
let invPageSize = 10;
let invSortCol = 'inventoryName';
let invSortAsc = true;
// Per-inventory stats table state
let invStatsSortCol = 'inventoryName';
let invStatsSortAsc = true;
if (accessToken) tryLoadUsers();
async function login() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const err = document.getElementById('login-error');
err.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Login fehlgeschlagen';
err.style.display = 'block';
return;
}
const data = await res.json();
accessToken = data.accessToken;
sessionStorage.setItem('accessToken', accessToken);
showAdmin();
} catch (e) {
err.textContent = 'Verbindungsfehler';
err.style.display = 'block';
}
}
function showAdmin() {
document.getElementById('login-section').style.display = 'none';
document.getElementById('admin-section').style.display = 'block';
document.getElementById('logout-btn').style.display = 'inline-block';
loadAll();
}
function tryLoadUsers() {
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } })
.then(r => r.ok ? (showAdmin(), r.json()) : Promise.reject())
.then(renderUsers)
.catch(() => { accessToken = ''; sessionStorage.removeItem('accessToken'); });
}
async function loadAll() {
const [usersRes, inventoriesRes, statsRes, invStatsRes] = await Promise.all([
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/stats', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/stats/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } })
]);
if (!usersRes.ok) { logout(); return; }
renderUsers(await usersRes.json());
if (inventoriesRes.ok) {
allInventories = await inventoriesRes.json();
}
if (statsRes.ok) {
renderStats(await statsRes.json());
}
if (invStatsRes.ok) {
allInvStats = await invStatsRes.json();
renderInvStatsTable();
invData = allInvStats;
applyInvFilter();
}
}
function renderStats(stats) {
document.getElementById('stat-items').textContent = stats.totalItems;
document.getElementById('stat-locations').textContent = stats.totalLocations;
document.getElementById('stat-categories').textContent = stats.totalCategories;
document.getElementById('stat-recent').textContent = stats.recentTransactions;
if (stats.lastUpdated) {
const d = new Date(stats.lastUpdated);
document.getElementById('stat-last-updated').textContent = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} else {
document.getElementById('stat-last-updated').textContent = '';
}
}
// ── Per-inventory stats table ────────────────────────────────────────────
function toggleInvStats() {
const section = document.getElementById('inv-stats-section');
const btn = document.querySelector('.inv-stats-toggle');
const open = section.classList.toggle('open');
btn.textContent = (open ? '▼' : '▶') + ' Statistiken pro Inventar ' + (open ? 'ausblenden' : 'anzeigen');
}
function sortInvStats(col) {
if (invStatsSortCol === col) { invStatsSortAsc = !invStatsSortAsc; }
else { invStatsSortCol = col; invStatsSortAsc = true; }
renderInvStatsTable();
}
function renderInvStatsTable() {
const data = [...allInvStats].sort((a, b) => {
const va = a[invStatsSortCol] ?? '';
const vb = b[invStatsSortCol] ?? '';
if (typeof va === 'number' && typeof vb === 'number') return invStatsSortAsc ? va - vb : vb - va;
return invStatsSortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const cols = ['inventoryName', 'totalItems', 'totalLocations', 'totalCategories', 'recentTransactions', 'lastUpdated', 'userCount'];
cols.forEach(c => {
const el = document.getElementById('isort-' + c);
if (el) el.textContent = invStatsSortCol === c ? (invStatsSortAsc ? '▲' : '▼') : '';
});
const tbody = document.getElementById('inv-stats-body');
tbody.innerHTML = '';
if (!data.length) {
const tr = document.createElement('tr');
const td = document.createElement('td'); td.colSpan = 7; td.textContent = 'Keine Inventare vorhanden.'; td.style.textAlign = 'center'; td.style.color = '#999';
tr.appendChild(td); tbody.appendChild(tr);
return;
}
data.forEach(inv => {
const tr = document.createElement('tr');
const name = inv.inventoryName || inv.inventoryId.slice(0, 8) + '…';
const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '';
[name, inv.totalItems, inv.totalLocations, inv.totalCategories, inv.recentTransactions, lastUpdFmt, inv.userCount].forEach(v => {
const td = document.createElement('td'); td.textContent = v; tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
// ── Inventory list table ─────────────────────────────────────────────────
function applyInvFilter() {
const search = document.getElementById('inv-search').value.toLowerCase();
const filter = document.getElementById('inv-filter').value;
invFiltered = invData.filter(inv => {
const name = (inv.inventoryName || inv.inventoryId || '').toLowerCase();
const id = (inv.inventoryId || '').toLowerCase();
if (search && !name.includes(search) && !id.includes(search)) return false;
if (filter === 'with-users' && inv.userCount === 0) return false;
if (filter === 'without-users' && inv.userCount > 0) return false;
return true;
});
invPage = 0;
renderInvTable();
}
function sortInv(col) {
if (invSortCol === col) { invSortAsc = !invSortAsc; }
else { invSortCol = col; invSortAsc = true; }
renderInvTable();
}
function renderInvTable() {
const sorted = [...invFiltered].sort((a, b) => {
const va = a[invSortCol] ?? '';
const vb = b[invSortCol] ?? '';
if (typeof va === 'number' && typeof vb === 'number') return invSortAsc ? va - vb : vb - va;
return invSortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const cols = ['inventoryName', 'totalItems', 'totalLocations', 'totalCategories', 'userCount', 'lastUpdated'];
cols.forEach(c => {
const el = document.getElementById('sort-' + c);
if (el) el.textContent = invSortCol === c ? (invSortAsc ? '▲' : '▼') : '';
});
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / invPageSize));
if (invPage >= totalPages) invPage = totalPages - 1;
const start = invPage * invPageSize;
const page = sorted.slice(start, start + invPageSize);
const tbody = document.getElementById('inv-table-body');
tbody.innerHTML = '';
if (!page.length) {
const tr = document.createElement('tr');
const td = document.createElement('td'); td.colSpan = 7; td.textContent = 'Keine Inventare gefunden.'; td.style.textAlign = 'center'; td.style.color = '#999';
tr.appendChild(td); tbody.appendChild(tr);
} else {
page.forEach(inv => {
const tr = document.createElement('tr');
const hasName = inv.inventoryName && inv.inventoryName.trim();
const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '';
const tdName = document.createElement('td');
if (hasName) {
tdName.textContent = inv.inventoryName;
const badge = document.createElement('span'); badge.className = 'inv-badge'; badge.title = inv.inventoryId;
badge.textContent = inv.inventoryId.slice(0, 8) + '…'; badge.style.marginLeft = '6px';
tdName.appendChild(badge);
} else {
const badge = document.createElement('span'); badge.className = 'inv-badge'; badge.title = inv.inventoryId;
badge.textContent = inv.inventoryId;
tdName.appendChild(badge);
}
const tds = [tdName];
[inv.totalItems, inv.totalLocations, inv.totalCategories, inv.userCount, lastUpdFmt].forEach(v => {
const td = document.createElement('td'); td.textContent = v; tds.push(td);
});
const tdAct = document.createElement('td'); tdAct.className = 'actions';
const btnUsers = document.createElement('button'); btnUsers.className = 'info'; btnUsers.textContent = 'Benutzer';
btnUsers.onclick = () => showInvUsers(inv.inventoryId);
tdAct.appendChild(btnUsers);
tds.push(tdAct);
tds.forEach(td => tr.appendChild(td));
tbody.appendChild(tr);
});
}
document.getElementById('inv-page-info').textContent = total === 0 ? 'Keine Einträge' : `Seite ${invPage + 1} von ${totalPages} (${total} Inventare)`;
document.getElementById('inv-prev').disabled = invPage === 0;
document.getElementById('inv-next').disabled = invPage >= totalPages - 1;
}
function showInvUsers(inventoryId) {
const inv = allInventories.find(i => i.inventoryId === inventoryId);
if (!inv) { alert('Keine Benutzerinformationen verfügbar.'); return; }
const names = inv.users.length ? inv.users.map(u => u.username + (u.isAdmin ? ' [Admin]' : '')).join('\n') : '(keine Benutzer)';
alert('Inventar ' + inventoryId.slice(0, 8) + '…\n\nBenutzer:\n' + names);
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
tbody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = u.username;
const tdDate = document.createElement('td'); tdDate.textContent = new Date(u.createdAt).toLocaleDateString('de-DE');
const tdAdmin = document.createElement('td'); tdAdmin.textContent = u.isAdmin ? '✓' : '';
const tdInv = document.createElement('td');
if (u.inventoryId) {
const badge = document.createElement('span');
badge.className = 'inv-badge';
badge.title = u.inventoryId;
badge.textContent = u.inventoryId.slice(0, 8) + '…';
tdInv.appendChild(badge);
} else {
tdInv.textContent = '—';
}
const tdActions = document.createElement('td'); tdActions.className = 'actions';
const btnPw = document.createElement('button'); btnPw.className = 'secondary'; btnPw.textContent = 'PW ändern'; btnPw.onclick = () => openPasswordModal(u.id);
const btnAssign = document.createElement('button'); btnAssign.className = 'info'; btnAssign.textContent = 'Inventar wechseln'; btnAssign.onclick = () => openAssignModal(u.id);
const btnNew = document.createElement('button'); btnNew.className = 'secondary'; btnNew.textContent = 'Neues Inventar'; btnNew.onclick = () => createNewInventory(u.id);
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen'; btnDel.onclick = () => openDeleteModal(u.id, u.username);
tdActions.append(btnPw, btnAssign, btnNew, btnDel);
tr.append(tdName, tdDate, tdAdmin, tdInv, tdActions);
tbody.appendChild(tr);
});
}
function toggleAddForm() {
document.getElementById('add-user-form').classList.toggle('open');
}
async function createUser() {
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const err = document.getElementById('add-user-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
toggleAddForm();
loadAll();
}
function openPasswordModal(userId) {
pendingUserId = userId;
document.getElementById('pw-input').value = '';
document.getElementById('pw-error').style.display = 'none';
document.getElementById('pw-modal').classList.add('open');
}
async function confirmPasswordChange() {
const password = document.getElementById('pw-input').value;
const err = document.getElementById('pw-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
}
function openDeleteModal(userId, username) {
pendingUserId = userId;
document.getElementById('del-confirm-text').textContent = `Benutzer "${username}" wirklich löschen?`;
document.getElementById('del-modal').classList.add('open');
}
async function confirmDelete() {
await fetch('/api/admin/users/' + pendingUserId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
closeModal();
loadAll();
}
function openAssignModal(userId) {
pendingUserId = userId;
const select = document.getElementById('assign-inventory-select');
select.innerHTML = '';
allInventories.forEach(inv => {
const opt = document.createElement('option');
opt.value = inv.inventoryId;
const names = inv.users.map(u => u.username).join(', ') || '(leer)';
opt.textContent = inv.inventoryId.slice(0, 8) + '… ' + names;
select.appendChild(opt);
});
document.getElementById('assign-error').style.display = 'none';
document.getElementById('assign-modal').classList.add('open');
}
async function confirmAssign() {
const inventoryId = document.getElementById('assign-inventory-select').value;
const err = document.getElementById('assign-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId + '/inventory', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ inventoryId })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
loadAll();
}
async function createNewInventory(userId) {
if (!confirm('Für diesen Benutzer ein neues, leeres Inventar erstellen?')) return;
await fetch('/api/admin/users/' + userId + '/inventory/new', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
loadAll();
}
function closeModal() {
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
pendingUserId = null;
}
function logout() {
accessToken = '';
sessionStorage.removeItem('accessToken');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-section').style.display = 'block';
document.getElementById('admin-section').style.display = 'none';
document.getElementById('logout-btn').style.display = 'none';
}
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
// ── Tab Navigation ───────────────────────────────────────────────────────
let backupsLoaded = false;
let resourcesLoaded = false;
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.toggle('active', tc.id === tabId));
if (tabId === 'tab-backups' && !backupsLoaded) {
loadBackups();
}
if (tabId === 'tab-resources' && !resourcesLoaded) {
loadResources();
}
}
// ── Backups ──────────────────────────────────────────────────────────────
async function loadBackups() {
const tbody = document.getElementById('backups-body');
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
try {
const res = await fetch('/api/admin/backups', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="3" class="error">Fehler beim Laden</td></tr>'; return; }
const backups = await res.json();
backupsLoaded = true;
if (!backups.length) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Keine Backups vorhanden</td></tr>';
return;
}
tbody.innerHTML = '';
backups.forEach(b => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = b.name;
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(b.sizeBytes);
const tdDate = document.createElement('td');
const d = new Date(b.createdAt);
tdDate.textContent = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
tr.append(tdName, tdSize, tdDate);
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" class="error">Verbindungsfehler</td></tr>';
}
}
// ── Ressourcen ─────────────────────────────────────────────────────────
function toggleResourceForm() {
const form = document.getElementById('add-resource-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
function toggleDndZone() {
const zone = document.getElementById('dnd-zone');
zone.style.display = zone.style.display === 'none' ? 'block' : 'none';
}
// Drag & Drop setup
document.addEventListener('DOMContentLoaded', () => {
const dndArea = document.getElementById('dnd-area');
if (!dndArea) return;
['dragenter', 'dragover'].forEach(ev => {
dndArea.addEventListener(ev, e => {
e.preventDefault();
dndArea.style.borderColor = '#C1440E';
dndArea.style.background = '#2E2B22';
});
});
['dragleave', 'drop'].forEach(ev => {
dndArea.addEventListener(ev, e => {
e.preventDefault();
dndArea.style.borderColor = '#4A3F2F';
dndArea.style.background = '#1E1C17';
});
});
dndArea.addEventListener('drop', e => {
const files = e.dataTransfer.files;
if (files.length > 0) analyzeDroppedFile(files[0]);
});
dndArea.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.epub,.png,.jpg,.jpeg,.webp,.gif,.zip,.7z';
input.onchange = () => { if (input.files.length) analyzeDroppedFile(input.files[0]); };
input.click();
});
});
async function analyzeDroppedFile(file) {
const progress = document.getElementById('dnd-progress');
const dndArea = document.getElementById('dnd-area');
const review = document.getElementById('dnd-review');
dndArea.style.display = 'none';
progress.style.display = 'block';
review.style.display = 'none';
review.innerHTML = '';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/admin/resources/analyze', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken },
body: formData
});
progress.style.display = 'none';
if (!res.ok) {
const body = await res.json().catch(() => ({}));
alert('Analyse fehlgeschlagen: ' + (body.message || res.status));
dndArea.style.display = 'block';
return;
}
const items = await res.json();
if (!items.length) {
alert('Keine unterstützten Dateien gefunden.');
dndArea.style.display = 'block';
return;
}
renderReviewPanel(items);
} catch (e) {
progress.style.display = 'none';
dndArea.style.display = 'block';
alert('Verbindungsfehler: ' + e.message);
}
}
function renderReviewPanel(items) {
const review = document.getElementById('dnd-review');
review.style.display = 'block';
review.innerHTML = '<h3 style="color:#E8D5B0; margin-bottom:12px;">Erkannte Ressourcen (' + items.length + ')</h3>';
items.forEach((item, idx) => {
const card = document.createElement('div');
card.style.cssText = 'background:#1E1C17; border:1px solid #4A3F2F; border-radius:3px; padding:16px; margin-bottom:12px;';
card.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<strong style="color:#E8D5B0;">${item.fileFormat.toUpperCase()} ${formatBytes(item.fileSize)}</strong>
<button class="primary" onclick="confirmAnalyzedResource(${idx})">✓ Übernehmen</button>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div><label>Titel *</label><input class="rv-title" value="${escHtml(item.title)}" style="width:100%"></div>
<div><label>Autor</label><input class="rv-author" value="${escHtml(item.author || '')}" style="width:100%"></div>
</div>
<div style="margin-top:8px;">
<label>Beschreibung (Markdown)</label>
<textarea class="rv-description" maxlength="4096" rows="4" style="width:100%; resize:vertical; background:#1C1A14; color:#E8D5B0; border:1px solid #4A3F2F; border-radius:3px; padding:8px 12px; font-size:1rem; font-family:inherit;">${escHtml(item.description)}</textarea>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-top:8px;">
<div><label>Sprache</label><input class="rv-language" value="${escHtml(item.language || '')}" style="width:100%" placeholder="de, en, …"></div>
<div><label>Edition</label><input class="rv-edition" value="${escHtml(item.edition || '')}" style="width:100%"></div>
<div><label>Erscheinungsdatum</label><input class="rv-releaseDate" type="date" value="${escHtml(item.releaseDate || '')}" style="width:100%"></div>
</div>
<div style="margin-top:8px;">
<label>Tags</label>
<input class="rv-tags" value="${escHtml((item.tags||[]).join(', '))}" style="width:100%; margin-bottom:6px;" placeholder="Kommagetrennt eingeben oder unten klicken">
<div class="rv-tag-suggestions" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
`;
card.dataset.item = JSON.stringify(item);
review.appendChild(card);
});
// Load tag suggestions
loadTagSuggestions();
const btnAll = document.createElement('button');
btnAll.className = 'primary';
btnAll.textContent = 'Alle übernehmen (' + items.length + ')';
btnAll.style.marginTop = '8px';
btnAll.onclick = confirmAllAnalyzed;
review.appendChild(btnAll);
const btnCancel = document.createElement('button');
btnCancel.className = 'secondary';
btnCancel.textContent = 'Abbrechen';
btnCancel.style.cssText = 'margin-top:8px; margin-left:8px;';
btnCancel.onclick = () => {
review.style.display = 'none';
review.innerHTML = '';
document.getElementById('dnd-area').style.display = 'block';
};
review.appendChild(btnCancel);
}
async function confirmAnalyzedResource(idx) {
const review = document.getElementById('dnd-review');
const cards = review.querySelectorAll('[data-item]');
const card = cards[idx];
if (!card) return;
const item = JSON.parse(card.dataset.item);
const payload = {
filename: item.filename,
title: card.querySelector('.rv-title').value.trim(),
description: card.querySelector('.rv-description').value.trim(),
author: card.querySelector('.rv-author').value.trim() || null,
tags: card.querySelector('.rv-tags').value.split(',').map(t => t.trim()).filter(Boolean),
fileFormat: item.fileFormat,
mimeType: item.mimeType,
fileSize: item.fileSize,
language: card.querySelector('.rv-language').value.trim() || null,
edition: card.querySelector('.rv-edition').value.trim() || null,
releaseDate: card.querySelector('.rv-releaseDate').value || null
};
try {
const res = await fetch('/api/admin/resources/confirm', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
card.style.opacity = '0.4';
card.querySelector('button').disabled = true;
card.querySelector('button').textContent = '✓ Gespeichert';
} else {
const body = await res.json().catch(() => ({}));
alert('Fehler: ' + (body.message || res.status));
}
} catch (e) {
alert('Verbindungsfehler: ' + e.message);
}
}
async function confirmAllAnalyzed() {
const review = document.getElementById('dnd-review');
const cards = review.querySelectorAll('[data-item]');
for (let i = 0; i < cards.length; i++) {
if (cards[i].style.opacity !== '0.4') {
await confirmAnalyzedResource(i);
}
}
resourcesLoaded = false;
loadResources();
}
function escHtml(str) {
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
let cachedTags = null;
async function loadTagSuggestions() {
if (!cachedTags) {
try {
const res = await fetch('/api/admin/resources/tags', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (res.ok) cachedTags = await res.json();
else cachedTags = [];
} catch (e) { cachedTags = []; }
}
document.querySelectorAll('.rv-tag-suggestions').forEach(container => {
container.innerHTML = '';
const input = container.parentElement.querySelector('.rv-tags');
cachedTags.forEach(t => {
const chip = document.createElement('button');
chip.type = 'button';
chip.textContent = t.tag + (t.count > 0 ? ' (' + t.count + ')' : '');
chip.style.cssText = 'background:#3A2E10; color:#C8A840; border:1px solid #8B6914; border-radius:3px; padding:3px 8px; font-size:.75rem; font-family:monospace; cursor:pointer; transition:background .15s;';
chip.onmouseenter = () => { chip.style.background = '#5A4A20'; };
chip.onmouseleave = () => { updateChipStyle(chip, input); };
chip.onclick = () => toggleTag(chip, input, t.tag);
// Check if already selected
const current = input.value.split(',').map(s => s.trim().toLowerCase());
if (current.includes(t.tag.toLowerCase())) {
chip.style.background = '#C1440E';
chip.style.color = '#E8D5B0';
chip.style.borderColor = '#D95A20';
}
container.appendChild(chip);
});
});
}
function toggleTag(chip, input, tag) {
const tags = input.value.split(',').map(s => s.trim()).filter(Boolean);
const idx = tags.findIndex(t => t.toLowerCase() === tag.toLowerCase());
if (idx >= 0) {
tags.splice(idx, 1);
chip.style.background = '#3A2E10';
chip.style.color = '#C8A840';
chip.style.borderColor = '#8B6914';
} else {
tags.push(tag);
chip.style.background = '#C1440E';
chip.style.color = '#E8D5B0';
chip.style.borderColor = '#D95A20';
}
input.value = tags.join(', ');
}
function updateChipStyle(chip, input) {
const tag = chip.textContent.replace(/\s*\(\d+\)$/, '');
const current = input.value.split(',').map(s => s.trim().toLowerCase());
if (current.includes(tag.toLowerCase())) {
chip.style.background = '#C1440E';
chip.style.color = '#E8D5B0';
chip.style.borderColor = '#D95A20';
} else {
chip.style.background = '#3A2E10';
chip.style.color = '#C8A840';
chip.style.borderColor = '#8B6914';
}
}
async function loadResources() {
const tbody = document.getElementById('resources-body');
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
try {
const res = await fetch('/api/resources', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="7" class="error">Fehler beim Laden</td></tr>'; return; }
const resources = await res.json();
resourcesLoaded = true;
if (!resources.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Keine Ressourcen vorhanden</td></tr>';
return;
}
tbody.innerHTML = '';
resources.forEach(r => {
const tr = document.createElement('tr');
const tdTitle = document.createElement('td'); tdTitle.textContent = r.title;
const tdFormat = document.createElement('td'); tdFormat.textContent = (r.fileFormat || '').toUpperCase();
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(r.fileSize || 0);
const tdTags = document.createElement('td'); tdTags.textContent = (r.tags || []).join(', ');
const tdAuthor = document.createElement('td'); tdAuthor.textContent = r.author || '';
const tdDate = document.createElement('td');
tdDate.textContent = r.createdAt ? new Date(r.createdAt).toLocaleDateString('de-DE') : '';
const tdAct = document.createElement('td'); tdAct.className = 'actions';
const btnDl = document.createElement('button'); btnDl.className = 'secondary'; btnDl.textContent = 'Download';
btnDl.onclick = () => { window.open('/api/resources/' + r.guid + '/download?token=' + accessToken, '_blank'); };
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen';
btnDel.onclick = () => deleteResource(r.guid, r.title);
tdAct.append(btnDl, btnDel);
tr.append(tdTitle, tdFormat, tdSize, tdTags, tdAuthor, tdDate, tdAct);
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="error">Verbindungsfehler</td></tr>';
}
}
async function uploadResource() {
const err = document.getElementById('resource-error');
err.style.display = 'none';
const title = document.getElementById('res-title').value.trim();
const fileInput = document.getElementById('res-file');
if (!title) { err.textContent = 'Titel ist erforderlich'; err.style.display = 'block'; return; }
if (!fileInput.files.length) { err.textContent = 'Datei ist erforderlich'; err.style.display = 'block'; return; }
const file = fileInput.files[0];
const ext = file.name.split('.').pop().toLowerCase();
const mimeMap = { pdf: 'application/pdf', epub: 'application/epub+zip', zip: 'application/zip', '7z': 'application/x-7z-compressed' };
const metadata = {
guid: '',
title: title,
description: document.getElementById('res-description').value.trim(),
tags: document.getElementById('res-tags').value.split(',').map(t => t.trim()).filter(Boolean),
fileFormat: ext,
mimeType: mimeMap[ext] || 'application/octet-stream',
fileSize: 0,
releaseDate: document.getElementById('res-releaseDate').value || null,
createdAt: 0,
updatedAt: 0,
author: document.getElementById('res-author').value.trim() || null,
language: document.getElementById('res-language').value.trim() || null,
edition: document.getElementById('res-edition').value.trim() || null,
downloadUrl: ''
};
const formData = new FormData();
formData.append('metadata', JSON.stringify(metadata));
formData.append('file', file);
try {
const res = await fetch('/api/admin/resources', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken },
body: formData
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
err.textContent = body.message || 'Upload fehlgeschlagen (HTTP ' + res.status + ')';
err.style.display = 'block';
return;
}
// Reset form
document.getElementById('res-title').value = '';
document.getElementById('res-description').value = '';
document.getElementById('res-author').value = '';
document.getElementById('res-language').value = '';
document.getElementById('res-edition').value = '';
document.getElementById('res-releaseDate').value = '';
document.getElementById('res-tags').value = '';
fileInput.value = '';
toggleResourceForm();
loadResources();
} catch (e) {
err.textContent = 'Verbindungsfehler';
err.style.display = 'block';
}
}
async function deleteResource(guid, title) {
if (!confirm('Ressource "' + title + '" wirklich löschen?')) return;
await fetch('/api/admin/resources/' + guid, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
loadResources();
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return val.toFixed(i === 0 ? 0 : 1).replace('.', ',') + ' ' + units[i];
}
</script>
<footer>Bollwerk Server v0.2 &middot; &copy; 2026 faenocasul</footer>
</body>
</html>