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:
parent
a84d130495
commit
de94e3371a
1 changed files with 201 additions and 28 deletions
|
|
@ -83,6 +83,9 @@
|
||||||
#inv-table th { cursor: pointer; user-select: none; white-space: nowrap; }
|
#inv-table th { cursor: pointer; user-select: none; white-space: nowrap; }
|
||||||
#inv-table th:hover { background: #3A3428; }
|
#inv-table th:hover { background: #3A3428; }
|
||||||
#inv-table th .sort-arrow { margin-left: 4px; color: #6B5B3E; font-size: .8rem; }
|
#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 { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
|
||||||
.paging-bar button { padding: 4px 10px; font-size: .85rem; }
|
.paging-bar button { padding: 4px 10px; font-size: .85rem; }
|
||||||
.paging-bar span { font-size: .85rem; color: #A89070; }
|
.paging-bar span { font-size: .85rem; color: #A89070; }
|
||||||
|
|
@ -282,20 +285,46 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<table id="resources-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Titel</th>
|
<th onclick="sortRes('title')">Titel <span class="sort-arrow" id="rssort-title"></span></th>
|
||||||
<th>Format</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>Tags</th>
|
||||||
<th>Autor</th>
|
<th onclick="sortRes('author')">Autor <span class="sort-arrow" id="rssort-author"></span></th>
|
||||||
<th>Hochgeladen</th>
|
<th onclick="sortRes('createdAt')">Hochgeladen <span class="sort-arrow" id="rssort-createdAt"></span></th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="resources-body"><tr><td colspan="7" style="text-align:center;color:#6B5B3E">Tab auswählen um zu laden…</td></tr></tbody>
|
<tbody id="resources-body"><tr><td colspan="7" style="text-align:center;color:#6B5B3E">Tab auswählen um zu laden…</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="paging-bar" id="res-paging-bar"></div>
|
||||||
</section>
|
</section>
|
||||||
</div><!-- /tab-resources -->
|
</div><!-- /tab-resources -->
|
||||||
|
|
||||||
|
|
@ -1089,20 +1118,119 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadResources() {
|
// ── 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');
|
const tbody = document.getElementById('resources-body');
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
|
if (!page.length) {
|
||||||
try {
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Keine Einträge gefunden</td></tr>';
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
resources.forEach(r => {
|
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;">▣ ${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 tr = document.createElement('tr');
|
||||||
const tdTitle = document.createElement('td'); tdTitle.textContent = r.title;
|
const tdTitle = document.createElement('td'); tdTitle.textContent = r.title;
|
||||||
const tdFormat = document.createElement('td'); tdFormat.textContent = (r.fileFormat || '').toUpperCase();
|
const tdFormat = document.createElement('td'); tdFormat.textContent = (r.fileFormat || '').toUpperCase();
|
||||||
|
|
@ -1118,8 +1246,53 @@
|
||||||
btnDel.onclick = () => deleteResource(r.guid, r.title);
|
btnDel.onclick = () => deleteResource(r.guid, r.title);
|
||||||
tdAct.append(btnDl, btnDel);
|
tdAct.append(btnDl, btnDel);
|
||||||
tr.append(tdTitle, tdFormat, tdSize, tdTags, tdAuthor, tdDate, tdAct);
|
tr.append(tdTitle, tdFormat, tdSize, tdTags, tdAuthor, tdDate, tdAct);
|
||||||
tbody.appendChild(tr);
|
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; }
|
||||||
|
allResources = await res.json();
|
||||||
|
resourcesLoaded = true;
|
||||||
|
updateResFilterDropdowns();
|
||||||
|
renderResTable();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="error">Verbindungsfehler</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="error">Verbindungsfehler</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue