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 && (
@@ -3423,6 +3424,47 @@ export default function Settings({ me }) { setServerDraft({ ...serverDraft, mods_url: e.target.value })} /> setServerDraft({ ...serverDraft, image_url: e.target.value })} /> + + + {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)), + })} + /> + +
+ ))} + {serverDraft.links.length < 8 && ( + + )} +
{serverDraft.id && } 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() };