Modliste: melden was sich geaendert hat, einzeln laden
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher gab es fuer Mods nur einen Link auf das Sammelpaket des Hosters — vier
Gigabyte, auch wenn sich zwei Dateien geaendert haben. Jetzt liest der Bot die
Modliste aus der Statusabfrage, die er ohnehin holt, und meldet im Kanal, was
neu, aktualisiert oder entfernt wurde. Beim ersten Durchlauf bleibt es still,
sonst kaeme eine Meldung ueber 110 "neue" Mods.

Geaendert heisst: andere Version oder anderer Hash. Beides einzeln reicht
nicht — manche Modder bessern nach, ohne die Version zu erhoehen.

Dazu die Seite /mods/<server>: jeder Mod mit Version und Groesse, Suche ueber
Titel, Dateiname und Autor, und ein Download je Zeile. Der laeuft durch den
Bot, damit der Zugangs-Code des Spielservers nicht in einem Link landet — mit
dem Code liesse sich auch der ganze Spielstand lesen. Angefragt wird nur, was
wirklich in der Modliste steht; ueber den Dateinamen kommt man an nichts
anderes heran.

Groessen lernt der Bot beim Weiterleiten. Der Spielserver beantwortet kein
HEAD (501) und ignoriert Range, es gibt also keinen billigen Weg, sie vorher
zu erfahren — und 4 GB nur fuers Anzeigen zu holen waere keiner.

