Files
rin-mobile-userscript/rin-mobile.user.js
T
Andre 2474cd3b9f feat: group the list view by section and let the groups collapse
Every page of a forum repeats its global announcements, announcements and
stickies before the first real topic. On a phone that is the whole first
screen, scrolled past on every page turn, and the rebuild made it worse: the
board separates those blocks with header rows the card stream dropped, so
what remained was one undifferentiated run of cards.

Each block now carries a header with its name and card count, and tapping it
folds the block away. The announcement blocks start folded, which is the
point of the change; folding rather than filtering keeps them reachable
without editing the script. The state is kept per kind, so a decision taken
in one forum holds in all of them.

localStorage rather than GM_setValue: no @grant, and identical behaviour in
Tampermonkey and the iOS app, against the risk that Safari clears the value
after long inactivity and the sections return to their default. That is a
reset of one preference, not data loss.

Rows are classified by the icon in their first column instead of a title
prefix, because the board marks the type nowhere else. The header labels come
from the board's own separator rows, so sections nobody anticipated still
read correctly.

The menu bar links the forum rules and the FAQ as topics. That row has
neither a topic title nor numeric columns, but it passed the layout brake and
had been yielding a ghost card at the head of every list — visible now that
it would claim a section header of its own.
2026-08-02 15:26:04 +02:00

