Files
rin-mobile-userscript/rin-mobile.user.js
T
Andre e459973149 fix: start threads at the top on iOS Safari
The mobile view is assembled after DOMContentLoaded, long after the browser
has settled on a scroll position, and the rebuild never touched it. A rebuilt
thread stays taller than the viewport, so an inherited offset was never
clamped back to zero and the thread opened somewhere inside its first post.
Safari makes this visible where desktop Chrome does not: it restores the
position of an address it has seen before and revises it again after load and
after late images settle, so resetting once is not enough.

Deciding what "top" means also required a target for addresses that carry a
fragment. Post anchors existed only in the original markup, which the rebuild
hides, so a jump from the topic list had nowhere to land and ended up wherever
the browser happened to stand.
2026-08-03 11:33:14 +02:00

1779 lines
73 KiB
JavaScript
Raw Permalink 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.15
// @match *://cs.rin.ru/forum/*
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
var DEBUG = false;
var log = [];
// Die Version steht ausschliesslich im Metadatenblock. Ein zweiter Literal
// im Code lief beim Release regelmaessig davon weg, ohne dass es auffiel —
// GM_info liest denselben Header zurueck. Fehlt es (Userscript-Manager ohne
// GM_info), bleibt das Overlay ohne Versionsangabe, statt eine falsche zu
// zeigen.
var VERSION = (typeof GM_info !== 'undefined' && GM_info.script &&
GM_info.script.version) || '';
function h() { return document.head || document.documentElement; }
function txt(el) { return (el ? el.textContent || '' : '')
.replace(/\s+/g, ' ').trim(); }
// input[type=submit]/[type=reset] behalten in iOS Safari trotz
// -webkit-appearance:none teils ihre native Knopfoptik und setzen eigenes
// Padding und Zentrierung nur unzuverlaessig um — ein echtes <button> wird
// unter appearance:none dagegen zuverlaessig respektiert. name und value
// wandern mit, damit sich am uebermittelten Formularfeld nichts aendert.
function inputToButton(input) {
if (!input) return null;
var btn = document.createElement('button');
btn.type = input.type;
if (input.name) btn.name = input.name;
if (input.hasAttribute('value')) btn.value = input.value;
btn.className = input.className;
btn.textContent = input.value;
input.replaceWith(btn);
return btn;
}
// 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;
// index.php wird ueber den Pfad geprueft, nicht ueber "here": der
// Login-Redirect haengt "index.php" gelegentlich als Wert eines
// Query-Parameters an ucp.php, ueber "here" faellt das faelschlich hier
// hinein statt in den Login-Zweig.
var view = /viewtopic\.php/.test(here) ? 'thread'
: /viewforum\.php|search\.php/.test(here) ||
/\/index\.php$/.test(location.pathname) ||
location.pathname === '/forum/' ? 'liste'
: /ucp\.php/.test(here) && /mode=login/.test(here) ? 'login'
: '';
log.push('ansicht=' + (view || 'keine'));
// Muss zum fruehestmoeglichen Zeitpunkt stehen, nicht erst beim Aufbau: iOS
// Safari stellt die Scrollposition einer schon besuchten Adresse von sich aus
// wieder her, und zwar bevor das Script ueberhaupt etwas gebaut hat. Ein
// spaeterer Reset kaeme dagegen nur hinterher.
if (view && 'scrollRestoration' in history) history.scrollRestoration = 'manual';
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; }
/* Traegt die Lupe und die Login/Logout-Pille zusammen als eine Einheit
rechts — nur eine gemeinsame margin-left:auto, statt jedes Element
einzeln an den Rand zu schieben, sonst klafft zwischen beiden eine
Luecke statt "direkt links neben der Pille". */
.rm-crumbs .rm-auth-group { flex:none; margin-left:auto;
display:flex; align-items:center; gap:6px; }
.rm-crumbs a.rm-auth { flex:none; 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); }
.rm-crumbs a.rm-search { flex:none; min-width:44px; height:29px;
display:flex; align-items:center; justify-content:center;
padding:0 16px !important; font-size:14px; 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)); }
/* Zweite Leiste unter den Brotkrumen, nur im Thread: Ersatz fuer
"Search this topic..." der Originalseite. Das dortige Formular wird
unveraendert hierher verschoben statt neu gebaut, wie das
Login-Formular in .rm-login form. */
.rm-search-bar { box-sizing:border-box;
position:fixed; left:0; right:0; z-index:9997;
top:calc(44px + env(safe-area-inset-top));
display:flex; align-items:center;
padding:8px 12px;
background:rgba(28,28,30,.96);
-webkit-backdrop-filter:blur(12px);
backdrop-filter:blur(12px);
border-bottom:1px solid rgba(128,128,128,.3);
transform:translateY(-110%);
transition:transform .18s ease; }
.rm-search-bar.rm-open { transform:translateY(0); }
.rm-search-bar form { flex:1; min-width:0;
display:flex; align-items:center; gap:8px; }
.rm-search-bar input[type="text"] {
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-search-bar button[type="submit"] {
-webkit-appearance:none; appearance:none;
display:flex; align-items:center; justify-content:center;
box-sizing:border-box; line-height:normal; text-align:center;
flex:none; min-height:44px; font-size:14px; font-weight:650;
padding:12px 16px;
border-radius:10px; color:#fff; border:0;
background:rgba(120,170,255,.85); }
/* === 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; }
/* Die Karte traegt seit dem Anker-Uebertrag die id des Beitrags und ist
damit Sprungziel von #pXXXX. Ohne den Freiraum landet sie hinter der
fixierten Brotkrumenleiste, der Kartenkopf waere verdeckt. */
.rm-post { box-sizing:border-box !important;
width:100% !important; max-width:100% !important;
padding:14px !important;
scroll-margin-top:calc(53px + env(safe-area-inset-top));
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; }
/* Die verschobene td.row1 wird per Klasse zum Block, wie form/table/tr
ueber .rm-login form weiter unten. */
.rm-login-msg { display:block !important; box-sizing:border-box;
padding:22px 14px calc(60px + env(safe-area-inset-bottom)) !important;
text-align:center; font-size:16px; line-height:1.6; }
.rm-login-msg a { display:inline-block; margin-top:8px; padding:12px 24px;
font-size:16px; font-weight:650; border-radius:10px;
color:#fff !important; text-decoration:none !important;
background:rgba(120,170,255,.85); }
/* 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 button[type="submit"] {
-webkit-appearance:none; appearance:none;
display:flex; align-items:center; justify-content:center;
box-sizing:border-box; line-height:normal; text-align:center;
width:100%; min-height:48px; font-size:16px; font-weight:650;
padding:16px 13px; margin-top:20px; border-radius:10px; color:#fff;
border:0; background:rgba(120,170,255,.85); }
/* === Suchformular (search.php ohne Treffer) === */
.rm-search-shortcuts { display:flex; flex-wrap:wrap; gap:8px;
padding:0 14px 16px; }
.rm-search-shortcuts a { font-size:13px; padding:6px 12px; border-radius:99px;
border:1px solid rgba(128,128,128,.4);
text-decoration:none !important; color:inherit !important; }
/* Derselbe Tabelle-zu-Block-Trick wie bei .rm-login form, eigener Name
statt Wiederverwendung: das Formular hier ist ein anderes und hat
andere Feldtypen (radio, select statt checkbox). */
.rm-searchform form { display:block;
padding:4px 14px calc(80px + env(safe-area-inset-bottom)); }
.rm-searchform form table, .rm-searchform form tbody,
.rm-searchform form tr, .rm-searchform form td {
display:block !important; width:100% !important; }
.rm-searchform form td { padding:0 !important; }
/* Bleibt sichtbar statt wie beim Login versteckt: die beiden th-Zeilen
("Search query", "Search options") sind hier Abschnittstrenner, keine
Formular-Kopfzelle ohne eigenen Inhalt. */
.rm-searchform form th { display:block !important; padding:16px 0 4px;
font-size:12px; font-weight:700;
text-transform:uppercase; letter-spacing:.06em;
opacity:.55; }
.rm-searchform form b.genmed { display:block; margin:14px 0 4px;
font-size:14px; font-weight:650; }
.rm-searchform form span.gensmall { display:block; margin-bottom:6px;
font-size:12px; opacity:.6; }
.rm-searchform form input[type="text"] {
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-searchform form select {
box-sizing:border-box; width:100%; font-size:16px; padding:9px 12px;
margin-top:4px; border-radius:10px; color:#eee;
background:rgba(120,120,120,.18); border:1px solid rgba(160,160,160,.35); }
.rm-searchform form select[multiple] { font-size:15px; padding:4px; }
.rm-searchform form input[type="radio"] { width:18px; height:18px;
vertical-align:middle; margin-right:6px; }
/* Einzige Zeile ohne Block-Stapelung: Suchen/Zuruecksetzen sollen
nebeneinander stehen, nicht wie der Rest des Formulars untereinander. */
.rm-searchform form td.cat { display:flex !important; gap:10px;
padding-top:10px !important; }
/* input[type=submit]/[type=reset] behalten in iOS Safari trotz
-webkit-appearance:none teils die native Knopfoptik und setzen Padding,
Rahmen und Zentrierung nur unzuverlaessig um — deshalb echte <button>
statt der Original-<input>-Elemente, siehe inputToButton(). */
.rm-searchform form button[type="submit"],
.rm-searchform form button[type="reset"] {
-webkit-appearance:none; appearance:none;
display:flex; align-items:center; justify-content:center;
box-sizing:border-box; line-height:normal; text-align:center;
min-height:48px; font-size:16px; font-weight:650;
padding:16px 13px; border-radius:10px;
border:0; }
.rm-searchform form button[type="submit"] { flex:1; color:#fff;
background:rgba(120,170,255,.85); }
.rm-searchform form button[type="reset"] { flex:none; color:#eee;
background:rgba(120,120,120,.18);
border:1px solid rgba(160,160,160,.45) !important; }
/* === Suchtreffer (search.php mit Ergebnissen, Modus "Posts") === */
.rm-hits-info { padding:0 14px 14px; font-size:13px; opacity:.75;
overflow-wrap:break-word; }
.rm-hits-info a { color:inherit !important; }
.rm-hit { box-sizing:border-box !important; display:block !important;
width:100% !important; padding:14px !important;
border-top:1px solid rgba(128,128,128,.25) !important; }
.rm-hit-ctx { display:block; margin-bottom:8px; font-size:12px;
font-weight:650; text-transform:uppercase; letter-spacing:.03em;
opacity:.6; text-decoration:none !important;
color:inherit !important; overflow-wrap:break-word; }
.rm-hit-head { display:flex; align-items:baseline; justify-content:space-between;
gap:8px; }
.rm-hit-user { font-size:14px; font-weight:650; }
.rm-hit-when { flex:none; font-size:12px; opacity:.55; }
/* Wiederverwendet: dieselbe .rm-meta-Regel aus der Themenliste, gleiche
"X Antworten · Y Views"-Optik. */
.rm-hit .rm-meta { margin-top:4px; }
/* .rm-body wird 1:1 vom Thread uebernommen (gleiches Beitragsmarkup),
nur der verschobene Container ist hier ein td statt div/span und
braucht die Umstellung auf Block, die .rm-body selbst nicht mitbringt. */
.rm-root.rm-hits .rm-body { margin-top:8px; }
.rm-root.rm-hits .rm-body > td { display:block !important; padding:0 !important; }
.rm-hit-go { display:inline-block; margin-top:10px; font-size:12px;
padding:5px 10px; border:1px solid rgba(128,128,128,.4);
border-radius:99px; text-decoration:none !important;
color:inherit !important; }
/* === 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 group = document.createElement('div');
group.className = 'rm-auth-group';
// Lupe nur bei bestehender Sitzung. Ausserhalb von viewtopic fuehrt sie
// direkt auf search.php, im Thread blendet sie stattdessen die zweite
// Leiste ein — Ersatz fuer "Search this topic...", das mit dem
// Original-DOM ausgeblendet wurde.
if (st.in) {
var search = document.createElement('a');
search.className = 'rm-search';
search.href = 'https://cs.rin.ru/forum/search.php';
search.textContent = '🔍';
search.setAttribute('aria-label', 'Suche');
search.addEventListener('click', function (e) {
if (view === 'thread') { e.preventDefault(); toggleThreadSearch(); }
});
group.appendChild(search);
}
var auth = document.createElement('a');
auth.className = 'rm-auth';
auth.href = st.href;
auth.textContent = st.in ? (st.name || 'Logout') : 'Login';
group.appendChild(auth);
bar.appendChild(group);
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 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 '';
// "Mark topics read" (eingeloggt oberhalb der Themenliste) und "Mark
// forums read" verlinken mit ?mark=topics/forums auf die aktuelle Seite
// selbst. Der Link liegt zwar in einer eigenen inneren <tr> ohne
// verschachtelte Tabelle, .closest('td.cat') findet aber trotzdem die
// aeussere Kategoriezelle und machte daraus bislang eine dauerhafte,
// nicht einklappbare .rm-sec-Karte statt gar keine.
if (/[?&]mark=/.test(a.getAttribute('href') || '')) 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;
}
// search.php ohne Treffer zeigt nur das Suchformular — keine topictitle-
// oder forumlink-Zeilen, an denen buildList() sonst seine Karten festmacht.
// Erkannt wird das Formular am Forenwaehler select[name="fid[]"]: der
// steckt nur im grossen Suchformular, nicht in den kleinen "Search this
// forum/topic..."-Formularen auf Foren- und Themenseiten, die denselben
// Formularnamen "keywords" verwenden wuerden und sonst faelschlich
// getroffen werden koennten.
function buildSearchForm() {
var picker = document.querySelector('select[name="fid[]"]');
var form = picker && picker.closest('form');
if (!form) return false;
// Vorbelegung fuer die mobile Nutzung: Main Forum (Forum-ID 10, siehe
// dev/viewforum.php) vorausgewaehlt statt "kein Forum", Unterforen dabei
// nicht automatisch mitgesucht — das serverseitige Formular startet sonst
// mit leerem Forenwaehler und "Search subforums: Yes".
var mainForum = picker.querySelector('option[value="10"]');
if (mainForum) mainForum.selected = true;
var noSubforums = form.querySelector('input[name="sc"][value="0"]');
if (noSubforums) noSubforums.checked = true;
var root = document.createElement('div');
root.className = 'rm-root rm-searchform';
var title = document.createElement('div');
title.className = 'rm-login-title';
title.textContent = 'Suche';
root.appendChild(title);
// Schnellzugriffe (View new posts, View your posts, ...) stehen als
// eigene Linkliste ausserhalb des Formulars und würden sonst mit dem Rest
// der Originalseite verschwinden.
var quick = document.querySelector('p.searchbar');
if (quick) {
var links = quick.querySelectorAll('a');
if (links.length) {
var chips = document.createElement('div');
chips.className = 'rm-search-shortcuts';
links.forEach(function (a) {
var c = document.createElement('a');
c.href = a.href;
c.textContent = txt(a);
chips.appendChild(c);
});
root.appendChild(chips);
}
}
// Die Textfelder tragen ein inline style="width: 300px", das jede externe
// Breitenregel ohne !important sticht.
form.querySelectorAll('input[type="text"]').forEach(function (inp) {
inp.removeAttribute('style');
});
form.querySelectorAll('input[type="submit"], input[type="reset"]')
.forEach(inputToButton);
root.appendChild(form);
replaceBody(root);
addCrumbs();
log.push('suche=formular');
return true;
}
// Eine Suche im Modus "Posts" (Standard beim Suchen ueber die zweite Leiste
// im Thread, da deren Formular kein sr setzt) liefert etwas ganz anderes
// als eine Themenliste: pro Treffer Autor, Zeitstempel und ein Auszug aus
// dem Beitrag selbst, keine topictitle-/forumlink-Zeilen. buildList() wuerde
// die darin enthaltenen viewtopic.php-Links trotzdem einsammeln und an
// rowKind() falsch klassifizieren, ohne diese eigene Behandlung davor.
// Erkannt wird der Treffer-Modus an p.topictitle — als <p>, nicht als <a>
// wie das gleichnamige forumlink-Gegenstueck einer Themenzeile.
function buildSearchResults() {
var hits = document.querySelectorAll('p.topictitle');
if (!hits.length) return false;
var root = document.createElement('div');
root.className = 'rm-root rm-hits';
var summary = document.querySelector('span.titles');
var title = document.createElement('div');
title.className = 'rm-login-title';
title.textContent = summary ? txt(summary) : 'Suchergebnisse';
root.appendChild(title);
// "Searched topic: ... / Search term used: ..." traegt funktionierende
// Links (erneute Suche, Sprung zum Thema) und wird deshalb wie das
// Formular selbst uebernommen statt aus dem Text nachgebaut.
var form = hits[0].closest('form');
var infoCell = form && form.querySelector('td.genmed');
if (infoCell) {
infoCell.classList.add('rm-hits-info');
root.appendChild(infoCell);
}
var built = 0;
hits.forEach(function (p) {
try {
var card = buildHit(p);
if (card) { root.appendChild(card); built++; }
} catch (e) { log.push('treffer: ' + e.message); }
});
log.push('treffer=' + built);
if (!built) return false;
// Muss vor replaceBody() laufen: pageState() liest das Original-DOM.
var pg = pageState();
if (pg.total > 1) pageNav(root, pg);
replaceBody(root);
addCrumbs();
return true;
}
// Ein Treffer besteht aus drei aufeinanderfolgenden <tr> derselben Tabelle:
// Titelzeile (Forum/Thema), Autor- und Betreffzeile, Antworten/Views samt
// Beitragsauszug. Die Zuordnung laeuft ueber nextElementSibling statt ueber
// Klassen, weil alle drei row1/row2-Zeilen sonst gleich aussehen.
function buildHit(p) {
var titleRow = p.closest('tr');
var infoRow = titleRow && titleRow.nextElementSibling;
var bodyRow = infoRow && infoRow.nextElementSibling;
var forumA = p.querySelector('a[href*="viewforum.php"]');
var topicA = p.querySelector('a[href*="viewtopic.php"]');
if (!topicA) return null;
var el = document.createElement('div');
el.className = 'rm-hit';
var ctx = document.createElement('a');
ctx.className = 'rm-hit-ctx';
ctx.href = topicA.href;
ctx.textContent = breakable((forumA ? txt(forumA) + ' › ' : '') + txt(topicA));
el.appendChild(ctx);
var author = '', posted = '', subjectA = null;
if (infoRow) {
var ua = infoRow.querySelector('.postauthor');
author = ua ? txt(ua) : '';
subjectA = infoRow.querySelector('a[href*="viewtopic.php"]');
var pm = txt(infoRow).match(/Posted:\s*(.+)$/i);
if (pm) posted = pm[1].trim();
}
var head = document.createElement('div');
head.className = 'rm-hit-head';
var who = document.createElement('span');
who.className = 'rm-hit-user';
who.textContent = author || 'Gast';
head.appendChild(who);
if (posted) {
var when = document.createElement('span');
when.className = 'rm-hit-when';
when.textContent = posted;
head.appendChild(when);
}
el.appendChild(head);
if (bodyRow) {
// txt() haengt "Replies:" und "Views:" ohne Trennzeichen aneinander,
// weil das <br /> dazwischen keinen Leerraum hinterlaesst — deshalb
// einzeln herausgegriffen und ueber dieselbe metaRow() wie bei
// Themenkarten mit " · " verbunden.
var metaTxt = txt(bodyRow.querySelector('.postdetails'));
var reM = metaTxt.match(/Replies:\s*([\d.,]+)/i);
var viM = metaTxt.match(/Views:\s*([\d.,]+)/i);
var metaEl = metaRow([reM ? reM[1] + ' Antworten' : '',
viM ? viM[1] + ' Views' : '']);
if (metaEl) el.appendChild(metaEl);
var pb = bodyRow.querySelector('.postbody');
if (pb) {
var wrap = document.createElement('div');
wrap.className = 'rm-body';
wrap.appendChild(pb);
el.appendChild(wrap);
}
}
var jump = document.createElement('a');
jump.className = 'rm-hit-go';
jump.href = (subjectA || topicA).href;
jump.textContent = 'Zum Beitrag ↓';
el.appendChild(jump);
return el;
}
function buildList() {
if (buildSearchForm()) return;
if (buildSearchResults()) return;
// 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
// ==========================================================================
// "Search this topic..." steht dreifach identisch im Original-DOM (Kopf,
// Fuss, ueber der Beitragsliste), samt versteckter Felder fuer Themen-Id
// und Scope (sf=msgonly). replaceBody() versteckt das nur per Klasse, das
// erste Formular bleibt im DOM erreichbar und wird beim ersten Antippen
// der Lupe unveraendert in die zweite Leiste verschoben statt neu gebaut —
// wie das Login-Formular in buildLogin().
var threadSearchBar = null;
function buildThreadSearch() {
var form = document.querySelector('#topic-search');
if (!form) return null;
// Die Originalseite emuliert einen Platzhalter per onclick/onblur statt
// per placeholder-Attribut.
var inp = form.querySelector('input[name="keywords"]');
if (inp) {
inp.removeAttribute('onclick');
inp.removeAttribute('onblur');
if (inp.value === 'Search this topic…') inp.value = '';
inp.placeholder = 'In diesem Thema suchen';
}
var submit = form.querySelector('input[type="submit"]');
if (submit) { submit.value = 'Suchen'; inputToButton(submit); }
var bar = document.createElement('div');
bar.className = 'rm-search-bar';
bar.appendChild(form);
document.body.appendChild(bar);
return bar;
}
function toggleThreadSearch() {
if (!threadSearchBar) threadSearchBar = buildThreadSearch();
if (!threadSearchBar) return;
var open = threadSearchBar.classList.toggle('rm-open');
if (open) {
var inp = threadSearchBar.querySelector('input[type="text"]');
if (inp) inp.focus();
}
}
// 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;
}
// --- Sprungziel ---
// Der Anker eines Beitrags steht als <a name="pXXXX"> in der Autorenzelle
// und wandert mit dem Original-DOM in die Unsichtbarkeit. Ohne ihn auf der
// Karte hat eine Adresse mit #pXXXX kein Ziel mehr, und der Browser bleibt
// stehen, wo er beim Umbau gerade stand.
var anc = null;
for (var ci = 0; ci < cells.length && !anc; ci++)
anc = cells[ci].querySelector('a[name^="p"]');
if (anc) card.id = anc.getAttribute('name');
// --- 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) { buildLoginInfo(); 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');
});
inputToButton(form.querySelector('input[type="submit"]'));
root.appendChild(form);
replaceBody(root);
addCrumbs();
log.push('login=formular');
}
// ucp.php?mode=login liefert ausser dem Formular auch Zwischenseiten ueber
// dieselbe generische "Information"-Vorlage aus — zum Beispiel die
// Bestaetigung nach erfolgreichem Login, vermutlich aber auch andere
// Meldungen wie ein falsches Passwort, fuer die keine Fixture vorliegt.
// Erkannt wird die Vorlage an der Form, nicht am Text: eine Kopfzeile mit
// genau einer th-Zelle traegt in diesem View sonst nirgends eine einzelne
// Nachricht samt Link. Wie beim Formular wandert die Original-Zelle
// unveraendert in die neue Ansicht, damit der Link darin (zurueck zum Index
// oder zu einem anderen Ziel) unangetastet bleibt.
function buildLoginInfo() {
var th = null;
document.querySelectorAll('table.tablebg th').forEach(function (t) {
if (th) return;
var row = t.closest('tr');
if (row && row.children.length === 1) th = t;
});
var msgCell = th && th.closest('table').querySelector('td.row1');
if (!th || !msgCell) { log.push('login: kein Formular, keine Meldung'); return; }
var root = document.createElement('div');
root.className = 'rm-root rm-login';
var title = document.createElement('div');
title.className = 'rm-login-title';
title.textContent = txt(th) || 'Information';
root.appendChild(title);
msgCell.classList.add('rm-login-msg');
root.appendChild(msgCell);
replaceBody(root);
addCrumbs();
log.push('login=meldung');
}
// ==========================================================================
// Der Umbau laeuft erst auf DOMContentLoaded, also lange nachdem der Browser
// seine Scrollposition festgelegt hat — und replaceBody() ruehrt sie nicht an.
// Weil ein Thread auch nach dem Umbau laenger als der Viewport bleibt, klemmt
// der Browser sie auch nicht auf 0 zurueck: der Thread startet dann mitten im
// ersten Beitrag statt an seinem Anfang. Auf dem Geraet faellt das auf, im
// Chrome-Debugging nicht, weil dort jede Adresse frisch geladen wird und es
// gar keine Position zu uebernehmen gibt.
function toStart() {
var id = location.hash ? location.hash.slice(1) : '';
var target = id ? document.getElementById(id) : null;
if (target) target.scrollIntoView();
else window.scrollTo(0, 0);
}
// Einmal zuruecksetzen genuegt nicht: Safari legt die Position nicht in einem
// Rutsch fest, sondern springt nach 'load' und nach dem Layout nachgeladener
// Bilder noch einmal nach — ein Reset auf DOMContentLoaded wird davon einfach
// ueberschrieben. Der Anfang wird deshalb kurz festgehalten. Die Bremse loest
// bei der ersten eigenen Geste des Lesers sofort aus, sonst risse sie ihn
// waehrend des Lesens zurueck; 'scroll' taugt dafuer nicht als Signal, weil
// toStart() selbst eines ausloest.
function holdStart() {
var free = false;
function release() { free = true; }
['touchstart', 'wheel', 'keydown'].forEach(function (ev) {
window.addEventListener(ev, release, { passive: true, once: true });
});
var tries = 0;
(function tick() {
if (free) return;
toStart();
if (++tries < 12) setTimeout(tick, 50);
})();
// Faengt den Sprung ab, der erst nach dem letzten Bild kommt und damit
// jenseits der 600 ms der Schleife liegen kann.
window.addEventListener('load', function () { if (!free) toStart(); });
}
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 (view && document.querySelector('.rm-root')) holdStart();
if (DEBUG) {
var d = document.createElement('div');
d.className = 'rm-dbg';
d.textContent = 'RIN Mobile' + (VERSION ? ' v' + VERSION : '') +
'\n' + log.join('\n');
document.body.insertBefore(d, document.body.firstChild);
}
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', run);
else run();
})();