Modliste: melden was sich geaendert hat, einzeln laden
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:
@@ -11,6 +11,7 @@ import Commits from './pages/Commits.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import Profil from './pages/Profil.jsx';
|
||||
import Server from './pages/Server.jsx';
|
||||
import Mods from './pages/Mods.jsx';
|
||||
import Status from './pages/Status.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||
@@ -219,6 +220,7 @@ export default function App() {
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/wunsch/:id" element={<WunschDetail />} />
|
||||
<Route path="/server" element={<Server />} />
|
||||
<Route path="/mods/:id" element={<Mods />} />
|
||||
<Route path="/status" element={<Status />} />
|
||||
<Route path="/galerie" element={<Galerie />} />
|
||||
<Route path="/level" element={<Level />} />
|
||||
|
||||
@@ -165,6 +165,19 @@ export default {
|
||||
'server.offline': 'Offline',
|
||||
'server.waiting': 'Warte auf Check',
|
||||
'server.players24h': 'Spielerzahl · letzte 24h',
|
||||
// ── Mods eines LS-Servers ─────────────────────────
|
||||
'mods.tag': '// Modliste',
|
||||
'mods.title': 'Mods',
|
||||
'mods.subtitle': 'Alle Mods dieses Servers einzeln zum Laden — statt eines Pakets über alles.',
|
||||
'mods.error': 'Modliste konnte nicht geladen werden.',
|
||||
'mods.none': 'Für diesen Server ist noch keine Modliste bekannt.',
|
||||
'mods.noHit': 'Nichts gefunden für „%s".',
|
||||
'mods.count': 'Mods',
|
||||
'mods.search': 'Mod suchen …',
|
||||
'mods.download': 'Laden',
|
||||
'mods.hint': 'Die Dateien gehören in deinen Mod-Ordner: Dokumente\\My Games\\FarmingSimulator2025\\mods. Gekaufte Inhalte (DLC) fehlen hier — die kommen über Steam.',
|
||||
'mods.back': '← Zurück zu den Servern',
|
||||
'server.modList': 'Mods ansehen',
|
||||
'server.peak': 'Peak',
|
||||
'server.uptime': 'Uptime',
|
||||
'server.measuredSince': 'misst seit %s',
|
||||
@@ -306,7 +319,7 @@ export default {
|
||||
'commands.bdaySet': 'Trägt deinen Geburtstag ein. Der Bot gratuliert morgens und vergibt eine Rolle für den Tag.',
|
||||
'commands.bdayDel': 'Trägt ihn wieder aus.',
|
||||
'commands.remind': 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".',
|
||||
'commands.tag': 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.',
|
||||
'commands.cmdTag': 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.',
|
||||
'commands.ping': 'Lebenszeichen des Bots.',
|
||||
'commands.umfrage': 'Startet eine Abstimmung mit bis zu vier Antworten — Discord zählt selbst aus.',
|
||||
'commands.warn': 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.',
|
||||
|
||||
@@ -164,6 +164,19 @@ export default {
|
||||
'server.offline': 'Offline',
|
||||
'server.waiting': 'Waiting for check',
|
||||
'server.players24h': 'Player count · last 24h',
|
||||
// ── Mods of an FS server ──────────────────────────
|
||||
'mods.tag': '// Mod list',
|
||||
'mods.title': 'Mods',
|
||||
'mods.subtitle': 'Every mod on this server, one by one — instead of one big bundle.',
|
||||
'mods.error': 'Could not load the mod list.',
|
||||
'mods.none': 'No mod list known for this server yet.',
|
||||
'mods.noHit': 'Nothing found for "%s".',
|
||||
'mods.count': 'mods',
|
||||
'mods.search': 'Search mods …',
|
||||
'mods.download': 'Download',
|
||||
'mods.hint': 'The files belong in your mod folder: Documents\\My Games\\FarmingSimulator2025\\mods. Purchased content (DLC) is not listed here — that comes via Steam.',
|
||||
'mods.back': '← Back to the servers',
|
||||
'server.modList': 'View mods',
|
||||
'server.peak': 'Peak',
|
||||
'server.uptime': 'Uptime',
|
||||
'server.measuredSince': 'measuring since %s',
|
||||
@@ -395,7 +408,7 @@ export default {
|
||||
'commands.bdaySet': 'Saves your birthday. The bot congratulates you in the morning and hands out a role for the day.',
|
||||
'commands.bdayDel': 'Removes it again.',
|
||||
'commands.remind': 'Reminds you by direct message — something like “2h” or “30m”.',
|
||||
'commands.tag': 'Fetches a saved text snippet, with suggestions as you type.',
|
||||
'commands.cmdTag': 'Fetches a saved text snippet, with suggestions as you type.',
|
||||
'commands.ping': 'A sign of life from the bot.',
|
||||
'commands.umfrage': 'Starts a vote with up to four answers — Discord does the counting.',
|
||||
'commands.warn': 'Warns someone, sends them a message and writes it to the log.',
|
||||
|
||||
@@ -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'],
|
||||
]],
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -2900,3 +2900,64 @@ button.with-icon, a.with-icon { justify-content: center; }
|
||||
font-family: var(--mono); font-size: .68rem; color: var(--muted2);
|
||||
letter-spacing: .04em; margin-top: .1rem;
|
||||
}
|
||||
|
||||
/* ── MOD-LISTE (LS-Server) ─────────────────────────── */
|
||||
.mod-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 1rem; flex-wrap: wrap; margin-bottom: 1.2rem;
|
||||
}
|
||||
.mod-count {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
font-family: var(--mono); font-size: .72rem; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--muted);
|
||||
}
|
||||
.mod-count strong { color: var(--neon); font-size: 1rem; font-weight: 400; }
|
||||
.mod-sum { color: var(--muted2); }
|
||||
.mod-search {
|
||||
display: flex; align-items: center; gap: .5rem; flex: 1 1 240px; max-width: 340px;
|
||||
border: 1px solid var(--border); background: var(--bg2); padding: .45rem .75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mod-search:focus-within { border-color: var(--neon); }
|
||||
.mod-search input {
|
||||
flex: 1; min-width: 0; background: none; border: 0; outline: none;
|
||||
color: var(--text); font-family: var(--body); font-size: 1rem;
|
||||
}
|
||||
.mod-list { list-style: none; display: flex; flex-direction: column; gap: 1px; }
|
||||
.mod-row {
|
||||
display: grid; align-items: center; gap: .9rem;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
padding: .7rem .9rem; background: var(--bg2);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.mod-row:hover { background: var(--bg3); border-left-color: var(--neon); }
|
||||
.mod-info { min-width: 0; }
|
||||
.mod-title { display: block; color: var(--text); font-size: 1.05rem; }
|
||||
.mod-meta {
|
||||
display: block; font-family: var(--mono); font-size: .62rem;
|
||||
color: var(--muted2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mod-version, .mod-size {
|
||||
font-family: var(--mono); font-size: .68rem; color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Feste Spalte, damit die Downloadknöpfe untereinander stehen und nicht
|
||||
je nach Dateigrösse wandern — auch dann, wenn die Grösse noch fehlt. */
|
||||
.mod-size { min-width: 4.5rem; text-align: right; }
|
||||
.mod-dl {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
border: 1px solid var(--border); padding: .35rem .7rem;
|
||||
font-family: var(--mono); font-size: .65rem; letter-spacing: .1em;
|
||||
text-transform: uppercase; color: var(--muted); white-space: nowrap;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.mod-dl:hover { border-color: var(--neon); color: var(--neon); }
|
||||
.mod-hint { color: var(--muted2); font-size: .9rem; margin-top: 1.2rem; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mod-row { grid-template-columns: 1fr auto; grid-template-areas: 'info dl' 'meta meta'; }
|
||||
.mod-info { grid-area: info; }
|
||||
.mod-dl { grid-area: dl; }
|
||||
.mod-version, .mod-size { grid-area: meta; text-align: left; min-width: 0; }
|
||||
.mod-version::after { content: ' ·'; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user