From 10e3f34b69a40f8792bbeed021b059186d977648 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:08:51 +0200 Subject: [PATCH] feat: add first, last and direct page jump to thread navigation Threads on this forum reach four-digit page counts, and stepping one page at a time was the only movement the navigation bar offered. The end of a thread, or any specific page, was effectively unreachable on a phone. Jump targets are derived arithmetically from the forum's own page size instead of read from the rendered pagination, because phpBB never renders more than a handful of page links regardless of thread length. Where that page size cannot be determined, the bar falls back to the links the forum itself provides and hides the jump. Both userscripts inject their stylesheet on thread pages, so class names are a shared namespace between them. The new ones avoid the two the list view already uses, which would otherwise collide at equal specificity and resolve by load order. Claude-Session: https://claude.ai/code/session_0158owAAsXAfFiT3hGkQt1Xk --- README.md | 17 +++ rin-thread.user.js | 258 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 258 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4935967..a5440b5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ existing page content is hidden and a freshly built DOM is appended to ``. | `rin-mobile.user.js` | `*://cs.rin.ru/forum/*` | Topic lists (`viewforum.php`, `search.php`) as cards | | `rin-thread.user.js` | `*://cs.rin.ru/forum/viewtopic.php*` | Single thread as a post stream with a fixed bottom navigation bar | +The thread navigation bar holds first / previous / next / last and a page +indicator. Tapping the indicator opens a sheet with a number field and a grid of +all pages. Targets are computed as `start = (page - 1) × per_page` rather than +taken from the rendered page links, because phpBB only ever shows a handful of +them. `per_page` is read from the `jumpto()` function the forum embeds in the +page; without it, only first page and the original previous/next links remain +available. + Both scripts run at `document-start`, inject a mobile viewport meta tag, and carry a `DEBUG` flag. While `DEBUG` is `true`, a green diagnostic overlay is prepended to the page showing how many rows were found and converted. Set it to @@ -164,6 +172,15 @@ ein neu gebautes DOM an `` gehängt. | `rin-mobile.user.js` | `*://cs.rin.ru/forum/*` | Themenlisten (`viewforum.php`, `search.php`) als Karten | | `rin-thread.user.js` | `*://cs.rin.ru/forum/viewtopic.php*` | Einzelner Thread als Beitragsstrom mit fixierter Navigationsleiste | +Die Navigationsleiste im Thread enthält erste / vorherige / nächste / letzte +Seite und eine Positionsanzeige. Ein Tipp darauf öffnet ein Sheet mit +Zahlenfeld und einem Raster aller Seiten. Die Ziele werden als +`start = (Seite - 1) × per_page` berechnet und nicht aus den gerenderten +Seitenlinks übernommen, weil phpBB davon immer nur eine Handvoll anzeigt. +`per_page` stammt aus der `jumpto()`-Funktion, die das Forum in die Seite +einbettet; fehlt sie, bleiben nur die erste Seite und die ursprünglichen +Zurück-/Weiter-Links nutzbar. + Beide laufen bei `document-start`, setzen ein mobiles Viewport-Meta-Tag und haben ein `DEBUG`-Flag. Solange `DEBUG` auf `true` steht, wird ein grünes Diagnose-Overlay über die Seite gelegt, das zeigt, wie viele Zeilen gefunden und diff --git a/rin-thread.user.js b/rin-thread.user.js index ac6fe02..a7c7a6f 100644 --- a/rin-thread.user.js +++ b/rin-thread.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name RIN Mobile Thread // @description Mobile Beitragsansicht fuer cs.rin.ru -// @version 2.2 +// @version 2.3 // @match *://cs.rin.ru/forum/viewtopic.php* // @run-at document-start // ==/UserScript== @@ -93,8 +93,55 @@ white-space:nowrap; } .rm-nav a { padding:7px 13px; border-radius:99px; border:1px solid rgba(160,160,160,.45); } + .rm-nav .rm-step { min-width:22px; text-align:center; + font-size:17px; line-height:1; padding:9px 12px; } .rm-nav .rm-pos { opacity:.6; font-size:13px; } + .rm-nav .rm-pos-btn { opacity:1; padding:7px 12px; border-radius:99px; + border:1px solid rgba(160,160,160,.45); + cursor:pointer; } .rm-nav .rm-off { opacity:.28; border-color:rgba(160,160,160,.2); } + + .rm-sheet-bg { position:fixed; top:0; left:0; right:0; bottom:0; + z-index:10000; background:rgba(0,0,0,.5); + opacity:0; pointer-events:none; transition:opacity .18s; } + .rm-sheet-bg.rm-open { opacity:1; pointer-events:auto; } + + .rm-sheet { box-sizing:border-box; + position:fixed; left:0; right:0; bottom:0; z-index:10001; + display:flex; flex-direction:column; max-height:72vh; + padding:8px 12px calc(12px + env(safe-area-inset-bottom)); + color:#eee; background:rgba(28,28,30,.98); + -webkit-backdrop-filter:blur(14px); + backdrop-filter:blur(14px); + border-top:1px solid rgba(128,128,128,.3); + border-radius:14px 14px 0 0; + transform:translateY(102%); + transition:transform .22s ease; } + .rm-sheet.rm-open { transform:translateY(0); } + .rm-grip { flex:none; width:36px; height:4px; margin:2px auto 12px; + border-radius:2px; background:rgba(200,200,200,.35); } + .rm-goto { flex:none; display:flex; gap:8px; margin-bottom:12px; } + /* 16px verhindert, dass iOS beim Fokussieren in das Feld hineinzoomt. */ + .rm-goto input { flex:1; min-width:0; box-sizing:border-box; + font-size:16px; padding:9px 12px; border-radius:10px; + color:#eee; background:rgba(120,120,120,.18); + border:1px solid rgba(160,160,160,.35); } + .rm-goto button { flex:none; font-size:14px; padding:9px 18px; + border-radius:10px; color:#eee; + background:rgba(120,120,120,.18); + border:1px solid rgba(160,160,160,.45); } + .rm-grid { flex:1; min-height:0; overflow-y:auto; + -webkit-overflow-scrolling:touch; + display:grid; gap:8px; padding-bottom:4px; + grid-template-columns:repeat(auto-fill, minmax(56px, 1fr)); } + .rm-grid a { display:flex; align-items:center; justify-content:center; + min-height:42px; font-size:14px; border-radius:10px; + color:#eee !important; text-decoration:none !important; + background:rgba(120,120,120,.12); + border:1px solid rgba(160,160,160,.3); } + .rm-grid a.rm-cur { font-weight:700; + background:rgba(120,170,255,.22); + border-color:rgba(120,170,255,.9); } `; h().appendChild(st); @@ -116,6 +163,174 @@ return null; } + // --- Seitenzustand --- + // Der Block wird aus dem Original-DOM gelesen, bevor es ausgeblendet wird. + // phpBB zeigt nie alle Seitenzahlen an, deshalb wird nicht auf gerenderte + // Links gesetzt, sondern jede Ziel-URL aus start = (Seite - 1) * per_page + // selbst gebaut. + function pageState() { + var s = { cur: 1, total: 1, per: 0, prev: null, next: null }; + + // Der Paginierungsblock ist der einzige Ort, an dem Ziffern-Links + // Seitenzahlen bedeuten. Beitragstexte enthalten ebenfalls Links mit + // start=, die hier nicht mitgezaehlt werden duerfen. + var box = null; + var jumpA = document.querySelector('a[onclick*="jumpto"]'); + if (jumpA) box = jumpA.parentElement; + if (!box) { + document.querySelectorAll('a[href*="start="]').forEach(function (a) { + if (!box && /^(Next|Weiter|Nächste)$/i.test(txt(a))) + box = a.parentElement; + }); + } + + // per_page steht als Literal in der jumpto()-Funktion der Seite. + document.querySelectorAll('script').forEach(function (el) { + if (s.per) return; + var m = (el.textContent || '').match(/per_page\s*=\s*'?(\d+)/); + if (m) s.per = parseInt(m[1], 10); + }); + + if (box) { + box.querySelectorAll('a[href*="start="]').forEach(function (a) { + var t = txt(a); + if (!/^\d+$/.test(t)) return; + var n = parseInt(t, 10); + if (n > s.total) s.total = n; + var ms = a.href.match(/[?&]start=(\d+)/); + if (!s.per && ms && n > 1) { + var per = parseInt(ms[1], 10) / (n - 1); + if (per > 0 && per === Math.floor(per)) s.per = per; + } + }); + } + + var cs = location.search.match(/[?&]start=(\d+)/); + if (s.per) s.cur = Math.floor((cs ? parseInt(cs[1], 10) : 0) / s.per) + 1; + else if (box) { + var strong = box.querySelector('strong, b > strong'); + if (strong && /^\d+$/.test(txt(strong))) s.cur = parseInt(txt(strong), 10); + } + + // Aeltere Themes geben stattdessen "Page X of Y" aus. + var pos = txt(document.body).match(/Page\s+(\d+)\s+of\s+(\d+)/i); + if (pos) { + if (!s.per) s.cur = parseInt(pos[1], 10); + s.total = Math.max(s.total, parseInt(pos[2], 10)); + } + if (s.cur > s.total) s.total = s.cur; + + s.url = function (n) { + if (n === s.cur || n < 1 || n > s.total) return null; + if (n !== 1 && !s.per) return null; + var u = location.href.split('#')[0]; + var start = (n - 1) * s.per; + if (/[?&]start=\d+/.test(u)) + return u.replace(/([?&]start=)\d+/, '$1' + start); + return u + (u.indexOf('?') === -1 ? '?' : '&') + 'start=' + start; + }; + + s.prev = s.url(s.cur - 1); + s.next = s.url(s.cur + 1); + // Ohne bekanntes per_page bleibt nur, die Original-Links zu uebernehmen. + if (!s.per) { + document.querySelectorAll('a').forEach(function (a) { + var t = txt(a); + if (!s.prev && /^(Previous|Vorherige)$/i.test(t)) s.prev = a.href; + if (!s.next && /^(Next|Weiter|Nächste)$/i.test(t)) s.next = a.href; + }); + } + + log.push('page=' + s.cur + '/' + s.total + ' per=' + s.per); + return s; + } + + // Seitenwahl als Sheet ueber der Leiste. Wird erst beim ersten Antippen + // gebaut: bei 1000+ Seiten waere das Raster sonst umsonst im DOM. + function pageSheet(s) { + var bg = null, sheet = null; + + function go(n) { + n = Math.min(Math.max(n, 1), s.total); + var u = s.url(n); + if (u) location.href = u; + else close(); + } + + function close() { + if (!bg) return; + bg.classList.remove('rm-open'); + sheet.classList.remove('rm-open'); + document.body.style.overflow = ''; + } + + function make() { + bg = document.createElement('div'); + bg.className = 'rm-sheet-bg'; + bg.addEventListener('click', close); + + sheet = document.createElement('div'); + sheet.className = 'rm-sheet'; + + var grip = document.createElement('div'); + grip.className = 'rm-grip'; + sheet.appendChild(grip); + + var row = document.createElement('div'); + row.className = 'rm-goto'; + var inp = document.createElement('input'); + inp.type = 'text'; + inp.inputMode = 'numeric'; + inp.pattern = '[0-9]*'; + inp.placeholder = 'Seite 1 – ' + s.total; + inp.setAttribute('aria-label', 'Seitenzahl'); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'Los'; + function submit() { + var n = parseInt(inp.value, 10); + if (n > 0) go(n); + } + btn.addEventListener('click', submit); + inp.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + }); + row.appendChild(inp); + row.appendChild(btn); + sheet.appendChild(row); + + var grid = document.createElement('div'); + grid.className = 'rm-grid'; + var curEl = null; + for (var i = 1; i <= s.total; i++) { + var a = document.createElement('a'); + a.textContent = i; + if (i === s.cur) { a.className = 'rm-cur'; curEl = a; } + else { + var u = s.url(i); + if (u) a.href = u; + } + grid.appendChild(a); + } + sheet.appendChild(grid); + + document.body.appendChild(bg); + document.body.appendChild(sheet); + return curEl; + } + + return function open() { + var curEl = bg ? null : make(); + document.body.style.overflow = 'hidden'; + // Ein Frame Abstand, sonst faellt der Uebergang beim ersten Oeffnen aus. + requestAnimationFrame(function () { + bg.classList.add('rm-open'); + sheet.classList.add('rm-open'); + if (curEl) curEl.scrollIntoView({ block: 'center' }); + }); + }; + } + function build() { var bodies = document.querySelectorAll('span.postbody, .postbody'); log.push('postbodies=' + bodies.length); @@ -256,28 +471,37 @@ if (!built) return; // --- Navigationsleiste --- + var pg = pageState(); var nav = document.createElement('div'); nav.className = 'rm-nav'; - var prevA = null, nextA = null; - document.querySelectorAll('a').forEach(function (a) { - var t = txt(a); - if (!prevA && /^(Previous|Vorherige)$/i.test(t)) prevA = a; - if (!nextA && /^(Next|Weiter|Nächste)$/i.test(t)) nextA = a; - }); - var pos = txt(document.body).match(/Page\s+(\d+)\s+of\s+(\d+)/i); - function navBtn(a, label) { + function navBtn(href, label, aria) { var e = document.createElement('a'); + e.className = 'rm-step' + (href ? '' : ' rm-off'); e.textContent = label; - if (a) e.href = a.href; else e.className = 'rm-off'; + e.setAttribute('aria-label', aria); + if (href) e.href = href; return e; } - nav.appendChild(navBtn(prevA, '‹ Zurück')); - var ps = document.createElement('span'); - ps.className = 'rm-pos'; - ps.textContent = pos ? pos[1] + ' / ' + pos[2] : ''; - nav.appendChild(ps); - nav.appendChild(navBtn(nextA, 'Weiter ›')); + nav.appendChild(navBtn(pg.url(1), '«', 'Erste Seite')); + nav.appendChild(navBtn(pg.prev, '‹', 'Vorherige Seite')); + + var pos = document.createElement('span'); + pos.className = 'rm-pos'; + pos.textContent = pg.cur + ' / ' + pg.total; + // Ohne bekanntes per_page laesst sich kein Sprungziel bauen, dann bleibt + // die Anzeige reine Beschriftung. + if (pg.total > 1 && pg.per) { + pos.className += ' rm-pos-btn'; + pos.textContent += ' ▾'; + pos.setAttribute('role', 'button'); + pos.setAttribute('aria-label', 'Seite wählen'); + pos.addEventListener('click', pageSheet(pg)); + } + nav.appendChild(pos); + + nav.appendChild(navBtn(pg.next, '›', 'Nächste Seite')); + nav.appendChild(navBtn(pg.url(pg.total), '»', 'Letzte Seite')); root.appendChild(nav); Array.prototype.forEach.call(document.body.children, function (el) { @@ -291,7 +515,7 @@ if (DEBUG) { var d = document.createElement('div'); d.className = 'rm-dbg'; - d.textContent = 'RIN Thread v2.2\n' + log.join('\n'); + d.textContent = 'RIN Thread v2.3\n' + log.join('\n'); document.body.insertBefore(d, document.body.firstChild); } }