diff --git a/public/index.html b/public/index.html index f4ce463..ca8dbae 100644 --- a/public/index.html +++ b/public/index.html @@ -1913,19 +1913,20 @@
Ghost Investigation Dashboard
- Login with discord to submit Tips and check the status of your submissions.

- Admin access will only be granted to unlocked Discord accounts. + Melde dich mit Discord an, um Tips einzureichen und den Status deiner Einsendungen zu + sehen.

+ Admin-Zugriff wird nur für freigeschaltete Discord-Accounts gewährt.
- Login with Discord + Mit Discord anmelden - or - -
Guests can only view Ghost Cards, Cheatsheet and Hunt Guide.
+ oder + +
Gäste können nur Ghost Cards, Cheatsheet und Hunt Guide sehen.
@@ -1941,14 +1942,14 @@
BLAIR
-
Ghost Investigation Dashboard  ·  Blair Cheatsheet
+
Ghost Investigation Dashboard  ·  Roblox
@@ -1973,7 +1974,7 @@
-
Evidence Filter — single click: found  ·  double click: excluded
+
Evidence Filter — einmal: gefunden  ·  zweimal: ausgeschlossen
@@ -2151,35 +2152,35 @@
✦ Community Hub
- submit tips · report errors · add information
+ Tips einreichen · Fehler melden · Infos ergänzen
-
Submit something
+
Etwas einreichen
-
+
-
+
-
+
- The more detailed, the better for the admin! + placeholder="Beschreib deinen Tip so genau wie möglich..." rows="4"> + Je detaillierter, desto besser für den Admin!
- +
-
Your submissions
+
Deine Einreichungen
@@ -2201,7 +2202,7 @@ -
+
-
-
- +
@@ -2255,7 +2256,7 @@ - ${c.desc ? `
${escH(c.desc)}
` : ''} + ${(c.desc || c.description) ? `
${escH(c.desc || c.description)}
` : ''} ${linksHtml ? `` : ''} ${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(); }); } diff --git a/routes/admin.js b/routes/admin.js index 0c63a0a..c226347 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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' }); + } +}); diff --git a/routes/api.js b/routes/api.js index 5326e58..2fceb18 100644 --- a/routes/api.js +++ b/routes/api.js @@ -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' }); + } +}); diff --git a/schema.sql b/schema.sql index ee6d8a1..ddc3514 100644 --- a/schema.sql +++ b/schema.sql @@ -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":"Parabolic scream audible within ~28 studs"},{"icon":"😢","text":"Crying Event 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"}]'),