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:
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user