refactor: merge the two userscripts into one

The list script matched the whole forum, thread pages included, and injected its
stylesheet there before bailing out of its own build. Both stylesheets were
therefore live on every thread, and rules sharing a class name resolved by
userscript load order rather than by intent — a layout that could differ between
two machines with the same files installed. One of those clashes was already
costing the clearance behind the fixed bar.

A single script picks its view from the page type instead, which turns the
shared class namespace from a hazard into a plain fact of the file, resolved
with a modifier on the root element. Pages outside either view now stay
untouched, where before they still received a full stylesheet.

Two things the merge brought to light: the global image rule was overriding the
avatar dimensions, which only went unnoticed because square avatars happen to
scale to the same result, and a zero-width space had been sitting in the source
as a literal character rather than an escape.

Installations carrying the old thread script have to remove it; the remaining
one keeps its name and updates in place.

Claude-Session: https://claude.ai/code/session_0158owAAsXAfFiT3hGkQt1Xk
This commit is contained in:
Andre
2026-08-02 14:24:29 +02:00
parent bd4fc85d38
commit eff957ed07
6 changed files with 634 additions and 665 deletions
+529 -27
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name RIN Mobile
// @description Mobile Karten-Ansicht fuer cs.rin.ru
// @version 1.4
// @description Mobile Ansicht fuer cs.rin.ru: Themenlisten als Karten, Threads als Beitragsstrom
// @version 3.0
// @match *://cs.rin.ru/forum/*
// @run-at document-start
// ==/UserScript==
@@ -14,7 +14,22 @@
function h() { return document.head || document.documentElement; }
if (!document.querySelector('meta[name="viewport"][data-rm]')) {
function txt(el) { return (el ? el.textContent || '' : '')
.replace(/\s+/g, ' ').trim(); }
// Der Seitentyp entscheidet, welche Ansicht gebaut wird. Beide Ansichten
// liegen in einem Script, weil sie sich Grundstile und Klassennamen teilen.
// Als getrennte Scripts waren auf Threadseiten beide Stylesheets aktiv und
// die Ladereihenfolge entschied bei gleich benannten Regeln ueber das
// Layout.
var here = location.pathname + location.search;
var view = /viewtopic\.php/.test(here) ? 'thread'
: /viewforum\.php|search\.php/.test(here) ||
location.pathname === '/forum/' ? 'liste'
: '';
log.push('ansicht=' + (view || 'keine'));
if (view && !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';
@@ -22,8 +37,11 @@
h().appendChild(m);
}
var st = document.createElement('style');
st.textContent = `
if (view) {
var st = document.createElement('style');
st.textContent = `
/* === Grundlage, beide Ansichten === */
html, body { max-width:100% !important; overflow-x:hidden !important;
margin:0 !important; padding:0 !important; }
body { font-size:16px !important;
@@ -33,12 +51,17 @@
.rm-dbg { background:#222; color:#0f0; font:12px/1.45 monospace;
padding:8px 10px; white-space:pre-wrap;
border-bottom:2px solid #0f0; }
.rm-hidden { display:none !important; }
.rm-root { box-sizing:border-box !important;
display:block !important;
.rm-root { box-sizing:border-box !important; display:block !important;
width:100vw !important; max-width:100vw !important;
margin:0 !important; padding:0 !important;
overflow:hidden !important; }
/* Freihaltung hinter der fixierten Leiste der Threadansicht. */
.rm-root.rm-thread { padding-bottom:64px !important; }
/* === Themenliste === */
.rm-card { box-sizing:border-box !important;
display:block !important;
width:100% !important; max-width:100% !important;
@@ -76,11 +99,132 @@
.rm-pages a { padding:6px 11px; border-radius:8px;
border:1px solid rgba(128,128,128,.35);
text-decoration:none !important; color:inherit !important; }
.rm-hidden { display:none !important; }
`;
h().appendChild(st);
function txt(el) { return (el.textContent || '').replace(/\s+/g, ' ').trim(); }
/* === Thread === */
.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; }
/* Die Masse muessen sich gegen die globale img-Regel durchsetzen. */
.rm-av { flex:none; width:28px !important; height:28px !important;
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);
}
// Originalinhalt ausblenden und die neue Ansicht als einziges sichtbares
// body-Kind einhaengen. Alles, was spaeter noch an body soll, muss nach
// diesem Aufruf kommen.
function replaceBody(root) {
Array.prototype.forEach.call(document.body.children, function (el) {
if (el !== root) el.classList.add('rm-hidden');
});
document.body.appendChild(root);
}
// ==========================================================================
// Themenliste
// ==========================================================================
function breakable(s) {
return s.replace(/([|\/\\,;:>\]\)])/g, '$1\u200B');
@@ -156,13 +300,7 @@
return card;
}
function build() {
var onList = /viewforum\.php|search\.php/
.test(location.pathname + location.search) ||
location.pathname === '/forum/';
log.push('listenseite=' + onList);
if (!onList) return;
function buildList() {
var links = document.querySelectorAll(
'a.topictitle,a[href*="viewtopic.php"]');
log.push('themenlinks=' + links.length);
@@ -205,11 +343,7 @@
});
if (pages.children.length) root.appendChild(pages);
// Originalinhalt ausblenden, Liste als direktes body-Kind einhaengen
Array.prototype.forEach.call(document.body.children, function (el) {
if (el !== root) el.classList.add('rm-hidden');
});
document.body.appendChild(root);
replaceBody(root);
if (DEBUG) {
var c0 = root.querySelector('.rm-card');
@@ -219,18 +353,386 @@
}
}
// ==========================================================================
// Thread
// ==========================================================================
// 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 m2 = (el.textContent || '').match(/per_page\s*=\s*'?(\d+)/);
if (m2) s.per = parseInt(m2[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 buildThread() {
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 rm-thread';
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);
replaceBody(root);
}
// ==========================================================================
function run() {
try { build(); } catch (e) { log.push('build: ' + e.message); }
try {
if (view === 'liste') buildList();
else if (view === 'thread') buildThread();
} catch (e) { log.push('build: ' + e.message); }
if (DEBUG) {
var d = document.createElement('div');
d.className = 'rm-dbg';
d.textContent = 'RIN Mobile v1.4\n' + log.join('\n');
d.textContent = 'RIN Mobile v3.0\n' + log.join('\n');
document.body.insertBefore(d, document.body.firstChild);
}
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', run);
else
run();
else run();
})();