diff --git a/README.md b/README.md
index 791b645..2ab0f56 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
| Funktion | Was sie macht |
|---|---|
-| 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden und Mod-Download, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer |
+| 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden, Mod-Download und frei eintragbare Links wie Regeln oder TeamSpeak, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer |
| 🚜 **LS — Höfe & Preise** | Für Farming-Simulator-Server: Besitzkarte auf dem Luftbild des Servers (Parzellen nach Hof eingefärbt, Fläche = Hektar, Fahrzeuge und Spieler live), Hof-Übersicht mit Hektar und Wert, Meldung bei Landkauf, dazu die Preiskurven übers Jahr mit „jetzt verkaufen oder warten". **Nicht möglich:** welche Frucht auf welchem Feld steht — das gibt der Feed nicht her (siehe [docs/ls-feed.md](docs/ls-feed.md)) |
### Server & Technik
diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx
index 005bb95..594242c 100644
--- a/frontend/src/pages/Server.jsx
+++ b/frontend/src/pages/Server.jsx
@@ -180,7 +180,7 @@ function ServerCard({ s }) {
- {(s.connect_url || s.mods_url || s.address) && (
+ {(s.connect_url || s.mods_url || s.links?.length > 0 || s.address) && (
{s.connect_url && (
@@ -192,6 +192,13 @@ function ServerCard({ s }) {
{t('server.modsDownload')}
)}
+ {/* Frei eingetragene Links. `noreferrer` weil sie überall
+ hinführen können — das ist ja der Sinn. */}
+ {(s.links ?? []).map((l) => (
+
+ {l.label}
+
+ ))}
{s.address && (
{copied ? {t('server.copied')} : s.address}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 8da47a7..f58f3b9 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -406,7 +406,7 @@ export default function Settings({ me }) {
const [triggers, setTriggers] = useState([]);
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
// Game-Server — die Spielliste kommt aus gamedig (siehe /api/gamedig-games)
- const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '', token: '', mods_url: '' };
+ const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '', token: '', mods_url: '', links: [] };
const [servers, setServers] = useState([]);
const [games, setGames] = useState([]);
const [serverDraft, setServerDraft] = useState(emptyServer);
@@ -3354,6 +3354,7 @@ export default function Settings({ me }) {
id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
image_url: s.image_url ?? '', token: s.token ?? '', mods_url: s.mods_url ?? '',
+ links: s.links ?? [],
})}>Bearbeiten
removeServer(s.id)}>✕
@@ -3423,6 +3424,47 @@ export default function Settings({ me }) {
setServerDraft({ ...serverDraft, mods_url: e.target.value })} />
Logo-URL https-Bild (z. B. vom CDN) — erscheint auf der /server-Seite und als Embed-Thumbnail
setServerDraft({ ...serverDraft, image_url: e.target.value })} />
+
+
+ Weitere Links
+ Regeln, TeamSpeak, Shop … je ein Knopf unter dem Embed und auf der /server-Seite.
+ Mit „Verbinden" und „Mods" zusammen höchstens zehn — mehr nimmt Discord nicht.
+
+
+ {serverDraft.links.map((l, i) => (
+
+ setServerDraft({
+ ...serverDraft,
+ links: serverDraft.links.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)),
+ })}
+ />
+ setServerDraft({
+ ...serverDraft,
+ links: serverDraft.links.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)),
+ })}
+ />
+ setServerDraft({
+ ...serverDraft,
+ links: serverDraft.links.filter((_, j) => j !== i),
+ })}
+ >✕
+
+ ))}
+ {serverDraft.links.length < 8 && (
+ setServerDraft({ ...serverDraft, links: [...serverDraft.links, { label: '', url: '' }] })}
+ >+ Link
+ )}
+
{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}
{serverDraft.id && setServerDraft(emptyServer)}>Abbrechen }
diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js
index 059ab5a..fbe880f 100644
--- a/src/bot/server-monitor.js
+++ b/src/bot/server-monitor.js
@@ -142,17 +142,36 @@ export function istWebLink(url) {
* eckigen Klammern wörtlich da. Ein Knopf lässt diese Frage gar nicht erst
* aufkommen und ist obendrein als Knopf erkennbar.
*/
+// Discord: fünf Knöpfe je Reihe. Mehr als zwei Reihen erschlägt das Embed,
+// also ist bei zehn Schluss — und zwar sichtbar hier, nicht als Fehler beim
+// Senden, den niemand sieht.
+const PRO_REIHE = 5;
+const MAX_REIHEN = 2;
+
+/** Alle Links eines Servers in einer Liste: die festen zuerst, dann die freien */
+export function serverLinks(r) {
+ return [
+ { label: '🎮 Verbinden', url: r.connect_url },
+ { label: '📦 Mods laden', url: r.mods_url },
+ ...(Array.isArray(r.links) ? r.links : []),
+ ].filter((l) => l?.url && String(l.label ?? '').trim());
+}
+
export function serverKnoepfe(r) {
- const knoepfe = [
- ['🎮 Verbinden', r.connect_url],
- ['📦 Mods laden', r.mods_url],
- ]
- .filter(([, url]) => istWebLink(url))
- .map(([label, url]) => new ButtonBuilder()
+ const knoepfe = serverLinks(r)
+ .filter((l) => istWebLink(l.url))
+ .slice(0, PRO_REIHE * MAX_REIHEN)
+ .map((l) => new ButtonBuilder()
.setStyle(ButtonStyle.Link)
- .setLabel(label)
- .setURL(url.trim()));
- return knoepfe.length > 0 ? [new ActionRowBuilder().addComponents(knoepfe)] : [];
+ // Discord nimmt höchstens 80 Zeichen und weist längere ab
+ .setLabel(String(l.label).trim().slice(0, 80))
+ .setURL(l.url.trim()));
+
+ const reihen = [];
+ for (let i = 0; i < knoepfe.length; i += PRO_REIHE) {
+ reihen.push(new ActionRowBuilder().addComponents(knoepfe.slice(i, i + PRO_REIHE)));
+ }
+ return reihen;
}
/** Embed + Knöpfe — das, was der Monitor in den Kanal schreibt */
@@ -191,9 +210,9 @@ export function buildServerEmbed(r) {
// Connect und Mods stehen als Knopf darunter, wenn es https ist. Alles
// andere (fivem://, steam://) macht Discord nicht klickbar — dann lieber
// als Codeblock zum Kopieren, statt einen toten Link vorzugaukeln.
- for (const [titel, url] of [['Connect', r.connect_url], ['Mods', r.mods_url]]) {
- if (url && !istWebLink(url)) {
- embed.addFields({ name: titel, value: `\`\`\`\n${url}\n\`\`\`` });
+ for (const l of serverLinks(r)) {
+ if (!istWebLink(l.url)) {
+ embed.addFields({ name: String(l.label).trim().slice(0, 256), value: `\`\`\`\n${l.url}\n\`\`\`` });
}
}
if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` });
diff --git a/src/db.js b/src/db.js
index 771f71a..adafa7a 100644
--- a/src/db.js
+++ b/src/db.js
@@ -927,6 +927,10 @@ db.exec(`
// Eigener Link für den Mod-Download. Vorher landete der beim Connect-Link,
// weil es kein anderes Feld gab — und dann führte "Connect" zu einer ZIP.
if (!cols.includes('mods_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN mods_url TEXT NOT NULL DEFAULT ''`);
+ // Freie Links: Regeln, TeamSpeak, Shop, was auch immer. Als JSON-Liste,
+ // weil sonst für jeden neuen Zweck eine Spalte dazukäme — und genau so
+ // sind connect_url und mods_url entstanden.
+ if (!cols.includes('links')) db.exec(`ALTER TABLE gameservers ADD COLUMN links TEXT NOT NULL DEFAULT '[]'`);
}
// Die Auswahl im Panel bot mal Spiel-IDs an, die es bei gamedig gar nicht gibt.
// Wer eine davon gewählt hat, hatte einen Server, der dauerhaft offline stand,
@@ -940,13 +944,13 @@ db.exec(`
for (const [alt, neu] of Object.entries(echteIds)) um.run(neu, alt);
}
const insertGameserver = db.prepare(`
- INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url)
- VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url)
+ INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url, links)
+ VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url, @links)
`);
const updateGameserverStmt = db.prepare(`
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address,
host = @host, port = @port, connect_url = @connect_url, image_url = @image_url, token = @token,
- mods_url = @mods_url
+ mods_url = @mods_url, links = @links
WHERE id = @id
`);
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
@@ -958,14 +962,36 @@ const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?');
// auffüllen ist billiger, als das an jeder Aufrufstelle nachzuziehen.
const gameserverFelder = {
name: '', type: 'fivem', query_url: '', address: '', host: '', port: null,
- connect_url: '', image_url: '', token: '', mods_url: '',
+ connect_url: '', image_url: '', token: '', mods_url: '', links: '[]',
+};
+// Links dürfen als Liste hereinkommen — so gibt getGameserver sie ja auch
+// heraus. better-sqlite3 kann kein Array binden und würde sonst werfen.
+const vollstaendig = (s) => {
+ const voll = { ...gameserverFelder, ...s };
+ if (Array.isArray(voll.links)) voll.links = JSON.stringify(voll.links);
+ return voll;
};
-const vollstaendig = (s) => ({ ...gameserverFelder, ...s });
export const createGameserver = (s) => insertGameserver.run(vollstaendig(s)).lastInsertRowid;
export const updateGameserver = (s) => updateGameserverStmt.run(vollstaendig(s)).changes > 0;
export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id);
-export const listGameservers = () => listGameserversStmt.all();
-export const getGameserver = (id) => getGameserverStmt.get(id) ?? null;
+/**
+ * Links kommen als JSON aus der Spalte und gehen als Liste weiter.
+ *
+ * Einmal hier statt an jeder Aufrufstelle: sonst parst jeder selbst, und der
+ * erste, der es vergisst, mappt über einen String. Kaputtes JSON ergibt eine
+ * leere Liste — ein Server ohne Links ist besser als ein Absturz im Monitor.
+ */
+function mitLinks(row) {
+ if (!row) return null;
+ let links = [];
+ try {
+ const roh = JSON.parse(row.links || '[]');
+ if (Array.isArray(roh)) links = roh.filter((l) => l?.url);
+ } catch { /* kaputt gespeichert — dann eben ohne */ }
+ return { ...row, links };
+}
+export const listGameservers = () => listGameserversStmt.all().map(mitLinks);
+export const getGameserver = (id) => mitLinks(getGameserverStmt.get(id));
export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0;
// Spielerzahl-Verlauf für die öffentliche /server-Seite (Sample alle 2 min, 7 Tage Retention)
diff --git a/src/web/api.js b/src/web/api.js
index 3757acc..f21cb8b 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -226,6 +226,7 @@ export function registerApiRoutes(app, client) {
address: s.address || null,
connect_url: s.connect_url || null,
mods_url: s.mods_url || null,
+ links: s.links ?? [],
online: r?.online ?? null, // null = noch nie gecheckt
players: r?.players ?? null,
max: r?.max ?? null,
@@ -1609,9 +1610,28 @@ ${rssItems}
mods_url: /^https?:\/\/.+/i.test(String(body.mods_url ?? '').trim())
? String(body.mods_url).trim().slice(0, 300)
: '',
+ links: freieLinks(body.links),
};
}
+ /**
+ * Freie Links säubern.
+ *
+ * Zeilen ohne Beschriftung oder ohne Adresse fliegen raus statt als leerer
+ * Knopf im Discord zu landen. Zehn ist die Grenze, weil Discord fünf
+ * Knöpfe je Reihe nimmt und zwei Reihen unter einem Embed genug sind.
+ */
+ function freieLinks(roh) {
+ if (!Array.isArray(roh)) return [];
+ return roh
+ .map((l) => ({
+ label: String(l?.label ?? '').trim().slice(0, 80),
+ url: String(l?.url ?? '').trim().slice(0, 400),
+ }))
+ .filter((l) => l.label && l.url)
+ .slice(0, 10);
+ }
+
app.get('/api/gameservers', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
return { servers: listGameservers() };