feat(admin): Ressourcen-UI mit Paging, Suche, Sortierung, Filter und Gruppierung

Komplett clientseitige Implementierung (alle Ressourcen werden einmal geladen,
dann in-memory gefiltert/sortiert/paginiert).

- Suche: Freitext über Titel und Autor (sofort-Filter via oninput)
- Sortierung: Titel, Autor, Hochgeladen, Größe (A-Z/Z-A, asc/desc)
  Klickbare Tabellen-Header mit Sortierungspfeilen (↑↓)
- Filter-Dropdowns: Format (ePub/PDF), Autor, Kategorie (Tag)
  Dropdowns werden dynamisch aus geladenen Daten befüllt
- Gruppierung: Optional nach Typ/Autor/Kategorie mit visuellen Gruppen-Trennzeilen
- Paging: 20/50/100 Einträge pro Seite, Vor/Zurück + Seitenzahlen (5 sichtbar)
  Anzeige: 'Zeigt 1–20 von 138 · Seite 1 von 7'
- CSS: #resources-table th sortierbar (wie #inv-table)

Closes #130
This commit is contained in:
Jens Reinemann 2026-05-19 00:29:00 +02:00
parent a84d130495
commit de94e3371a

View file

@ -83,6 +83,9 @@
#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; }
#resources-table th { cursor: pointer; user-select: none; white-space: nowrap; }
#resources-table th:hover { background: #3A3428; }
#resources-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; }
@ -282,20 +285,46 @@
</div>
</div>
<div class="table-controls">
<input type="text" id="res-search-filter" placeholder="Suche nach Titel oder Autor…" oninput="applyResFilter()">
<select id="res-filter-format" onchange="applyResFilter()">
<option value="">Alle Formate</option>
<option value="epub">ePub</option>
<option value="pdf">PDF</option>
</select>
<select id="res-filter-author" onchange="applyResFilter()">
<option value="">Alle Autoren</option>
</select>
<select id="res-filter-tag" onchange="applyResFilter()">
<option value="">Alle Kategorien</option>
</select>
<select id="res-group-by" onchange="applyResFilter()">
<option value="">Keine Gruppierung</option>
<option value="fileFormat">Nach Typ</option>
<option value="author">Nach Autor</option>
<option value="tag">Nach Kategorie</option>
</select>
<select id="res-page-size" onchange="resPageSize=+this.value; resPage=0; renderResTable()">
<option value="20" selected>20 / Seite</option>
<option value="50">50 / Seite</option>
<option value="100">100 / Seite</option>
</select>
</div>
<table id="resources-table">
<thead>
<tr>
<th>Titel</th>
<th onclick="sortRes('title')">Titel <span class="sort-arrow" id="rssort-title"></span></th>
<th>Format</th>
<th>Größe</th>
<th onclick="sortRes('fileSize')">Größe <span class="sort-arrow" id="rssort-fileSize"></span></th>
<th>Tags</th>
<th>Autor</th>
<th>Hochgeladen</th>
<th onclick="sortRes('author')">Autor <span class="sort-arrow" id="rssort-author"></span></th>
<th onclick="sortRes('createdAt')">Hochgeladen <span class="sort-arrow" id="rssort-createdAt"></span></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>
<div class="paging-bar" id="res-paging-bar"></div>
</section>
</div><!-- /tab-resources -->
@ -1089,37 +1118,181 @@
}
}
// ── Ressourcen: Paging, Suche, Sortierung, Filter, Gruppierung ──────────
let allResources = [];
let resPage = 0;
let resPageSize = 20;
let resSortKey = 'createdAt';
let resSortDir = 'desc';
function applyResFilter() {
resPage = 0;
renderResTable();
}
function sortRes(key) {
if (resSortKey === key) { resSortDir = resSortDir === 'asc' ? 'desc' : 'asc'; }
else { resSortKey = key; resSortDir = 'asc'; }
resPage = 0;
renderResTable();
}
function renderResTable() {
const search = (document.getElementById('res-search-filter')?.value || '').toLowerCase();
const filterFormat = document.getElementById('res-filter-format')?.value || '';
const filterAuthor = document.getElementById('res-filter-author')?.value || '';
const filterTag = document.getElementById('res-filter-tag')?.value || '';
const groupBy = document.getElementById('res-group-by')?.value || '';
// 1. Filter
let filtered = allResources.filter(r => {
if (search && !r.title.toLowerCase().includes(search) && !(r.author || '').toLowerCase().includes(search)) return false;
if (filterFormat && r.fileFormat !== filterFormat) return false;
if (filterAuthor && (r.author || '') !== filterAuthor) return false;
if (filterTag && !(r.tags || []).includes(filterTag)) return false;
return true;
});
// 2. Sort
filtered.sort((a, b) => {
let av = a[resSortKey], bv = b[resSortKey];
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av == null) av = typeof bv === 'number' ? 0 : '';
if (bv == null) bv = typeof av === 'number' ? 0 : '';
return resSortDir === 'asc' ? (av > bv ? 1 : av < bv ? -1 : 0) : (av < bv ? 1 : av > bv ? -1 : 0);
});
// 3. Update sort arrows
['title', 'author', 'createdAt', 'fileSize'].forEach(k => {
const el = document.getElementById('rssort-' + k);
if (el) el.textContent = resSortKey === k ? (resSortDir === 'asc' ? '↑' : '↓') : '';
});
// 4. Paging
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / resPageSize));
if (resPage >= totalPages) resPage = totalPages - 1;
const start = resPage * resPageSize;
const end = Math.min(start + resPageSize, total);
const page = filtered.slice(start, end);
// 5. Paging bar
const pagingBar = document.getElementById('res-paging-bar');
pagingBar.innerHTML = '';
const info = document.createElement('span');
info.textContent = total === 0 ? 'Keine Einträge' : `Zeigt ${start + 1}${end} von ${total} · Seite ${resPage + 1} von ${totalPages}`;
pagingBar.appendChild(info);
if (resPage > 0) {
const prev = document.createElement('button'); prev.className = 'secondary'; prev.textContent = '← Zurück';
prev.onclick = () => { resPage--; renderResTable(); };
pagingBar.appendChild(prev);
}
const pageRange = resPagingRange(resPage, totalPages, 5);
pageRange.forEach(p => {
const btn = document.createElement('button');
btn.className = p === resPage ? 'primary' : 'secondary';
btn.textContent = String(p + 1);
btn.onclick = () => { resPage = p; renderResTable(); };
pagingBar.appendChild(btn);
});
if (resPage < totalPages - 1) {
const next = document.createElement('button'); next.className = 'secondary'; next.textContent = 'Weiter →';
next.onclick = () => { resPage++; renderResTable(); };
pagingBar.appendChild(next);
}
// 6. Render table
const tbody = document.getElementById('resources-body');
if (!page.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Keine Einträge gefunden</td></tr>';
return;
}
tbody.innerHTML = '';
if (groupBy) {
const groups = {};
page.forEach(r => {
let key;
if (groupBy === 'fileFormat') key = (r.fileFormat || '').toUpperCase();
else if (groupBy === 'author') key = r.author || '(kein Autor)';
else key = (r.tags && r.tags.length) ? r.tags[0] : '(kein Tag)';
if (!groups[key]) groups[key] = [];
groups[key].push(r);
});
Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0])).forEach(([groupKey, items]) => {
const groupRow = document.createElement('tr');
groupRow.innerHTML = `<td colspan="7" style="background:#2E2B22; color:#A89070; font-family:monospace; font-size:.8rem; text-transform:uppercase; padding:6px 14px; letter-spacing:.05em;">&#9635; ${escHtml(groupKey)} (${items.length})</td>`;
tbody.appendChild(groupRow);
items.forEach(r => tbody.appendChild(buildResRow(r)));
});
} else {
page.forEach(r => tbody.appendChild(buildResRow(r)));
}
}
function buildResRow(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);
return tr;
}
function resPagingRange(current, total, maxVisible) {
if (total <= maxVisible) return Array.from({ length: total }, (_, i) => i);
const half = Math.floor(maxVisible / 2);
let start = Math.max(0, current - half);
let end = start + maxVisible;
if (end > total) { end = total; start = Math.max(0, end - maxVisible); }
return Array.from({ length: end - start }, (_, i) => start + i);
}
function updateResFilterDropdowns() {
const authors = [...new Set(allResources.map(r => r.author).filter(Boolean))].sort();
const tags = [...new Set(allResources.flatMap(r => r.tags || []))].sort();
const authorSel = document.getElementById('res-filter-author');
const tagSel = document.getElementById('res-filter-tag');
if (authorSel) {
const cur = authorSel.value;
authorSel.innerHTML = '<option value="">Alle Autoren</option>';
authors.forEach(a => {
const opt = document.createElement('option'); opt.value = a; opt.textContent = a;
if (a === cur) opt.selected = true;
authorSel.appendChild(opt);
});
}
if (tagSel) {
const cur = tagSel.value;
tagSel.innerHTML = '<option value="">Alle Kategorien</option>';
tags.forEach(t => {
const opt = document.createElement('option'); opt.value = t; opt.textContent = t;
if (t === cur) opt.selected = true;
tagSel.appendChild(opt);
});
}
}
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();
allResources = 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);
});
updateResFilterDropdowns();
renderResTable();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="error">Verbindungsfehler</td></tr>';
}