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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">
|
||||
<h1>QSO Log</h1>
|
||||
<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,7 +434,50 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="showing">Showing {qsos.length} QSOs</p>
|
||||
|
||||
{#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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -76,7 +76,10 @@
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Settings</h1>
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user