feat(web): add pagination and fix port binding for Tailscale access

- Paginate comic grid at 48 per page with smart page number controls
- Bind container port to 0.0.0.0 so Tailscale traffic can reach WSL2
This commit is contained in:
2026-03-09 08:53:26 -04:00
parent 9d1ca16704
commit 1a567a19fe
2 changed files with 95 additions and 5 deletions

View File

@@ -450,6 +450,44 @@
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
}
/* ── Pagination ──────────────────────────────────────────────────────── */
#pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 36px;
padding-bottom: 36px;
flex-wrap: wrap;
}
.page-btn {
height: 34px;
min-width: 34px;
padding: 0 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text2);
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.12s, color 0.12s, background 0.12s;
white-space: nowrap;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
.page-ellipsis {
color: var(--muted);
font-size: 0.8rem;
padding: 0 4px;
line-height: 34px;
}
/* ── Skeleton loading cards ──────────────────────────────────────────── */
.skeleton-card {
width: 150px;
@@ -645,6 +683,7 @@
</div>
<div id="comics-grid"></div>
<div id="pagination"></div>
<div id="empty-state">
<div class="empty-box">
@@ -661,10 +700,12 @@
<script>
// ── State ──────────────────────────────────────────────────────────────
const PAGE_SIZE = 48;
let knownJobs = {};
let dismissedJobs = JSON.parse(localStorage.getItem('dismissedJobs') || '{}');
let allComics = [];
let currentSort = localStorage.getItem('comicSort') || 'newest';
let currentPage = 1;
// ── DOM refs ───────────────────────────────────────────────────────────
const form = document.getElementById('url-form');
@@ -784,7 +825,7 @@
}
// ── Filter & sort ──────────────────────────────────────────────────────
function applyFilterAndSort() {
function applyFilterAndSort(resetPage = false) {
const query = filterInput.value.trim().toLowerCase();
let comics = query
@@ -799,7 +840,56 @@
comics = comics.slice().sort((a, b) => b.title.localeCompare(a.title));
}
renderComics(comics);
const totalPages = Math.max(1, Math.ceil(comics.length / PAGE_SIZE));
if (resetPage || currentPage > totalPages) currentPage = 1;
const paged = comics.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
renderComics(paged);
renderPagination(totalPages);
}
function renderPagination(totalPages) {
const el = document.getElementById('pagination');
el.innerHTML = '';
if (totalPages <= 1) return;
const mkBtn = (label, page, active = false, disabled = false) => {
const b = document.createElement('button');
b.className = 'page-btn' + (active ? ' active' : '');
b.textContent = label;
b.disabled = disabled;
b.addEventListener('click', () => {
currentPage = page;
applyFilterAndSort();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
return b;
};
const mkEllipsis = () => {
const s = document.createElement('span');
s.className = 'page-ellipsis';
s.textContent = '…';
return s;
};
el.append(mkBtn('← Prev', currentPage - 1, false, currentPage === 1));
// Page numbers with ellipsis: always show first, last, and window around current
const pages = new Set([1, totalPages]);
for (let p = currentPage - 2; p <= currentPage + 2; p++) {
if (p > 1 && p < totalPages) pages.add(p);
}
const sorted = [...pages].sort((a, b) => a - b);
let prev = 0;
for (const p of sorted) {
if (p - prev > 1) el.append(mkEllipsis());
el.append(mkBtn(p, p, p === currentPage));
prev = p;
}
el.append(mkBtn('Next →', currentPage + 1, false, currentPage === totalPages));
}
// ── Render comics ──────────────────────────────────────────────────────
@@ -921,11 +1011,11 @@
currentSort = b.dataset.sort;
localStorage.setItem('comicSort', currentSort);
sortBtns.forEach(x => x.classList.toggle('active', x === b));
applyFilterAndSort();
applyFilterAndSort(true);
});
});
filterInput.addEventListener('input', applyFilterAndSort);
filterInput.addEventListener('input', () => applyFilterAndSort(true));
showSkeletons();
fetchComics();