510 lines
18 KiB
HTML
510 lines
18 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Pinkudex — Pagination behavior options (functional)</title>
|
||
<style>
|
||
:root {
|
||
--bg-0: #0b0d10;
|
||
--bg-1: #14171c;
|
||
--bg-2: #1c2027;
|
||
--fg: #e6e8ec;
|
||
--fg-dim: #a4abb6;
|
||
--fg-muted: #6b7380;
|
||
--cyan: #22d3ee;
|
||
--mint: #34d399;
|
||
--amber: #fbbf24;
|
||
--coral: #f87171;
|
||
--violet: #a78bfa;
|
||
--glass: rgba(255,255,255,0.04);
|
||
--glass-border: rgba(255,255,255,0.10);
|
||
--glass-border-strong: rgba(255,255,255,0.18);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; }
|
||
body {
|
||
background: var(--bg-0);
|
||
color: var(--fg);
|
||
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI";
|
||
min-height: 100vh;
|
||
}
|
||
.page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||
|
||
header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 24px; }
|
||
h1 { font-weight: 500; font-size: 20px; margin: 0 0 4px; }
|
||
.sub { color: var(--fg-muted); font-size: 13px; }
|
||
|
||
.modes {
|
||
display: flex; gap: 4px; padding: 4px;
|
||
background: var(--bg-1); border: 1px solid var(--glass-border);
|
||
border-radius: 10px;
|
||
}
|
||
.modes button {
|
||
background: transparent; border: 0; color: var(--fg-dim);
|
||
font-family: inherit; font-size: 12px; font-weight: 500;
|
||
padding: 8px 14px; border-radius: 7px; cursor: pointer;
|
||
transition: background 120ms, color 120ms;
|
||
}
|
||
.modes button:hover { color: var(--fg); }
|
||
.modes button.active {
|
||
background: rgba(34,211,238,0.15);
|
||
color: var(--cyan);
|
||
box-shadow: inset 0 0 0 1px rgba(34,211,238,0.4);
|
||
}
|
||
|
||
/* Hud panel */
|
||
.hud {
|
||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||
background: var(--bg-1); border: 1px solid var(--glass-border);
|
||
border-radius: 12px; padding: 14px 16px; margin-bottom: 14px;
|
||
font-family: ui-monospace, "SF Mono", monospace; font-size: 12px;
|
||
}
|
||
.hud .item { display: flex; flex-direction: column; gap: 4px; }
|
||
.hud .label { color: var(--fg-muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||
.hud .value { color: var(--fg); font-weight: 500; }
|
||
.hud .value .accent { color: var(--cyan); }
|
||
|
||
.description {
|
||
background: var(--bg-1); border: 1px solid var(--glass-border);
|
||
border-radius: 12px; padding: 14px 16px; margin-bottom: 14px;
|
||
font-size: 13px; color: var(--fg-dim); line-height: 1.55;
|
||
}
|
||
.description b { color: var(--fg); }
|
||
|
||
/* Event log */
|
||
.log {
|
||
background: rgba(0,0,0,0.35); border: 1px solid var(--glass-border);
|
||
border-radius: 10px; padding: 10px 12px; margin-bottom: 14px;
|
||
font-family: ui-monospace, "SF Mono", monospace; font-size: 11.5px;
|
||
height: 110px; overflow-y: auto;
|
||
}
|
||
.log .line { padding: 1px 0; color: var(--fg-dim); }
|
||
.log .line .t { color: var(--fg-muted); margin-right: 8px; }
|
||
.log .line .k { font-weight: 600; margin-right: 8px; }
|
||
.log .line .k.url { color: var(--coral); }
|
||
.log .line .k.scroll { color: var(--mint); }
|
||
.log .line .k.fetch { color: var(--amber); }
|
||
.log .line .k.click { color: var(--cyan); }
|
||
.log .line .k.remount { color: var(--violet); }
|
||
.log .line .k.note { color: var(--fg-muted); }
|
||
|
||
/* Grid viewport — emulates a virtualized scroll area */
|
||
.viewport {
|
||
background: var(--bg-1); border: 1px solid var(--glass-border);
|
||
border-radius: 14px; height: 540px; overflow-y: auto;
|
||
scroll-behavior: smooth;
|
||
position: relative;
|
||
}
|
||
.viewport-inner { padding: 16px; }
|
||
|
||
.grid {
|
||
display: grid; grid-template-columns: repeat(5, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.card {
|
||
aspect-ratio: 0.7; border-radius: 8px;
|
||
background: linear-gradient(135deg, var(--bg-2), #232831);
|
||
border: 1px solid var(--glass-border);
|
||
display: flex; flex-direction: column; justify-content: flex-end;
|
||
padding: 6px 8px;
|
||
font-family: ui-monospace, monospace; font-size: 10px;
|
||
color: var(--fg-muted);
|
||
position: relative;
|
||
overflow: hidden;
|
||
animation: fadeIn 200ms ease-out both;
|
||
}
|
||
.card::before {
|
||
content: ""; position: absolute; inset: 0;
|
||
background: radial-gradient(circle at 30% 30%, rgba(34,211,238,0.06), transparent 60%);
|
||
}
|
||
.card.flash { animation: flashCard 600ms ease-out; }
|
||
@keyframes flashCard {
|
||
0% { box-shadow: inset 0 0 0 2px var(--cyan); }
|
||
100% { box-shadow: inset 0 0 0 0 transparent; }
|
||
}
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(4px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
|
||
.page-marker {
|
||
grid-column: 1 / -1;
|
||
margin: 18px 0 8px;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
background: rgba(34,211,238,0.06);
|
||
border-left: 3px solid var(--cyan);
|
||
font-family: ui-monospace, monospace; font-size: 11px;
|
||
color: var(--cyan); font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
}
|
||
.page-marker.current { background: rgba(34,211,238,0.14); }
|
||
|
||
/* Pagination bar */
|
||
.bar {
|
||
display: flex; justify-content: center; align-items: center; gap: 10px;
|
||
margin-top: 16px; padding: 12px;
|
||
background: var(--bg-1); border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
}
|
||
.bar button {
|
||
font-family: inherit; font-size: 13px; padding: 7px 14px;
|
||
border-radius: 8px; border: 1px solid var(--glass-border-strong);
|
||
background: var(--glass); color: var(--fg); cursor: pointer;
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
transition: background 120ms;
|
||
}
|
||
.bar button:hover:not(:disabled) { background: rgba(255,255,255,0.07); }
|
||
.bar button:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
.bar .label {
|
||
font-family: ui-monospace, monospace; font-size: 13px;
|
||
color: var(--fg-dim); padding: 0 14px; min-width: 200px; text-align: center;
|
||
}
|
||
.bar .label .num { color: var(--cyan); font-weight: 600; }
|
||
.bar .jump {
|
||
margin-left: 8px; display: inline-flex; align-items: center; gap: 6px;
|
||
}
|
||
.bar .jump input {
|
||
width: 56px; padding: 6px 8px; border-radius: 6px;
|
||
border: 1px solid var(--glass-border-strong);
|
||
background: var(--bg-0); color: var(--fg);
|
||
font-family: ui-monospace, monospace; font-size: 12px; text-align: center;
|
||
outline: none;
|
||
}
|
||
.bar .jump input:focus { border-color: var(--cyan); }
|
||
|
||
.load-more {
|
||
width: 100%; margin-top: 10px;
|
||
padding: 10px; border-radius: 8px;
|
||
background: rgba(52,211,153,0.08); color: var(--mint);
|
||
border: 1px dashed rgba(52,211,153,0.4); cursor: pointer;
|
||
font-family: inherit; font-size: 12px;
|
||
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
|
||
transition: background 120ms;
|
||
}
|
||
.load-more:hover { background: rgba(52,211,153,0.14); }
|
||
.load-more:disabled { opacity: 0.3; cursor: not-allowed; }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||
background: var(--bg-2); border: 1px solid var(--glass-border-strong);
|
||
border-radius: 10px; padding: 8px 14px;
|
||
font-family: ui-monospace, monospace; font-size: 12px; color: var(--fg);
|
||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||
opacity: 0; pointer-events: none;
|
||
transition: opacity 200ms, transform 200ms;
|
||
z-index: 100;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-4px); }
|
||
.toast .tag {
|
||
display: inline-block; padding: 1px 6px; border-radius: 4px;
|
||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
||
margin-right: 8px; font-weight: 700;
|
||
}
|
||
.toast.url .tag { background: rgba(248,113,113,0.2); color: var(--coral); }
|
||
.toast.scroll .tag { background: rgba(52,211,153,0.2); color: var(--mint); }
|
||
.toast.fetch .tag { background: rgba(251,191,36,0.2); color: var(--amber); }
|
||
|
||
.remount-flash {
|
||
position: absolute; inset: 0; pointer-events: none;
|
||
background: rgba(248,113,113,0.08);
|
||
opacity: 0; transition: opacity 120ms;
|
||
}
|
||
.remount-flash.on { opacity: 1; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="page">
|
||
<header>
|
||
<div>
|
||
<h1>Pagination behavior — functional comparison</h1>
|
||
<div class="sub">Click <b>Prev / Next / Jump</b> with each mode active and watch the HUD + event log.</div>
|
||
</div>
|
||
<div class="modes" role="tablist">
|
||
<button id="mode-1" class="active">1 · Always URL</button>
|
||
<button id="mode-2">2 · Scroll + prefetch</button>
|
||
<button id="mode-3">3 · Two affordances</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="hud">
|
||
<div class="item">
|
||
<span class="label">URL</span>
|
||
<span class="value" id="hud-url">/?page=1</span>
|
||
</div>
|
||
<div class="item">
|
||
<span class="label">Logical page</span>
|
||
<span class="value"><span class="accent" id="hud-page">1</span> / <span id="hud-total">12</span></span>
|
||
</div>
|
||
<div class="item">
|
||
<span class="label">Loaded buffer</span>
|
||
<span class="value">page <span id="hud-buf-lo">1</span> – <span id="hud-buf-hi">1</span></span>
|
||
</div>
|
||
<div class="item">
|
||
<span class="label">DOM cards</span>
|
||
<span class="value" id="hud-card-count">25</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="description" id="mode-desc"></div>
|
||
|
||
<div class="log" id="log"></div>
|
||
|
||
<div class="viewport" id="viewport">
|
||
<div class="remount-flash" id="remount-flash"></div>
|
||
<div class="viewport-inner" id="viewport-inner"></div>
|
||
</div>
|
||
|
||
<div class="bar">
|
||
<button id="prev-btn">← Prev</button>
|
||
<span class="label">Page <span class="num" id="bar-page">1</span> of <span id="bar-total">12</span></span>
|
||
<button id="next-btn">Next →</button>
|
||
<form class="jump" id="jump-form" onsubmit="return false;">
|
||
<span style="color: var(--fg-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;">Jump</span>
|
||
<input id="jump-input" type="text" inputmode="numeric" placeholder="1-12">
|
||
<button type="submit">→</button>
|
||
</form>
|
||
</div>
|
||
|
||
<button class="load-more" id="load-more" style="display: none;">↓ Load more (extend buffer)</button>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ---------- model ----------
|
||
const TOTAL_PAGES = 12;
|
||
const PAGE_SIZE = 25; // cards per page (5×5)
|
||
const FETCH_LATENCY_MS = 280;
|
||
|
||
let mode = 1; // 1 | 2 | 3
|
||
let currentPage = 1; // logical page
|
||
let initialPage = 1; // page that's anchored as the "SSR" first page
|
||
let bufLo = 1; // first loaded page
|
||
let bufHi = 1; // last loaded page
|
||
let inflight = false;
|
||
|
||
// ---------- helpers ----------
|
||
const $ = (id) => document.getElementById(id);
|
||
const viewportEl = $("viewport");
|
||
const innerEl = $("viewport-inner");
|
||
const logEl = $("log");
|
||
|
||
function fmtTime() {
|
||
const d = new Date();
|
||
return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:${String(d.getSeconds()).padStart(2,"0")}.${String(d.getMilliseconds()).padStart(3,"0")}`;
|
||
}
|
||
function log(kind, msg) {
|
||
const line = document.createElement("div");
|
||
line.className = "line";
|
||
line.innerHTML = `<span class="t">${fmtTime()}</span><span class="k ${kind}">${kind.toUpperCase()}</span>${msg}`;
|
||
logEl.appendChild(line);
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
while (logEl.children.length > 50) logEl.removeChild(logEl.firstChild);
|
||
}
|
||
function toast(kind, msg) {
|
||
const el = $("toast");
|
||
el.className = `toast ${kind}`;
|
||
el.innerHTML = `<span class="tag">${kind}</span>${msg}`;
|
||
requestAnimationFrame(() => el.classList.add("show"));
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => el.classList.remove("show"), 1400);
|
||
}
|
||
|
||
function buildCard(pageNum, idx) {
|
||
const el = document.createElement("div");
|
||
el.className = "card";
|
||
el.dataset.page = String(pageNum);
|
||
el.innerHTML = `<div>p${pageNum}·#${idx+1}</div>`;
|
||
return el;
|
||
}
|
||
|
||
function renderBuffer({ animate = false } = {}) {
|
||
innerEl.innerHTML = "";
|
||
const grid = document.createElement("div");
|
||
grid.className = "grid";
|
||
|
||
for (let p = bufLo; p <= bufHi; p++) {
|
||
const marker = document.createElement("div");
|
||
marker.className = "page-marker" + (p === currentPage ? " current" : "");
|
||
marker.textContent = `Page ${p}` + (p === currentPage ? " · current" : "");
|
||
marker.dataset.pageMarker = String(p);
|
||
grid.appendChild(marker);
|
||
for (let i = 0; i < PAGE_SIZE; i++) {
|
||
const c = buildCard(p, i);
|
||
if (!animate) c.style.animation = "none";
|
||
grid.appendChild(c);
|
||
}
|
||
}
|
||
innerEl.appendChild(grid);
|
||
updateHud();
|
||
}
|
||
|
||
function updateHud() {
|
||
$("hud-url").textContent = currentPage === 1 ? "/" : `/?page=${currentPage}`;
|
||
$("hud-page").textContent = String(currentPage);
|
||
$("hud-total").textContent = String(TOTAL_PAGES);
|
||
$("hud-buf-lo").textContent = String(bufLo);
|
||
$("hud-buf-hi").textContent = String(bufHi);
|
||
$("hud-card-count").textContent = String((bufHi - bufLo + 1) * PAGE_SIZE);
|
||
$("bar-page").textContent = String(currentPage);
|
||
$("bar-total").textContent = String(TOTAL_PAGES);
|
||
$("prev-btn").disabled = currentPage <= 1;
|
||
$("next-btn").disabled = currentPage >= TOTAL_PAGES;
|
||
const lm = $("load-more");
|
||
lm.disabled = bufHi >= TOTAL_PAGES;
|
||
lm.textContent = bufHi >= TOTAL_PAGES
|
||
? "(end of library)"
|
||
: `↓ Load page ${bufHi + 1} (extend buffer)`;
|
||
}
|
||
|
||
function flashRemount() {
|
||
const f = $("remount-flash");
|
||
f.classList.add("on");
|
||
setTimeout(() => f.classList.remove("on"), 200);
|
||
}
|
||
|
||
function flashPage(p) {
|
||
[...innerEl.querySelectorAll(`.card[data-page="${p}"]`)].forEach((el) => {
|
||
el.classList.remove("flash");
|
||
void el.offsetWidth;
|
||
el.classList.add("flash");
|
||
});
|
||
}
|
||
|
||
function scrollToPageMarker(p) {
|
||
const m = innerEl.querySelector(`[data-page-marker="${p}"]`);
|
||
if (!m) return false;
|
||
const target = m.offsetTop - 12;
|
||
viewportEl.scrollTo({ top: target, behavior: "smooth" });
|
||
return true;
|
||
}
|
||
|
||
// ---------- the three modes ----------
|
||
|
||
// Mode 1: always URL nav. Buffer is reset to {target}.
|
||
function mode1Nav(target) {
|
||
log("click", `Prev/Next clicked, target=${target}`);
|
||
log("url", `router.push(?page=${target})`);
|
||
log("remount", `LibraryGrid remounts (key includes effectivePage)`);
|
||
flashRemount();
|
||
initialPage = target;
|
||
bufLo = target; bufHi = target;
|
||
currentPage = target;
|
||
renderBuffer({ animate: true });
|
||
viewportEl.scrollTop = 0;
|
||
toast("url", `Navigated to page ${target} (remount)`);
|
||
}
|
||
|
||
// Mode 2: scroll if in buffer; else fetch+append; backward beyond initial → URL.
|
||
async function mode2Nav(target) {
|
||
log("click", `Prev/Next clicked, target=${target}`);
|
||
|
||
if (target < initialPage) {
|
||
log("note", `target < initialPage (${target} < ${initialPage}) — fall back to URL`);
|
||
log("url", `router.push(?page=${target})`);
|
||
log("remount", `LibraryGrid remounts`);
|
||
flashRemount();
|
||
initialPage = target;
|
||
bufLo = target; bufHi = target;
|
||
currentPage = target;
|
||
renderBuffer({ animate: true });
|
||
viewportEl.scrollTop = 0;
|
||
toast("url", `Backward across buffer → URL nav (page ${target})`);
|
||
return;
|
||
}
|
||
|
||
if (target > bufHi) {
|
||
log("note", `target > bufHi (${target} > ${bufHi}) — prefetching`);
|
||
if (inflight) return;
|
||
inflight = true;
|
||
for (let p = bufHi + 1; p <= target; p++) {
|
||
log("fetch", `fetch page ${p}…`);
|
||
await new Promise((r) => setTimeout(r, FETCH_LATENCY_MS));
|
||
bufHi = p;
|
||
renderBuffer({ animate: false });
|
||
log("note", `appended page ${p}, buffer now ${bufLo}–${bufHi}`);
|
||
}
|
||
inflight = false;
|
||
}
|
||
|
||
currentPage = target;
|
||
log("scroll", `scrollToIndex(rowOf(page ${target}))`);
|
||
renderBuffer({ animate: false });
|
||
requestAnimationFrame(() => scrollToPageMarker(target));
|
||
flashPage(target);
|
||
toast("scroll", `Scrolled to page ${target} (in-buffer)`);
|
||
}
|
||
|
||
// Mode 3: Prev/Next is always URL. Load-more button does the buffer growth.
|
||
function mode3Nav(target) {
|
||
// Same as mode 1 for Prev/Next.
|
||
mode1Nav(target);
|
||
}
|
||
async function mode3LoadMore() {
|
||
if (bufHi >= TOTAL_PAGES) return;
|
||
log("click", "Load-more clicked");
|
||
log("fetch", `fetch page ${bufHi + 1}…`);
|
||
inflight = true;
|
||
await new Promise((r) => setTimeout(r, FETCH_LATENCY_MS));
|
||
bufHi += 1;
|
||
inflight = false;
|
||
renderBuffer({ animate: false });
|
||
log("note", `appended page ${bufHi}, buffer now ${bufLo}–${bufHi}`);
|
||
toast("fetch", `Buffer extended to page ${bufHi}`);
|
||
}
|
||
|
||
function navTo(target) {
|
||
target = Math.max(1, Math.min(TOTAL_PAGES, target));
|
||
if (target === currentPage && target >= bufLo && target <= bufHi) return;
|
||
if (mode === 1) mode1Nav(target);
|
||
else if (mode === 2) mode2Nav(target);
|
||
else mode3Nav(target);
|
||
}
|
||
|
||
// ---------- mode switching ----------
|
||
const MODE_DESC = {
|
||
1: `<b>Mode 1 — Always URL.</b> Every Prev/Next click pushes a new URL. The grid
|
||
remounts (red flash). Buffer always reduces to a single page. Predictable but you
|
||
lose the smooth in-buffer jump.`,
|
||
2: `<b>Mode 2 — Scroll + prefetch.</b> If the target is already in the buffer it
|
||
scrolls (green flash). Past <code>bufHi</code> it fetches missing pages then scrolls.
|
||
Backward past <code>initialPage</code> still falls back to URL nav (residual seam).
|
||
Try Next a few times then Prev.`,
|
||
3: `<b>Mode 3 — Two affordances.</b> Prev/Next behaves like Mode 1 (always URL).
|
||
A separate <span style="color: var(--mint)">Load more</span> button below extends
|
||
the buffer in place. Each affordance has exactly one behavior.`,
|
||
};
|
||
function setMode(n) {
|
||
mode = n;
|
||
document.querySelectorAll(".modes button").forEach((b) => b.classList.remove("active"));
|
||
$(`mode-${n}`).classList.add("active");
|
||
$("mode-desc").innerHTML = MODE_DESC[n];
|
||
$("load-more").style.display = n === 3 ? "block" : "none";
|
||
// Reset state on mode change so each demo starts clean.
|
||
currentPage = 1; initialPage = 1; bufLo = 1; bufHi = 1;
|
||
renderBuffer({ animate: true });
|
||
viewportEl.scrollTop = 0;
|
||
log("note", `Mode switched to ${n}`);
|
||
}
|
||
|
||
// ---------- wire up ----------
|
||
$("mode-1").addEventListener("click", () => setMode(1));
|
||
$("mode-2").addEventListener("click", () => setMode(2));
|
||
$("mode-3").addEventListener("click", () => setMode(3));
|
||
$("prev-btn").addEventListener("click", () => navTo(currentPage - 1));
|
||
$("next-btn").addEventListener("click", () => navTo(currentPage + 1));
|
||
$("jump-form").addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
const v = Number($("jump-input").value);
|
||
if (Number.isFinite(v) && v >= 1 && v <= TOTAL_PAGES) navTo(v);
|
||
$("jump-input").value = "";
|
||
});
|
||
$("load-more").addEventListener("click", mode3LoadMore);
|
||
|
||
setMode(1);
|
||
</script>
|
||
</body>
|
||
</html>
|