Files
rin-mobile-userscript/rin-thread.user.js
T
Andre 10e3f34b69 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
2026-08-02 14:08:51 +02:00

527 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name RIN Mobile Thread
// @description Mobile Beitragsansicht fuer cs.rin.ru
// @version 2.3
// @match *://cs.rin.ru/forum/viewtopic.php*
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
var DEBUG = true;
var log = [];
function h() { return document.head || document.documentElement; }
if (!document.querySelector('meta[name="viewport"][data-rm]')) {
var m = document.createElement('meta');
m.name = 'viewport';
m.content = 'width=device-width, initial-scale=1, viewport-fit=cover';
m.setAttribute('data-rm', '1');
h().appendChild(m);
}
var st = document.createElement('style');
st.textContent = `
html, body { max-width:100% !important; overflow-x:hidden !important;
margin:0 !important; padding:0 !important; }
body { font-size:16px !important;
-webkit-text-size-adjust:100% !important; }
.rm-dbg { background:#222; color:#0f0; font:12px/1.45 monospace;
padding:8px 10px; white-space:pre-wrap; }
.rm-hidden { display:none !important; }
.rm-root { box-sizing:border-box !important; display:block !important;
width:100vw !important; max-width:100vw !important;
margin:0 !important; padding:0 0 64px 0 !important;
overflow:hidden !important; }
.rm-topic { box-sizing:border-box; padding:16px 14px 12px;
font-size:21px; font-weight:650; line-height:1.3;
overflow-wrap:break-word; }
.rm-post { box-sizing:border-box !important;
width:100% !important; max-width:100% !important;
padding:14px !important;
border-top:1px solid rgba(128,128,128,.25) !important; }
.rm-head { display:flex; align-items:center; gap:9px;
margin-bottom:10px; min-width:0; }
.rm-av { flex:none; width:28px; height:28px; border-radius:50%;
object-fit:cover; background:rgba(128,128,128,.25); }
.rm-who { min-width:0; flex:1; }
.rm-user { font-size:14px; font-weight:650; line-height:1.2;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rm-sub { font-size:11px; opacity:.55; line-height:1.3;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rm-body { font-size:17px !important; line-height:1.55 !important;
overflow-wrap:break-word !important;
word-break:normal !important; }
.rm-body * { max-width:100% !important; }
.rm-body img { height:auto !important; border-radius:6px; }
.rm-body a { overflow-wrap:anywhere !important; }
.rm-body table { display:block !important; overflow-x:auto !important;
width:auto !important; }
.rm-body pre, .rm-body code {
display:block; overflow-x:auto; white-space:pre;
font-size:13px; padding:10px; border-radius:8px;
background:rgba(128,128,128,.14); }
.rm-body blockquote, .rm-body .quote, .rm-body table.quote {
display:block !important; margin:12px 0 !important;
padding:8px 0 8px 12px !important;
border-left:3px solid rgba(140,140,255,.5) !important;
font-size:15px !important; opacity:.8 !important;
background:none !important; }
.rm-body ul, .rm-body ol { padding-left:22px !important; }
.rm-body hr { border:0; border-top:1px solid rgba(128,128,128,.3); }
.rm-sig { margin-top:12px; font-size:13px; opacity:.5; }
.rm-sig summary { cursor:pointer; opacity:.8; }
.rm-nav { position:fixed; left:0; right:0; bottom:0; z-index:9999;
display:flex; align-items:center; justify-content:space-between;
gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom));
background:rgba(28,28,30,.94);
-webkit-backdrop-filter:blur(12px);
backdrop-filter:blur(12px);
border-top:1px solid rgba(128,128,128,.3); }
.rm-nav a, .rm-nav span { font-size:14px; color:#eee !important;
text-decoration:none !important;
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);
function txt(el) { return (el ? el.textContent || '' : '')
.replace(/\s+/g, ' ').trim(); }
// Der Beitragstext liegt in einer verschachtelten Tabelle. closest('tr')
// trifft deshalb die innere Zeile, der Autor- und Profilspalte fehlen.
// Aufsteigen, bis die Zeile eine Profil- oder Autorenspalte enthaelt.
function postRow(pb) {
var tr = pb.closest('tr');
while (tr) {
if (tr.querySelector(':scope > td.profile') ||
tr.querySelector(':scope > td .postauthor')) return tr;
var up = tr.parentElement ? tr.parentElement.closest('tr') : null;
if (!up) return tr;
tr = up;
}
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);
if (!bodies.length) return;
var root = document.createElement('div');
root.className = 'rm-root';
var tt = document.querySelector('a.maintitle, h1, .maintitle');
if (tt) {
var th = document.createElement('div');
th.className = 'rm-topic';
th.textContent = txt(tt);
root.appendChild(th);
}
var built = 0, seen = new Set();
bodies.forEach(function (pb) {
var row = postRow(pb);
if (!row || seen.has(row)) return;
seen.add(row);
var card = document.createElement('div');
card.className = 'rm-post';
// --- Zellen dieser Zeile + der Kopfzeilen darueber ---
var cells = [];
Array.prototype.forEach.call(row.children, function (td) {
if (!td.contains(pb)) cells.push(td);
});
var pr = row.previousElementSibling, g = 0;
while (pr && g++ < 3) {
Array.prototype.forEach.call(pr.children, function (td) {
cells.push(td);
});
if (pr.querySelector('.postauthor, a[href*="profile.php"]')) break;
pr = pr.previousElementSibling;
}
// --- Username ---
var user = '';
for (var i = 0; i < cells.length && !user; i++) {
var ua = cells[i].querySelector(
'.postauthor, .username-coloured, a[href*="profile.php"]');
if (ua) user = txt(ua);
}
if (!user) {
for (var i2 = 0; i2 < cells.length && !user; i2++) {
var nm = cells[i2].querySelector('span.name b, span.name, b.name');
if (nm) user = txt(nm);
}
}
user = user.replace(/^Post subject:.*/i, '').trim();
// --- Avatar ---
var avImg = null, bestArea = 0;
cells.forEach(function (td) {
td.querySelectorAll('img').forEach(function (im) {
var s = im.getAttribute('src') || '';
if (/spacer|blank|icon|rank|star|folder|button|pm|email|quote|edit|report|www|msn|aim|yim|icq|topic/i.test(s))
return;
if (/avatar/i.test(s)) { avImg = im; bestArea = 1e9; return; }
var w = parseInt(im.getAttribute('width'), 10) || im.naturalWidth || 0;
var hh = parseInt(im.getAttribute('height'), 10) || im.naturalHeight || 0;
var a = w * hh;
if (w >= 40 && a > bestArea) { avImg = im; bestArea = a; }
});
});
// --- Rang, Postzahl, Zeitstempel ---
var allTxt = cells.map(txt).join(' ');
var rank = '';
var rr = allTxt.match(/\b(Administrator|Site Admin|Global Moderator|Moderator|VIP|Uploader|Supporter|Member|Newbie|Guest)\b/i);
if (rr) rank = rr[1];
var pc = allTxt.match(/Posts:\s*([\d.,]+)/i);
if (pc) rank = (rank ? rank + ' · ' : '') + pc[1] + ' Posts';
var when = '';
var wm = allTxt.match(/Posted:\s*([^|]+?)(?:\s{2,}|Post subject|$)/i);
if (wm) when = wm[1].trim();
// --- Kopfzeile ---
var head = document.createElement('div');
head.className = 'rm-head';
var av;
if (avImg) {
av = document.createElement('img');
av.src = avImg.src;
} else {
av = document.createElement('div');
av.textContent = (user || '?').charAt(0).toUpperCase();
av.style.cssText = 'display:flex;align-items:center;' +
'justify-content:center;font-size:13px;font-weight:700;' +
'color:rgba(255,255,255,.75);';
}
av.className = 'rm-av';
head.appendChild(av);
var who = document.createElement('div');
who.className = 'rm-who';
var un = document.createElement('div');
un.className = 'rm-user';
un.textContent = user || 'Gast';
who.appendChild(un);
var sub = document.createElement('div');
sub.className = 'rm-sub';
sub.textContent = [rank, when].filter(Boolean).join(' · ');
who.appendChild(sub);
head.appendChild(who);
card.appendChild(head);
// --- Signatur abtrennen ---
var sig = null, hr = pb.querySelector('hr');
if (hr) {
sig = document.createElement('details');
sig.className = 'rm-sig';
var sm = document.createElement('summary');
sm.textContent = 'Signatur';
sig.appendChild(sm);
var n = hr.nextSibling;
while (n) { var nx = n.nextSibling; sig.appendChild(n); n = nx; }
hr.remove();
}
var bodyWrap = document.createElement('div');
bodyWrap.className = 'rm-body';
bodyWrap.appendChild(pb);
card.appendChild(bodyWrap);
if (sig && sig.childNodes.length > 1) card.appendChild(sig);
root.appendChild(card);
built++;
});
log.push('posts=' + built);
if (!built) return;
// --- Navigationsleiste ---
var pg = pageState();
var nav = document.createElement('div');
nav.className = 'rm-nav';
function navBtn(href, label, aria) {
var e = document.createElement('a');
e.className = 'rm-step' + (href ? '' : ' rm-off');
e.textContent = label;
e.setAttribute('aria-label', aria);
if (href) e.href = href;
return e;
}
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) {
if (el !== root) el.classList.add('rm-hidden');
});
document.body.appendChild(root);
}
function run() {
try { build(); } catch (e) { log.push('build: ' + e.message); }
if (DEBUG) {
var d = document.createElement('div');
d.className = 'rm-dbg';
d.textContent = 'RIN Thread v2.3\n' + log.join('\n');
document.body.insertBefore(d, document.body.firstChild);
}
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', run);
else run();
})();