Add pagination to QSO Log and back buttons to pages

- Add backend pagination support for /api/qsos endpoint (page, limit params)
- Return paginated results with metadata (totalCount, totalPages, hasNext, hasPrev)
- Add pagination UI to QSOs page with prev/next buttons and page numbers
- Add back button to QSO Log and Settings pages linking to main menu
- Reset to page 1 when applying filters

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 22:10:23 +01:00
parent dde0c18f51
commit 40a3e3642e
4 changed files with 215 additions and 20 deletions

View File

@@ -391,6 +391,7 @@ const app = new Elysia()
/**
* GET /api/qsos
* Get user's QSOs (requires authentication)
* Supports pagination: ?page=1&limit=100
*/
.get('/api/qsos', async ({ user, query, set }) => {
if (!user) {
@@ -404,12 +405,17 @@ const app = new Elysia()
if (query.mode) filters.mode = query.mode;
if (query.confirmed) filters.confirmed = query.confirmed === 'true';
const qsos = await getUserQSOs(user.id, filters);
// Pagination options
const options = {
page: query.page ? parseInt(query.page) : 1,
limit: query.limit ? parseInt(query.limit) : 100,
};
const result = await getUserQSOs(user.id, filters, options);
return {
success: true,
qsos,
count: qsos.length,
...result,
};
} catch (error) {
set.status = 500;

View File

@@ -468,15 +468,18 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
}
/**
* Get QSOs for a user
* Get QSOs for a user with pagination
* @param {number} userId - User ID
* @param {Object} filters - Query filters
* @returns {Promise<Array>} Array of QSOs
* @param {Object} options - Pagination options { page, limit }
* @returns {Promise<Object>} Paginated QSOs
*/
export async function getUserQSOs(userId, filters = {}) {
const { eq, and } = await import('drizzle-orm');
export async function getUserQSOs(userId, filters = {}, options = {}) {
const { eq, and, desc, sql } = await import('drizzle-orm');
console.error('getUserQSOs called with userId:', userId, 'filters:', filters);
const { page = 1, limit = 100 } = options;
console.error('getUserQSOs called with userId:', userId, 'filters:', filters, 'page:', page, 'limit:', limit);
// Build where conditions
const conditions = [eq(qsos.userId, userId)];
@@ -493,17 +496,35 @@ export async function getUserQSOs(userId, filters = {}) {
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
}
// Use and() to combine all conditions
const results = await db.select().from(qsos).where(and(...conditions));
// Get total count for pagination
const allResults = await db.select().from(qsos).where(and(...conditions));
const totalCount = allResults.length;
console.error('getUserQSOs returning', results.length, 'QSOs');
// Calculate offset
const offset = (page - 1) * limit;
// Order by date descending, then time
return results.sort((a, b) => {
const dateCompare = b.qsoDate.localeCompare(a.qsoDate);
if (dateCompare !== 0) return dateCompare;
return b.timeOn.localeCompare(a.timeOn);
});
// Get paginated results
const results = await db
.select()
.from(qsos)
.where(and(...conditions))
.orderBy(desc(qsos.qsoDate), desc(qsos.timeOn))
.limit(limit)
.offset(offset);
console.error('getUserQSOs returning', results.length, 'QSOs (page', page, 'of', Math.ceil(totalCount / limit), ')');
return {
qsos: results,
pagination: {
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
hasNext: page * limit < totalCount,
hasPrev: page > 1,
},
};
}
/**

View File

@@ -8,6 +8,11 @@
let loading = true;
let error = null;
// Pagination state
let currentPage = 1;
let pageSize = 100;
let pagination = null;
// Job polling state
let syncJobId = null;
let syncStatus = null;
@@ -51,8 +56,12 @@
if (filters.mode) activeFilters.mode = filters.mode;
if (filters.confirmed) activeFilters.confirmed = 'true';
activeFilters.page = currentPage;
activeFilters.limit = pageSize;
const response = await qsosAPI.getAll(activeFilters);
qsos = response.qsos;
pagination = response.pagination;
} catch (err) {
error = err.message;
} finally {
@@ -163,10 +172,12 @@
let syncResult = null;
async function applyFilters() {
currentPage = 1;
await loadQSOs();
}
function clearFilters() {
currentPage = 1;
filters = {
band: '',
mode: '',
@@ -175,6 +186,25 @@
loadQSOs();
}
function goToPage(page) {
currentPage = page;
loadQSOs();
}
function goToPrevious() {
if (pagination && pagination.hasPrev) {
currentPage--;
loadQSOs();
}
}
function goToNext() {
if (pagination && pagination.hasNext) {
currentPage++;
loadQSOs();
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
// ADIF format: YYYYMMDD
@@ -227,7 +257,10 @@
<div class="container">
<div class="header">
<div class="header-left">
<a href="/" class="back-button">← Back</a>
<h1>QSO Log</h1>
</div>
<div class="header-buttons">
{#if qsos.length > 0}
<button
@@ -401,8 +434,51 @@
</tbody>
</table>
</div>
{#if pagination && pagination.totalPages > 1}
<div class="pagination">
<div class="pagination-info">
Showing {pagination.page * pagination.limit - pagination.limit + 1}-{Math.min(pagination.page * pagination.limit, pagination.totalCount)} of {pagination.totalCount}
</div>
<div class="pagination-controls">
<button
class="btn btn-secondary btn-small"
on:click={goToPrevious}
disabled={!pagination.hasPrev || loading}
>
← Previous
</button>
<div class="page-numbers">
{#each Array(pagination.totalPages) as _, i}
{@const page = i + 1}
{#if page === 1 || page === pagination.totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<button
class="btn btn-small {page === currentPage ? 'btn-primary' : 'btn-secondary'}"
on:click={() => goToPage(page)}
disabled={loading}
>
{page}
</button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="page-ellipsis">...</span>
{/if}
{/each}
</div>
<button
class="btn btn-secondary btn-small"
on:click={goToNext}
disabled={!pagination.hasNext || loading}
>
Next →
</button>
</div>
</div>
{:else if qsos.length > 0}
<p class="showing">Showing {qsos.length} QSOs</p>
{/if}
{/if}
</div>
<style>
@@ -421,11 +497,32 @@
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header h1 {
margin: 0;
color: #333;
}
.back-button {
padding: 0.5rem 1rem;
background-color: #6c757d;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #5a6268;
}
.header-buttons {
display: flex;
gap: 1rem;
@@ -669,4 +766,49 @@
font-size: 0.875rem;
margin-top: 1rem;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
gap: 1rem;
}
.pagination-info {
color: #666;
font-size: 0.9rem;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-numbers {
display: flex;
align-items: center;
gap: 0.25rem;
}
.page-ellipsis {
padding: 0 0.5rem;
color: #666;
}
.btn-small {
padding: 0.4rem 0.8rem;
font-size: 0.875rem;
min-width: 2.5rem;
}
.btn-small:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -76,7 +76,10 @@
<div class="container">
<div class="header">
<div class="header-left">
<a href="/" class="back-button">← Back</a>
<h1>Settings</h1>
</div>
<button class="btn btn-secondary" on:click={handleLogout}>Logout</button>
</div>
@@ -168,6 +171,14 @@
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header h1 {
@@ -175,6 +186,21 @@
color: #333;
}
.back-button {
padding: 0.5rem 1rem;
background-color: #6c757d;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #5a6268;
}
.user-info {
background: #f8f9fa;
padding: 1.5rem;