Nebenbei gefunden und behoben: 'server.mods' und 'commands.tag' gab es je
zweimal in den Sprachdateien. Der spaetere Eintrag gewinnt still, deshalb
stand auf dem neuen Knopf woertlich "%s Mods (110)" und auf der Befehlsseite
die Beschreibung von /tag statt der Kopfzeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:22:55 +02:00
co-authored by Claude Opus 5
parent 9b681754fa
commit 1ef06f747c
12 changed files with 527 additions and 10 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ const GROUPS = [
['/geburtstag setzen tag:… monat:…', 'commands.bdaySet'],
['/geburtstag entfernen', 'commands.bdayDel'],
['/remind dauer:… text:…', 'commands.remind'],
['/tag name:…', 'commands.tag'],
['/tag name:…', 'commands.cmdTag'],
['/ping', 'commands.ping'],
['/umfrage frage:… a:… b:…', 'commands.umfrage'],
]],
+130
View File
@@ -0,0 +1,130 @@
// Modliste eines LS-Servers mit Download.
//
// Der Sinn ist nicht, 110 Mods hübsch zu zeigen, sondern die Frage „was fehlt
// mir noch" zu beantworten. Deshalb steht die Suche oben und jede Zeile hat
// ihren eigenen Download — statt eines Sammel-Links über alles, bei dem man
// 4 GB zieht, um zwei geänderte Dateien zu bekommen.
//
// Die Downloads laufen über den Bot, nicht direkt zum Spielserver: dessen
// Zugangs-Code öffnet auch den Spielstand und gehört nicht in einen Link.
import { useEffect, useMemo, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconSearch, IconDownload, IconPackage } from '../icons.jsx';
import { useT } from '../i18n.jsx';
/** 8.2 MB — Grössen kennt der Bot erst, wenn ein Mod einmal geladen wurde */
function groesse(bytes) {
if (!bytes) return null;
return bytes >= 1073741824
? `${(bytes / 1073741824).toFixed(2)} GB`
: `${Math.round(bytes / 1048576)} MB`;
}
export default function Mods() {
const { id } = useParams();
const { t } = useT();
const [daten, setDaten] = useState(null);
const [fehler, setFehler] = useState(false);
const [suche, setSuche] = useState('');
useEffect(() => {
setDaten(null);
setFehler(false);
apiGet(`/api/servers/${id}/mods`).then(setDaten).catch(() => setFehler(true));
window.scrollTo(0, 0);
}, [id]);
const treffer = useMemo(() => {
const mods = daten?.mods ?? [];
const q = suche.trim().toLowerCase();
if (!q) return mods;
return mods.filter((m) => `${m.titel} ${m.name} ${m.autor}`.toLowerCase().includes(q));
}, [daten, suche]);
const bekannt = (daten?.mods ?? []).filter((m) => m.bytes);
const summe = bekannt.reduce((s, m) => s + m.bytes, 0);
return (
<>
<section className="page-header">
<div className="page-bg-text">MODS</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">{t('mods.tag')}</div>
<h1 className="page-title">{daten?.server?.name ?? t('mods.title')}</h1>
<p className="page-subtitle">{t('mods.subtitle')}</p>
</div>
</section>
<section className="content">
{fehler && <p className="notice">{t('mods.error')}</p>}
{!fehler && !daten && <SkeletonCards n={1} />}
{daten && (
<>
<div className="mod-bar">
<div className="mod-count">
<IconPackage />
<strong>{daten.mods.length}</strong> {t('mods.count')}
{/* Nur zeigen, was wir wirklich wissen — sonst
liest sich eine halbe Summe wie die ganze. */}
{bekannt.length === daten.mods.length && summe > 0 && (
<span className="mod-sum"> · {groesse(summe)}</span>
)}
</div>
<label className="mod-search">
<IconSearch />
<input
type="search"
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder={t('mods.search')}
aria-label={t('mods.search')}
/>
</label>
</div>
{daten.mods.length === 0 && <p className="notice">{t('mods.none')}</p>}
{daten.mods.length > 0 && treffer.length === 0 && (
<p className="notice">{t('mods.noHit', suche)}</p>
)}
<ul className="mod-list">
{treffer.map((m) => (
<li className="mod-row" key={m.name}>
<div className="mod-info">
<span className="mod-title">{m.titel}</span>
<span className="mod-meta">
{m.autor && <>{m.autor} · </>}
<code>{m.name}</code>
</span>
</div>
<span className="mod-version">{m.version}</span>
<span className="mod-size">{groesse(m.bytes) ?? ''}</span>
<a
className="mod-dl"
href={`/api/servers/${id}/mods/${encodeURIComponent(m.name)}.zip`}
// Kein target=_blank: der Browser lädt
// wegen Content-Disposition ohnehin
// herunter, ein leeres Tab hilft keinem.
download
>
<IconDownload />
<span>{t('mods.download')}</span>
</a>
</li>
))}
</ul>
<p className="mod-hint">{t('mods.hint')}</p>
<p className="mod-hint">
<Link to="/server">{t('mods.back')}</Link>
</p>
</>
)}
</section>
</>
);
}
+10 -1
View File
@@ -2,6 +2,7 @@
// Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak.
// Daten sammelt der Server-Monitor alle 2 Minuten.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive, IconLock } from '../icons.jsx';
@@ -189,7 +190,7 @@ function ServerCard({ s }) {
<UptimeTrack buckets={s.uptime} />
{(s.connect_url || s.mods_url || s.links?.length > 0 || s.address) && (
{(s.connect_url || s.mods_url || s.mod_liste || s.links?.length > 0 || s.address) && (
<div className="srv-connect">
{s.connect_url && (
<a className="btn btn-save with-icon" href={s.connect_url}>
@@ -201,6 +202,14 @@ function ServerCard({ s }) {
<IconDownload size={15} /> {t('server.modsDownload')}
</a>
)}
{/* Kennt der Bot die Modliste, führt der Weg auf die
eigene Seite: dort steht jeder Mod einzeln, statt dass
man für zwei Änderungen das ganze Paket zieht. */}
{s.mod_liste > 0 && (
<Link className="btn btn-ghost with-icon" to={`/mods/${s.id}`}>
<IconPackage size={15} /> {t('server.modList')} ({s.mod_liste})
</Link>
)}
{/* Frei eingetragene Links. `noreferrer` weil sie überall
hinführen können — das ist ja der Sinn. */}
{(s.links ?? []).map((l) => (