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 import piexif
from PIL import Image as PILImage 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.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -970,8 +970,16 @@ def list_assets(
params.extend([limit, offset]) params.extend([limit, offset])
rows = conn.execute(sql, params).fetchall() 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() 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") @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-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-danger { background: transparent; border: 1.5px solid var(--red); color: var(--red); }
.btn-green { background: var(--green); color: #000; } .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 INPUTS
@@ -1070,6 +1072,7 @@
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button> <button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div> </div>
<div id="assetList"></div> <div id="assetList"></div>
<div id="pagination" class="pagination-bar" style="display:none;margin-top:8px;"></div>
</div> </div>
<!-- ── Detail View ──────────────────────────────────────────────────── --> <!-- ── Detail View ──────────────────────────────────────────────────── -->
@@ -1297,6 +1300,8 @@
// API WRAPPER // API WRAPPER
// ========================================================================= // =========================================================================
async function api(url, opts = {}) { async function api(url, opts = {}) {
const returnMeta = opts._returnMeta;
delete opts._returnMeta;
// Auto-attach auth token if available // Auto-attach auth token if available
if (AppState.authToken) { if (AppState.authToken) {
opts.headers = opts.headers || {}; opts.headers = opts.headers || {};
@@ -1314,6 +1319,7 @@
const detail = (data && data.detail) || text || `HTTP ${res.status}`; const detail = (data && data.detail) || text || `HTTP ${res.status}`;
throw new Error(detail); throw new Error(detail);
} }
if (returnMeta) return { data, headers: res.headers };
return data; return data;
} catch (e) { } catch (e) {
if (e.name === 'TypeError' && e.message === 'Failed to fetch') { if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
@@ -3014,6 +3020,9 @@
// ── Filter state ────────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────────
let assetFilters = { category: null, status: null, make: null, disney_park: null, q: '' }; 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 assetDebounce = null;
let cachedAssets = []; let cachedAssets = [];
@@ -3053,10 +3062,13 @@
if (assetFilters.make) params.set('make', assetFilters.make); if (assetFilters.make) params.set('make', assetFilters.make);
if (assetFilters.q) params.set('q', assetFilters.q); if (assetFilters.q) params.set('q', assetFilters.q);
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to); 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 { 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 // Client-side filter fallback if server doesn't support assigned_to
var filtered = assetFilters.assigned_to var filtered = assetFilters.assigned_to
? assets.filter(function(a) { return a.assigned_to === assetFilters.assigned_to; }) ? assets.filter(function(a) { return a.assigned_to === assetFilters.assigned_to; })
@@ -3066,6 +3078,7 @@
: filtered; : filtered;
renderAssetList(filtered); renderAssetList(filtered);
renderFilterPills(assets); renderFilterPills(assets);
renderPagination();
} catch (e) { } catch (e) {
document.getElementById('assetList').innerHTML = document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>'; '<div class="empty-state">Failed to load assets</div>';
@@ -3138,14 +3151,47 @@
assetFilters.status = status; assetFilters.status = status;
assetFilters.make = make; assetFilters.make = make;
assetFilters.disney_park = disney_park; assetFilters.disney_park = disney_park;
assetOffset = 0;
loadAssets(); loadAssets();
} }
function clearAssetSearch() { function clearAssetSearch() {
document.getElementById('assetSearch').value = ''; document.getElementById('assetSearch').value = '';
assetOffset = 0;
loadAssets(); 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 ──────────────────────────────────────────────────── // ── View switching ────────────────────────────────────────────────────
function hideAllAssetViews() { function hideAllAssetViews() {
['assetsListView','assetsDetailView','assetsEditView','assetsImportView'].forEach(id => { ['assetsListView','assetsDetailView','assetsEditView','assetsImportView'].forEach(id => {