Files
rin-mobile-userscript/rin-mobile.user.js
T
Andre 27551e6937 feat: add a mobile login view and a login/logout status pill
The script previously had no way to log in or tell whether a session
was already active, since ucp.php fell outside the view switch and
rendered in the desktop layout. Session state is only visible in the
forum's own menu bar link (mode=login vs mode=logout), so every view
now surfaces it as a pill in the breadcrumb bar instead of leaving the
user to dig for it.

The login form itself is moved into the new layout rather than rebuilt
from scratch, keeping the hidden sid/redirect fields and the
autologin/viewonline checkboxes intact without the script having to
reimplement the session handshake. As the first short, unscrollable
view in the project, it also exposed a WebKit quirk that longer views
never triggered: without enough scroll headroom, Safari's own
partially-retracted toolbar has nothing to scroll away and permanently
covers the submit button. The generous bottom padding on the form
works around that, separately from the safe-area inset that only
accounts for the home indicator.
2026-08-02 16:06:14 +02:00

1242 lines
48 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
// @description Mobile Ansicht fuer cs.rin.ru: Themenlisten als Karten, Threads als Beitragsstrom
// @version 3.6
// @match *://cs.rin.ru/forum/*
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
var DEBUG = false;
var log = [];
function h() { return document.head || document.documentElement; }
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'
: /ucp\.php/.test(here) && /mode=login/.test(here) ? 'login'
: '';
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';
m.setAttribute('data-rm', '1');
h().appendChild(m);
}
if (view) {
var st = document.createElement('style');
st.textContent = `
/* === Grundlage, alle Ansichten === */
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; }
img { max-width:100% !important; height:auto !important; }
.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;
width:100vw !important; max-width:100vw !important;
margin:0 !important; padding:0 !important;
overflow:hidden !important; }
/* Freihaltung hinter der fixierten Seitenleiste. Die Klasse setzt die
Leiste selbst, damit ohne sie kein leerer Streifen bleibt. */
.rm-root.rm-paged { padding-bottom:64px !important; }
/* Brotkrumen, alle Ansichten. Fixiert statt sticky: .rm-root traegt
overflow:hidden und wuerde als Scrollport einer sticky-Leiste dienen,
die darin nie klebt. Fixierte Nachfahren klammert es nicht, weil der
Root keinen eigenen Containing Block aufspannt. */
.rm-crumbs { box-sizing:border-box;
position:fixed; top:0; left:0; right:0; z-index:9998;
display:flex; align-items:center; gap:6px;
height:calc(44px + env(safe-area-inset-top));
padding:env(safe-area-inset-top) 12px 0;
overflow-x:auto; overflow-y:hidden;
-webkit-overflow-scrolling:touch; white-space:nowrap;
background:rgba(28,28,30,.94);
-webkit-backdrop-filter:blur(12px);
backdrop-filter:blur(12px);
border-bottom:1px solid rgba(128,128,128,.3); }
.rm-crumbs a { flex:none; padding:8px 2px; font-size:14px;
color:#eee !important; text-decoration:none !important; }
.rm-crumbs a:last-of-type { font-weight:650; }
.rm-crumbs .rm-sep { flex:none; font-size:13px; color:#eee; opacity:.45; }
.rm-crumbs a.rm-auth { flex:none; margin-left:auto; padding:6px 12px !important;
font-size:13px !important; border-radius:99px;
background:rgba(128,128,128,.18);
border:1px solid rgba(160,160,160,.4); }
/* Nur gesetzt, wenn die Leiste wirklich gebaut wurde — ohne Brotkrumen
im Original-DOM bleibt der Platz sonst leer reserviert. */
body.rm-crumbed .rm-root {
padding-top:calc(45px + env(safe-area-inset-top)) !important; }
body.rm-crumbed .rm-dbg {
margin-top:calc(45px + env(safe-area-inset-top)); }
/* === Themen- und Forenliste === */
.rm-sec { padding:18px 14px 6px; font-size:12px; font-weight:700;
text-transform:uppercase; letter-spacing:.06em; opacity:.55; }
.rm-sec a { text-decoration:none !important; color:inherit !important; }
.rm-desc { margin-top:6px; font-size:14px; opacity:.72;
overflow-wrap:break-word; }
.rm-subs { margin-top:9px; display:flex; flex-wrap:wrap; gap:6px; }
.rm-subs a { font-size:12px; padding:4px 9px; border-radius:99px;
border:1px solid rgba(128,128,128,.4);
text-decoration:none !important; color:inherit !important; }
/* Foren und Themen stehen auf einer Forenseite in derselben Tabelle
untereinander. Im Kartenstrom unterscheiden sie sich sonst nur am Text,
der Uebergang von den Unterforen zu den Themen ist nicht mehr zu sehen. */
.rm-group { display:flex; align-items:baseline; gap:10px;
padding:11px 14px; font-size:12px; font-weight:700;
text-transform:uppercase; letter-spacing:.07em; opacity:.75;
background:rgba(128,128,128,.16);
border-bottom:1px solid rgba(128,128,128,.28);
cursor:pointer; -webkit-tap-highlight-color:transparent;
-webkit-user-select:none; user-select:none; }
.rm-group:active { background:rgba(128,128,128,.26); }
.rm-group .rm-gname { flex:1; min-width:0; overflow:hidden;
text-overflow:ellipsis; white-space:nowrap; }
.rm-group .rm-count { flex:none; font-weight:600; letter-spacing:0;
opacity:.7; }
.rm-caret { flex:none; display:inline-block; font-size:10px; opacity:.7;
transition:transform .15s; }
.rm-group.rm-closed .rm-caret { transform:rotate(-90deg); }
.rm-card { box-sizing:border-box !important;
display:block !important;
width:100% !important; max-width:100% !important;
margin:0 !important;
padding:14px 14px 15px !important;
border-bottom:1px solid rgba(128,128,128,.28) !important; }
.rm-card:active { background:rgba(128,128,128,.14); }
.rm-card.rm-forum { border-left:3px solid rgba(120,170,255,.7);
background:rgba(120,170,255,.07); }
.rm-main { box-sizing:border-box !important;
display:block !important;
width:100% !important; max-width:100% !important;
text-decoration:none !important; color:inherit !important;
font-size:20px !important; font-weight:600 !important;
line-height:1.32 !important;
white-space:normal !important;
word-break:normal !important;
overflow-wrap:break-word !important;
hyphens:none !important; }
.rm-badge { display:inline; font-size:11px; font-weight:700;
text-transform:uppercase; letter-spacing:.04em;
padding:2px 6px; margin-right:6px; border-radius:4px;
background:rgba(200,60,60,.18); color:#e06a6a;
white-space:nowrap; vertical-align:2px; }
.rm-meta { margin-top:8px; font-size:13px; opacity:.7;
overflow-wrap:break-word; }
.rm-meta span + span::before { content:" · "; opacity:.55; }
.rm-foot { margin-top:5px; display:flex; align-items:center;
justify-content:space-between; gap:10px; }
.rm-last { font-size:13px; opacity:.55; min-width:0;
overflow-wrap:break-word; }
.rm-jump { flex:none; font-size:12px; padding:5px 10px;
border:1px solid rgba(128,128,128,.4); border-radius:99px;
text-decoration:none !important; color:inherit !important;
white-space:nowrap; }
/* === 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; }
/* === Login === */
.rm-login-title { padding:16px 14px 6px; font-size:21px;
font-weight:650; line-height:1.3; }
.rm-login-note { margin:8px 14px 4px; padding:10px 12px;
font-size:13px; opacity:.75; border-radius:10px;
background:rgba(128,128,128,.14);
overflow-wrap:break-word; }
.rm-login-note summary { cursor:pointer; font-weight:650; opacity:.9; }
.rm-login-note[open] summary { margin-bottom:8px; }
/* Das Original-Formular ist tabellenbasiert und wird fuer den gestapelten
Kartenstil komplett auf Block-Elemente umgelegt — derselbe Trick wie bei
.rm-body table im Thread. Ohne Paginierung fehlt hier die .rm-nav, die
sonst per .rm-root.rm-paged den unteren Rand freihaelt. Der grosse
Bodensatz ist kein Stilmittel, sondern erzwingt genug Seitenhoehe zum
Scrollen: ist die Seite so kurz wie der Viewport, bleibt Safaris eigene,
nur teilweise eingefahrene Werkzeugleiste unten stehen und verdeckt den
Button dauerhaft, weil nichts mehr zu scrollen ist, um sie wegzuschieben. */
.rm-login form { display:block;
padding:4px 14px calc(140px + env(safe-area-inset-bottom)); }
.rm-login form table, .rm-login form tbody,
.rm-login form tr, .rm-login form td {
display:block !important; width:100% !important; }
.rm-login form th { display:none !important; }
.rm-login form td { padding:0 !important; }
.rm-login form b.gensmall { display:block; margin:16px 0 6px;
font-size:13px; font-weight:650; opacity:.8; }
.rm-login form input[type="text"],
.rm-login form input[type="password"] {
box-sizing:border-box; width:100%; font-size:16px; padding:11px 12px;
border-radius:10px; color:#eee; background:rgba(120,120,120,.18);
border:1px solid rgba(160,160,160,.35); }
.rm-login form span.gensmall, .rm-login form a.gensmall {
display:inline-block; margin-top:6px; font-size:12px; opacity:.6;
text-decoration:none !important; color:inherit !important; }
.rm-login form input[type="checkbox"] { width:18px; height:18px;
vertical-align:middle; margin-right:8px; }
.rm-login form input[type="submit"] {
width:100%; font-size:16px; font-weight:650; padding:13px;
margin-top:20px; border-radius:10px; color:#fff; border:0;
background:rgba(120,170,255,.85); }
/* === Seitennavigation, beide Ansichten === */
.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);
}
// Ob eine Sitzung besteht, verraet nur der Menuebalken der Originalseite:
// ausgeloggt verlinkt er ucp.php?mode=login, eingeloggt stattdessen
// mode=logout, ueblicherweise als "Logout [ Name ]". Das Namensmuster ist
// ungetestet, da dem Projekt keine eingeloggte Fixture vorliegt — schlaegt
// es fehl, bleibt die Pille ohne Namen stehen statt zu reissen.
function loginState() {
var out = document.querySelector('a[href*="mode=logout"]');
if (out) {
var m = txt(out).match(/\[\s*(.+?)\s*\]/);
return { in: true, href: out.href, name: m ? m[1] : '' };
}
var into = document.querySelector('a[href*="mode=login"]');
return { in: false, href: into ? into.href : './ucp.php?mode=login' };
}
// Ohne die Brotkrumen fuehrt aus der neuen Ansicht kein Weg zurueck: das
// Original-DOM ist ausgeblendet, und phpBB verlinkt das Elternforum nirgends
// sonst. Muss nach replaceBody() laufen, weil die Leiste an body haengt.
function addCrumbs() {
// Die Leiste steht zweimal im Markup, ueber und unter dem Inhalt. Die
// erste genuegt; a.breadcrumbs faengt Seiten ohne den Absatz drumherum.
var box = document.querySelector('p.breadcrumbs');
var items = box ? box.querySelectorAll('a')
: document.querySelectorAll('a.breadcrumbs');
var bar = document.createElement('div');
bar.className = 'rm-crumbs';
var n = 0;
Array.prototype.forEach.call(items, function (a) {
var t = txt(a);
if (!t) return;
if (n) {
var sep = document.createElement('span');
sep.className = 'rm-sep';
sep.textContent = '›';
bar.appendChild(sep);
}
var l = document.createElement('a');
l.href = a.href;
l.textContent = t;
bar.appendChild(l);
n++;
});
log.push('krumen=' + n);
if (!n) return;
var st = loginState();
var auth = document.createElement('a');
auth.className = 'rm-auth';
auth.href = st.href;
auth.textContent = st.in ? (st.name || 'Logout') : 'Login';
bar.appendChild(auth);
document.body.appendChild(bar);
document.body.classList.add('rm-crumbed');
}
// ==========================================================================
// Seitennavigation, beide Ansichten
// ==========================================================================
// Foren- und Themenseiten paginieren gleich: derselbe start-Parameter,
// dieselbe jumpto()-Funktion mit demselben per_page darin. Der Block gilt
// deshalb fuer beide Ansichten.
// --- 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' });
});
};
}
// Die fixierte Leiste am unteren Rand. Der Aufrufer reicht den Zustand
// herein, weil er allein entscheidet, ob die Leiste ueberhaupt lohnt: im
// Thread gehoert sie zum Geruest, eine einseitige Liste braucht sie nicht.
function pageNav(root, pg) {
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'));
// Die Freihaltung haengt an der Leiste selbst: ohne sie stuende unten ein
// leerer Streifen.
root.classList.add('rm-paged');
root.appendChild(nav);
}
// ==========================================================================
// Themenliste und Forenliste
// ==========================================================================
function breakable(s) {
return s.replace(/([|\/\\,;:>\]\)])/g, '$1\u200B');
}
// Karten teilen sich Titel, Zahlenzeile und Fusszeile; nur die Quellen
// dafuer unterscheiden sich zwischen Thema und Forum.
function card(titleA, badgeable, kind) {
var el = document.createElement('div');
el.className = 'rm-card' + (kind === 'forum' ? ' rm-forum' : '');
var main = document.createElement('a');
main.className = 'rm-main';
main.href = titleA.href;
var raw = txt(titleA);
var badge = badgeable ? (raw.match(/^\[([^\]]{1,20})\]/) || [])[1] : null;
if (badge) {
var b = document.createElement('span');
b.className = 'rm-badge';
b.textContent = badge;
main.appendChild(b);
raw = raw.replace(/^\[[^\]]{1,20}\]\s*/, '');
}
main.appendChild(document.createTextNode(breakable(raw)));
el.appendChild(main);
return el;
}
function metaRow(parts) {
var meta = document.createElement('div');
meta.className = 'rm-meta';
parts.forEach(function (s) {
if (!s) return;
var e = document.createElement('span');
e.textContent = s;
meta.appendChild(e);
});
return meta.children.length ? meta : null;
}
function footRow(last, jump, label) {
var foot = document.createElement('div');
foot.className = 'rm-foot';
var l = document.createElement('div');
l.className = 'rm-last';
l.textContent = last;
foot.appendChild(l);
if (jump) {
var j = document.createElement('a');
j.className = 'rm-jump';
j.href = jump.href;
j.textContent = label;
foot.appendChild(j);
}
return foot;
}
function convertTopic(tr) {
var titleA = tr.querySelector('a.topictitle') ||
tr.querySelector('a[href*="viewtopic.php"]');
if (!titleA) return null;
var titleCell = titleA.closest('td');
var nums = [], author = '', last = '', jump = null;
Array.prototype.forEach.call(tr.children, function (td) {
if (td === titleCell) return;
var t = txt(td);
if (/^\d+$/.test(t)) { nums.push(t); return; }
var pa = td.querySelector('a[href*="profile.php"],a[href*="memberlist.php"]');
if (pa && t.length < 40 && !/\d{4}/.test(t)) { author = t; return; }
if (t) {
last = t;
jump = td.querySelector('a[href*="#p"]') ||
td.querySelector('a[href*="viewtopic.php"]');
}
});
var el = card(titleA, true, 'thema');
var meta = metaRow([author,
nums[0] ? nums[0] + ' Antworten' : '',
nums[1] ? nums[1] + ' Views' : '']);
if (meta) el.appendChild(meta);
el.appendChild(footRow(last, jump, 'Neueste ↓'));
return el;
}
// Forenzeilen tragen ihren Namen in a.forumlink, die Beschreibung und die
// Unterforen stehen als weitere Absaetze in derselben Zelle.
function convertForum(tr) {
var titleA = forumTitle(tr);
if (!titleA) return null;
var titleCell = titleA.closest('td');
var nums = [], last = '', jump = null;
Array.prototype.forEach.call(tr.children, function (td) {
if (td === titleCell) return;
var t = txt(td);
if (/^\d[\d.,]*$/.test(t)) { nums.push(t); return; }
if (t) {
last = t;
jump = td.querySelector('a[href*="viewtopic.php"]');
}
});
var el = card(titleA, false, 'forum');
// Beschreibung, Moderatoren und Unterforen benutzen denselben Absatztyp.
// Die beiden letzten sind nur an ihrer Einleitung zu erkennen.
var desc = '';
if (titleCell) {
titleCell.querySelectorAll('p.forumdesc, span.forumdesc')
.forEach(function (p) {
var t = txt(p);
if (desc || !t || /^(Moderator|Subforum|Unterforum)/i.test(t)) return;
desc = t;
});
}
// Ohne die Klasse steht die Beschreibung als blosser Text neben dem Namen.
// Nur direkte Textknoten der Zelle: der Text der ganzen Zelle einzusammeln
// holt bei einer verkannten Zeile den halben Seiteninhalt mit.
if (!desc && titleCell) {
var loose = '';
Array.prototype.forEach.call(titleCell.childNodes, function (n) {
if (n.nodeType === 3) loose += ' ' + n.nodeValue;
});
desc = loose.replace(/\s+/g, ' ').trim()
.split(/Moderators?:|Subforums?:|Unterforen?:/)[0].trim();
if (desc.length > 300) desc = '';
}
if (desc) {
var d = document.createElement('div');
d.className = 'rm-desc';
d.textContent = desc;
el.appendChild(d);
}
// Unterforen sind sonst nur ueber den Umweg des Elternforums erreichbar.
var subs = document.createElement('div');
subs.className = 'rm-subs';
if (titleCell) {
titleCell.querySelectorAll('a[href*="viewforum.php"]')
.forEach(function (a) {
if (a === titleA || !txt(a)) return;
var s = document.createElement('a');
s.href = a.href;
s.textContent = txt(a);
subs.appendChild(s);
});
}
if (subs.children.length) el.appendChild(subs);
var meta = metaRow([nums[0] ? nums[0] + ' Themen' : '',
nums[1] ? nums[1] + ' Beiträge' : '']);
if (meta) el.appendChild(meta);
if (last || jump) el.appendChild(footRow(last, jump, 'Letzter ↓'));
return el;
}
function forumTitle(tr) {
var a = tr.querySelector('a.forumlink');
if (a) return a;
// Ohne die Klasse bleibt der erste Link auf ein Forum, der Text traegt und
// kein Unterforum ist.
var found = null;
tr.querySelectorAll('a[href*="viewforum.php"]').forEach(function (x) {
if (found || !txt(x) || x.classList.contains('subforum') ||
x.classList.contains('breadcrumbs') || x.closest('p.breadcrumbs')) return;
found = x;
});
return found;
}
function section(a) {
var el = document.createElement('div');
el.className = 'rm-sec';
var l = document.createElement('a');
l.href = a.href;
l.textContent = txt(a);
el.appendChild(l);
return el;
}
// Ankuendigungen und Stickies stehen vor jedem Thema jeder Seite eines
// Forums. Ihr Typ steht im Symbol der ersten Spalte, nicht am Titel:
// global_read.png, announce_read.png, sticky_read.png gegen topic_read.png.
function topicGroup(tr) {
var im = tr.querySelector('img[src]');
var m = im && (im.getAttribute('src') || '')
.match(/\b(global|announce|sticky)_(?:un)?read/);
return m ? m[1] : 'thema';
}
// phpBB stellt jeder dieser Gruppen eine Trennzeile voran. Sie traegt keinen
// Link und faellt deshalb beim Einsammeln durch — ihr Text ist aber die
// ehrlichste Beschriftung, die es fuer die Gruppe gibt, und deckt auch
// Abschnitte ab, die dieses Script nicht kennt.
function sepLabel(tr) {
var p = tr.previousElementSibling, n = 0;
while (p && n++ < 4) {
if (p.dataset.rm) return '';
if (p.children.length === 1 && p.children[0].hasAttribute('colspan') &&
!p.querySelector('a, table')) {
var t = txt(p);
if (t && t.length < 40) return t;
}
p = p.previousElementSibling;
}
return '';
}
var GNAME = { forum: 'Foren', thema: 'Themen', sticky: 'Stickies',
global: 'Globale Ankündigungen', announce: 'Ankündigungen' };
// Genau die Gruppen, die zwischen dem Leser und den eigentlichen Themen
// stehen, starten zugeklappt.
var GSHUT = { global: 1, announce: 1, sticky: 1 };
// Der Zustand liegt in localStorage und nicht in GM_setValue: das kommt ohne
// @grant aus und verhaelt sich in Tampermonkey und der iOS-App gleich. Der
// Schluessel ist die Gruppenart, nicht ihr Name — einmal zugeklappt gilt
// fuer jedes Forum.
var GKEY = 'rm-groups';
function groupState() {
try { return JSON.parse(localStorage.getItem(GKEY)) || {}; }
catch (e) { return {}; }
}
function saveGroupState(group, open) {
try {
var s = groupState();
s[group] = open;
localStorage.setItem(GKEY, JSON.stringify(s));
} catch (e) { log.push('speichern: ' + e.message); }
}
// Kopf ueber einem Block gleichartiger Karten, dazu der Kasten, in den sie
// gehoeren: die Originalseite trennt Foren, Ankuendigungen, Stickies und
// Themen ueber eigene Kopfzeilen, im Kartenstrom geht diese Grenze verloren.
// Der Kopf ist zugleich der Schalter, der den Block zuklappt.
function openGroup(root, group, label, n) {
var saved = groupState()[group];
var open = saved === undefined ? !GSHUT[group] : !!saved;
var head = document.createElement('div');
head.className = 'rm-group';
head.setAttribute('role', 'button');
var t = document.createElement('span');
t.className = 'rm-gname';
t.textContent = label;
head.appendChild(t);
var c = document.createElement('span');
c.className = 'rm-count';
c.textContent = n;
head.appendChild(c);
var caret = document.createElement('span');
caret.className = 'rm-caret';
caret.textContent = '▼';
head.appendChild(caret);
var body = document.createElement('div');
body.className = 'rm-gbody';
function apply() {
head.classList.toggle('rm-closed', !open);
body.classList.toggle('rm-hidden', !open);
head.setAttribute('aria-expanded', open ? 'true' : 'false');
}
apply();
head.addEventListener('click', function () {
open = !open;
apply();
saveGroupState(group, open);
});
root.appendChild(head);
root.appendChild(body);
return body;
}
// Welche Karte eine Zeile ergibt, entscheidet der erste verwertbare Link
// darin. Beiwerk wie die Brotkrumen oder "Mark topics read" verlinkt
// ebenfalls auf viewforum.php, steht aber in keiner Zeile, die eine Karte
// hergibt — deshalb muss eine Forenzeile als solche erkennbar sein.
function rowKind(a, tr) {
if (!txt(a) || a.classList.contains('breadcrumbs') ||
a.closest('p.breadcrumbs')) return '';
if (/viewtopic\.php/.test(a.getAttribute('href') || '')) {
if (tr.querySelector('a.forumlink')) return 'forum';
// Der Menuebalken der Seite verlinkt "Forum rules" und die FAQ ebenfalls
// auf Themen. Die Bremse fuer Layoutzeilen weiter unten greift dort
// nicht, weil die Zeile weder Tabelle noch Script enthaelt — es fehlen
// ihr aber die Merkmale einer Themenzeile.
return tr.querySelector('a.topictitle') || numCells(tr) >= 2 ? 'thema' : '';
}
// Das Seitengeruest besteht bei phpBB selbst aus Tabellen. Enthaelt eine
// Zeile eine weitere Tabelle oder ein Script, ist sie Layout und keine
// Forenzeile — ohne diese Bremse wird die halbe Seite zu einer Karte.
if (tr.querySelector('table, script, style, form')) return '';
if (a.closest('td.cat') || a.closest('h4')) return 'sektion';
if (a.classList.contains('forumlink')) return 'forum';
if (a.classList.contains('subforum')) return '';
// Fehlen die phpBB-Klassen, bleibt die Form der Zeile: zwei rein
// numerische Spalten hat nur eine Forenzeile mit Themen- und Beitragszahl.
return tr.querySelector('.forumdesc') || numCells(tr) >= 2 ? 'forum' : '';
}
function numCells(tr) {
var n = 0;
Array.prototype.forEach.call(tr.children, function (td) {
if (/^\d[\d.,]*$/.test(txt(td))) n++;
});
return n;
}
function buildList() {
// Dokumentreihenfolge entscheidet: in einer Forenzeile steht der Forenname
// vor dem Link auf den letzten Beitrag. Ohne diese Reihenfolge faellt die
// Zeile als Thema an, der Forenname verschwindet und nur die Beschreibung
// bleibt uebrig.
var links = document.querySelectorAll(
'a.forumlink,a.topictitle,a[href*="viewforum.php"],a[href*="viewtopic.php"]');
var rows = [];
links.forEach(function (a) {
var tr = a.closest('tr');
if (!tr || tr.dataset.rm || !tr.closest('table')) return;
var kind = rowKind(a, tr);
if (!kind) return;
var group = kind === 'sektion' ? ''
: kind === 'forum' ? 'forum'
: topicGroup(tr);
// Vor dem Setzen der Marke: sepLabel liest sie an den Zeilen darueber,
// um an einer bereits verwerteten Zeile stehen zu bleiben.
var label = sepLabel(tr) || GNAME[group] || GNAME.thema;
tr.dataset.rm = '1';
rows.push({ tr: tr, a: a, kind: kind, group: group, label: label });
});
log.push('zeilen=' + rows.length);
if (!rows.length) return;
var root = document.createElement('div');
root.className = 'rm-root';
// Erst alle Karten bauen, dann ausgeben: der Gruppenkopf nennt die Zahl der
// Karten unter sich, und die steht erst fest, wenn die Zeilen durch sind,
// von denen manche keine Karte hergeben.
var items = [];
rows.forEach(function (r) {
try {
var c = r.kind === 'thema' ? convertTopic(r.tr)
: r.kind === 'forum' ? convertForum(r.tr)
: section(r.a);
if (c) items.push({ kind: r.kind, group: r.group, label: r.label, el: c });
} catch (e) { log.push('convert: ' + e.message); }
});
var built = 0, last = '', box = null;
items.forEach(function (it, i) {
if (it.kind === 'sektion') {
root.appendChild(it.el);
last = 'sektion';
return;
}
built++;
if (it.group !== last) {
// Eine Kategoriezeile leitet die Foren darunter bereits ein — auf der
// Forenwurzel wuerde ein Gruppenkopf sie nur doppeln.
if (last === 'sektion') box = root;
else {
var n = 0;
while (i + n < items.length && items[i + n].group === it.group) n++;
box = openGroup(root, it.group, it.label, n);
}
last = it.group;
}
box.appendChild(it.el);
});
log.push('karten=' + built);
if (!built) return;
// Muss vor replaceBody() laufen: pageState() liest das Original-DOM, und
// "Page X of Y" wuerde sonst im Text der eigenen Karten gesucht.
var pg = pageState();
if (pg.total > 1) pageNav(root, pg);
replaceBody(root);
addCrumbs();
if (DEBUG) {
var c0 = root.querySelector('.rm-card');
log.push('fenster=' + window.innerWidth +
' body=' + document.body.scrollWidth +
' karte=' + (c0 ? Math.round(c0.getBoundingClientRect().width) : '?'));
}
}
// ==========================================================================
// 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;
}
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';
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;
pageNav(root, pageState());
replaceBody(root);
addCrumbs();
}
// ==========================================================================
// Login
// ==========================================================================
// Das Original-Formular wird unveraendert wiederverwendet statt neu gebaut
// — wie der Beitragstext im Thread. So bleiben versteckte Felder (sid,
// redirect) und die serverseitige Validierung erhalten; das Script muss
// weder Sitzungs- noch Fehlerzustaende selbst nachbilden.
function buildLogin() {
var userInput = document.querySelector('input[name="username"]');
var form = userInput && userInput.closest('form');
if (!form) { log.push('login: kein Formular'); return; }
var root = document.createElement('div');
root.className = 'rm-root rm-login';
var th = form.querySelector('th');
var title = document.createElement('div');
title.className = 'rm-login-title';
title.textContent = txt(th) || 'Login';
root.appendChild(title);
// Der Regeltext steckt als einziger Inhalt in td.row1 und wird separat als
// aufklappbarer Kasten gefuehrt statt mitten im Formular zu stehen.
var note = form.querySelector('.postbody');
if (note && txt(note)) {
var det = document.createElement('details');
det.className = 'rm-login-note';
var sm = document.createElement('summary');
sm.textContent = 'Regeln';
det.appendChild(sm);
det.appendChild(note);
root.appendChild(det);
}
// Zellen ohne Element-Kinder und ohne Text sind reine Layout-Fuellzellen
// (blosses "&nbsp;" als Platzhalter neben Checkboxen) — im gestapelten
// Formular bleiben sie sonst als leere Zeilen stehen.
Array.prototype.forEach.call(form.querySelectorAll('td'), function (td) {
if (!td.children.length && !txt(td)) td.classList.add('rm-hidden');
});
root.appendChild(form);
replaceBody(root);
addCrumbs();
log.push('login=formular');
}
// ==========================================================================
function run() {
try {
if (view === 'liste') buildList();
else if (view === 'thread') buildThread();
else if (view === 'login') buildLogin();
} catch (e) { log.push('build: ' + e.message); }
if (DEBUG) {
var d = document.createElement('div');
d.className = 'rm-dbg';
d.textContent = 'RIN Mobile v3.6\n' + log.join('\n');
document.body.insertBefore(d, document.body.firstChild);
}
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', run);
else run();
})();