This commit is contained in:
Jeremy Kirsch 2026-05-18 18:48:29 +02:00
parent 9ee465dd59
commit 95fc830017
4 changed files with 193 additions and 102 deletions

View File

@ -1913,19 +1913,20 @@
<div class="login-logo">BLAIR</div>
<div class="login-sub">Ghost Investigation Dashboard</div>
<div class="login-desc">
Login with discord to submit <Strong>Tips</strong> and check the status of your submissions.<br><br>
<strong>Admin access</strong> will only be granted to unlocked Discord accounts.
Melde dich mit Discord an, um <strong>Tips einzureichen</strong> und den Status deiner Einsendungen zu
sehen.<br><br>
<strong>Admin-Zugriff</strong> wird nur für freigeschaltete Discord-Accounts gewährt.
</div>
<a class="btn-discord" href="/auth/discord" style="text-decoration:none;">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.101 18.08.114 18.1.134 18.114a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
Login with Discord
Mit Discord anmelden
</a>
<span class="login-or">or</span>
<button class="btn-sm" id="guest-btn">Login with discord</button>
<div class="login-guest-note">Guests can only view Ghost Cards, Cheatsheet and Hunt Guide.</div>
<span class="login-or">oder</span>
<button class="btn-sm" id="guest-btn">Als Gast fortfahren (nur lesen)</button>
<div class="login-guest-note">Gäste können nur Ghost Cards, Cheatsheet und Hunt Guide sehen.</div>
</div>
</div>
@ -1941,14 +1942,14 @@
<div class="header-inner">
<div>
<div class="site-title">BLAIR</div>
<div class="site-sub">Ghost Investigation Dashboard &nbsp;·&nbsp; Blair Cheatsheet</div>
<div class="site-sub">Ghost Investigation Dashboard &nbsp;·&nbsp; Roblox</div>
</div>
<div class="header-right">
<div class="auth-chip" id="auth-chip" style="display:none;">
<img class="auth-avatar" id="auth-av" src="" alt="">
<span class="auth-name" id="auth-nm"></span>
<span class="auth-role" id="auth-rl"></span>
<button class="btn-logout">Logout</button>
<button class="btn-logout">Abmelden</button>
</div>
<span id="guest-badge"
style="display:none;font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;color:var(--muted);">GAST</span>
@ -1973,7 +1974,7 @@
</nav>
<div class="panel active" id="vtab-cards">
<div class="filter-row" id="ev-filter">
<div class="filter-label-sm">Evidence Filter — single click: found &nbsp;·&nbsp; double click: excluded</div>
<div class="filter-label-sm">Evidence Filter — einmal: gefunden &nbsp;·&nbsp; zweimal: ausgeschlossen</div>
</div>
<div class="ghost-grid" id="ghost-grid"></div>
</div>
@ -2151,35 +2152,35 @@
<div class="user-title">✦ Community Hub</div>
<div
style="font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;margin:4px 0 22px;">
submit tips · report errors · add information</div>
Tips einreichen · Fehler melden · Infos ergänzen</div>
<div class="form-section" style="margin-bottom:28px;">
<div class="form-title">Submit something</div>
<div class="form-title">Etwas einreichen</div>
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label class="form-label">Ghost</label><select class="form-select" id="usr-ghost">
<option value="">select ghost</option>
<div class="form-group"><label class="form-label">Geist</label><select class="form-select" id="usr-ghost">
<option value="">Geist auswählen</option>
</select></div>
<div class="form-group"><label class="form-label">Type</label>
<div class="form-group"><label class="form-label">Art</label>
<select class="form-select" id="usr-type">
<option value="tip">💡 New tip / trick</option>
<option value="edit">✏️ Error / correction</option>
<option value="new">🆕 Ghost missing</option>
<option value="tip">💡 Neuer Tip / Trick</option>
<option value="edit">✏️ Fehler / Korrektur</option>
<option value="new">🆕 Neuer Geist fehlt</option>
</select>
</div>
</div>
<div class="form-row full">
<div class="form-group"><label class="form-label">Description</label>
<div class="form-group"><label class="form-label">Beschreibung</label>
<textarea class="form-textarea" id="usr-content"
placeholder="Describe your tip as detailed as possible..." rows="4"></textarea>
<span class="form-hint">The more detailed, the better for the admin!</span>
placeholder="Beschreib deinen Tip so genau wie möglich..." rows="4"></textarea>
<span class="form-hint">Je detaillierter, desto besser für den Admin!</span>
</div>
</div>
<div class="form-footer"><button class="btn-blue" id="btn-submit-tip">Submit</button></div>
<div class="form-footer"><button class="btn-blue" id="btn-submit-tip">Einreichen</button></div>
</div>
</div>
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text">Your submissions</div>
<div class="divider-text">Deine Einreichungen</div>
<div class="divider-line"></div>
</div>
<div id="user-sub-list"></div>
@ -2201,7 +2202,7 @@
<div class="modal-bg" id="edit-modal">
<div class="modal-box wide"><button class="modal-close" id="close-edit-modal"></button>
<div class="modal-hd">
<div class="modal-title" id="em-title">Edit Ghost</div>
<div class="modal-title" id="em-title">Geist bearbeiten</div>
<div class="modal-sub" id="em-sub">Admin · Ghost Editor</div>
</div>
<div class="modal-body">
@ -2222,27 +2223,27 @@
</select>
</div>
<div class="form-group"><label class="form-label">Sanity Note</label><input type="text" class="form-input"
id="ef-ss" placeholder="e.g. Target's individual sanity"></div>
id="ef-ss" placeholder="z.B. Target's individual sanity"></div>
</div>
<div class="form-group"><label class="form-label">Evidence (select exactly 3)</label>
<div class="form-group"><label class="form-label">Evidence (genau 3 auswählen)</label>
<div class="ev-checks" id="ef-evs"></div>
</div>
<div class="form-row full">
<div class="form-group"><label class="form-label">Short Tip (Ghost Card)</label><textarea
<div class="form-group"><label class="form-label">Kurz-Tip (Ghost Card)</label><textarea
class="form-textarea" id="ef-tip" rows="2"></textarea></div>
</div>
<div class="form-row full">
<div class="form-group"><label class="form-label">Description (Wiki)</label><textarea class="form-textarea"
<div class="form-group"><label class="form-label">Beschreibung (Wiki)</label><textarea class="form-textarea"
id="ef-desc" rows="3"></textarea></div>
</div>
<div class="form-group">
<label class="form-label">Identification Tells</label>
<div class="tells-editor" id="ef-tells"></div>
<button class="add-tell-btn" id="btn-add-tell">+ Add tell</button>
<button class="add-tell-btn" id="btn-add-tell">+ Tell hinzufügen</button>
</div>
<div class="form-footer">
<button class="btn-sm" id="btn-cancel-edit">Cancel</button>
<button class="btn-accent" id="btn-save-ghost">Save</button>
<button class="btn-sm" id="btn-cancel-edit">Abbrechen</button>
<button class="btn-accent" id="btn-save-ghost">Speichern</button>
</div>
</div>
</div>
@ -2255,7 +2256,7 @@
<div class="modal-box" style="max-width:600px;">
<button class="modal-close" id="close-credits-modal" onclick="closeModal('credits-modal')"></button>
<div class="modal-hd">
<div class="modal-title" id="cm-title">Add Entry</div>
<div class="modal-title" id="cm-title">Eintrag hinzufügen</div>
<div class="modal-sub">Admin · Credits Editor</div>
</div>
<div class="modal-body">
@ -2263,10 +2264,10 @@
<div class="form-row">
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="cf-name" placeholder="e.g. GhostHunter99">
<input type="text" class="form-input" id="cf-name" placeholder="z.B. GhostHunter99">
</div>
<div class="form-group">
<label class="form-label">Type</label>
<label class="form-label">Typ</label>
<select class="form-select" id="cf-type">
<option value="credit">✦ Credit</option>
<option value="partner">⭐ Partner</option>
@ -2275,8 +2276,8 @@
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Role / Title</label>
<input type="text" class="form-input" id="cf-role" placeholder="e.g. Wiki-Author, Community Manager">
<label class="form-label">Rolle / Titel</label>
<input type="text" class="form-input" id="cf-role" placeholder="z.B. Wiki-Autor, Community Manager">
</div>
<div class="form-group">
<label class="form-label">Avatar URL (optional)</label>
@ -2285,20 +2286,20 @@
</div>
<div class="form-row full">
<div class="form-group">
<label class="form-label">Emoji / Icon (if no Avatar)</label>
<label class="form-label">Emoji / Icon (falls kein Avatar)</label>
<input type="text" class="form-input" id="cf-emoji" placeholder="👻" maxlength="4" style="width:80px;">
</div>
</div>
<div class="form-row full">
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="cf-desc" rows="2" placeholder="Short description..."></textarea>
<label class="form-label">Beschreibung</label>
<textarea class="form-textarea" id="cf-desc" rows="2" placeholder="Kurze Beschreibung..."></textarea>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Discord (optional)</label>
<input type="text" class="form-input" id="cf-discord" placeholder="Username or server link">
<input type="text" class="form-input" id="cf-discord" placeholder="Username oder Server-Link">
</div>
<div class="form-group">
<label class="form-label">Website (optional)</label>
@ -2307,13 +2308,13 @@
</div>
<div class="form-row full">
<div class="form-group">
<label class="form-label">Roblox Profile URL (optional)</label>
<label class="form-label">Roblox Profil URL (optional)</label>
<input type="text" class="form-input" id="cf-roblox" placeholder="https://www.roblox.com/users/...">
</div>
</div>
<div class="form-footer">
<button class="btn-sm" id="btn-cancel-credits" onclick="closeModal('credits-modal')">Cancel</button>
<button class="btn-accent" id="btn-save-credit" onclick="saveCreditEntry()">Save</button>
<button class="btn-sm" id="btn-cancel-credits" onclick="closeModal('credits-modal')">Abbrechen</button>
<button class="btn-accent" id="btn-save-credit" onclick="saveCreditEntry()">Speichern</button>
</div>
</div>
</div>
@ -2322,10 +2323,10 @@
<div class="confirm-bg" id="confirm-bg">
<div class="confirm-box">
<div class="confirm-title" id="confirm-title">Sure?</div>
<div class="confirm-title" id="confirm-title">Sicher?</div>
<div class="confirm-text" id="confirm-text"></div>
<div class="confirm-btns"><button class="btn-sm">Cancel</button><button class="btn-danger"
id="confirm-ok">Confirm</button></div>
<div class="confirm-btns"><button class="btn-sm">Abbrechen</button><button class="btn-danger"
id="confirm-ok">Bestätigen</button></div>
</div>
</div>
<div class="toast-wrap" id="toast-wrap"></div>
@ -2396,7 +2397,6 @@
if (u.isAdmin) document.getElementById('pill-admin').style.display = 'block';
buildCards(); buildCheat();
populateGhostSelect();
loadCredits();
if (u.isAdmin) updateAdminBadge();
// Credits-Tab sofort vorbereiten falls aktiv
if (document.getElementById('vtab-credits')?.classList.contains('active')) renderCredits();
@ -2405,7 +2405,6 @@
function setupGuestUI() {
document.getElementById('guest-badge').style.display = 'block';
buildCards(); buildCheat();
loadCredits();
}
/* ── AUTH ── */
@ -2595,18 +2594,18 @@
}
async function approveSub(id) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); toast("Accepted ✓", "success"); renderAdmin(); }
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); toast("Angenommen ✓", "success"); renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
}
async function rejectSub(id) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'rejected' }); toast("Rejected ✕", "danger"); renderAdmin(); }
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'rejected' }); toast("Abgelehnt.", "danger"); renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
}
async function injectSub(id, ghostName) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); } catch { }
const g = ghosts.find(x => x.name === ghostName);
openGhostForm(g?.id || null);
setTimeout(() => toast("💡 Show submission info: check the tip!", "info"), 300);
setTimeout(() => toast("💡 Submission-Info anzeigen: prüfe den Tip!", "info"), 300);
renderAdmin();
}
@ -2614,8 +2613,8 @@
function openGhostForm(id) {
const g = id ? ghosts.find(x => x.id === id) : null;
editingId = id || null;
document.getElementById('em-title').textContent = g ? `Edit: ${g.name}` : 'New Ghost';
document.getElementById('em-sub').textContent = g ? `Admin · Ghost Editor · ID: ${g.id}` : 'Admin · Ghost Editor · New';
document.getElementById('em-title').textContent = g ? `Bearbeiten: ${g.name}` : 'Neuen Geist erstellen';
document.getElementById('em-sub').textContent = g ? `Admin · Ghost Editor · ID: ${g.id}` : 'Admin · Ghost Editor · Neu';
document.getElementById('ef-name').value = g?.name || '';
document.getElementById('ef-sanity').value = g?.sanity ?? 50;
document.getElementById('ef-hunt').value = g?.hunt || 'mid';
@ -2692,19 +2691,19 @@
const ul = document.getElementById('user-sub-list');
try {
const subs = await api('GET', '/api/submissions/mine');
if (!subs.length) { ul.innerHTML = '<div class="empty-state">📭 You have not submitted anything yet.</div>'; return; }
if (!subs.length) { ul.innerHTML = '<div class="empty-state">📭 Du hast noch nichts eingereicht.</div>'; return; }
ul.innerHTML = '';
subs.forEach(sub => {
const tl = sub.type === 'tip' ? '💡 Tip' : sub.type === 'edit' ? '✏️ Korrektur' : '🆕 Neuer Geist';
const sc = sub.status === 'approved' ? 'ssa' : sub.status === 'rejected' ? 'ssr' : 'ssp';
const sl = sub.status === 'approved' ? '✓ Accepted' : sub.status === 'rejected' ? '✕ Rejected' : '⏳ Waiting';
const sl = sub.status === 'approved' ? '✓ Angenommen' : sub.status === 'rejected' ? '✕ Abgelehnt' : '⏳ Ausstehend';
const d = document.createElement('div'); d.className = 'usi';
d.innerHTML = `<div class="usi-meta"><span style="font-family:'Cinzel',serif;font-size:13px;color:var(--accent2)">${escH(sub.ghost_name || 'Allgemein')}</span><span style="font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted2)">${tl}</span><span class="usi-st ${sc}">${sl}</span><span class="sub-time">${fmtTime(sub.created_at)}</span></div><div style="font-size:13.5px;color:var(--muted2);line-height:1.55;font-style:italic">${escH(sub.content)}</div>${sub.admin_note ? `<div style="font-size:12px;color:var(--accent2);margin-top:6px">Admin: ${escH(sub.admin_note)}</div>` : ''}`;
ul.appendChild(d);
});
const approved = subs.filter(s => s.status === 'approved').length;
const ub = document.getElementById('badge-user'); ub.textContent = approved; ub.classList.toggle('show', approved > 0);
} catch { ul.innerHTML = '<div class="empty-state">Failed to load.</div>'; }
} catch { ul.innerHTML = '<div class="empty-state">Fehler beim Laden.</div>'; }
}
/* ── EXPORT ── */
@ -2736,17 +2735,17 @@
let credits = [];
let editingCreditId = null;
// Load from localStorage (no separate API needed — admin manages locally)
function loadCredits() {
try { credits = JSON.parse(localStorage.getItem('blair_credits') || '[]'); }
catch { credits = []; }
}
function saveCredits() {
localStorage.setItem('blair_credits', JSON.stringify(credits));
async function loadCredits() {
try {
credits = await api('GET', '/api/credits');
} catch (e) {
console.error('Credits laden fehlgeschlagen:', e);
credits = [];
}
}
function renderCredits() {
loadCredits();
async function renderCredits() {
await loadCredits();
const partners = credits.filter(c => c.type === 'partner');
const crds = credits.filter(c => c.type === 'credit');
@ -2809,7 +2808,7 @@
</div>
${c.type === 'partner' ? '<span class="partner-badge">Partner</span>' : ''}
</div>
${c.desc ? `<div class="credit-desc">${escH(c.desc)}</div>` : ''}
${(c.desc || c.description) ? `<div class="credit-desc">${escH(c.desc || c.description)}</div>` : ''}
${linksHtml ? `<div class="credit-links">${linksHtml}</div>` : ''}
${adminBtns}`;
@ -2825,14 +2824,14 @@
document.getElementById('cf-role').value = c?.role || '';
document.getElementById('cf-avatar').value = c?.avatar || '';
document.getElementById('cf-emoji').value = c?.emoji || '';
document.getElementById('cf-desc').value = c?.desc || '';
document.getElementById('cf-desc').value = c?.desc || c?.description || '';
document.getElementById('cf-discord').value = c?.discord || '';
document.getElementById('cf-website').value = c?.website || '';
document.getElementById('cf-roblox').value = c?.roblox || '';
openModal('credits-modal');
}
function saveCreditEntry() {
async function saveCreditEntry() {
const name = document.getElementById('cf-name').value.trim();
if (!name) { toast("Name darf nicht leer sein!", "danger"); return; }
@ -2843,33 +2842,41 @@
role: document.getElementById('cf-role').value.trim(),
avatar: document.getElementById('cf-avatar').value.trim(),
emoji: document.getElementById('cf-emoji').value.trim() || '👤',
desc: document.getElementById('cf-desc').value.trim(),
description: document.getElementById('cf-desc').value.trim(),
discord: document.getElementById('cf-discord').value.trim(),
website: document.getElementById('cf-website').value.trim(),
roblox: document.getElementById('cf-roblox').value.trim(),
};
if (editingCreditId) {
const idx = credits.findIndex(c => c.id === editingCreditId);
if (idx > -1) credits[idx] = entry;
} else {
credits.push(entry);
showLoading("Speichere...");
try {
if (editingCreditId) {
await api('PUT', `/admin/credits/${editingCreditId}`, entry);
} else {
await api('POST', '/admin/credits', entry);
}
closeModal('credits-modal');
await renderCredits();
toast(`✓ ${name} gespeichert!`, "success");
} catch (e) {
toast(e.message, "danger");
}
saveCredits();
closeModal('credits-modal');
renderCredits();
toast(`✓ ${name} gespeichert!`, "success");
hideLoading();
}
function confirmDeleteCredit(id) {
const c = credits.find(x => x.id === id);
if (!c) return;
showConfirm(`${c.name} entfernen?`, 'Dieser Eintrag wird aus Credits & Partner entfernt.', () => {
credits = credits.filter(x => x.id !== id);
saveCredits();
renderCredits();
toast(`${c.name} entfernt.`, "danger");
showConfirm(`${c.name} entfernen?`, 'Dieser Eintrag wird aus Credits & Partner entfernt.', async () => {
showLoading("Lösche...");
try {
await api('DELETE', `/admin/credits/${id}`);
await renderCredits();
toast(`${c.name} entfernt.`, "danger");
} catch (e) {
toast(e.message, "danger");
}
hideLoading();
});
}

View File

@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const router = express.Router();
const db = require('../db');
const { requireAdmin } = require('../middleware/auth');
// Alle Admin-Routen erfordern Admin-Zugriff
@ -10,11 +10,11 @@ router.use(requireAdmin);
router.get('/stats', async (req, res) => {
try {
const [[{ ghosts }]] = await db.execute('SELECT COUNT(*) as ghosts FROM ghosts');
const [[{ total }]] = await db.execute('SELECT COUNT(*) as total FROM submissions');
const [[{ ghosts }]] = await db.execute('SELECT COUNT(*) as ghosts FROM ghosts');
const [[{ total }]] = await db.execute('SELECT COUNT(*) as total FROM submissions');
const [[{ pending }]] = await db.execute("SELECT COUNT(*) as pending FROM submissions WHERE status='pending'");
const [[{ approved }]]= await db.execute("SELECT COUNT(*) as approved FROM submissions WHERE status='approved'");
const [[{ rejected }]]= await db.execute("SELECT COUNT(*) as rejected FROM submissions WHERE status='rejected'");
const [[{ approved }]] = await db.execute("SELECT COUNT(*) as approved FROM submissions WHERE status='approved'");
const [[{ rejected }]] = await db.execute("SELECT COUNT(*) as rejected FROM submissions WHERE status='rejected'");
res.json({ ghosts, total, pending, approved, rejected });
} catch (e) {
res.status(500).json({ error: 'DB-Fehler' });
@ -33,9 +33,9 @@ router.post('/ghosts', async (req, res) => {
await db.query(
`INSERT INTO ghosts (id, name, evidence, sanity, hunt, sanity_special, tip, description, tells, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, name, JSON.stringify(evidence), sanity||50, hunt||'mid',
sanity_special||'', tip||'', description||'', JSON.stringify(tells||[]),
req.user.username]
[id, name, JSON.stringify(evidence), sanity || 50, hunt || 'mid',
sanity_special || '', tip || '', description || '', JSON.stringify(tells || []),
req.user.username]
);
res.json({ ok: true });
} catch (e) {
@ -55,9 +55,9 @@ router.put('/ghosts/:id', async (req, res) => {
const result = await db.query(
`UPDATE ghosts SET name=?, evidence=?, sanity=?, hunt=?, sanity_special=?,
tip=?, description=?, tells=?, updated_by=? WHERE id=?`,
[name, JSON.stringify(evidence), sanity||50, hunt||'mid',
sanity_special||'', tip||'', description||'', JSON.stringify(tells||[]),
req.user.username, req.params.id]
[name, JSON.stringify(evidence), sanity || 50, hunt || 'mid',
sanity_special || '', tip || '', description || '', JSON.stringify(tells || []),
req.user.username, req.params.id]
);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Geist nicht gefunden' });
res.json({ ok: true });
@ -96,11 +96,11 @@ router.get('/submissions', async (req, res) => {
// Submission reviewen (approve/reject)
router.patch('/submissions/:id', async (req, res) => {
const { status, admin_note } = req.body;
if (!['approved','rejected'].includes(status)) return res.status(400).json({ error: 'Status muss approved oder rejected sein' });
if (!['approved', 'rejected'].includes(status)) return res.status(400).json({ error: 'Status muss approved oder rejected sein' });
try {
await db.query(
`UPDATE submissions SET status=?, admin_note=?, reviewed_at=NOW(), reviewed_by=? WHERE id=?`,
[status, admin_note||'', req.user.username, req.params.id]
[status, admin_note || '', req.user.username, req.params.id]
);
res.json({ ok: true });
} catch (e) {
@ -127,7 +127,7 @@ router.post('/whitelist', async (req, res) => {
try {
await db.query(
'INSERT INTO admin_whitelist (discord_id, discord_username, added_by) VALUES (?,?,?)',
[discord_id.trim(), discord_username||discord_id, req.user.username]
[discord_id.trim(), discord_username || discord_id, req.user.username]
);
res.json({ ok: true });
} catch (e) {
@ -150,3 +150,53 @@ router.delete('/whitelist/:discord_id', async (req, res) => {
});
module.exports = router;
// ── Credits CRUD (admin only) ─────────────────────────
// Erstellen
router.post('/credits', async (req, res) => {
const { id, name, type, role, avatar, emoji, description, discord, website, roblox, sort_order } = req.body;
if (!id || !name) return res.status(400).json({ error: 'id und name erforderlich' });
try {
await db.query(
`INSERT INTO credits (id, name, type, role, avatar, emoji, description, discord, website, roblox, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, name, type || 'credit', role || '', avatar || '', emoji || '👤',
description || '', discord || '', website || '', roblox || '', sort_order || 0]
);
res.json({ ok: true });
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'ID bereits vorhanden' });
console.error(e);
res.status(500).json({ error: 'DB-Fehler' });
}
});
// Bearbeiten
router.put('/credits/:id', async (req, res) => {
const { name, type, role, avatar, emoji, description, discord, website, roblox, sort_order } = req.body;
if (!name) return res.status(400).json({ error: 'name erforderlich' });
try {
const result = await db.query(
`UPDATE credits SET name=?, type=?, role=?, avatar=?, emoji=?, description=?,
discord=?, website=?, roblox=?, sort_order=? WHERE id=?`,
[name, type || 'credit', role || '', avatar || '', emoji || '👤',
description || '', discord || '', website || '', roblox || '', sort_order || 0, req.params.id]
);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ ok: true });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'DB-Fehler' });
}
});
// Löschen
router.delete('/credits/:id', async (req, res) => {
try {
await db.query('DELETE FROM credits WHERE id=?', [req.params.id]);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'DB-Fehler' });
}
});

View File

@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const router = express.Router();
const db = require('../db');
const { requireAuth } = require('../middleware/auth');
// ── Ghosts (public read) ──────────────────────────────
@ -12,7 +12,7 @@ router.get('/ghosts', async (req, res) => {
const ghosts = rows.map(row => ({
...row,
evidence: typeof row.evidence === 'string' ? JSON.parse(row.evidence) : row.evidence,
tells: typeof row.tells === 'string' ? JSON.parse(row.tells) : row.tells,
tells: typeof row.tells === 'string' ? JSON.parse(row.tells) : row.tells,
}));
res.json(ghosts);
} catch (e) {
@ -40,7 +40,7 @@ router.get('/submissions/mine', requireAuth, async (req, res) => {
router.post('/submissions', requireAuth, async (req, res) => {
const { ghost_name, type, content } = req.body;
if (!content?.trim()) return res.status(400).json({ error: 'Inhalt fehlt' });
if (!['tip','edit','new'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
if (!['tip', 'edit', 'new'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
try {
await db.query(
@ -56,3 +56,17 @@ router.post('/submissions', requireAuth, async (req, res) => {
});
module.exports = router;
// ── Credits (public read) ─────────────────────────────
router.get('/credits', async (req, res) => {
try {
const rows = await db.query(
'SELECT * FROM credits ORDER BY type DESC, sort_order ASC, created_at ASC'
);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'DB-Fehler' });
}
});

View File

@ -74,6 +74,26 @@ CREATE TABLE IF NOT EXISTS submissions (
INDEX idx_discord_id (discord_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Credits & Partner ──────────────────────────────────
CREATE TABLE IF NOT EXISTS credits (
id VARCHAR(80) NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'credit',
role VARCHAR(150) NOT NULL DEFAULT '',
avatar VARCHAR(255) NOT NULL DEFAULT '',
emoji VARCHAR(10) NOT NULL DEFAULT '👤',
description TEXT NOT NULL DEFAULT '',
discord VARCHAR(255) NOT NULL DEFAULT '',
website VARCHAR(255) NOT NULL DEFAULT '',
roblox VARCHAR(255) NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_sort (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Default Ghosts ─────────────────────────────────────
INSERT IGNORE INTO ghosts (id, name, evidence, sanity, hunt, sanity_special, tip, description, tells) VALUES
('banshee','Banshee','["EMF","Freezing","SLS"]',50,'mid','Target individual sanity — not team average','Special Scream via Parabolic Mic · höhere Wahrscheinlichkeit Weinen','The Banshee chooses a single target and only chases that player until they die, then re-rolls.','[{"icon":"🎙️","text":"<strong>Parabolic scream</strong> audible within ~28 studs"},{"icon":"😢","text":"<strong>Crying Event</strong> more common than most ghosts"},{"icon":"🎯","text":"Ignores all others — only chases its chosen target"},{"icon":"📻","text":"Music Box: ignores non-target if target is inside"}]'),