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-logo">BLAIR</div>
<div class="login-sub">Ghost Investigation Dashboard</div> <div class="login-sub">Ghost Investigation Dashboard</div>
<div class="login-desc"> <div class="login-desc">
Login with discord to submit <Strong>Tips</strong> and check the status of your submissions.<br><br> Melde dich mit Discord an, um <strong>Tips einzureichen</strong> und den Status deiner Einsendungen zu
<strong>Admin access</strong> will only be granted to unlocked Discord accounts. sehen.<br><br>
<strong>Admin-Zugriff</strong> wird nur für freigeschaltete Discord-Accounts gewährt.
</div> </div>
<a class="btn-discord" href="/auth/discord" style="text-decoration:none;"> <a class="btn-discord" href="/auth/discord" style="text-decoration:none;">
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
<path <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" /> 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> </svg>
Login with Discord Mit Discord anmelden
</a> </a>
<span class="login-or">or</span> <span class="login-or">oder</span>
<button class="btn-sm" id="guest-btn">Login with discord</button> <button class="btn-sm" id="guest-btn">Als Gast fortfahren (nur lesen)</button>
<div class="login-guest-note">Guests can only view Ghost Cards, Cheatsheet and Hunt Guide.</div> <div class="login-guest-note">Gäste können nur Ghost Cards, Cheatsheet und Hunt Guide sehen.</div>
</div> </div>
</div> </div>
@ -1941,14 +1942,14 @@
<div class="header-inner"> <div class="header-inner">
<div> <div>
<div class="site-title">BLAIR</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>
<div class="header-right"> <div class="header-right">
<div class="auth-chip" id="auth-chip" style="display:none;"> <div class="auth-chip" id="auth-chip" style="display:none;">
<img class="auth-avatar" id="auth-av" src="" alt=""> <img class="auth-avatar" id="auth-av" src="" alt="">
<span class="auth-name" id="auth-nm"></span> <span class="auth-name" id="auth-nm"></span>
<span class="auth-role" id="auth-rl"></span> <span class="auth-role" id="auth-rl"></span>
<button class="btn-logout">Logout</button> <button class="btn-logout">Abmelden</button>
</div> </div>
<span id="guest-badge" <span id="guest-badge"
style="display:none;font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;color:var(--muted);">GAST</span> 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> </nav>
<div class="panel active" id="vtab-cards"> <div class="panel active" id="vtab-cards">
<div class="filter-row" id="ev-filter"> <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>
<div class="ghost-grid" id="ghost-grid"></div> <div class="ghost-grid" id="ghost-grid"></div>
</div> </div>
@ -2151,35 +2152,35 @@
<div class="user-title">✦ Community Hub</div> <div class="user-title">✦ Community Hub</div>
<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;"> 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-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-grid">
<div class="form-row"> <div class="form-row">
<div class="form-group"><label class="form-label">Ghost</label><select class="form-select" id="usr-ghost"> <div class="form-group"><label class="form-label">Geist</label><select class="form-select" id="usr-ghost">
<option value="">select ghost</option> <option value="">Geist auswählen</option>
</select></div> </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"> <select class="form-select" id="usr-type">
<option value="tip">💡 New tip / trick</option> <option value="tip">💡 Neuer Tip / Trick</option>
<option value="edit">✏️ Error / correction</option> <option value="edit">✏️ Fehler / Korrektur</option>
<option value="new">🆕 Ghost missing</option> <option value="new">🆕 Neuer Geist fehlt</option>
</select> </select>
</div> </div>
</div> </div>
<div class="form-row full"> <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" <textarea class="form-textarea" id="usr-content"
placeholder="Describe your tip as detailed as possible..." rows="4"></textarea> placeholder="Beschreib deinen Tip so genau wie möglich..." rows="4"></textarea>
<span class="form-hint">The more detailed, the better for the admin!</span> <span class="form-hint">Je detaillierter, desto besser für den Admin!</span>
</div> </div>
</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> </div>
<div class="divider"> <div class="divider">
<div class="divider-line"></div> <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 class="divider-line"></div>
</div> </div>
<div id="user-sub-list"></div> <div id="user-sub-list"></div>
@ -2201,7 +2202,7 @@
<div class="modal-bg" id="edit-modal"> <div class="modal-bg" id="edit-modal">
<div class="modal-box wide"><button class="modal-close" id="close-edit-modal"></button> <div class="modal-box wide"><button class="modal-close" id="close-edit-modal"></button>
<div class="modal-hd"> <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 class="modal-sub" id="em-sub">Admin · Ghost Editor</div>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -2222,27 +2223,27 @@
</select> </select>
</div> </div>
<div class="form-group"><label class="form-label">Sanity Note</label><input type="text" class="form-input" <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>
<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 class="ev-checks" id="ef-evs"></div>
</div> </div>
<div class="form-row full"> <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> class="form-textarea" id="ef-tip" rows="2"></textarea></div>
</div> </div>
<div class="form-row full"> <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> id="ef-desc" rows="3"></textarea></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Identification Tells</label> <label class="form-label">Identification Tells</label>
<div class="tells-editor" id="ef-tells"></div> <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>
<div class="form-footer"> <div class="form-footer">
<button class="btn-sm" id="btn-cancel-edit">Cancel</button> <button class="btn-sm" id="btn-cancel-edit">Abbrechen</button>
<button class="btn-accent" id="btn-save-ghost">Save</button> <button class="btn-accent" id="btn-save-ghost">Speichern</button>
</div> </div>
</div> </div>
</div> </div>
@ -2255,7 +2256,7 @@
<div class="modal-box" style="max-width:600px;"> <div class="modal-box" style="max-width:600px;">
<button class="modal-close" id="close-credits-modal" onclick="closeModal('credits-modal')"></button> <button class="modal-close" id="close-credits-modal" onclick="closeModal('credits-modal')"></button>
<div class="modal-hd"> <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 class="modal-sub">Admin · Credits Editor</div>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -2263,10 +2264,10 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Name</label> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Type</label> <label class="form-label">Typ</label>
<select class="form-select" id="cf-type"> <select class="form-select" id="cf-type">
<option value="credit">✦ Credit</option> <option value="credit">✦ Credit</option>
<option value="partner">⭐ Partner</option> <option value="partner">⭐ Partner</option>
@ -2275,8 +2276,8 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Role / Title</label> <label class="form-label">Rolle / Titel</label>
<input type="text" class="form-input" id="cf-role" placeholder="e.g. Wiki-Author, Community Manager"> <input type="text" class="form-input" id="cf-role" placeholder="z.B. Wiki-Autor, Community Manager">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Avatar URL (optional)</label> <label class="form-label">Avatar URL (optional)</label>
@ -2285,20 +2286,20 @@
</div> </div>
<div class="form-row full"> <div class="form-row full">
<div class="form-group"> <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;"> <input type="text" class="form-input" id="cf-emoji" placeholder="👻" maxlength="4" style="width:80px;">
</div> </div>
</div> </div>
<div class="form-row full"> <div class="form-row full">
<div class="form-group"> <div class="form-group">
<label class="form-label">Description</label> <label class="form-label">Beschreibung</label>
<textarea class="form-textarea" id="cf-desc" rows="2" placeholder="Short description..."></textarea> <textarea class="form-textarea" id="cf-desc" rows="2" placeholder="Kurze Beschreibung..."></textarea>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Discord (optional)</label> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Website (optional)</label> <label class="form-label">Website (optional)</label>
@ -2307,13 +2308,13 @@
</div> </div>
<div class="form-row full"> <div class="form-row full">
<div class="form-group"> <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/..."> <input type="text" class="form-input" id="cf-roblox" placeholder="https://www.roblox.com/users/...">
</div> </div>
</div> </div>
<div class="form-footer"> <div class="form-footer">
<button class="btn-sm" id="btn-cancel-credits" onclick="closeModal('credits-modal')">Cancel</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()">Save</button> <button class="btn-accent" id="btn-save-credit" onclick="saveCreditEntry()">Speichern</button>
</div> </div>
</div> </div>
</div> </div>
@ -2322,10 +2323,10 @@
<div class="confirm-bg" id="confirm-bg"> <div class="confirm-bg" id="confirm-bg">
<div class="confirm-box"> <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-text" id="confirm-text"></div>
<div class="confirm-btns"><button class="btn-sm">Cancel</button><button class="btn-danger" <div class="confirm-btns"><button class="btn-sm">Abbrechen</button><button class="btn-danger"
id="confirm-ok">Confirm</button></div> id="confirm-ok">Bestätigen</button></div>
</div> </div>
</div> </div>
<div class="toast-wrap" id="toast-wrap"></div> <div class="toast-wrap" id="toast-wrap"></div>
@ -2396,7 +2397,6 @@
if (u.isAdmin) document.getElementById('pill-admin').style.display = 'block'; if (u.isAdmin) document.getElementById('pill-admin').style.display = 'block';
buildCards(); buildCheat(); buildCards(); buildCheat();
populateGhostSelect(); populateGhostSelect();
loadCredits();
if (u.isAdmin) updateAdminBadge(); if (u.isAdmin) updateAdminBadge();
// Credits-Tab sofort vorbereiten falls aktiv // Credits-Tab sofort vorbereiten falls aktiv
if (document.getElementById('vtab-credits')?.classList.contains('active')) renderCredits(); if (document.getElementById('vtab-credits')?.classList.contains('active')) renderCredits();
@ -2405,7 +2405,6 @@
function setupGuestUI() { function setupGuestUI() {
document.getElementById('guest-badge').style.display = 'block'; document.getElementById('guest-badge').style.display = 'block';
buildCards(); buildCheat(); buildCards(); buildCheat();
loadCredits();
} }
/* ── AUTH ── */ /* ── AUTH ── */
@ -2595,18 +2594,18 @@
} }
async function approveSub(id) { 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"); } catch (e) { toast(e.message, "danger"); }
} }
async function rejectSub(id) { 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"); } catch (e) { toast(e.message, "danger"); }
} }
async function injectSub(id, ghostName) { async function injectSub(id, ghostName) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); } catch { } try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); } catch { }
const g = ghosts.find(x => x.name === ghostName); const g = ghosts.find(x => x.name === ghostName);
openGhostForm(g?.id || null); 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(); renderAdmin();
} }
@ -2614,8 +2613,8 @@
function openGhostForm(id) { function openGhostForm(id) {
const g = id ? ghosts.find(x => x.id === id) : null; const g = id ? ghosts.find(x => x.id === id) : null;
editingId = id || null; editingId = id || null;
document.getElementById('em-title').textContent = g ? `Edit: ${g.name}` : 'New Ghost'; 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 · New'; 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-name').value = g?.name || '';
document.getElementById('ef-sanity').value = g?.sanity ?? 50; document.getElementById('ef-sanity').value = g?.sanity ?? 50;
document.getElementById('ef-hunt').value = g?.hunt || 'mid'; document.getElementById('ef-hunt').value = g?.hunt || 'mid';
@ -2692,19 +2691,19 @@
const ul = document.getElementById('user-sub-list'); const ul = document.getElementById('user-sub-list');
try { try {
const subs = await api('GET', '/api/submissions/mine'); 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 = ''; ul.innerHTML = '';
subs.forEach(sub => { subs.forEach(sub => {
const tl = sub.type === 'tip' ? '💡 Tip' : sub.type === 'edit' ? '✏️ Korrektur' : '🆕 Neuer Geist'; const tl = sub.type === 'tip' ? '💡 Tip' : sub.type === 'edit' ? '✏️ Korrektur' : '🆕 Neuer Geist';
const sc = sub.status === 'approved' ? 'ssa' : sub.status === 'rejected' ? 'ssr' : 'ssp'; 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'; 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>` : ''}`; 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); ul.appendChild(d);
}); });
const approved = subs.filter(s => s.status === 'approved').length; const approved = subs.filter(s => s.status === 'approved').length;
const ub = document.getElementById('badge-user'); ub.textContent = approved; ub.classList.toggle('show', approved > 0); 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 ── */ /* ── EXPORT ── */
@ -2736,17 +2735,17 @@
let credits = []; let credits = [];
let editingCreditId = null; let editingCreditId = null;
// Load from localStorage (no separate API needed — admin manages locally) async function loadCredits() {
function loadCredits() { try {
try { credits = JSON.parse(localStorage.getItem('blair_credits') || '[]'); } credits = await api('GET', '/api/credits');
catch { credits = []; } } catch (e) {
console.error('Credits laden fehlgeschlagen:', e);
credits = [];
} }
function saveCredits() {
localStorage.setItem('blair_credits', JSON.stringify(credits));
} }
function renderCredits() { async function renderCredits() {
loadCredits(); await loadCredits();
const partners = credits.filter(c => c.type === 'partner'); const partners = credits.filter(c => c.type === 'partner');
const crds = credits.filter(c => c.type === 'credit'); const crds = credits.filter(c => c.type === 'credit');
@ -2809,7 +2808,7 @@
</div> </div>
${c.type === 'partner' ? '<span class="partner-badge">Partner</span>' : ''} ${c.type === 'partner' ? '<span class="partner-badge">Partner</span>' : ''}
</div> </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>` : ''} ${linksHtml ? `<div class="credit-links">${linksHtml}</div>` : ''}
${adminBtns}`; ${adminBtns}`;
@ -2825,14 +2824,14 @@
document.getElementById('cf-role').value = c?.role || ''; document.getElementById('cf-role').value = c?.role || '';
document.getElementById('cf-avatar').value = c?.avatar || ''; document.getElementById('cf-avatar').value = c?.avatar || '';
document.getElementById('cf-emoji').value = c?.emoji || ''; 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-discord').value = c?.discord || '';
document.getElementById('cf-website').value = c?.website || ''; document.getElementById('cf-website').value = c?.website || '';
document.getElementById('cf-roblox').value = c?.roblox || ''; document.getElementById('cf-roblox').value = c?.roblox || '';
openModal('credits-modal'); openModal('credits-modal');
} }
function saveCreditEntry() { async function saveCreditEntry() {
const name = document.getElementById('cf-name').value.trim(); const name = document.getElementById('cf-name').value.trim();
if (!name) { toast("Name darf nicht leer sein!", "danger"); return; } if (!name) { toast("Name darf nicht leer sein!", "danger"); return; }
@ -2843,33 +2842,41 @@
role: document.getElementById('cf-role').value.trim(), role: document.getElementById('cf-role').value.trim(),
avatar: document.getElementById('cf-avatar').value.trim(), avatar: document.getElementById('cf-avatar').value.trim(),
emoji: document.getElementById('cf-emoji').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(), discord: document.getElementById('cf-discord').value.trim(),
website: document.getElementById('cf-website').value.trim(), website: document.getElementById('cf-website').value.trim(),
roblox: document.getElementById('cf-roblox').value.trim(), roblox: document.getElementById('cf-roblox').value.trim(),
}; };
showLoading("Speichere...");
try {
if (editingCreditId) { if (editingCreditId) {
const idx = credits.findIndex(c => c.id === editingCreditId); await api('PUT', `/admin/credits/${editingCreditId}`, entry);
if (idx > -1) credits[idx] = entry;
} else { } else {
credits.push(entry); await api('POST', '/admin/credits', entry);
} }
saveCredits();
closeModal('credits-modal'); closeModal('credits-modal');
renderCredits(); await renderCredits();
toast(`✓ ${name} gespeichert!`, "success"); toast(`✓ ${name} gespeichert!`, "success");
} catch (e) {
toast(e.message, "danger");
}
hideLoading();
} }
function confirmDeleteCredit(id) { function confirmDeleteCredit(id) {
const c = credits.find(x => x.id === id); const c = credits.find(x => x.id === id);
if (!c) return; if (!c) return;
showConfirm(`${c.name} entfernen?`, 'Dieser Eintrag wird aus Credits & Partner entfernt.', () => { showConfirm(`${c.name} entfernen?`, 'Dieser Eintrag wird aus Credits & Partner entfernt.', async () => {
credits = credits.filter(x => x.id !== id); showLoading("Lösche...");
saveCredits(); try {
renderCredits(); await api('DELETE', `/admin/credits/${id}`);
await renderCredits();
toast(`${c.name} entfernt.`, "danger"); toast(`${c.name} entfernt.`, "danger");
} catch (e) {
toast(e.message, "danger");
}
hideLoading();
}); });
} }

View File

@ -150,3 +150,53 @@ router.delete('/whitelist/:discord_id', async (req, res) => {
}); });
module.exports = router; 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

@ -56,3 +56,17 @@ router.post('/submissions', requireAuth, async (req, res) => {
}); });
module.exports = router; 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) INDEX idx_discord_id (discord_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 ───────────────────────────────────── -- ─── Default Ghosts ─────────────────────────────────────
INSERT IGNORE INTO ghosts (id, name, evidence, sanity, hunt, sanity_special, tip, description, tells) VALUES 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"}]'), ('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"}]'),