Files
bordervillegame/admin_points.html
T
2026-08-22 08:40:29 +02:00

777 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Orte & Inhalte verwalten</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
background: #111;
color: #eee;
font-family: Arial, sans-serif;
display: flex;
min-height: 100vh;
}
a.back { color: #6fbf73; text-decoration: none; font-size: 14px; }
a.back:hover { text-decoration: underline; }
#sidebar {
width: 240px;
flex-shrink: 0;
background: #161616;
border-right: 1px solid #333;
padding: 16px 0;
height: 100vh;
overflow-y: auto;
position: sticky;
top: 0;
}
#sidebar .backLink { padding: 0 16px 12px; display: block; }
.sidebar-group-label {
padding: 10px 16px 4px; font-size: 11px; color: #666;
text-transform: uppercase; letter-spacing: 0.5px;
}
.sidebar-btn {
display: block; width: 100%; text-align: left; background: none; border: none;
color: #ccc; padding: 8px 16px; cursor: pointer; font-size: 13px;
}
.sidebar-btn:hover { background: #1f1f1f; }
.sidebar-btn.active { background: #2c7a3d; color: white; }
#main { flex: 1; padding: 24px; max-width: 900px; }
h1 { margin-top: 0; font-size: 20px; }
.panel { display: none; }
.panel.active { display: block; }
.box { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; margin-top: 6px; }
th, td { text-align: left; padding: 7px 6px; border-bottom: 1px solid #2a2a2a; font-size: 13px; }
th { color: #999; font-weight: normal; }
input, select, textarea {
background: #222; border: 1px solid #444; color: #eee;
padding: 6px 8px; border-radius: 4px; font-size: 13px; font-family: inherit;
}
textarea { width: 100%; resize: vertical; }
button {
background: #2c7a3d; border: none; color: white; padding: 6px 12px;
border-radius: 4px; cursor: pointer; font-size: 13px;
}
button.danger { background: #a83232; }
button.secondary { background: #555; }
button:hover { opacity: 0.85; }
.form-grid { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; margin-top: 10px; }
.form-grid label { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: #999; }
.msg { margin-top: 10px; font-size: 13px; min-height: 18px; }
.news-columns { display: flex; gap: 16px; }
.news-columns > div { flex: 1; }
</style>
</head>
<body>
<div id="sidebar">
<a class="back backLink" href="/admin.html">&larr; Admin-Menü</a>
<div class="sidebar-group-label">Orte</div>
<div id="poiSidebarButtons"></div>
<button class="sidebar-btn" data-panel="highway_links">🛣️ Autobahn-Verbindungen</button>
<div class="sidebar-group-label">Inhalte</div>
<button class="sidebar-btn" data-panel="news">📰 News</button>
<button class="sidebar-btn" data-panel="changelog">📝 Changelog</button>
<div class="sidebar-group-label">Integrationen</div>
<button class="sidebar-btn" data-panel="discord">🔗 Discord-Webhooks</button>
<div class="sidebar-group-label">Admin-Dienst</div>
<button class="sidebar-btn" data-panel="adminduty">🛡️ Admin-Look</button>
</div>
<div id="main">
<div id="poiPanels"></div>
<div class="panel" id="panel-highway_links">
<h1>🛣️ Autobahn-Verbindungen verwalten</h1>
<p style="color:#888; font-size:13px; margin-top:-8px;">
Verbindet zwei Punkte (auch auf unterschiedlichen Karten) miteinander. Spieler werden beim
Herannahen automatisch dorthin teleportiert - zu Fuß oder mit Auto (das Auto wird mitgenommen).
Kein Tastendruck nötig.
</p>
<div class="box">
<table>
<thead>
<tr><th>Name</th><th>Seite A (Welt/X/Y)</th><th>Seite B (Welt/X/Y)</th><th></th></tr>
</thead>
<tbody id="highwayLinksTable"></tbody>
</table>
</div>
<div class="box">
<h3 style="margin-top:0;">Neue Verbindung anlegen</h3>
<div class="form-grid">
<label>Name <input type="text" id="hlName" placeholder="z.B. Autobahn Nord"></label>
</div>
<div class="form-grid" style="margin-top:10px;">
<label>Welt A <input type="text" id="hlWorldA" placeholder="z.B. stadt"></label>
<label>X A (Tile) <input type="number" id="hlXA" style="width:80px;"></label>
<label>Y A (Tile) <input type="number" id="hlYA" style="width:80px;"></label>
</div>
<div class="form-grid" style="margin-top:10px;">
<label>Welt B <input type="text" id="hlWorldB" placeholder="z.B. neustadt"></label>
<label>X B (Tile) <input type="number" id="hlXB" style="width:80px;"></label>
<label>Y B (Tile) <input type="number" id="hlYB" style="width:80px;"></label>
</div>
<button onclick="addHighwayLink()" style="margin-top:12px;">Anlegen</button>
<div class="msg" id="hlMsg"></div>
</div>
</div>
<div class="panel" id="panel-news">
<h1>📰 News verwalten</h1>
<div class="box">
<table>
<thead><tr><th>Titel</th><th>Autor</th><th>Datum</th><th></th></tr></thead>
<tbody id="newsTableBody"></tbody>
</table>
</div>
<div class="box">
<h3 style="margin-top:0;">Neue News</h3>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<label>Titel <input type="text" id="newsTitle"></label>
<label>Inhalt <textarea id="newsContent" rows="4"></textarea></label>
<button onclick="addNews()" style="align-self:flex-start;">Veröffentlichen</button>
</div>
<div class="msg" id="newsMsg"></div>
</div>
</div>
<div class="panel" id="panel-changelog">
<h1>📝 Changelog verwalten</h1>
<div class="box">
<table>
<thead><tr><th>Version</th><th>Titel</th><th>Datum</th><th></th></tr></thead>
<tbody id="changelogTableBody"></tbody>
</table>
</div>
<div class="box">
<h3 style="margin-top:0;">Neuer Eintrag</h3>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<label>Version <input type="text" id="changelogVersion" placeholder="z.B. 1.4.0" style="max-width:150px;"></label>
<label>Titel <input type="text" id="changelogTitle"></label>
<label>Inhalt <textarea id="changelogContent" rows="4"></textarea></label>
<button onclick="addChangelog()" style="align-self:flex-start;">Veröffentlichen</button>
</div>
<div class="msg" id="changelogMsg"></div>
</div>
</div>
<div class="panel" id="panel-discord">
<h1>🔗 Discord-Webhooks</h1>
<p style="color:#888; font-size:13px;">
Für jeden Bereich kannst du separat festlegen, in welchen Discord-Kanal die Nachrichten gehen sollen.
Webhook-URL bekommst du in Discord unter Servereinstellungen → Integrationen → Webhooks → "Neuer Webhook".
Feld leer lassen und speichern entfernt den Webhook wieder.
</p>
<div class="box">
<h3 style="margin-top:0;">📰 News-Kanal</h3>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_news" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('news')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_news"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">📝 Changelog-Kanal</h3>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_changelog" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('changelog')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_changelog"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">⚠️ Neustart-Ankündigungen-Kanal</h3>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_restart" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('restart')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_restart"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">🔧 Wartungsmodus-Kanal</h3>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_maintenance" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('maintenance')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_maintenance"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">🟢 Online-Status-Kanal</h3>
<p style="color:#888; font-size:12px; margin-top:-4px;">
Postet eine sich selbst aktualisierende Nachricht mit der aktuellen Spielerzahl (bearbeitet
immer dieselbe Nachricht statt zu spammen) - aktualisiert bei Login/Logout und alle 5 Minuten.
</p>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_online_status" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('online_status')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_online_status"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">🎫 Support-Tickets</h3>
<p style="color:#888; font-size:12px; margin-top:-4px;">Meldet jedes neue Support-Ticket.</p>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_tickets" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('tickets')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_tickets"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">💡 Wünsche</h3>
<p style="color:#888; font-size:12px; margin-top:-4px;">Meldet jeden neuen Spieler-Wunsch.</p>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_wishes" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('wishes')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_wishes"></div>
</div>
<div class="box">
<h3 style="margin-top:0;">📊 Umfragen</h3>
<p style="color:#888; font-size:12px; margin-top:-4px;">Meldet jede neu erstellte Umfrage.</p>
<div class="form-grid" style="flex-direction:column; align-items:stretch;">
<input type="text" id="discordUrl_surveys" placeholder="https://discord.com/api/webhooks/...">
<button onclick="saveDiscordWebhook('surveys')" style="align-self:flex-start;">Speichern</button>
</div>
<div class="msg" id="discordMsg_surveys"></div>
</div>
</div>
<div class="panel" id="panel-adminduty">
<h1>🛡️ Admin-Dienst: Aussehen</h1>
<p style="color:#888; font-size:13px;">
Wenn ein Admin den Admin-Dienstmodus aktiviert, zieht er automatisch die hier festgelegte
Kleidung + den Skin an (bildet eine erkennbare "Held"-Figur) und darf Admin-Befehle nutzen.
Beim Beenden des Dienstes kommt die vorherige Kleidung automatisch zurück.
Leer lassen = kein automatischer Wechsel für dieses Feld.
</p>
<div class="box">
<div class="form-grid">
<label>Oberteil
<select id="adminShirtSelect"><option value="">- keins -</option></select>
</label>
<label>Hose
<select id="adminPantsSelect"><option value="">- keine -</option></select>
</label>
<label>Schuhe
<select id="adminShoesSelect"><option value="">- keine -</option></select>
</label>
<label>Helm
<select id="adminHelmetSelect"><option value="">- keiner -</option></select>
</label>
<label>Skin (Figur)
<select id="adminSkinSelect"><option value="">- keiner -</option></select>
</label>
<button onclick="saveAdminLook()">Speichern</button>
</div>
<div class="msg" id="adminLookMsg"></div>
</div>
</div>
</div>
<script>
// -------------------------------------------------------------
// ZUGRIFFSSCHUTZ
// -------------------------------------------------------------
const token = localStorage.getItem("token");
// Voll-Admin ODER passende Gruppen-Berechtigung reicht - der Server prüft
// das pro Bereich final ab, authFetch() fängt fehlende Rechte sauber ab
let isAdmin = localStorage.getItem("isAdmin") === "true";
if (!token) {
alert("Bitte zuerst einloggen.");
location.href = "/index.html";
}
async function authFetch(url, options = {}) {
options.headers = { ...(options.headers || {}), "Authorization": "Bearer " + token };
const res = await fetch(url, options);
if (res.status === 401 || res.status === 403) {
alert("Sitzung abgelaufen oder kein Admin-Zugriff. Bitte erneut einloggen.");
location.href = "/index.html";
throw new Error("Nicht autorisiert");
}
return res;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
}
// -------------------------------------------------------------
// KONFIGURATION ALLER ORTS-TYPEN (config-gesteuert statt 12x Copy-Paste)
// -------------------------------------------------------------
const POI_CONFIGS = [
{ key: "gas_stations", label: "Tankstellen", icon: "⛽", endpoint: "/api/admin/gas_stations", listKey: "gasStations",
priceField: "price", priceLabel: "Preis/Liter", priceDefault: 2 },
{ key: "repair_shops", label: "Werkstätten", icon: "🔧", endpoint: "/api/admin/repair_shops", listKey: "repairShops",
priceField: "pricePerPoint", priceLabel: "Preis/Reparatur-Punkt", priceDefault: 5 },
{ key: "garages", label: "Garagen", icon: "🚗", endpoint: "/api/admin/garages", listKey: "garages",
extraFields: [{ name: "jobId", label: "Job-ID (optional)", type: "number" }] },
{ key: "taxi_stands", label: "Taxistände", icon: "🚕", endpoint: "/api/admin/taxi_stands", listKey: "taxiStands" },
{ key: "hospitals", label: "Krankenhäuser", icon: "🏥", endpoint: "/api/admin/hospitals", listKey: "hospitals" },
{ key: "prisons", label: "Gefängnisse", icon: "🔒", endpoint: "/api/admin/prisons", listKey: "prisons" },
{ key: "fire_stations", label: "Feuerwachen", icon: "🚒", endpoint: "/api/admin/fire_stations", listKey: "stations" },
{ key: "impound_lots", label: "Abschlepphöfe", icon: "🚛", endpoint: "/api/admin/impound_lots", listKey: "lots" },
{ key: "jobcenters", label: "Jobcenter", icon: "💼", endpoint: "/api/admin/jobcenters", listKey: "jobcenters" },
{ key: "insurance_offices", label: "Versicherungsbüros", icon: "🛡️", endpoint: "/api/admin/insurance_offices", listKey: "offices" },
{ key: "plate_offices", label: "Zulassungsstellen", icon: "🔖", endpoint: "/api/admin/plate_offices", listKey: "offices" },
{ key: "clothing_shops", label: "Kleidungsläden", icon: "👕", endpoint: "/api/admin/clothing_shops", listKey: "shops" },
{ key: "trailer_shops", label: "Anhänger-Shops", icon: "🚛", endpoint: "/api/admin/trailer_shops", listKey: "shops" },
{ key: "black_market_spots", label: "Hehler (Schwarzmarkt)", icon: "🕶️", endpoint: "/api/admin/black_market_spots", listKey: "shops" }
];
// -------------------------------------------------------------
// SIDEBAR + PANELS AUFBAUEN
// -------------------------------------------------------------
const sidebarButtonsEl = document.getElementById("poiSidebarButtons");
const poiPanelsEl = document.getElementById("poiPanels");
POI_CONFIGS.forEach(cfg => {
const btn = document.createElement("button");
btn.className = "sidebar-btn";
btn.dataset.panel = "poi_" + cfg.key;
btn.textContent = `${cfg.icon} ${cfg.label}`;
sidebarButtonsEl.appendChild(btn);
const panel = document.createElement("div");
panel.className = "panel";
panel.id = "panel-poi_" + cfg.key;
panel.innerHTML = `
<h1>${cfg.icon} ${cfg.label} verwalten</h1>
<div class="box">
<table>
<thead>
<tr><th>Name</th><th>Welt</th><th>X</th><th>Y</th>${cfg.priceField ? `<th>${cfg.priceLabel}</th>` : ""}<th></th></tr>
</thead>
<tbody id="poiTable_${cfg.key}"></tbody>
</table>
</div>
<div class="box">
<h3 style="margin-top:0;">Neuen Eintrag anlegen</h3>
<p style="color:#888; font-size:12px; margin-top:-4px;">
Schneller geht's meist direkt im Map-Editor per Klick - hier für schnelle Textmengen-Erfassung.
</p>
<div class="form-grid">
<label>Name <input type="text" id="poiName_${cfg.key}"></label>
<label>Welt <input type="text" id="poiWorld_${cfg.key}" placeholder="z.B. stadt"></label>
<label>X (Tile) <input type="number" id="poiX_${cfg.key}" style="width:80px;"></label>
<label>Y (Tile) <input type="number" id="poiY_${cfg.key}" style="width:80px;"></label>
${cfg.priceField ? `<label>${cfg.priceLabel} <input type="number" step="0.01" id="poiPrice_${cfg.key}" value="${cfg.priceDefault}" style="width:100px;"></label>` : ""}
${(cfg.extraFields || []).map(f => `<label>${f.label} <input type="${f.type}" id="poiExtra_${cfg.key}_${f.name}" style="width:120px;"></label>`).join("")}
<button onclick="addPoi('${cfg.key}')">Anlegen</button>
</div>
<div class="msg" id="poiMsg_${cfg.key}"></div>
</div>
`;
poiPanelsEl.appendChild(panel);
});
function poiConfig(key) {
return POI_CONFIGS.find(c => c.key === key);
}
async function loadPoi(key) {
const cfg = poiConfig(key);
const res = await authFetch(cfg.endpoint);
const data = await res.json();
const items = data[cfg.listKey] || [];
const tbody = document.getElementById("poiTable_" + key);
tbody.innerHTML = "";
items.forEach(item => {
const tr = document.createElement("tr");
let priceCell = "";
if (cfg.priceField) {
const currentPrice = item.price !== undefined ? item.price : item.price_per_point;
priceCell = `<td>
<input type="number" step="0.01" value="${currentPrice}" id="poiPriceEdit_${key}_${item.id}" style="width:70px;">
<button class="secondary" onclick="updatePoiPrice('${key}', ${item.id})" style="padding:4px 6px; font-size:11px;">✓</button>
</td>`;
}
tr.innerHTML = `
<td>#${item.id} ${escapeHtml(item.name)}</td>
<td>${escapeHtml(item.world)}</td>
<td>${item.x}</td>
<td>${item.y}</td>
${priceCell}
<td><button class="danger" onclick="deletePoi('${key}', ${item.id})">Löschen</button></td>
`;
tbody.appendChild(tr);
});
}
async function addPoi(key) {
const cfg = poiConfig(key);
const name = document.getElementById(`poiName_${key}`).value.trim();
const world = document.getElementById(`poiWorld_${key}`).value.trim();
const x = Number(document.getElementById(`poiX_${key}`).value);
const y = Number(document.getElementById(`poiY_${key}`).value);
if (!name || !world || isNaN(x) || isNaN(y)) {
showPoiMsg(key, "Name, Welt, X und Y sind Pflicht.", true);
return;
}
const payload = { name, world, x, y };
if (cfg.priceField) {
payload[cfg.priceField] = Number(document.getElementById(`poiPrice_${key}`).value) || cfg.priceDefault;
}
(cfg.extraFields || []).forEach(f => {
const val = document.getElementById(`poiExtra_${key}_${f.name}`).value;
if (val !== "") payload[f.name] = f.type === "number" ? Number(val) : val;
});
const res = await authFetch(cfg.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.ok) {
showPoiMsg(key, "Angelegt.");
document.getElementById(`poiName_${key}`).value = "";
document.getElementById(`poiX_${key}`).value = "";
document.getElementById(`poiY_${key}`).value = "";
await loadPoi(key);
} else {
showPoiMsg(key, "Fehler: " + (data.error || "unbekannt"), true);
}
}
async function updatePoiPrice(key, id) {
const cfg = poiConfig(key);
const value = Number(document.getElementById(`poiPriceEdit_${key}_${id}`).value);
const payload = { id };
payload[cfg.priceField] = value;
const res = await authFetch(cfg.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
showPoiMsg(key, data.ok ? "Preis aktualisiert." : "Fehler beim Aktualisieren.", !data.ok);
if (data.ok) await loadPoi(key);
}
async function deletePoi(key, id) {
if (!confirm("Diesen Eintrag wirklich löschen?")) return;
const cfg = poiConfig(key);
const res = await authFetch(`${cfg.endpoint}/${id}`, { method: "DELETE" });
const data = await res.json();
showPoiMsg(key, data.ok ? "Gelöscht." : "Löschen fehlgeschlagen.", !data.ok);
if (data.ok) await loadPoi(key);
}
function showPoiMsg(key, text, isError) {
const el = document.getElementById("poiMsg_" + key);
el.style.color = isError ? "#e06c6c" : "#6fbf73";
el.textContent = text;
setTimeout(() => { el.textContent = ""; }, 4000);
}
// -------------------------------------------------------------
// NEWS
// -------------------------------------------------------------
async function loadNews() {
const res = await fetch("/api/news");
const data = await res.json();
const tbody = document.getElementById("newsTableBody");
tbody.innerHTML = "";
(data.news || []).forEach(n => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(n.title)}</td>
<td>${escapeHtml(n.author)}</td>
<td>${new Date(n.created_at).toLocaleString("de-DE")}</td>
<td><button class="danger" onclick="deleteNews(${n.id})">Löschen</button></td>
`;
tbody.appendChild(tr);
});
}
async function addNews() {
const title = document.getElementById("newsTitle").value.trim();
const content = document.getElementById("newsContent").value.trim();
if (!title || !content) {
showMsg("newsMsg", "Titel und Inhalt erforderlich.", true);
return;
}
const res = await authFetch("/api/admin/news", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content })
});
const data = await res.json();
if (data.ok) {
showMsg("newsMsg", "Veröffentlicht.");
document.getElementById("newsTitle").value = "";
document.getElementById("newsContent").value = "";
await loadNews();
} else {
showMsg("newsMsg", "Fehler: " + (data.error || "unbekannt"), true);
}
}
async function deleteNews(id) {
if (!confirm("Diese News wirklich löschen?")) return;
const res = await authFetch("/api/admin/news/" + id, { method: "DELETE" });
const data = await res.json();
showMsg("newsMsg", data.ok ? "Gelöscht." : "Löschen fehlgeschlagen.", !data.ok);
if (data.ok) await loadNews();
}
// -------------------------------------------------------------
// CHANGELOG
// -------------------------------------------------------------
async function loadChangelog() {
const res = await fetch("/api/changelog");
const data = await res.json();
const tbody = document.getElementById("changelogTableBody");
tbody.innerHTML = "";
(data.changelog || []).forEach(c => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(c.version)}</td>
<td>${escapeHtml(c.title)}</td>
<td>${new Date(c.created_at).toLocaleString("de-DE")}</td>
<td><button class="danger" onclick="deleteChangelog(${c.id})">Löschen</button></td>
`;
tbody.appendChild(tr);
});
}
async function addChangelog() {
const version = document.getElementById("changelogVersion").value.trim();
const title = document.getElementById("changelogTitle").value.trim();
const content = document.getElementById("changelogContent").value.trim();
if (!version || !title || !content) {
showMsg("changelogMsg", "Version, Titel und Inhalt erforderlich.", true);
return;
}
const res = await authFetch("/api/admin/changelog", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version, title, content })
});
const data = await res.json();
if (data.ok) {
showMsg("changelogMsg", "Veröffentlicht.");
document.getElementById("changelogVersion").value = "";
document.getElementById("changelogTitle").value = "";
document.getElementById("changelogContent").value = "";
await loadChangelog();
} else {
showMsg("changelogMsg", "Fehler: " + (data.error || "unbekannt"), true);
}
}
async function deleteChangelog(id) {
if (!confirm("Diesen Changelog-Eintrag wirklich löschen?")) return;
const res = await authFetch("/api/admin/changelog/" + id, { method: "DELETE" });
const data = await res.json();
showMsg("changelogMsg", data.ok ? "Gelöscht." : "Löschen fehlgeschlagen.", !data.ok);
if (data.ok) await loadChangelog();
}
// -------------------------------------------------------------
// DISCORD-WEBHOOKS
// -------------------------------------------------------------
async function loadDiscordWebhooks() {
const res = await authFetch("/api/admin/discord_webhooks");
const data = await res.json();
const webhooks = data.webhooks || {};
["news", "changelog", "restart", "maintenance", "online_status", "tickets", "wishes", "surveys"].forEach(key => {
const input = document.getElementById("discordUrl_" + key);
if (input) input.value = webhooks[key] || "";
});
}
async function saveDiscordWebhook(key) {
const url = document.getElementById("discordUrl_" + key).value.trim();
const res = await authFetch("/api/admin/discord_webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, url })
});
const data = await res.json();
if (data.ok) {
showMsg("discordMsg_" + key, url ? "Gespeichert." : "Webhook entfernt.");
} else {
showMsg("discordMsg_" + key, "Fehler: " + (data.error || "unbekannt"), true);
}
}
// -------------------------------------------------------------
// ADMIN-DIENST: AUSSEHEN
// -------------------------------------------------------------
async function loadAdminLookPanel() {
const [clothingRes, skinRes, lookRes] = await Promise.all([
authFetch("/api/admin/clothing_items"),
authFetch("/api/skin_catalog"),
authFetch("/api/admin/admin_look")
]);
const clothingData = await clothingRes.json();
const skinData = await skinRes.json();
const look = await lookRes.json();
const bySlot = { shirt: [], pants: [], shoes: [], helmet: [] };
(clothingData.items || []).forEach(item => {
if (bySlot[item.slot]) bySlot[item.slot].push(item);
});
fillSelect("adminShirtSelect", bySlot.shirt, look.shirtId);
fillSelect("adminPantsSelect", bySlot.pants, look.pantsId);
fillSelect("adminShoesSelect", bySlot.shoes, look.shoesId);
fillSelect("adminHelmetSelect", bySlot.helmet, look.helmetId);
fillSelect("adminSkinSelect", skinData.items || [], look.skinId);
}
function fillSelect(selectId, items, currentValue) {
const select = document.getElementById(selectId);
const placeholder = select.options[0];
select.innerHTML = "";
select.appendChild(placeholder);
items.forEach(item => {
const opt = document.createElement("option");
opt.value = item.id;
opt.textContent = item.name;
if (String(item.id) === String(currentValue)) opt.selected = true;
select.appendChild(opt);
});
}
async function loadHighwayLinksTable() {
const res = await authFetch("/api/admin/highway_links");
const data = await res.json();
const links = data.links || [];
const tbody = document.getElementById("highwayLinksTable");
tbody.innerHTML = "";
links.forEach(l => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(l.name)}</td>
<td>${escapeHtml(l.world_a)} / ${l.x_a} / ${l.y_a}</td>
<td>${escapeHtml(l.world_b)} / ${l.x_b} / ${l.y_b}</td>
<td><button class="secondary" onclick="deleteHighwayLink(${l.id})">Löschen</button></td>
`;
tbody.appendChild(tr);
});
}
async function addHighwayLink() {
const name = document.getElementById("hlName").value.trim();
const worldA = document.getElementById("hlWorldA").value.trim();
const xA = Number(document.getElementById("hlXA").value);
const yA = Number(document.getElementById("hlYA").value);
const worldB = document.getElementById("hlWorldB").value.trim();
const xB = Number(document.getElementById("hlXB").value);
const yB = Number(document.getElementById("hlYB").value);
if (!worldA || !worldB || isNaN(xA) || isNaN(yA) || isNaN(xB) || isNaN(yB)) {
showMsg("hlMsg", "Beide Welten und alle Koordinaten sind Pflicht.", true);
return;
}
const res = await authFetch("/api/admin/highway_links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, worldA, xA, yA, worldB, xB, yB })
});
const data = await res.json();
if (data.ok) {
showMsg("hlMsg", "Verbindung angelegt.");
await loadHighwayLinksTable();
} else {
showMsg("hlMsg", "Fehler: " + (data.error || "unbekannt"), true);
}
}
async function deleteHighwayLink(id) {
if (!confirm("Diese Autobahn-Verbindung wirklich löschen?")) return;
await authFetch(`/api/admin/highway_links/${id}`, { method: "DELETE" });
await loadHighwayLinksTable();
}
async function saveAdminLook() {
const payload = {
shirtId: document.getElementById("adminShirtSelect").value,
pantsId: document.getElementById("adminPantsSelect").value,
shoesId: document.getElementById("adminShoesSelect").value,
helmetId: document.getElementById("adminHelmetSelect").value,
skinId: document.getElementById("adminSkinSelect").value
};
const res = await authFetch("/api/admin/admin_look", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
showMsg("adminLookMsg", data.ok ? "Gespeichert." : "Fehler beim Speichern.", !data.ok);
}
function showMsg(elId, text, isError) {
const el = document.getElementById(elId);
el.style.color = isError ? "#e06c6c" : "#6fbf73";
el.textContent = text;
setTimeout(() => { el.textContent = ""; }, 4000);
}
// -------------------------------------------------------------
// TAB-NAVIGATION
// -------------------------------------------------------------
document.querySelectorAll(".sidebar-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".sidebar-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
const panel = document.getElementById("panel-" + btn.dataset.panel);
if (panel) panel.classList.add("active");
if (btn.dataset.panel.startsWith("poi_")) {
loadPoi(btn.dataset.panel.replace("poi_", ""));
} else if (btn.dataset.panel === "news") {
loadNews();
} else if (btn.dataset.panel === "changelog") {
loadChangelog();
} else if (btn.dataset.panel === "discord") {
loadDiscordWebhooks();
} else if (btn.dataset.panel === "highway_links") {
loadHighwayLinksTable();
} else if (btn.dataset.panel === "adminduty") {
loadAdminLookPanel();
}
});
});
// Ersten Tab (erste Orts-Kategorie) initial anzeigen
document.querySelector(".sidebar-btn").click();
</script>
</body>
</html>