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

@@ -2,7 +2,7 @@ services:
yoink: yoink:
build: . build: .
ports: ports:
- "8080:8080" - "0.0.0.0:8080:8080"
volumes: volumes:
- ./library:/library - ./library:/library
environment: environment:

View File

@@ -450,6 +450,44 @@
text-shadow: 0 1px 4px rgba(0,0,0,0.8); 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 loading cards ──────────────────────────────────────────── */
.skeleton-card { .skeleton-card {
width: 150px; width: 150px;
@@ -645,6 +683,7 @@
</div> </div>
<div id="comics-grid"></div> <div id="comics-grid"></div>
<div id="pagination"></div>
<div id="empty-state"> <div id="empty-state">
<div class="empty-box"> <div class="empty-box">
@@ -661,10 +700,12 @@
<script> <script>
// ── State ────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────
const PAGE_SIZE = 48;
let knownJobs = {}; let knownJobs = {};
let dismissedJobs = JSON.parse(localStorage.getItem('dismissedJobs') || '{}'); let dismissedJobs = JSON.parse(localStorage.getItem('dismissedJobs') || '{}');
let allComics = []; let allComics = [];
let currentSort = localStorage.getItem('comicSort') || 'newest'; let currentSort = localStorage.getItem('comicSort') || 'newest';
let currentPage = 1;
// ── DOM refs ─────────────────────────────────────────────────────────── // ── DOM refs ───────────────────────────────────────────────────────────
const form = document.getElementById('url-form'); const form = document.getElementById('url-form');
@@ -784,7 +825,7 @@
} }
// ── Filter & sort ────────────────────────────────────────────────────── // ── Filter & sort ──────────────────────────────────────────────────────
function applyFilterAndSort() { function applyFilterAndSort(resetPage = false) {
const query = filterInput.value.trim().toLowerCase(); const query = filterInput.value.trim().toLowerCase();
let comics = query let comics = query
@@ -799,7 +840,56 @@
comics = comics.slice().sort((a, b) => b.title.localeCompare(a.title)); 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 ────────────────────────────────────────────────────── // ── Render comics ──────────────────────────────────────────────────────
@@ -921,11 +1011,11 @@
currentSort = b.dataset.sort; currentSort = b.dataset.sort;
localStorage.setItem('comicSort', currentSort); localStorage.setItem('comicSort', currentSort);
sortBtns.forEach(x => x.classList.toggle('active', x === b)); sortBtns.forEach(x => x.classList.toggle('active', x === b));
applyFilterAndSort(); applyFilterAndSort(true);
}); });
}); });
filterInput.addEventListener('input', applyFilterAndSort); filterInput.addEventListener('input', () => applyFilterAndSort(true));
showSkeletons(); showSkeletons();
fetchComics(); fetchComics();