Add pagination (Prev/Next with counter) and X-Total-Count header

This commit is contained in:
2026-05-22 17:57:24 -04:00
parent cce5cf2b5f
commit 95b5bd26eb
2 changed files with 58 additions and 4 deletions
+10 -2
View File
@@ -23,7 +23,7 @@ import pytesseract
import piexif
from PIL import Image as PILImage
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
@@ -970,8 +970,16 @@ def list_assets(
params.extend([limit, offset])
rows = conn.execute(sql, params).fetchall()
# Get total count for pagination
count_sql = "SELECT COUNT(*) FROM assets"
if where:
count_sql += f" WHERE {where}"
total = conn.execute(count_sql, params[:len(params)-2]).fetchone()[0]
conn.close()
return [row_to_dict(r) for r in rows]
response = JSONResponse([row_to_dict(r) for r in rows])
response.headers["X-Total-Count"] = str(total)
return response
@app.get("/api/assets/search")
+48 -2
View File
@@ -242,6 +242,8 @@
.btn-outline { background: transparent; border: 1.5px solid var(--border); color: var(--text); }
.btn-danger { background: transparent; border: 1.5px solid var(--red); color: var(--red); }
.btn-green { background: var(--green); color: #000; }
.btn-xs { padding: 6px 12px; font-size: 12px; width: auto; line-height: 1; }
.pagination-bar { display: flex; align-items: center; gap: 6px; }
/* ═══════════════════════════════════════════════════════════════════════
INPUTS
@@ -1070,6 +1072,7 @@
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div>
<div id="assetList"></div>
<div id="pagination" class="pagination-bar" style="display:none;margin-top:8px;"></div>
</div>
<!-- ── Detail View ──────────────────────────────────────────────────── -->
@@ -1297,6 +1300,8 @@
// API WRAPPER
// =========================================================================
async function api(url, opts = {}) {
const returnMeta = opts._returnMeta;
delete opts._returnMeta;
// Auto-attach auth token if available
if (AppState.authToken) {
opts.headers = opts.headers || {};
@@ -1314,6 +1319,7 @@
const detail = (data && data.detail) || text || `HTTP ${res.status}`;
throw new Error(detail);
}
if (returnMeta) return { data, headers: res.headers };
return data;
} catch (e) {
if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
@@ -3014,6 +3020,9 @@
// ── Filter state ──────────────────────────────────────────────────────
let assetFilters = { category: null, status: null, make: null, disney_park: null, q: '' };
let assetOffset = 0;
const ASSET_PAGE_SIZE = 100;
let assetTotal = 0;
let assetDebounce = null;
let cachedAssets = [];
@@ -3053,10 +3062,13 @@
if (assetFilters.make) params.set('make', assetFilters.make);
if (assetFilters.q) params.set('q', assetFilters.q);
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to);
params.set('limit', '100');
params.set('limit', String(ASSET_PAGE_SIZE));
params.set('offset', String(assetOffset));
try {
var assets = await api('/api/assets?' + params.toString());
var result = await api('/api/assets?' + params.toString(), {_returnMeta: true});
var assets = result.data;
assetTotal = parseInt(result.headers.get('X-Total-Count')) || assets.length;
// Client-side filter fallback if server doesn't support assigned_to
var filtered = assetFilters.assigned_to
? assets.filter(function(a) { return a.assigned_to === assetFilters.assigned_to; })
@@ -3066,6 +3078,7 @@
: filtered;
renderAssetList(filtered);
renderFilterPills(assets);
renderPagination();
} catch (e) {
document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>';
@@ -3138,14 +3151,47 @@
assetFilters.status = status;
assetFilters.make = make;
assetFilters.disney_park = disney_park;
assetOffset = 0;
loadAssets();
}
function clearAssetSearch() {
document.getElementById('assetSearch').value = '';
assetOffset = 0;
loadAssets();
}
// ── Pagination ──────────────────────────────────────────────────────────
function renderPagination() {
const el = document.getElementById('pagination');
if (assetTotal <= ASSET_PAGE_SIZE) {
el.style.display = 'none';
return;
}
el.style.display = 'flex';
const current = assetOffset + 1;
const end = Math.min(assetOffset + ASSET_PAGE_SIZE, assetTotal);
el.innerHTML =
'<span style="font-size:12px;color:var(--text2);margin-right:auto;">' +
current + '' + end + ' of ' + assetTotal + '</span>' +
'<button class="btn btn-outline btn-xs" onclick="prevPage()" ' + (assetOffset === 0 ? 'disabled' : '') + '> Prev</button>' +
'<button class="btn btn-outline btn-xs" onclick="nextPage()" ' + (end >= assetTotal ? 'disabled' : '') + '>Next </button>';
}
function prevPage() {
if (assetOffset > 0) {
assetOffset = Math.max(0, assetOffset - ASSET_PAGE_SIZE);
_loadAssets();
}
}
function nextPage() {
if (assetOffset + ASSET_PAGE_SIZE < assetTotal) {
assetOffset += ASSET_PAGE_SIZE;
_loadAssets();
}
}
// ── View switching ────────────────────────────────────────────────────
function hideAllAssetViews() {
['assetsListView','assetsDetailView','assetsEditView','assetsImportView'].forEach(id => {