1115 lines
42 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.4
// @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'
: '';
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, beide 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 Leiste der Threadansicht. */
.rm-root.rm-thread { padding-bottom:64px !important; }
/* Brotkrumen, beide 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; }
/* 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; }
.rm-pages { padding:14px; display:flex; flex-wrap:wrap; gap:8px; }
.rm-pages a { padding:6px 11px; border-radius:8px;
border:1px solid rgba(128,128,128,.35);
text-decoration:none !important; color:inherit !important; }
/* === Thread === */
.rm-topic { box-sizing:border-box; padding:16px 14px 12px;
font-size:21px; font-weight:650; line-height:1.3;
overflow-wrap:break-word; }
.rm-post { box-sizing:border-box !important;
width:100% !important; max-width:100% !important;
padding:14px !important;
border-top:1px solid rgba(128,128,128,.25) !important; }
.rm-head { display:flex; align-items:center; gap:9px;
margin-bottom:10px; min-width:0; }
/* Die Masse muessen sich gegen die globale img-Regel durchsetzen. */
.rm-av { flex:none; width:28px !important; height:28px !important;
border-radius:50%; object-fit:cover;
background:rgba(128,128,128,.25); }
.rm-who { min-width:0; flex:1; }
.rm-user { font-size:14px; font-weight:650; line-height:1.2;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rm-sub { font-size:11px; opacity:.55; line-height:1.3;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rm-body { font-size:17px !important; line-height:1.55 !important;
overflow-wrap:break-word !important;
word-break:normal !important; }
.rm-body * { max-width:100% !important; }
.rm-body img { height:auto !important; border-radius:6px; }
.rm-body a { overflow-wrap:anywhere !important; }
.rm-body table { display:block !important; overflow-x:auto !important;
width:auto !important; }
.rm-body pre, .rm-body code {
display:block; overflow-x:auto; white-space:pre;
font-size:13px; padding:10px; border-radius:8px;
background:rgba(128,128,128,.14); }
.rm-body blockquote, .rm-body .quote, .rm-body table.quote {
display:block !important; margin:12px 0 !important;
padding:8px 0 8px 12px !important;
border-left:3px solid rgba(140,140,255,.5) !important;
font-size:15px !important; opacity:.8 !important;
background:none !important; }
.rm-body ul, .rm-body ol { padding-left:22px !important; }
.rm-body hr { border:0; border-top:1px solid rgba(128,128,128,.3); }
.rm-sig { margin-top:12px; font-size:13px; opacity:.5; }
.rm-sig summary { cursor:pointer; opacity:.8; }
.rm-nav { position:fixed; left:0; right:0; bottom:0; z-index:9999;
display:flex; align-items:center; justify-content:space-between;
gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom));
background:rgba(28,28,30,.94);
-webkit-backdrop-filter:blur(12px);
backdrop-filter:blur(12px);
border-top:1px solid rgba(128,128,128,.3); }
.rm-nav a, .rm-nav span { font-size:14px; color:#eee !important;
text-decoration:none !important;
white-space:nowrap; }
.rm-nav a { padding:7px 13px; border-radius:99px;
border:1px solid rgba(160,160,160,.45); }
.rm-nav .rm-step { min-width:22px; text-align:center;
font-size:17px; line-height:1; padding:9px 12px; }
.rm-nav .rm-pos { opacity:.6; font-size:13px; }
.rm-nav .rm-pos-btn { opacity:1; padding:7px 12px; border-radius:99px;
border:1px solid rgba(160,160,160,.45);
cursor:pointer; }
.rm-nav .rm-off { opacity:.28; border-color:rgba(160,160,160,.2); }
.rm-sheet-bg { position:fixed; top:0; left:0; right:0; bottom:0;
z-index:10000; background:rgba(0,0,0,.5);
opacity:0; pointer-events:none; transition:opacity .18s; }
.rm-sheet-bg.rm-open { opacity:1; pointer-events:auto; }
.rm-sheet { box-sizing:border-box;
position:fixed; left:0; right:0; bottom:0; z-index:10001;
display:flex; flex-direction:column; max-height:72vh;
padding:8px 12px calc(12px + env(safe-area-inset-bottom));
color:#eee; background:rgba(28,28,30,.98);
-webkit-backdrop-filter:blur(14px);
backdrop-filter:blur(14px);
border-top:1px solid rgba(128,128,128,.3);
border-radius:14px 14px 0 0;
transform:translateY(102%);
transition:transform .22s ease; }
.rm-sheet.rm-open { transform:translateY(0); }
.rm-grip { flex:none; width:36px; height:4px; margin:2px auto 12px;
border-radius:2px; background:rgba(200,200,200,.35); }
.rm-goto { flex:none; display:flex; gap:8px; margin-bottom:12px; }
/* 16px verhindert, dass iOS beim Fokussieren in das Feld hineinzoomt. */
.rm-goto input { flex:1; min-width:0; box-sizing:border-box;
font-size:16px; padding:9px 12px; border-radius:10px;
color:#eee; background:rgba(120,120,120,.18);
border:1px solid rgba(160,160,160,.35); }
.rm-goto button { flex:none; font-size:14px; padding:9px 18px;
border-radius:10px; color:#eee;
background:rgba(120,120,120,.18);
border:1px solid rgba(160,160,160,.45); }
.rm-grid { flex:1; min-height:0; overflow-y:auto;
-webkit-overflow-scrolling:touch;
display:grid; gap:8px; padding-bottom:4px;
grid-template-columns:repeat(auto-fill, minmax(56px, 1fr)); }
.rm-grid a { display:flex; align-items:center; justify-content:center;
min-height:42px; font-size:14px; border-radius:10px;
color:#eee !important; text-decoration:none !important;
background:rgba(120,120,120,.12);
border:1px solid rgba(160,160,160,.3); }
.rm-grid a.rm-cur { font-weight:700;
background:rgba(120,170,255,.22);
border-color:rgba(120,170,255,.9); }
`;
h().appendChild(st);
}
// Originalinhalt ausblenden und die neue Ansicht als einziges sichtbares
// body-Kind einhaengen. Alles, was spaeter noch an body soll, muss nach
// diesem Aufruf kommen.
function replaceBody(root) {
Array.prototype.forEach.call(document.body.children, function (el) {
if (el !== root) el.classList.add('rm-hidden');
});
document.body.appendChild(root);
}
// 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;
document.body.appendChild(bar);
document.body.classList.add('rm-crumbed');
}
// ==========================================================================
// 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;
var seen = {}, pages = document.createElement('div');
pages.className = 'rm-pages';
document.querySelectorAll('a[href*="start="]').forEach(function (a) {
var t = txt(a);
if (!t || t.length > 4 || seen[t]) return;
seen[t] = 1;
var n = document.createElement('a');
n.href = a.href;
n.textContent = t;
pages.appendChild(n);
});
if (pages.children.length) root.appendChild(pages);
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;
}
// --- Seitenzustand ---
// Der Block wird aus dem Original-DOM gelesen, bevor es ausgeblendet wird.
// phpBB zeigt nie alle Seitenzahlen an, deshalb wird nicht auf gerenderte
// Links gesetzt, sondern jede Ziel-URL aus start = (Seite - 1) * per_page
// selbst gebaut.
function pageState() {
var s = { cur: 1, total: 1, per: 0, prev: null, next: null };
// Der Paginierungsblock ist der einzige Ort, an dem Ziffern-Links
// Seitenzahlen bedeuten. Beitragstexte enthalten ebenfalls Links mit
// start=, die hier nicht mitgezaehlt werden duerfen.
var box = null;
var jumpA = document.querySelector('a[onclick*="jumpto"]');
if (jumpA) box = jumpA.parentElement;
if (!box) {
document.querySelectorAll('a[href*="start="]').forEach(function (a) {
if (!box && /^(Next|Weiter|Nächste)$/i.test(txt(a)))
box = a.parentElement;
});
}
// per_page steht als Literal in der jumpto()-Funktion der Seite.
document.querySelectorAll('script').forEach(function (el) {
if (s.per) return;
var m2 = (el.textContent || '').match(/per_page\s*=\s*'?(\d+)/);
if (m2) s.per = parseInt(m2[1], 10);
});
if (box) {
box.querySelectorAll('a[href*="start="]').forEach(function (a) {
var t = txt(a);
if (!/^\d+$/.test(t)) return;
var n = parseInt(t, 10);
if (n > s.total) s.total = n;
var ms = a.href.match(/[?&]start=(\d+)/);
if (!s.per && ms && n > 1) {
var per = parseInt(ms[1], 10) / (n - 1);
if (per > 0 && per === Math.floor(per)) s.per = per;
}
});
}
var cs = location.search.match(/[?&]start=(\d+)/);
if (s.per) s.cur = Math.floor((cs ? parseInt(cs[1], 10) : 0) / s.per) + 1;
else if (box) {
var strong = box.querySelector('strong, b > strong');
if (strong && /^\d+$/.test(txt(strong))) s.cur = parseInt(txt(strong), 10);
}
// Aeltere Themes geben stattdessen "Page X of Y" aus.
var pos = txt(document.body).match(/Page\s+(\d+)\s+of\s+(\d+)/i);
if (pos) {
if (!s.per) s.cur = parseInt(pos[1], 10);
s.total = Math.max(s.total, parseInt(pos[2], 10));
}
if (s.cur > s.total) s.total = s.cur;
s.url = function (n) {
if (n === s.cur || n < 1 || n > s.total) return null;
if (n !== 1 && !s.per) return null;
var u = location.href.split('#')[0];
var start = (n - 1) * s.per;
if (/[?&]start=\d+/.test(u))
return u.replace(/([?&]start=)\d+/, '$1' + start);
return u + (u.indexOf('?') === -1 ? '?' : '&') + 'start=' + start;
};
s.prev = s.url(s.cur - 1);
s.next = s.url(s.cur + 1);
// Ohne bekanntes per_page bleibt nur, die Original-Links zu uebernehmen.
if (!s.per) {
document.querySelectorAll('a').forEach(function (a) {
var t = txt(a);
if (!s.prev && /^(Previous|Vorherige)$/i.test(t)) s.prev = a.href;
if (!s.next && /^(Next|Weiter|Nächste)$/i.test(t)) s.next = a.href;
});
}
log.push('page=' + s.cur + '/' + s.total + ' per=' + s.per);
return s;
}
// Seitenwahl als Sheet ueber der Leiste. Wird erst beim ersten Antippen
// gebaut: bei 1000+ Seiten waere das Raster sonst umsonst im DOM.
function pageSheet(s) {
var bg = null, sheet = null;
function go(n) {
n = Math.min(Math.max(n, 1), s.total);
var u = s.url(n);
if (u) location.href = u;
else close();
}
function close() {
if (!bg) return;
bg.classList.remove('rm-open');
sheet.classList.remove('rm-open');
document.body.style.overflow = '';
}
function make() {
bg = document.createElement('div');
bg.className = 'rm-sheet-bg';
bg.addEventListener('click', close);
sheet = document.createElement('div');
sheet.className = 'rm-sheet';
var grip = document.createElement('div');
grip.className = 'rm-grip';
sheet.appendChild(grip);
var row = document.createElement('div');
row.className = 'rm-goto';
var inp = document.createElement('input');
inp.type = 'text';
inp.inputMode = 'numeric';
inp.pattern = '[0-9]*';
inp.placeholder = 'Seite 1 – ' + s.total;
inp.setAttribute('aria-label', 'Seitenzahl');
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Los';
function submit() {
var n = parseInt(inp.value, 10);
if (n > 0) go(n);
}
btn.addEventListener('click', submit);
inp.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); submit(); }
});
row.appendChild(inp);
row.appendChild(btn);
sheet.appendChild(row);
var grid = document.createElement('div');
grid.className = 'rm-grid';
var curEl = null;
for (var i = 1; i <= s.total; i++) {
var a = document.createElement('a');
a.textContent = i;
if (i === s.cur) { a.className = 'rm-cur'; curEl = a; }
else {
var u = s.url(i);
if (u) a.href = u;
}
grid.appendChild(a);
}
sheet.appendChild(grid);
document.body.appendChild(bg);
document.body.appendChild(sheet);
return curEl;
}
return function open() {
var curEl = bg ? null : make();
document.body.style.overflow = 'hidden';
// Ein Frame Abstand, sonst faellt der Uebergang beim ersten Oeffnen aus.
requestAnimationFrame(function () {
bg.classList.add('rm-open');
sheet.classList.add('rm-open');
if (curEl) curEl.scrollIntoView({ block: 'center' });
});
};
}
function buildThread() {
var bodies = document.querySelectorAll('span.postbody, .postbody');
log.push('postbodies=' + bodies.length);
if (!bodies.length) return;
var root = document.createElement('div');
root.className = 'rm-root rm-thread';
var tt = document.querySelector('a.maintitle, h1, .maintitle');
if (tt) {
var th = document.createElement('div');
th.className = 'rm-topic';
th.textContent = txt(tt);
root.appendChild(th);
}
var built = 0, seen = new Set();
bodies.forEach(function (pb) {
var row = postRow(pb);
if (!row || seen.has(row)) return;
seen.add(row);
var card = document.createElement('div');
card.className = 'rm-post';
// --- Zellen dieser Zeile + der Kopfzeilen darueber ---
var cells = [];
Array.prototype.forEach.call(row.children, function (td) {
if (!td.contains(pb)) cells.push(td);
});
var pr = row.previousElementSibling, g = 0;
while (pr && g++ < 3) {
Array.prototype.forEach.call(pr.children, function (td) {
cells.push(td);
});
if (pr.querySelector('.postauthor, a[href*="profile.php"]')) break;
pr = pr.previousElementSibling;
}
// --- Username ---
var user = '';
for (var i = 0; i < cells.length && !user; i++) {
var ua = cells[i].querySelector(
'.postauthor, .username-coloured, a[href*="profile.php"]');
if (ua) user = txt(ua);
}
if (!user) {
for (var i2 = 0; i2 < cells.length && !user; i2++) {
var nm = cells[i2].querySelector('span.name b, span.name, b.name');
if (nm) user = txt(nm);
}
}
user = user.replace(/^Post subject:.*/i, '').trim();
// --- Avatar ---
var avImg = null, bestArea = 0;
cells.forEach(function (td) {
td.querySelectorAll('img').forEach(function (im) {
var s = im.getAttribute('src') || '';
if (/spacer|blank|icon|rank|star|folder|button|pm|email|quote|edit|report|www|msn|aim|yim|icq|topic/i.test(s))
return;
if (/avatar/i.test(s)) { avImg = im; bestArea = 1e9; return; }
var w = parseInt(im.getAttribute('width'), 10) || im.naturalWidth || 0;
var hh = parseInt(im.getAttribute('height'), 10) || im.naturalHeight || 0;
var a = w * hh;
if (w >= 40 && a > bestArea) { avImg = im; bestArea = a; }
});
});
// --- Rang, Postzahl, Zeitstempel ---
var allTxt = cells.map(txt).join(' ');
var rank = '';
var rr = allTxt.match(/\b(Administrator|Site Admin|Global Moderator|Moderator|VIP|Uploader|Supporter|Member|Newbie|Guest)\b/i);
if (rr) rank = rr[1];
var pc = allTxt.match(/Posts:\s*([\d.,]+)/i);
if (pc) rank = (rank ? rank + ' · ' : '') + pc[1] + ' Posts';
var when = '';
var wm = allTxt.match(/Posted:\s*([^|]+?)(?:\s{2,}|Post subject|$)/i);
if (wm) when = wm[1].trim();
// --- Kopfzeile ---
var head = document.createElement('div');
head.className = 'rm-head';
var av;
if (avImg) {
av = document.createElement('img');
av.src = avImg.src;
} else {
av = document.createElement('div');
av.textContent = (user || '?').charAt(0).toUpperCase();
av.style.cssText = 'display:flex;align-items:center;' +
'justify-content:center;font-size:13px;font-weight:700;' +
'color:rgba(255,255,255,.75);';
}
av.className = 'rm-av';
head.appendChild(av);
var who = document.createElement('div');
who.className = 'rm-who';
var un = document.createElement('div');
un.className = 'rm-user';
un.textContent = user || 'Gast';
who.appendChild(un);
var sub = document.createElement('div');
sub.className = 'rm-sub';
sub.textContent = [rank, when].filter(Boolean).join(' · ');
who.appendChild(sub);
head.appendChild(who);
card.appendChild(head);
// --- Signatur abtrennen ---
var sig = null, hr = pb.querySelector('hr');
if (hr) {
sig = document.createElement('details');
sig.className = 'rm-sig';
var sm = document.createElement('summary');
sm.textContent = 'Signatur';
sig.appendChild(sm);
var n = hr.nextSibling;
while (n) { var nx = n.nextSibling; sig.appendChild(n); n = nx; }
hr.remove();
}
var bodyWrap = document.createElement('div');
bodyWrap.className = 'rm-body';
bodyWrap.appendChild(pb);
card.appendChild(bodyWrap);
if (sig && sig.childNodes.length > 1) card.appendChild(sig);
root.appendChild(card);
built++;
});
log.push('posts=' + built);
if (!built) return;
// --- Navigationsleiste ---
var pg = pageState();
var nav = document.createElement('div');
nav.className = 'rm-nav';
function navBtn(href, label, aria) {
var e = document.createElement('a');
e.className = 'rm-step' + (href ? '' : ' rm-off');
e.textContent = label;
e.setAttribute('aria-label', aria);
if (href) e.href = href;
return e;
}
nav.appendChild(navBtn(pg.url(1), '«', 'Erste Seite'));
nav.appendChild(navBtn(pg.prev, '‹', 'Vorherige Seite'));
var pos = document.createElement('span');
pos.className = 'rm-pos';
pos.textContent = pg.cur + ' / ' + pg.total;
// Ohne bekanntes per_page laesst sich kein Sprungziel bauen, dann bleibt
// die Anzeige reine Beschriftung.
if (pg.total > 1 && pg.per) {
pos.className += ' rm-pos-btn';
pos.textContent += ' ▾';
pos.setAttribute('role', 'button');
pos.setAttribute('aria-label', 'Seite wählen');
pos.addEventListener('click', pageSheet(pg));
}
nav.appendChild(pos);
nav.appendChild(navBtn(pg.next, '›', 'Nächste Seite'));
nav.appendChild(navBtn(pg.url(pg.total), '»', 'Letzte Seite'));
root.appendChild(nav);
replaceBody(root);
addCrumbs();
}
// ==========================================================================
function run() {
try {
if (view === 'liste') buildList();
else if (view === 'thread') buildThread();
} catch (e) { log.push('build: ' + e.message); }
if (DEBUG) {
var d = document.createElement('div');
d.className = 'rm-dbg';
d.textContent = 'RIN Mobile v3.4\n' + log.join('\n');
document.body.insertBefore(d, document.body.firstChild);
}
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', run);
else run();
})();