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 /api/qsos
|
||||||
* Get user's QSOs (requires authentication)
|
* Get user's QSOs (requires authentication)
|
||||||
|
* Supports pagination: ?page=1&limit=100
|
||||||
*/
|
*/
|
||||||
.get('/api/qsos', async ({ user, query, set }) => {
|
.get('/api/qsos', async ({ user, query, set }) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -404,12 +405,17 @@ const app = new Elysia()
|
|||||||
if (query.mode) filters.mode = query.mode;
|
if (query.mode) filters.mode = query.mode;
|
||||||
if (query.confirmed) filters.confirmed = query.confirmed === 'true';
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
qsos,
|
...result,
|
||||||
count: qsos.length,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set.status = 500;
|
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 {number} userId - User ID
|
||||||
* @param {Object} filters - Query filters
|
* @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 = {}) {
|
export async function getUserQSOs(userId, filters = {}, options = {}) {
|
||||||
const { eq, and } = await import('drizzle-orm');
|
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
|
// Build where conditions
|
||||||
const conditions = [eq(qsos.userId, userId)];
|
const conditions = [eq(qsos.userId, userId)];
|
||||||
@@ -493,17 +496,35 @@ export async function getUserQSOs(userId, filters = {}) {
|
|||||||
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use and() to combine all conditions
|
// Get total count for pagination
|
||||||
const results = await db.select().from(qsos).where(and(...conditions));
|
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
|
// Get paginated results
|
||||||
return results.sort((a, b) => {
|
const results = await db
|
||||||
const dateCompare = b.qsoDate.localeCompare(a.qsoDate);
|
.select()
|
||||||
if (dateCompare !== 0) return dateCompare;
|
.from(qsos)
|
||||||
return b.timeOn.localeCompare(a.timeOn);
|
.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 loading = true;
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
let currentPage = 1;
|
||||||
|
let pageSize = 100;
|
||||||
|
let pagination = null;
|
||||||
|
|
||||||
// Job polling state
|
// Job polling state
|
||||||
let syncJobId = null;
|
let syncJobId = null;
|
||||||
let syncStatus = null;
|
let syncStatus = null;
|
||||||
@@ -51,8 +56,12 @@
|
|||||||
if (filters.mode) activeFilters.mode = filters.mode;
|
if (filters.mode) activeFilters.mode = filters.mode;
|
||||||
if (filters.confirmed) activeFilters.confirmed = 'true';
|
if (filters.confirmed) activeFilters.confirmed = 'true';
|
||||||
|
|
||||||
|
activeFilters.page = currentPage;
|
||||||
|
activeFilters.limit = pageSize;
|
||||||
|
|
||||||
const response = await qsosAPI.getAll(activeFilters);
|
const response = await qsosAPI.getAll(activeFilters);
|
||||||
qsos = response.qsos;
|
qsos = response.qsos;
|
||||||
|
pagination = response.pagination;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,10 +172,12 @@
|
|||||||
let syncResult = null;
|
let syncResult = null;
|
||||||
|
|
||||||
async function applyFilters() {
|
async function applyFilters() {
|
||||||
|
currentPage = 1;
|
||||||
await loadQSOs();
|
await loadQSOs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
|
currentPage = 1;
|
||||||
filters = {
|
filters = {
|
||||||
band: '',
|
band: '',
|
||||||
mode: '',
|
mode: '',
|
||||||
@@ -175,6 +186,25 @@
|
|||||||
loadQSOs();
|
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) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
// ADIF format: YYYYMMDD
|
// ADIF format: YYYYMMDD
|
||||||
@@ -227,7 +257,10 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<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">
|
<div class="header-buttons">
|
||||||
{#if qsos.length > 0}
|
{#if qsos.length > 0}
|
||||||
<button
|
<button
|
||||||
@@ -401,7 +434,50 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -421,11 +497,32 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
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 {
|
.header-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -669,4 +766,49 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-top: 1rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -76,7 +76,10 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<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>
|
<button class="btn btn-secondary" on:click={handleLogout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,6 +171,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
@@ -175,6 +186,21 @@
|
|||||||
color: #333;
|
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 {
|
.user-info {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user