s
This commit is contained in:
commit
60262323a3
34
.env.example
Normal file
34
.env.example
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# ═══════════════════════════════════════
|
||||||
|
# Blair Dashboard — Environment Config
|
||||||
|
# Kopiere diese Datei zu .env und fülle sie aus
|
||||||
|
# ═══════════════════════════════════════
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Session Secret — beliebiger langer zufälliger String
|
||||||
|
# Generieren mit: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||||
|
SESSION_SECRET=AENDERN_ZU_LANGEM_ZUFALLS_STRING
|
||||||
|
|
||||||
|
# MySQL / MariaDB
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=blair_dashboard
|
||||||
|
DB_USER=blair_user
|
||||||
|
DB_PASS=SICHERES_PASSWORT
|
||||||
|
|
||||||
|
# Discord OAuth App
|
||||||
|
# Erstellen unter: https://discord.com/developers/applications
|
||||||
|
DISCORD_CLIENT_ID=DEINE_CLIENT_ID
|
||||||
|
DISCORD_CLIENT_SECRET=DEIN_CLIENT_SECRET
|
||||||
|
|
||||||
|
# Die URL deines Servers (ohne trailing slash)
|
||||||
|
# Lokal: http://localhost:3000
|
||||||
|
# Produktiv: https://blair.deinedomain.de
|
||||||
|
APP_URL=https://blair.deinedomain.de
|
||||||
|
|
||||||
|
# Deine Discord User ID (wird automatisch als erster Admin eingetragen)
|
||||||
|
# Discord → Einstellungen → Erweitert → Entwicklermodus → Rechtsklick auf dich → ID kopieren
|
||||||
|
OWNER_DISCORD_ID=DEINE_DISCORD_ID
|
||||||
|
OWNER_DISCORD_NAME=DeinDiscordName
|
||||||
233
DEPLOY.md
Normal file
233
DEPLOY.md
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
# Blair Dashboard — Deployment Guide
|
||||||
|
## Node.js + MySQL auf eigenem Linux VPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Voraussetzungen auf dem Server installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js 20 LTS
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# MySQL / MariaDB (falls noch nicht vorhanden)
|
||||||
|
sudo apt install mariadb-server -y
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
|
||||||
|
# PM2 (Prozessmanager — hält den Server am Laufen)
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# Nginx (Reverse Proxy)
|
||||||
|
sudo apt install nginx -y
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Projekt auf den Server übertragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A: Per SCP (von deinem PC aus)
|
||||||
|
scp -r blair-selfhosted/ user@dein-server.de:/var/www/blair
|
||||||
|
|
||||||
|
# Option B: Git (empfohlen für Updates)
|
||||||
|
# Auf dem Server:
|
||||||
|
cd /var/www
|
||||||
|
git clone https://github.com/dein-repo/blair-dashboard blair
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Datenbank einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mysql -u root -p
|
||||||
|
|
||||||
|
# Im MySQL-Prompt:
|
||||||
|
CREATE DATABASE blair_dashboard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'blair_user'@'localhost' IDENTIFIED BY 'SICHERES_PASSWORT_HIER';
|
||||||
|
GRANT ALL PRIVILEGES ON blair_dashboard.* TO 'blair_user'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EXIT;
|
||||||
|
|
||||||
|
# Schema importieren
|
||||||
|
mysql -u blair_user -p blair_dashboard < /var/www/blair/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Discord App erstellen
|
||||||
|
|
||||||
|
1. Gehe zu https://discord.com/developers/applications
|
||||||
|
2. "New Application" → Name: "Blair Dashboard"
|
||||||
|
3. Links → **OAuth2** → Redirects → Add Redirect:
|
||||||
|
```
|
||||||
|
https://deinedomain.de/auth/discord/callback
|
||||||
|
```
|
||||||
|
4. Kopiere **Client ID** und **Client Secret**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. .env Datei erstellen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/blair
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausfüllen:
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
SESSION_SECRET=<langer-zufälliger-string>
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=blair_dashboard
|
||||||
|
DB_USER=blair_user
|
||||||
|
DB_PASS=<dein-db-passwort>
|
||||||
|
DISCORD_CLIENT_ID=<deine-discord-client-id>
|
||||||
|
DISCORD_CLIENT_SECRET=<dein-discord-client-secret>
|
||||||
|
APP_URL=https://deinedomain.de
|
||||||
|
OWNER_DISCORD_ID=<deine-discord-id>
|
||||||
|
OWNER_DISCORD_NAME=<dein-discord-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Session Secret generieren:
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dependencies installieren & starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/blair
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Testlauf (Fehler prüfen)
|
||||||
|
node server.js
|
||||||
|
|
||||||
|
# Wenn alles läuft: mit PM2 als Dienst starten
|
||||||
|
pm2 start server.js --name blair
|
||||||
|
pm2 save
|
||||||
|
pm2 startup # zeigt Befehl an, den du dann als root ausführst
|
||||||
|
```
|
||||||
|
|
||||||
|
PM2 Befehle:
|
||||||
|
```bash
|
||||||
|
pm2 status # Status aller Apps
|
||||||
|
pm2 logs blair # Live-Logs
|
||||||
|
pm2 restart blair # Neustart nach Änderungen
|
||||||
|
pm2 stop blair # Stoppen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Nginx als Reverse Proxy einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/blair
|
||||||
|
```
|
||||||
|
|
||||||
|
Inhalt:
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name deinedomain.de www.deinedomain.de;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/blair /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t # Konfiguration prüfen
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. HTTPS mit Let's Encrypt (kostenlos)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx -y
|
||||||
|
sudo certbot --nginx -d deinedomain.de -d www.deinedomain.de
|
||||||
|
|
||||||
|
# Automatische Erneuerung testen
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Discord OAuth Redirect URL updaten
|
||||||
|
|
||||||
|
Wenn du jetzt HTTPS hast, gehe nochmal zu:
|
||||||
|
https://discord.com/developers/applications → deine App → OAuth2 → Redirects
|
||||||
|
|
||||||
|
Und aktualisiere zu:
|
||||||
|
```
|
||||||
|
https://deinedomain.de/auth/discord/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fertig! 🎃
|
||||||
|
|
||||||
|
Öffne `https://deinedomain.de` im Browser.
|
||||||
|
|
||||||
|
- **Als Admin anmelden:** Discord-Login → deine `OWNER_DISCORD_ID` wird automatisch als erster Admin eingetragen
|
||||||
|
- **Weitere Admins hinzufügen:** Im Admin-Panel → Whitelist-Sektion → Discord ID eingeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
blair-selfhosted/
|
||||||
|
├── server.js ← Express-Server (Einstiegspunkt)
|
||||||
|
├── db.js ← MySQL Connection Pool
|
||||||
|
├── schema.sql ← Datenbankschema + Standardgeister
|
||||||
|
├── package.json
|
||||||
|
├── .env.example ← Kopieren zu .env, ausfüllen
|
||||||
|
├── middleware/
|
||||||
|
│ ├── passport.js ← Discord OAuth + Admin-Check
|
||||||
|
│ └── auth.js ← requireAuth / requireAdmin
|
||||||
|
├── routes/
|
||||||
|
│ ├── auth.js ← /auth/discord, /auth/me, /auth/logout
|
||||||
|
│ ├── api.js ← /api/ghosts, /api/submissions (public)
|
||||||
|
│ └── admin.js ← /admin/* (nur Whitelist-Admins)
|
||||||
|
└── public/
|
||||||
|
└── index.html ← Das komplette Frontend (SPA)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheitsarchitektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│
|
||||||
|
├─ GET /auth/me → Sucht Session → gibt User + isAdmin zurück
|
||||||
|
├─ GET /auth/discord → Startet Discord OAuth
|
||||||
|
├─ GET /auth/discord/callback → Discord bestätigt → Session anlegen
|
||||||
|
│
|
||||||
|
├─ GET /api/ghosts → Öffentlich, kein Auth nötig
|
||||||
|
├─ POST /api/submissions → Nur eingeloggte User (requireAuth)
|
||||||
|
│
|
||||||
|
└─ /admin/* → requireAdmin-Middleware prüft:
|
||||||
|
1. Ist User eingeloggt? (Session)
|
||||||
|
2. Ist Discord-ID in admin_whitelist? (DB-Abfrage)
|
||||||
|
→ NEIN → 403 Forbidden
|
||||||
|
→ JA → Route ausführen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Admin-Prüfung passiert bei JEDEM Request live in der DB** — wenn du jemanden aus der Whitelist entfernst, verliert er sofort den Zugriff (nicht erst beim nächsten Login).
|
||||||
152
admin.js
Normal file
152
admin.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Alle Admin-Routen erfordern Admin-Zugriff
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 [[{ 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'");
|
||||||
|
res.json({ ghosts, total, pending, approved, rejected });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Ghosts CRUD ───────────────────────────────────────
|
||||||
|
|
||||||
|
// Geist erstellen
|
||||||
|
router.post('/ghosts', async (req, res) => {
|
||||||
|
const { id, name, evidence, sanity, hunt, sanity_special, tip, description, tells } = req.body;
|
||||||
|
if (!id || !name || !Array.isArray(evidence) || evidence.length !== 3) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Daten (id, name, genau 3 evidence erforderlich)' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geist bearbeiten
|
||||||
|
router.put('/ghosts/:id', async (req, res) => {
|
||||||
|
const { name, evidence, sanity, hunt, sanity_special, tip, description, tells } = req.body;
|
||||||
|
if (!name || !Array.isArray(evidence) || evidence.length !== 3) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Daten' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Geist nicht gefunden' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geist löschen
|
||||||
|
router.delete('/ghosts/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query('DELETE FROM ghosts WHERE id=?', [req.params.id]);
|
||||||
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Geist nicht gefunden' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Submissions ───────────────────────────────────────
|
||||||
|
|
||||||
|
// Alle Submissions laden (mit Filter)
|
||||||
|
router.get('/submissions', async (req, res) => {
|
||||||
|
const { status } = req.query;
|
||||||
|
try {
|
||||||
|
const rows = status
|
||||||
|
? await db.query('SELECT * FROM submissions WHERE status=? ORDER BY created_at DESC', [status])
|
||||||
|
: await db.query('SELECT * FROM submissions ORDER BY created_at DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin Whitelist ───────────────────────────────────
|
||||||
|
|
||||||
|
// Whitelist laden
|
||||||
|
router.get('/whitelist', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await db.query('SELECT * FROM admin_whitelist ORDER BY added_at ASC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin hinzufügen
|
||||||
|
router.post('/whitelist', async (req, res) => {
|
||||||
|
const { discord_id, discord_username } = req.body;
|
||||||
|
if (!discord_id || discord_id.length < 10) return res.status(400).json({ error: 'Ungültige Discord ID' });
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Bereits in der Whitelist' });
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin entfernen (kann sich nicht selbst entfernen)
|
||||||
|
router.delete('/whitelist/:discord_id', async (req, res) => {
|
||||||
|
if (req.params.discord_id === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Du kannst dich nicht selbst aus der Whitelist entfernen' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.query('DELETE FROM admin_whitelist WHERE discord_id=?', [req.params.discord_id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
58
api.js
Normal file
58
api.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// ── Ghosts (public read) ──────────────────────────────
|
||||||
|
|
||||||
|
// Alle Geister laden
|
||||||
|
router.get('/ghosts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await db.query('SELECT * FROM ghosts ORDER BY name ASC');
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
res.json(ghosts);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Submissions (auth required) ───────────────────────
|
||||||
|
|
||||||
|
// Eigene Submissions laden
|
||||||
|
router.get('/submissions/mine', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT * FROM submissions WHERE discord_id = ? ORDER BY created_at DESC',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Neue Submission einreichen
|
||||||
|
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' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO submissions (ghost_name, type, username, discord_id, content)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[ghost_name || '', type, req.user.username, req.user.id, content.trim()]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'DB-Fehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
42
auth.js
Normal file
42
auth.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const passport = require('passport');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Discord OAuth starten
|
||||||
|
router.get('/discord',
|
||||||
|
passport.authenticate('discord')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Discord OAuth Callback
|
||||||
|
router.get('/discord/callback',
|
||||||
|
passport.authenticate('discord', {
|
||||||
|
failureRedirect: '/?error=login_failed',
|
||||||
|
}),
|
||||||
|
(req, res) => {
|
||||||
|
res.redirect('/');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Abmelden
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
req.logout(() => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aktuellen User zurückgeben (für Frontend)
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.json({ user: null });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
avatar: req.user.avatar,
|
||||||
|
isAdmin: req.user.isAdmin,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
21
db.js
Normal file
21
db.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT) || 3306,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hilfsfunktion für einfachere Abfragen
|
||||||
|
pool.query = async (sql, params) => {
|
||||||
|
const [rows] = await pool.execute(sql, params || []);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
833
index.html
Normal file
833
index.html
Normal file
@ -0,0 +1,833 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Blair — Ghost Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0a0a0f;--bg2:#111118;--bg3:#16161f;--bg4:#1d1d2a;--bg5:#22222e;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--border3:rgba(255,255,255,.22);--text:#e8e4d8;--muted:#7a7568;--muted2:#a09890;--accent:#c8a96e;--accent2:#e8c98e;--red:#c04040;--red2:#e05050;--green:#4a9c6a;--green2:#5ab87e;--blue:#4a7ac0;--blue2:#6a9add;--purple:#8a5ac0;--purple2:#a87add;--discord:#5865F2;--orange2:#e09860;}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{background:var(--bg);color:var(--text);font-family:'Crimson Pro',Georgia,serif;font-size:16px;line-height:1.6;min-height:100vh;}
|
||||||
|
body::before{content:'';position:fixed;inset:0;background-image:radial-gradient(ellipse 80% 60% at 20% 10%,rgba(100,40,140,.06) 0%,transparent 60%),radial-gradient(ellipse 60% 80% at 80% 90%,rgba(40,80,160,.05) 0%,transparent 60%);pointer-events:none;z-index:0;}
|
||||||
|
.wrap{position:relative;z-index:1;max-width:1440px;margin:0 auto;padding:0 24px 80px;}
|
||||||
|
|
||||||
|
/* HEADER */
|
||||||
|
.site-header{padding:34px 0 24px;border-bottom:1px solid var(--border);margin-bottom:0;}
|
||||||
|
.header-inner{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;}
|
||||||
|
.site-title{font-family:'Cinzel',serif;font-size:40px;font-weight:700;letter-spacing:8px;color:var(--accent);text-transform:uppercase;line-height:1;}
|
||||||
|
.site-sub{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:4px;color:var(--muted);text-transform:uppercase;margin-top:5px;}
|
||||||
|
.header-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||||
|
.auth-chip{display:flex;align-items:center;gap:8px;background:var(--bg3);border:1px solid var(--border2);border-radius:3px;padding:7px 13px;}
|
||||||
|
.auth-avatar{width:26px;height:26px;border-radius:50%;object-fit:cover;}
|
||||||
|
.auth-name{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1.5px;color:var(--text);}
|
||||||
|
.auth-role{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;padding:2px 7px;border-radius:2px;margin-left:4px;}
|
||||||
|
.role-admin{background:rgba(192,64,64,.15);color:var(--red2);border:1px solid rgba(192,64,64,.3);}
|
||||||
|
.role-user{background:rgba(74,122,192,.12);color:var(--blue2);border:1px solid rgba(74,122,192,.3);}
|
||||||
|
.mode-pills{display:flex;gap:4px;background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:4px;}
|
||||||
|
.mode-pill{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;background:none;border:none;color:var(--muted);padding:7px 14px;border-radius:3px;cursor:pointer;transition:all .18s;position:relative;}
|
||||||
|
.mode-pill:hover{color:var(--muted2);}
|
||||||
|
.mode-pill.active{background:var(--bg5);color:var(--accent);border:1px solid var(--border2);}
|
||||||
|
.mode-pill.ap.active{background:rgba(192,64,64,.12);color:var(--red2);border-color:rgba(192,64,64,.3);}
|
||||||
|
.mode-pill.up.active{background:rgba(74,122,192,.12);color:var(--blue2);border-color:rgba(74,122,192,.3);}
|
||||||
|
.pill-badge{position:absolute;top:-6px;right:-6px;background:var(--red);color:#fff;font-family:'Share Tech Mono',monospace;font-size:9px;border-radius:10px;padding:1px 5px;min-width:16px;text-align:center;display:none;}
|
||||||
|
.pill-badge.show{display:block;}
|
||||||
|
|
||||||
|
/* TABS */
|
||||||
|
.top-nav{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:28px;padding-top:6px;}
|
||||||
|
.nav-btn{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:3px;text-transform:uppercase;background:none;border:none;border-bottom:2px solid transparent;color:var(--muted);padding:10px 18px 12px;cursor:pointer;transition:all .18s;margin-bottom:-1px;}
|
||||||
|
.nav-btn:hover{color:var(--muted2);}
|
||||||
|
.nav-btn.active{color:var(--accent);border-bottom-color:var(--accent);}
|
||||||
|
.panel{display:none;}.panel.active{display:block;}
|
||||||
|
|
||||||
|
/* EV FILTER */
|
||||||
|
.filter-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:24px;}
|
||||||
|
.filter-label-sm{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;flex-basis:100%;margin-bottom:4px;}
|
||||||
|
.ev-btn{display:flex;align-items:center;gap:7px;padding:7px 14px;background:var(--bg3);border:1px solid var(--border2);border-radius:3px;cursor:pointer;font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1.5px;color:var(--muted2);text-transform:uppercase;transition:all .18s;user-select:none;}
|
||||||
|
.ev-btn:hover{border-color:var(--border3);color:var(--text);}
|
||||||
|
.ev-btn.ef{background:rgba(200,169,110,.12);border-color:var(--accent);color:var(--accent2);}
|
||||||
|
.ev-btn.ex{background:rgba(192,64,64,.12);border-color:var(--red);color:var(--red2);text-decoration:line-through;}
|
||||||
|
.ev-num{background:var(--bg4);border-radius:10px;padding:1px 7px;font-size:9px;color:var(--muted);}
|
||||||
|
.ev-btn.ef .ev-num{background:rgba(200,169,110,.2);color:var(--accent);}
|
||||||
|
.result-bar{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--muted);flex-basis:100%;display:none;}
|
||||||
|
.result-bar span{color:var(--accent);}
|
||||||
|
|
||||||
|
/* GHOST GRID */
|
||||||
|
.ghost-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(295px,1fr));gap:14px;}
|
||||||
|
.ghost-card{background:var(--bg2);border:1px solid var(--border);border-radius:4px;overflow:hidden;cursor:pointer;transition:all .22s;}
|
||||||
|
.ghost-card:hover{border-color:var(--border2);transform:translateY(-2px);}
|
||||||
|
.ghost-card.hl{border-color:rgba(200,169,110,.45);box-shadow:0 0 0 1px rgba(200,169,110,.12) inset;}
|
||||||
|
.ghost-card.dm{opacity:.2;pointer-events:none;}
|
||||||
|
.card-hd{padding:15px 17px 11px;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;justify-content:space-between;gap:10px;}
|
||||||
|
.card-name{font-family:'Cinzel',serif;font-size:16px;font-weight:600;color:var(--accent2);letter-spacing:.8px;line-height:1.2;}
|
||||||
|
.card-num{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted);margin-top:3px;}
|
||||||
|
.sb{font-family:'Share Tech Mono',monospace;font-size:10px;padding:3px 8px;border-radius:2px;flex-shrink:0;white-space:nowrap;}
|
||||||
|
.sb-lo{background:rgba(74,156,106,.15);color:var(--green2);border:1px solid rgba(74,156,106,.3);}
|
||||||
|
.sb-mi{background:rgba(200,169,110,.12);color:var(--accent2);border:1px solid rgba(200,169,110,.25);}
|
||||||
|
.sb-hi{background:rgba(192,64,64,.15);color:var(--red2);border:1px solid rgba(192,64,64,.3);}
|
||||||
|
.sb-va{background:rgba(138,90,192,.12);color:var(--purple2);border:1px solid rgba(138,90,192,.3);}
|
||||||
|
.card-evs{padding:11px 17px;display:flex;flex-wrap:wrap;gap:5px;border-bottom:1px solid var(--border);}
|
||||||
|
.ev-pill{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:1.5px;text-transform:uppercase;padding:3px 8px;border-radius:2px;background:var(--bg4);border:1px solid var(--border2);color:var(--muted2);transition:all .18s;}
|
||||||
|
.ev-pill.pf{background:rgba(200,169,110,.15);border-color:rgba(200,169,110,.4);color:var(--accent2);}
|
||||||
|
.ev-pill.px{background:rgba(192,64,64,.12);border-color:rgba(192,64,64,.35);color:var(--red2);text-decoration:line-through;}
|
||||||
|
.card-tip{padding:11px 17px 13px;font-size:13.5px;color:var(--muted2);line-height:1.5;font-style:italic;}
|
||||||
|
|
||||||
|
/* CHEAT TABLE */
|
||||||
|
.cheat-wrap{overflow-x:auto;}
|
||||||
|
.cheat-table{width:100%;border-collapse:collapse;font-size:13.5px;}
|
||||||
|
.cheat-table th{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);padding:10px 11px;border-bottom:1px solid var(--border2);text-align:center;white-space:nowrap;position:sticky;top:0;background:var(--bg);}
|
||||||
|
.cheat-table th:first-child{text-align:left;min-width:105px;}
|
||||||
|
.cheat-table td{padding:8px 11px;border-bottom:1px solid var(--border);text-align:center;vertical-align:middle;}
|
||||||
|
.cheat-table td:first-child{text-align:left;font-family:'Cinzel',serif;font-size:13px;color:var(--accent2);cursor:pointer;letter-spacing:.5px;}
|
||||||
|
.cheat-table td:first-child:hover{color:var(--accent);text-decoration:underline;}
|
||||||
|
.cheat-table tr:hover td{background:rgba(255,255,255,.02);}
|
||||||
|
.chk{color:var(--accent);}
|
||||||
|
.tip-cell{text-align:left;font-size:12px;color:var(--muted2);font-style:italic;min-width:200px;max-width:360px;}
|
||||||
|
.scc{font-family:'Share Tech Mono',monospace;font-size:10px;padding:2px 7px;border-radius:2px;display:inline-block;}
|
||||||
|
|
||||||
|
/* GUIDE */
|
||||||
|
.guide-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:24px;}
|
||||||
|
.guide-card{background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:15px 17px;}
|
||||||
|
.guide-card-title{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;margin-bottom:8px;}
|
||||||
|
.guide-card-body{font-size:13.5px;color:var(--muted2);line-height:1.75;}
|
||||||
|
.guide-card-body strong{color:var(--text);}
|
||||||
|
.divider{display:flex;align-items:center;gap:14px;margin:28px 0 18px;}
|
||||||
|
.divider-line{flex:1;height:1px;background:var(--border);}
|
||||||
|
.divider-text{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:4px;color:var(--muted);text-transform:uppercase;white-space:nowrap;}
|
||||||
|
.acc-l{border-left:2px solid var(--accent);}
|
||||||
|
.red-l{border-left:2px solid var(--red);}
|
||||||
|
.blue-l{border-left:2px solid var(--blue);}
|
||||||
|
.purple-l{border-left:2px solid var(--purple);}
|
||||||
|
|
||||||
|
/* MODALS */
|
||||||
|
.modal-bg{display:none;position:fixed;inset:0;background:rgba(5,5,10,.88);z-index:200;overflow-y:auto;padding:36px 18px;}
|
||||||
|
.modal-bg.open{display:flex;align-items:flex-start;justify-content:center;}
|
||||||
|
.modal-box{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;max-width:760px;width:100%;position:relative;animation:mIn .2s ease;}
|
||||||
|
.modal-box.wide{max-width:920px;}
|
||||||
|
@keyframes mIn{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:none;}}
|
||||||
|
.modal-close{position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);cursor:pointer;font-size:20px;padding:4px 8px;transition:color .15s;}
|
||||||
|
.modal-close:hover{color:var(--text);}
|
||||||
|
.modal-hd{padding:26px 30px 18px;border-bottom:1px solid var(--border);}
|
||||||
|
.modal-title{font-family:'Cinzel',serif;font-size:26px;font-weight:700;color:var(--accent);letter-spacing:3px;}
|
||||||
|
.modal-sub{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;margin-top:5px;}
|
||||||
|
.modal-body{padding:22px 30px 30px;}
|
||||||
|
.wiki-sec{margin-bottom:22px;}.wiki-sec:last-child{margin-bottom:0;}
|
||||||
|
.wiki-hd{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--border);}
|
||||||
|
.wiki-text{font-size:14.5px;color:var(--muted2);line-height:1.7;}
|
||||||
|
.wiki-text strong{color:var(--text);}
|
||||||
|
.wiki-ev-row{display:flex;flex-wrap:wrap;gap:7px;}
|
||||||
|
.wiki-ev-p{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;padding:5px 13px;border-radius:3px;background:var(--bg3);border:1px solid var(--border2);color:var(--muted2);}
|
||||||
|
.tells-list{display:flex;flex-direction:column;gap:7px;}
|
||||||
|
.tell{display:flex;gap:11px;align-items:flex-start;padding:9px 13px;background:var(--bg3);border-radius:3px;border-left:2px solid var(--accent);}
|
||||||
|
.tell-ico{flex-shrink:0;font-size:13px;margin-top:2px;}
|
||||||
|
.tell-text{font-size:13.5px;color:var(--muted2);}
|
||||||
|
.tell-text strong{color:var(--text);}
|
||||||
|
.sanity-blk{display:inline-flex;align-items:center;gap:11px;background:var(--bg3);border-radius:3px;padding:9px 15px;border:1px solid var(--border2);}
|
||||||
|
.sanity-pct{font-family:'Cinzel',serif;font-size:22px;}
|
||||||
|
.sanity-note{font-size:13px;color:var(--muted2);}
|
||||||
|
|
||||||
|
/* ADMIN */
|
||||||
|
.admin-header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px;flex-wrap:wrap;}
|
||||||
|
.admin-title{font-family:'Cinzel',serif;font-size:22px;font-weight:600;color:var(--red2);letter-spacing:2px;}
|
||||||
|
.admin-sub{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;margin-top:3px;}
|
||||||
|
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin-bottom:24px;}
|
||||||
|
.stat-card{background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:12px 15px;}
|
||||||
|
.stat-val{font-family:'Cinzel',serif;font-size:22px;color:var(--accent2);line-height:1;}
|
||||||
|
.stat-label{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;color:var(--muted);text-transform:uppercase;margin-top:4px;}
|
||||||
|
.admin-ghost-list{display:flex;flex-direction:column;gap:8px;}
|
||||||
|
.agr{display:flex;align-items:center;gap:12px;background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:12px 16px;transition:border-color .18s;flex-wrap:wrap;}
|
||||||
|
.agr:hover{border-color:var(--border3);}
|
||||||
|
.agr-name{font-family:'Cinzel',serif;font-size:14px;color:var(--accent2);letter-spacing:.5px;min-width:100px;}
|
||||||
|
.agr-evs{display:flex;gap:5px;flex-wrap:wrap;flex:1;}
|
||||||
|
.agr-ev{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:1px;text-transform:uppercase;padding:2px 7px;background:var(--bg4);border:1px solid var(--border2);border-radius:2px;color:var(--muted2);}
|
||||||
|
.agr-meta{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted);white-space:nowrap;}
|
||||||
|
.agr-btns{display:flex;gap:6px;flex-shrink:0;margin-left:auto;}
|
||||||
|
.icon-btn{background:none;border:1px solid var(--border2);border-radius:3px;padding:5px 10px;cursor:pointer;font-size:13px;color:var(--muted);transition:all .18s;}
|
||||||
|
.icon-btn.ibe:hover{color:var(--accent2);border-color:rgba(200,169,110,.4);}
|
||||||
|
.icon-btn.ibd:hover{color:var(--red2);border-color:rgba(192,64,64,.4);}
|
||||||
|
.sub-list{display:flex;flex-direction:column;gap:10px;}
|
||||||
|
.sub-item{background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:15px 18px;}
|
||||||
|
.sub-meta{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap;}
|
||||||
|
.sub-ghost{font-family:'Cinzel',serif;font-size:13px;color:var(--accent2);}
|
||||||
|
.sub-type{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;padding:2px 8px;border-radius:2px;border:1px solid;}
|
||||||
|
.stt{color:var(--blue2);border-color:rgba(74,122,192,.35);background:rgba(74,122,192,.1);}
|
||||||
|
.ste{color:var(--orange2);border-color:rgba(192,120,64,.35);background:rgba(192,120,64,.1);}
|
||||||
|
.stn{color:var(--green2);border-color:rgba(74,156,106,.35);background:rgba(74,156,106,.1);}
|
||||||
|
.sub-time{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted);margin-left:auto;}
|
||||||
|
.sub-content{font-size:13.5px;color:var(--muted2);line-height:1.6;margin-bottom:10px;font-style:italic;}
|
||||||
|
.sub-actions{display:flex;gap:7px;flex-wrap:wrap;}
|
||||||
|
.badge-count{display:inline-flex;align-items:center;justify-content:center;background:var(--red);color:#fff;font-family:'Share Tech Mono',monospace;font-size:9px;border-radius:10px;padding:1px 6px;margin-left:6px;min-width:18px;}
|
||||||
|
.empty-state{text-align:center;padding:40px 24px;color:var(--muted);font-style:italic;font-size:14px;}
|
||||||
|
.wl-section{background:var(--bg3);border:1px solid rgba(192,64,64,.25);border-radius:4px;padding:18px 20px;margin-bottom:20px;}
|
||||||
|
.wl-title{font-family:'Cinzel',serif;font-size:14px;color:var(--red2);letter-spacing:1px;margin-bottom:12px;}
|
||||||
|
.wl-row{display:flex;align-items:center;gap:10px;background:var(--bg4);border-radius:3px;padding:8px 12px;border:1px solid var(--border2);margin-bottom:6px;}
|
||||||
|
.wl-name{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--accent2);}
|
||||||
|
.wl-id{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--muted2);}
|
||||||
|
.wl-date{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted);margin-left:auto;}
|
||||||
|
|
||||||
|
/* FORMS */
|
||||||
|
.form-section{background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:20px 22px;}
|
||||||
|
.form-title{font-family:'Cinzel',serif;font-size:15px;color:var(--accent2);letter-spacing:1px;margin-bottom:16px;}
|
||||||
|
.form-grid{display:flex;flex-direction:column;gap:14px;}
|
||||||
|
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
|
||||||
|
.form-row.full{grid-template-columns:1fr;}
|
||||||
|
.form-group{display:flex;flex-direction:column;gap:5px;}
|
||||||
|
.form-label{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);}
|
||||||
|
.form-input,.form-select,.form-textarea{background:var(--bg4);border:1px solid var(--border2);border-radius:3px;color:var(--text);font-family:'Crimson Pro',Georgia,serif;font-size:14px;padding:8px 12px;transition:border-color .18s;width:100%;}
|
||||||
|
.form-input:focus,.form-select:focus,.form-textarea:focus{outline:none;border-color:var(--border3);}
|
||||||
|
.form-textarea{resize:vertical;min-height:80px;line-height:1.55;}
|
||||||
|
.form-select option{background:var(--bg3);}
|
||||||
|
.form-hint{font-size:12px;color:var(--muted);font-style:italic;}
|
||||||
|
.ev-checks{display:flex;flex-wrap:wrap;gap:8px;}
|
||||||
|
.ev-check-lbl{display:flex;align-items:center;gap:6px;font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted2);cursor:pointer;padding:5px 10px;background:var(--bg4);border:1px solid var(--border2);border-radius:2px;transition:all .18s;user-select:none;}
|
||||||
|
.ev-check-lbl:hover{border-color:var(--border3);color:var(--text);}
|
||||||
|
.ev-check-lbl input{display:none;}
|
||||||
|
.ev-check-lbl.ck{background:rgba(200,169,110,.12);border-color:rgba(200,169,110,.4);color:var(--accent2);}
|
||||||
|
.tells-editor{display:flex;flex-direction:column;gap:8px;}
|
||||||
|
.tell-row{display:flex;gap:8px;align-items:center;}
|
||||||
|
.tell-row .form-input{flex:1;}
|
||||||
|
.tell-ico-in{width:58px;text-align:center;flex-shrink:0;}
|
||||||
|
.remove-btn{background:none;border:1px solid rgba(192,64,64,.3);border-radius:3px;color:var(--red2);cursor:pointer;padding:6px 10px;font-size:12px;transition:all .18s;flex-shrink:0;}
|
||||||
|
.remove-btn:hover{background:rgba(192,64,64,.12);}
|
||||||
|
.add-tell-btn{background:none;border:1px dashed var(--border2);border-radius:3px;color:var(--muted);cursor:pointer;padding:7px;font-size:11px;font-family:'Share Tech Mono',monospace;letter-spacing:2px;text-transform:uppercase;transition:all .18s;width:100%;}
|
||||||
|
.add-tell-btn:hover{border-color:var(--border3);color:var(--muted2);}
|
||||||
|
.form-footer{display:flex;gap:8px;justify-content:flex-end;margin-top:4px;flex-wrap:wrap;}
|
||||||
|
|
||||||
|
/* BUTTONS */
|
||||||
|
.btn-sm{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;background:none;border:1px solid var(--border2);border-radius:3px;padding:7px 14px;color:var(--muted);cursor:pointer;transition:all .18s;}
|
||||||
|
.btn-sm:hover{color:var(--text);border-color:var(--border3);}
|
||||||
|
.btn-accent{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;background:rgba(200,169,110,.12);border:1px solid rgba(200,169,110,.35);border-radius:3px;padding:8px 16px;color:var(--accent2);cursor:pointer;transition:all .18s;}
|
||||||
|
.btn-accent:hover{background:rgba(200,169,110,.22);}
|
||||||
|
.btn-danger{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;background:rgba(192,64,64,.12);border:1px solid rgba(192,64,64,.35);border-radius:3px;padding:8px 16px;color:var(--red2);cursor:pointer;transition:all .18s;}
|
||||||
|
.btn-danger:hover{background:rgba(192,64,64,.22);}
|
||||||
|
.btn-success{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;background:rgba(74,156,106,.12);border:1px solid rgba(74,156,106,.35);border-radius:3px;padding:8px 16px;color:var(--green2);cursor:pointer;transition:all .18s;}
|
||||||
|
.btn-success:hover{background:rgba(74,156,106,.22);}
|
||||||
|
.btn-blue{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;background:rgba(74,122,192,.12);border:1px solid rgba(74,122,192,.35);border-radius:3px;padding:8px 16px;color:var(--blue2);cursor:pointer;transition:all .18s;}
|
||||||
|
.btn-blue:hover{background:rgba(74,122,192,.22);}
|
||||||
|
.btn-discord{font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:2px;text-transform:uppercase;background:var(--discord);border:none;border-radius:3px;padding:11px 22px;color:#fff;cursor:pointer;transition:opacity .18s;display:inline-flex;align-items:center;gap:10px;}
|
||||||
|
.btn-discord:hover{opacity:.88;}
|
||||||
|
.btn-discord svg{width:18px;height:18px;}
|
||||||
|
.btn-logout{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;background:none;border:1px solid var(--border2);border-radius:3px;padding:5px 10px;color:var(--muted);cursor:pointer;transition:all .18s;}
|
||||||
|
.btn-logout:hover{color:var(--red2);border-color:rgba(192,64,64,.4);}
|
||||||
|
|
||||||
|
/* LOGIN */
|
||||||
|
.login-screen{display:none;position:fixed;inset:0;background:var(--bg);z-index:500;flex-direction:column;align-items:center;justify-content:center;padding:24px;}
|
||||||
|
.login-screen.show{display:flex;}
|
||||||
|
.login-box{background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:48px 52px;max-width:440px;width:100%;text-align:center;}
|
||||||
|
.login-logo{font-family:'Cinzel',serif;font-size:48px;font-weight:700;letter-spacing:10px;color:var(--accent);line-height:1;margin-bottom:8px;}
|
||||||
|
.login-sub{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:4px;color:var(--muted);text-transform:uppercase;margin-bottom:36px;}
|
||||||
|
.login-desc{font-size:14px;color:var(--muted2);line-height:1.7;margin-bottom:28px;}
|
||||||
|
.login-desc strong{color:var(--text);}
|
||||||
|
.login-or{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:3px;color:var(--muted);margin:18px 0;display:block;}
|
||||||
|
.login-guest-note{font-size:12px;color:var(--muted);margin-top:18px;font-style:italic;}
|
||||||
|
|
||||||
|
/* COMMUNITY */
|
||||||
|
.user-title{font-family:'Cinzel',serif;font-size:22px;font-weight:600;color:var(--blue2);letter-spacing:2px;margin-bottom:3px;}
|
||||||
|
.usi{background:var(--bg3);border:1px solid var(--border2);border-radius:4px;padding:13px 16px;margin-bottom:10px;}
|
||||||
|
.usi-meta{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;}
|
||||||
|
.usi-st{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;padding:2px 8px;border-radius:2px;border:1px solid;}
|
||||||
|
.ssp{color:var(--accent2);border-color:rgba(200,169,110,.35);background:rgba(200,169,110,.1);}
|
||||||
|
.ssa{color:var(--green2);border-color:rgba(74,156,106,.35);background:rgba(74,156,106,.1);}
|
||||||
|
.ssr{color:var(--red2);border-color:rgba(192,64,64,.35);background:rgba(192,64,64,.1);}
|
||||||
|
|
||||||
|
/* LOADING */
|
||||||
|
.loading{display:none;position:fixed;inset:0;background:rgba(5,5,10,.7);z-index:400;align-items:center;justify-content:center;flex-direction:column;gap:16px;}
|
||||||
|
.loading.show{display:flex;}
|
||||||
|
.spinner{width:36px;height:36px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg);}}
|
||||||
|
.loading-txt{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;}
|
||||||
|
|
||||||
|
/* CONFIRM */
|
||||||
|
.confirm-bg{display:none;position:fixed;inset:0;background:rgba(5,5,10,.88);z-index:300;align-items:center;justify-content:center;}
|
||||||
|
.confirm-bg.open{display:flex;}
|
||||||
|
.confirm-box{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;padding:28px 32px;max-width:380px;width:100%;text-align:center;}
|
||||||
|
.confirm-title{font-family:'Cinzel',serif;font-size:16px;color:var(--red2);letter-spacing:1px;margin-bottom:10px;}
|
||||||
|
.confirm-text{font-size:14px;color:var(--muted2);margin-bottom:22px;line-height:1.6;}
|
||||||
|
.confirm-btns{display:flex;gap:10px;justify-content:center;}
|
||||||
|
|
||||||
|
/* TOAST */
|
||||||
|
.toast-wrap{position:fixed;bottom:24px;right:24px;z-index:999;display:flex;flex-direction:column;gap:8px;pointer-events:none;}
|
||||||
|
.toast{font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:1px;padding:11px 18px;border-radius:4px;border-left:3px solid;animation:tIn .25s ease;pointer-events:all;max-width:340px;}
|
||||||
|
@keyframes tIn{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}
|
||||||
|
.toast-success{background:rgba(74,156,106,.18);border-color:var(--green2);color:var(--green2);}
|
||||||
|
.toast-danger{background:rgba(192,64,64,.18);border-color:var(--red2);color:var(--red2);}
|
||||||
|
.toast-info{background:rgba(74,122,192,.18);border-color:var(--blue2);color:var(--blue2);}
|
||||||
|
|
||||||
|
@media(max-width:640px){.site-title{font-size:28px;letter-spacing:4px;}.form-row{grid-template-columns:1fr;}.ghost-grid{grid-template-columns:1fr;}.modal-hd,.modal-body{padding-left:18px;padding-right:18px;}.login-box{padding:32px 24px;}.login-logo{font-size:34px;letter-spacing:6px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- LOGIN -->
|
||||||
|
<div class="login-screen" id="login-screen">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="login-logo">BLAIR</div>
|
||||||
|
<div class="login-sub">Ghost Investigation Dashboard</div>
|
||||||
|
<div class="login-desc">
|
||||||
|
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>
|
||||||
|
<button class="btn-discord" onclick="location.href='/auth/discord'">
|
||||||
|
<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>
|
||||||
|
Mit Discord anmelden
|
||||||
|
</button>
|
||||||
|
<span class="login-or">oder</span>
|
||||||
|
<button class="btn-sm" onclick="guestMode()">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>
|
||||||
|
|
||||||
|
<!-- LOADING -->
|
||||||
|
<div class="loading" id="loading"><div class="spinner"></div><div class="loading-txt" id="loading-txt">Lade...</div></div>
|
||||||
|
|
||||||
|
<!-- APP -->
|
||||||
|
<div class="wrap" id="app" style="display:none;">
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<div>
|
||||||
|
<div class="site-title">BLAIR</div>
|
||||||
|
<div class="site-sub">Ghost Investigation Dashboard · 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" onclick="doLogout()">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>
|
||||||
|
<div class="mode-pills">
|
||||||
|
<button class="mode-pill active" onclick="setMode('viewer')">👁 Viewer</button>
|
||||||
|
<button class="mode-pill up" id="pill-user" onclick="setMode('user')" style="display:none;">✦ Community<span class="pill-badge" id="badge-user"></span></button>
|
||||||
|
<button class="mode-pill ap" id="pill-admin" onclick="setMode('admin')" style="display:none;">⚙ Admin<span class="pill-badge" id="badge-admin"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- VIEWER -->
|
||||||
|
<div id="mode-viewer">
|
||||||
|
<nav class="top-nav" id="vnav">
|
||||||
|
<button class="nav-btn active" onclick="switchTab('cards')">Ghost Cards</button>
|
||||||
|
<button class="nav-btn" onclick="switchTab('cheat')">Cheatsheet</button>
|
||||||
|
<button class="nav-btn" onclick="switchTab('guide')">Hunt Guide</button>
|
||||||
|
</nav>
|
||||||
|
<div class="panel active" id="vtab-cards">
|
||||||
|
<div class="filter-row" id="ev-filter"><div class="filter-label-sm">Evidence Filter — einmal: gefunden · zweimal: ausgeschlossen</div></div>
|
||||||
|
<div class="ghost-grid" id="ghost-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="vtab-cheat">
|
||||||
|
<div class="cheat-wrap"><table class="cheat-table"><thead><tr>
|
||||||
|
<th>Ghost</th><th>EMF</th><th>❄</th><th>✍</th><th>UV</th><th>◎</th><th>📻</th><th>SLS</th><th>Sanity</th><th>Tips</th>
|
||||||
|
</tr></thead><tbody id="cheat-tbody"></tbody></table></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="vtab-guide">
|
||||||
|
<div style="max-width:800px;">
|
||||||
|
<div class="divider"><div class="divider-line"></div><div class="divider-text">Hunt Mechanics</div><div class="divider-line"></div></div>
|
||||||
|
<div class="guide-grid">
|
||||||
|
<div class="guide-card"><div class="guide-card-title">Grace Period</div><div class="guide-card-body">Easy — 5s<br>Medium — 4s<br>Hard — 3s<br><strong style="color:var(--red2)">Nightmare — 2s</strong></div></div>
|
||||||
|
<div class="guide-card"><div class="guide-card-title">Sanity Drain/min</div><div class="guide-card-body">Easy — 2%<br>Medium — 2%<br>Hard — 3%<br><strong style="color:var(--red2)">Nightmare — ~5%</strong></div></div>
|
||||||
|
<div class="guide-card"><div class="guide-card-title">Hunt Triggers</div><div class="guide-card-body">Sanity ≤ ghost threshold<br>Cursed objects bypass sanity<br>Spirit Board — no goodbye = hunt<br>Strigoi tap = 1/8 hunt chance</div></div>
|
||||||
|
<div class="guide-card"><div class="guide-card-title">Survival Priority</div><div class="guide-card-body">1. Crucifix in ghost room<br>2. Incense (stun/cleanse)<br>3. Break line of sight<br>4. Hide in closet/vehicle</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"><div class="divider-line"></div><div class="divider-text">Salt Tips</div><div class="divider-line"></div></div>
|
||||||
|
<div class="guide-card" style="margin-bottom:24px;"><div class="guide-card-body" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px 20px;">
|
||||||
|
<div><strong>Wraith</strong> — NEVER steps in salt.</div><div><strong>Harrow</strong> — Salt outside room = not Harrow.</div>
|
||||||
|
<div><strong>Revenant</strong> — Normal steps = no Revenant.</div><div><strong>Jiangshi</strong> — Skips the 2nd footstep.</div>
|
||||||
|
<div><strong>Yurei</strong> — Takes extra time to step on salt.</div><div><strong>Demon</strong> — Fast, accelerating steps.</div>
|
||||||
|
<div><strong>Strigoi</strong> — 4-finger UV handprint.</div><div><strong>Oni</strong> — Fast salt steps; opens/closes doors.</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="divider"><div class="divider-line"></div><div class="divider-text">Cursed Objects</div><div class="divider-line"></div></div>
|
||||||
|
<div class="guide-grid">
|
||||||
|
<div class="guide-card red-l"><div class="guide-card-title">Spirit Board</div><div class="guide-card-body">No "Goodbye" = forced hunt. ZoZo overrides 50%.</div></div>
|
||||||
|
<div class="guide-card purple-l"><div class="guide-card-title">Tarot Cards</div><div class="guide-card-body">Death (hunt 10%), Moon (→0% sanity), Sun (→100%), Fool (negates prev).</div></div>
|
||||||
|
<div class="guide-card acc-l"><div class="guide-card-title">Summoning Circle</div><div class="guide-card-body">Light all 5 candles → ghost manifests → hunt after 5s.</div></div>
|
||||||
|
<div class="guide-card blue-l"><div class="guide-card-title">Music Box</div><div class="guide-card-body">Triggers cursed hunt once/match. Can lure ghost during hunts.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ADMIN -->
|
||||||
|
<div id="mode-admin" style="display:none;margin-top:24px;">
|
||||||
|
<div class="admin-header">
|
||||||
|
<div><div class="admin-title">⚙ Admin Dashboard</div><div class="admin-sub">Ghost Database Management · Nur für Whitelist-Admins</div></div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<button class="btn-accent" onclick="openGhostForm(null)">+ New Ghost</button>
|
||||||
|
<button class="btn-sm" onclick="exportGhosts()">↓ Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-row" id="admin-stats"></div>
|
||||||
|
<div class="wl-section">
|
||||||
|
<div class="wl-title">🔐 Admin Whitelist</div>
|
||||||
|
<div id="wl-list" style="margin-bottom:12px;"></div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<input type="text" class="form-input" id="wl-id" placeholder="Discord ID (18-stellig)" style="width:210px;">
|
||||||
|
<input type="text" class="form-input" id="wl-name" placeholder="Username (optional)" style="width:160px;">
|
||||||
|
<button class="btn-success" onclick="addAdmin()">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint" style="margin-top:8px;">Discord → Einstellungen → Erweitert → Entwicklermodus → Rechtsklick auf Nutzer → ID kopieren</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"><div class="divider-line"></div><div class="divider-text" id="sub-title">Community Submissions</div><div class="divider-line"></div></div>
|
||||||
|
<div class="sub-list" id="admin-sub-list" style="margin-bottom:28px;"></div>
|
||||||
|
<div class="divider"><div class="divider-line"></div><div class="divider-text">Ghost Database</div><div class="divider-line"></div></div>
|
||||||
|
<div class="admin-ghost-list" id="admin-ghost-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMMUNITY -->
|
||||||
|
<div id="mode-user" style="display:none;margin-top:24px;max-width:820px;">
|
||||||
|
<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;">Tips einreichen · Fehler melden · Infos ergänzen</div>
|
||||||
|
<div class="form-section" style="margin-bottom:28px;">
|
||||||
|
<div class="form-title">Etwas einreichen</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row">
|
||||||
|
<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">Art</label>
|
||||||
|
<select class="form-select" id="usr-type">
|
||||||
|
<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">Beschreibung</label>
|
||||||
|
<textarea class="form-textarea" id="usr-content" 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" onclick="submitTip()">Einreichen →</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"><div class="divider-line"></div><div class="divider-text">Deine Einreichungen</div><div class="divider-line"></div></div>
|
||||||
|
<div id="user-sub-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ghost View Modal -->
|
||||||
|
<div class="modal-bg" id="ghost-modal" onclick="if(event.target===this)closeModal('ghost-modal')">
|
||||||
|
<div class="modal-box"><button class="modal-close" onclick="closeModal('ghost-modal')">✕</button>
|
||||||
|
<div class="modal-hd"><div class="modal-title" id="gm-title"></div><div class="modal-sub" id="gm-sub"></div></div>
|
||||||
|
<div class="modal-body" id="gm-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ghost Editor Modal -->
|
||||||
|
<div class="modal-bg" id="edit-modal" onclick="if(event.target===this)closeModal('edit-modal')">
|
||||||
|
<div class="modal-box wide"><button class="modal-close" onclick="closeModal('edit-modal')">✕</button>
|
||||||
|
<div class="modal-hd"><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">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Name</label><input type="text" class="form-input" id="ef-name"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Sanity Threshold (%)</label><input type="number" class="form-input" id="ef-sanity" min="0" max="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Hunt Type</label>
|
||||||
|
<select class="form-select" id="ef-hunt"><option value="mid">Mid (50%)</option><option value="low">Low (≤35%)</option><option value="high">High (≥80%)</option><option value="variable">Variable</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Sanity Note</label><input type="text" class="form-input" id="ef-ss" placeholder="z.B. Target's individual sanity"></div>
|
||||||
|
</div>
|
||||||
|
<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">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">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" onclick="addTellRow()">+ Tell hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button class="btn-sm" onclick="closeModal('edit-modal')">Abbrechen</button>
|
||||||
|
<button class="btn-accent" onclick="saveGhost()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-bg" id="confirm-bg">
|
||||||
|
<div class="confirm-box">
|
||||||
|
<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" onclick="closeConfirm()">Abbrechen</button><button class="btn-danger" id="confirm-ok">Bestätigen</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast-wrap" id="toast-wrap"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ── CONSTANTS ── */
|
||||||
|
const EV_KEYS = ["EMF","Freezing","Writing","UV","Orbs","SpiritBox","SLS"];
|
||||||
|
const EV_LABELS = {EMF:"EMF 5",Freezing:"Freezing",Writing:"Writing",UV:"UV",Orbs:"Orbs",SpiritBox:"Spirit Box",SLS:"SLS"};
|
||||||
|
|
||||||
|
/* ── STATE ── */
|
||||||
|
let ghosts=[], currentUser=null, ev_state={}, editingId=null, confirmCb=null;
|
||||||
|
EV_KEYS.forEach(k=>ev_state[k]=0);
|
||||||
|
|
||||||
|
/* ── INIT ── */
|
||||||
|
async function init() {
|
||||||
|
showLoading("Lade...");
|
||||||
|
const r = await fetch('/auth/me');
|
||||||
|
const { user } = await r.json();
|
||||||
|
currentUser = user;
|
||||||
|
|
||||||
|
if (!user) { hideLoading(); showLogin(); return; }
|
||||||
|
|
||||||
|
await loadGhosts();
|
||||||
|
setupUI();
|
||||||
|
hideLoading();
|
||||||
|
document.getElementById('app').style.display='block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function guestMode() {
|
||||||
|
hideLogin();
|
||||||
|
loadGhosts().then(()=>{
|
||||||
|
setupGuestUI();
|
||||||
|
document.getElementById('app').style.display='block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── UI SETUP ── */
|
||||||
|
function setupUI() {
|
||||||
|
const u = currentUser;
|
||||||
|
const chip = document.getElementById('auth-chip');
|
||||||
|
chip.style.display='flex';
|
||||||
|
const av = document.getElementById('auth-av');
|
||||||
|
if(u.avatar){av.src=u.avatar;av.style.display='block';}else{av.style.display='none';}
|
||||||
|
document.getElementById('auth-nm').textContent=u.username;
|
||||||
|
const rl = document.getElementById('auth-rl');
|
||||||
|
if(u.isAdmin){rl.textContent='Admin';rl.className='auth-role role-admin';}
|
||||||
|
else{rl.textContent='User';rl.className='auth-role role-user';}
|
||||||
|
document.getElementById('pill-user').style.display='block';
|
||||||
|
if(u.isAdmin) document.getElementById('pill-admin').style.display='block';
|
||||||
|
buildCards(); buildCheat();
|
||||||
|
populateGhostSelect();
|
||||||
|
if(u.isAdmin) updateAdminBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGuestUI() {
|
||||||
|
document.getElementById('guest-badge').style.display='block';
|
||||||
|
buildCards(); buildCheat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AUTH ── */
|
||||||
|
async function doLogout() {
|
||||||
|
await fetch('/auth/logout', {method:'POST'});
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FETCH HELPERS ── */
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers:{'Content-Type':'application/json'} };
|
||||||
|
if(body) opts.body=JSON.stringify(body);
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
const d = await r.json();
|
||||||
|
if(!r.ok) throw new Error(d.error||'Fehler');
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GHOSTS ── */
|
||||||
|
async function loadGhosts() {
|
||||||
|
const data = await api('GET','/api/ghosts');
|
||||||
|
ghosts = data.map(row=>({...row,
|
||||||
|
evidence: Array.isArray(row.evidence)?row.evidence:(typeof row.evidence==='string'?JSON.parse(row.evidence):[]),
|
||||||
|
tells: Array.isArray(row.tells)?row.tells:(typeof row.tells==='string'?JSON.parse(row.tells):[]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sbC(g){return g.hunt==='high'?'sb-hi':g.hunt==='low'?'sb-lo':g.hunt==='variable'?'sb-va':'sb-mi';}
|
||||||
|
|
||||||
|
function buildFilter() {
|
||||||
|
const bar=document.getElementById('ev-filter'), lbl=bar.querySelector('.filter-label-sm');
|
||||||
|
bar.innerHTML=''; bar.appendChild(lbl);
|
||||||
|
EV_KEYS.forEach(ev=>{
|
||||||
|
const cnt=ghosts.filter(g=>g.evidence.includes(ev)).length;
|
||||||
|
const b=document.createElement('button'); b.className='ev-btn'; b.dataset.ev=ev;
|
||||||
|
b.innerHTML=`${EV_LABELS[ev]} <span class="ev-num">${cnt}</span>`;
|
||||||
|
b.onclick=()=>{ev_state[ev]=(ev_state[ev]+1)%3;b.classList.toggle('ef',ev_state[ev]===1);b.classList.toggle('ex',ev_state[ev]===2);applyFilter();};
|
||||||
|
bar.appendChild(b);
|
||||||
|
});
|
||||||
|
const rb=document.createElement('button'); rb.className='btn-sm'; rb.textContent='Reset';
|
||||||
|
rb.onclick=()=>{EV_KEYS.forEach(k=>ev_state[k]=0);document.querySelectorAll('.ev-btn').forEach(b=>b.classList.remove('ef','ex'));applyFilter();};
|
||||||
|
bar.appendChild(rb);
|
||||||
|
const ri=document.createElement('div'); ri.className='result-bar'; ri.id='result-bar'; bar.appendChild(ri);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const found=EV_KEYS.filter(k=>ev_state[k]===1),excl=EV_KEYS.filter(k=>ev_state[k]===2);
|
||||||
|
let m=0;
|
||||||
|
document.querySelectorAll('.ghost-card').forEach(c=>{
|
||||||
|
const g=ghosts[c.dataset.idx];if(!g)return;
|
||||||
|
const ok=!found.some(e=>!g.evidence.includes(e))&&!excl.some(e=>g.evidence.includes(e));
|
||||||
|
const act=found.length||excl.length;
|
||||||
|
c.classList.toggle('hl',act&&ok); c.classList.toggle('dm',act&&!ok);
|
||||||
|
if(ok&&act)m++;
|
||||||
|
c.querySelectorAll('.ev-pill').forEach(p=>{p.classList.toggle('pf',ev_state[p.dataset.ev]===1);p.classList.toggle('px',ev_state[p.dataset.ev]===2);});
|
||||||
|
});
|
||||||
|
const ri=document.getElementById('result-bar');
|
||||||
|
if(ri){ri.style.display=(found.length||excl.length)?'block':'none';ri.innerHTML=`<span>${m}</span> possible ghost${m!==1?'s':''} matching evidence`;}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCards() {
|
||||||
|
buildFilter();
|
||||||
|
const grid=document.getElementById('ghost-grid'); grid.innerHTML='';
|
||||||
|
ghosts.forEach((g,i)=>{
|
||||||
|
const d=document.createElement('div'); d.className='ghost-card'; d.dataset.idx=i;
|
||||||
|
d.innerHTML=`<div class="card-hd"><div><div class="card-name">${g.name}</div><div class="card-num">Ghost #${String(i+1).padStart(2,'0')}</div></div><div class="sb ${sbC(g)}">${g.sanity}%${g.hunt==='variable'?'↕':''}</div></div><div class="card-evs">${g.evidence.map(e=>`<span class="ev-pill" data-ev="${e}">${EV_LABELS[e]||e}</span>`).join('')}</div><div class="card-tip">${g.tip||''}</div>`;
|
||||||
|
d.onclick=()=>openViewModal(i); grid.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCheat() {
|
||||||
|
const tb=document.getElementById('cheat-tbody'); tb.innerHTML='';
|
||||||
|
ghosts.forEach((g,i)=>{
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
tr.innerHTML=`<td onclick="openViewModal(${i})">${g.name}</td>${EV_KEYS.map(e=>`<td>${g.evidence.includes(e)?'<span class="chk">✓</span>':''}</td>`).join('')}<td><span class="scc ${sbC(g)}">${g.sanity}%${g.hunt==='variable'?'↕':''}</span></td><td class="tip-cell">${(g.tip||'').split(' · ').map(t=>`<div>· ${t}</div>`).join('')}</td>`;
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateGhostSelect() {
|
||||||
|
const sel=document.getElementById('usr-ghost');
|
||||||
|
sel.innerHTML='<option value="">— Geist auswählen —</option>'+ghosts.map(g=>`<option value="${g.name}">${g.name}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildAll() { buildCards(); buildCheat(); populateGhostSelect(); }
|
||||||
|
|
||||||
|
/* ── VIEW MODAL ── */
|
||||||
|
function openViewModal(idx) {
|
||||||
|
const g=ghosts[idx]; if(!g)return;
|
||||||
|
const sc=g.sanity>=80?'var(--red2)':g.sanity<=35?'var(--green2)':'var(--accent2)';
|
||||||
|
document.getElementById('gm-title').textContent=g.name.toUpperCase();
|
||||||
|
document.getElementById('gm-sub').textContent='Evidence · '+g.evidence.map(e=>EV_LABELS[e]||e).join(' · ');
|
||||||
|
document.getElementById('gm-body').innerHTML=`
|
||||||
|
<div class="wiki-sec"><div class="wiki-hd">Overview</div><div class="wiki-text">${g.description||'No description yet.'}</div></div>
|
||||||
|
<div class="wiki-sec"><div class="wiki-hd">Evidence</div><div class="wiki-ev-row">${g.evidence.map(e=>`<span class="wiki-ev-p">${EV_LABELS[e]||e}</span>`).join('')}</div></div>
|
||||||
|
<div class="wiki-sec"><div class="wiki-hd">Hunt Threshold</div><div class="sanity-blk"><span class="sanity-pct" style="color:${sc}">${g.sanity}%${g.hunt==='variable'?' ↕':''}</span><span class="sanity-note">${g.sanity_special||''}</span></div></div>
|
||||||
|
<div class="wiki-sec"><div class="wiki-hd">Identification Tells</div><div class="tells-list">${(g.tells||[]).map(t=>`<div class="tell"><span class="tell-ico">${t.icon}</span><span class="tell-text">${t.text}</span></div>`).join('')}</div></div>`;
|
||||||
|
openModal('ghost-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MODE ── */
|
||||||
|
function setMode(m) {
|
||||||
|
['viewer','admin','user'].forEach(x=>document.getElementById('mode-'+x).style.display=x===m?'block':'none');
|
||||||
|
document.querySelectorAll('.mode-pill').forEach(p=>p.classList.remove('active'));
|
||||||
|
const mp={viewer:document.querySelectorAll('.mode-pill')[0],user:document.getElementById('pill-user'),admin:document.getElementById('pill-admin')};
|
||||||
|
if(mp[m])mp[m].classList.add('active');
|
||||||
|
if(m==='admin')renderAdmin();
|
||||||
|
if(m==='user'){populateGhostSelect();renderUserSubs();}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(t) {
|
||||||
|
['cards','cheat','guide'].forEach((x,i)=>{
|
||||||
|
document.getElementById('vtab-'+x).classList.toggle('active',x===t);
|
||||||
|
document.querySelectorAll('#vnav .nav-btn')[i].classList.toggle('active',x===t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ADMIN ── */
|
||||||
|
async function renderAdmin() {
|
||||||
|
// Stats
|
||||||
|
try {
|
||||||
|
const s=await api('GET','/admin/stats');
|
||||||
|
document.getElementById('admin-stats').innerHTML=`
|
||||||
|
<div class="stat-card"><div class="stat-val">${s.ghosts}</div><div class="stat-label">Ghosts</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-val">${s.total}</div><div class="stat-label">Submissions</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-val" style="color:${s.pending?'var(--accent2)':'var(--muted2)'}">${s.pending}</div><div class="stat-label">Pending</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-val">${s.approved}</div><div class="stat-label">Approved</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-val">${s.rejected}</div><div class="stat-label">Rejected</div></div>`;
|
||||||
|
// badge
|
||||||
|
const b=document.getElementById('badge-admin');b.textContent=s.pending;b.classList.toggle('show',s.pending>0);
|
||||||
|
} catch{}
|
||||||
|
|
||||||
|
// Whitelist
|
||||||
|
try {
|
||||||
|
const wl=await api('GET','/admin/whitelist');
|
||||||
|
const wlEl=document.getElementById('wl-list');
|
||||||
|
if(!wl.length){wlEl.innerHTML='<div style="font-size:13px;color:var(--muted);font-style:italic;">Noch keine weiteren Admins.</div>';}
|
||||||
|
else{wlEl.innerHTML=wl.map(w=>`<div class="wl-row"><span style="font-size:16px">⚙</span><span class="wl-name">${escH(w.discord_username||'—')}</span><span class="wl-id">${w.discord_id}</span><span class="wl-date">${fmtTime(w.added_at)}</span><button class="icon-btn ibd" onclick="removeAdmin('${w.discord_id}')" title="Entfernen">🗑</button></div>`).join('');}
|
||||||
|
} catch{}
|
||||||
|
|
||||||
|
// Submissions (pending)
|
||||||
|
try {
|
||||||
|
const subs=await api('GET','/admin/submissions?status=pending');
|
||||||
|
const sl=document.getElementById('admin-sub-list');
|
||||||
|
document.getElementById('sub-title').innerHTML=`Community Submissions${subs.length?`<span class="badge-count">${subs.length}</span>`:''}`;
|
||||||
|
if(!subs.length){sl.innerHTML='<div class="empty-state">Keine ausstehenden Einreichungen 📭</div>';}
|
||||||
|
else{sl.innerHTML='';subs.forEach(sub=>{
|
||||||
|
const tl=sub.type==='tip'?'💡 Tip':sub.type==='edit'?'✏️ Korrektur':'🆕 Neuer Geist';
|
||||||
|
const tc=sub.type==='tip'?'stt':sub.type==='edit'?'ste':'stn';
|
||||||
|
const d=document.createElement('div');d.className='sub-item';
|
||||||
|
d.innerHTML=`<div class="sub-meta"><span class="sub-ghost">${escH(sub.ghost_name||'Allgemein')}</span><span class="sub-type ${tc}">${tl}</span><span style="font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted)">von ${escH(sub.username||'Anonym')}</span><span class="sub-time">${fmtTime(sub.created_at)}</span></div><div class="sub-content">${escH(sub.content)}</div><div class="sub-actions"><button class="btn-success" onclick="approveSub(${sub.id})">✓ Annehmen</button><button class="btn-danger" onclick="rejectSub(${sub.id})">✕ Ablehnen</button><button class="btn-accent" onclick="injectSub(${sub.id},'${escH(sub.ghost_name)}')">→ Bearbeiten & Einfügen</button></div>`;
|
||||||
|
sl.appendChild(d);
|
||||||
|
});}
|
||||||
|
} catch{}
|
||||||
|
|
||||||
|
// Ghost list
|
||||||
|
const gl=document.getElementById('admin-ghost-list');gl.innerHTML='';
|
||||||
|
ghosts.forEach(g=>{
|
||||||
|
const row=document.createElement('div');row.className='agr';
|
||||||
|
row.innerHTML=`<div class="agr-name">${g.name}</div><div class="agr-evs">${g.evidence.map(e=>`<span class="agr-ev">${EV_LABELS[e]||e}</span>`).join('')}</div><div class="agr-meta">von ${g.updated_by||'—'}</div><div class="agr-btns"><button class="icon-btn ibe" onclick="openGhostForm('${g.id}')" title="Bearbeiten">✏</button><button class="icon-btn ibd" onclick="confirmDel('${g.id}')" title="Löschen">🗑</button></div>`;
|
||||||
|
gl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAdminBadge() {
|
||||||
|
try{const s=await api('GET','/admin/stats');const b=document.getElementById('badge-admin');b.textContent=s.pending;b.classList.toggle('show',s.pending>0);}catch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAdmin() {
|
||||||
|
const id=document.getElementById('wl-id').value.trim(),name=document.getElementById('wl-name').value.trim();
|
||||||
|
if(!id||id.length<10){toast("Gültige Discord ID eingeben!","danger");return;}
|
||||||
|
try{await api('POST','/admin/whitelist',{discord_id:id,discord_username:name||id});toast("Admin hinzugefügt ✓","success");document.getElementById('wl-id').value='';document.getElementById('wl-name').value='';renderAdmin();}
|
||||||
|
catch(e){toast(e.message,"danger");}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAdmin(id) {
|
||||||
|
showConfirm("Admin entfernen?","Diese Person verliert sofort den Admin-Zugriff.",async()=>{
|
||||||
|
try{await api('DELETE',`/admin/whitelist/${id}`);toast("Admin entfernt.","danger");renderAdmin();}
|
||||||
|
catch(e){toast(e.message,"danger");}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveSub(id){
|
||||||
|
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("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("💡 Submission-Info anzeigen: prüfe den Tip!","info"),300);
|
||||||
|
renderAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GHOST EDITOR ── */
|
||||||
|
function openGhostForm(id) {
|
||||||
|
const g=id?ghosts.find(x=>x.id===id):null;
|
||||||
|
editingId=id||null;
|
||||||
|
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';
|
||||||
|
document.getElementById('ef-ss').value=g?.sanity_special||'';
|
||||||
|
document.getElementById('ef-tip').value=g?.tip||'';
|
||||||
|
document.getElementById('ef-desc').value=g?.description||'';
|
||||||
|
const evDiv=document.getElementById('ef-evs');evDiv.innerHTML='';
|
||||||
|
EV_KEYS.forEach(ev=>{
|
||||||
|
const ck=g?.evidence.includes(ev);
|
||||||
|
const lbl=document.createElement('label');lbl.className='ev-check-lbl'+(ck?' ck':'');
|
||||||
|
lbl.innerHTML=`<input type="checkbox" value="${ev}"${ck?' checked':''}>${EV_LABELS[ev]}`;
|
||||||
|
lbl.querySelector('input').onchange=e=>lbl.classList.toggle('ck',e.target.checked);
|
||||||
|
evDiv.appendChild(lbl);
|
||||||
|
});
|
||||||
|
const td=document.getElementById('ef-tells');td.innerHTML='';
|
||||||
|
(g?.tells||[{icon:'💡',text:''}]).forEach(t=>addTellRow(t.icon,t.text));
|
||||||
|
openModal('edit-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTellRow(icon='💡',text=''){
|
||||||
|
const td=document.getElementById('ef-tells');
|
||||||
|
const row=document.createElement('div');row.className='tell-row';
|
||||||
|
row.innerHTML=`<input type="text" class="form-input tell-ico-in" value="${escH(icon)}" placeholder="🔥" maxlength="4"><input type="text" class="form-input" value="${escH(text)}" placeholder="Tell text... use <strong>bold</strong>"><button class="remove-btn" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
td.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGhost() {
|
||||||
|
const name=document.getElementById('ef-name').value.trim();
|
||||||
|
if(!name){toast("Name darf nicht leer sein!","danger");return;}
|
||||||
|
const evs=Array.from(document.querySelectorAll('#ef-evs input:checked')).map(i=>i.value);
|
||||||
|
if(evs.length!==3){toast(`Genau 3 Evidenzen auswählen (aktuell: ${evs.length})!`,"danger");return;}
|
||||||
|
const tells=Array.from(document.querySelectorAll('.tell-row')).map(row=>{const ins=row.querySelectorAll('input');return{icon:ins[0].value||'💡',text:ins[1].value};}).filter(t=>t.text.trim());
|
||||||
|
const data={
|
||||||
|
id:editingId||name.toLowerCase().replace(/[^a-z0-9]/g,'')+Date.now().toString(36),
|
||||||
|
name,evidence:evs,sanity:parseInt(document.getElementById('ef-sanity').value)||50,
|
||||||
|
hunt:document.getElementById('ef-hunt').value,
|
||||||
|
sanity_special:document.getElementById('ef-ss').value,
|
||||||
|
tip:document.getElementById('ef-tip').value,
|
||||||
|
description:document.getElementById('ef-desc').value,
|
||||||
|
tells
|
||||||
|
};
|
||||||
|
showLoading("Speichere...");
|
||||||
|
try{
|
||||||
|
if(editingId){await api('PUT',`/admin/ghosts/${editingId}`,data);}
|
||||||
|
else{await api('POST','/admin/ghosts',data);}
|
||||||
|
toast(`✓ ${name} gespeichert!`,"success");
|
||||||
|
closeModal('edit-modal');
|
||||||
|
await loadGhosts(); rebuildAll(); renderAdmin();
|
||||||
|
}catch(e){toast(e.message,"danger");}
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDel(id){
|
||||||
|
const g=ghosts.find(x=>x.id===id);if(!g)return;
|
||||||
|
showConfirm(`${g.name} löschen?`,`Dauerhaft aus der Datenbank entfernen.`,async()=>{
|
||||||
|
showLoading("Lösche...");
|
||||||
|
try{await api('DELETE',`/admin/ghosts/${id}`);toast(`${g.name} gelöscht.`,"danger");await loadGhosts();rebuildAll();renderAdmin();}
|
||||||
|
catch(e){toast(e.message,"danger");}
|
||||||
|
hideLoading();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── COMMUNITY ── */
|
||||||
|
async function submitTip(){
|
||||||
|
const ghost=document.getElementById('usr-ghost').value,type=document.getElementById('usr-type').value,content=document.getElementById('usr-content').value.trim();
|
||||||
|
if(!content){toast("Bitte Inhalt eingeben!","danger");return;}
|
||||||
|
showLoading("Sende...");
|
||||||
|
try{await api('POST','/api/submissions',{ghost_name:ghost,type,content});toast("Einreichung gesendet ✓","success");document.getElementById('usr-content').value='';renderUserSubs();}
|
||||||
|
catch(e){toast(e.message,"danger");}
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderUserSubs(){
|
||||||
|
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">📭 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'?'✓ 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">Fehler beim Laden.</div>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── EXPORT ── */
|
||||||
|
function exportGhosts(){const b=new Blob([JSON.stringify({ghosts},null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='blair-ghosts.json';a.click();toast("Exportiert ↓","info");}
|
||||||
|
|
||||||
|
/* ── MODAL / CONFIRM / TOAST ── */
|
||||||
|
function openModal(id){document.getElementById(id).classList.add('open');document.body.style.overflow='hidden';}
|
||||||
|
function closeModal(id){document.getElementById(id).classList.remove('open');document.body.style.overflow='';}
|
||||||
|
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeModal('ghost-modal');closeModal('edit-modal');closeConfirm();}});
|
||||||
|
function showConfirm(title,text,cb){document.getElementById('confirm-title').textContent=title;document.getElementById('confirm-text').textContent=text;confirmCb=cb;document.getElementById('confirm-bg').classList.add('open');}
|
||||||
|
function closeConfirm(){document.getElementById('confirm-bg').classList.remove('open');confirmCb=null;}
|
||||||
|
document.getElementById('confirm-ok').onclick=()=>{if(confirmCb)confirmCb();closeConfirm();};
|
||||||
|
function toast(msg,type='info'){const w=document.getElementById('toast-wrap'),t=document.createElement('div');t.className=`toast toast-${type}`;t.textContent=msg;w.appendChild(t);setTimeout(()=>{t.style.cssText='opacity:0;transition:opacity .3s';setTimeout(()=>t.remove(),350);},3400);}
|
||||||
|
|
||||||
|
/* ── LOADING / LOGIN ── */
|
||||||
|
function showLoading(msg='Lade...'){document.getElementById('loading-txt').textContent=msg;document.getElementById('loading').classList.add('show');}
|
||||||
|
function hideLoading(){document.getElementById('loading').classList.remove('show');}
|
||||||
|
function showLogin(){document.getElementById('login-screen').classList.add('show');}
|
||||||
|
function hideLogin(){document.getElementById('login-screen').classList.remove('show');}
|
||||||
|
|
||||||
|
/* ── UTILS ── */
|
||||||
|
function fmtTime(ts){if(!ts)return'—';const d=new Date(ts);return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})+' '+d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});}
|
||||||
|
function escH(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||||
|
|
||||||
|
/* ── BOOT ── */
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
mnt/user-data/outputs/blair-selfhosted/middleware/auth.js
Normal file
13
mnt/user-data/outputs/blair-selfhosted/middleware/auth.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Nur eingeloggte User
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (req.isAuthenticated()) return next();
|
||||||
|
res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Admins (Whitelist)
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (req.isAuthenticated() && req.user?.isAdmin) return next();
|
||||||
|
res.status(403).json({ error: 'Kein Admin-Zugriff' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, requireAdmin };
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "blair-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Blair Roblox Ghost Dashboard — Self-Hosted",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"mysql2": "^3.6.0",
|
||||||
|
"passport": "^0.6.0",
|
||||||
|
"passport-discord": "^0.1.4",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"connect-mysql-session": "^0.4.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
passport.js
Normal file
48
passport.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const DiscordStrategy = require('passport-discord').Strategy;
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
module.exports = (passport) => {
|
||||||
|
passport.use(new DiscordStrategy({
|
||||||
|
clientID: process.env.DISCORD_CLIENT_ID,
|
||||||
|
clientSecret: process.env.DISCORD_CLIENT_SECRET,
|
||||||
|
callbackURL: `${process.env.APP_URL}/auth/discord/callback`,
|
||||||
|
scope: ['identify'],
|
||||||
|
},
|
||||||
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
// Admin-Status prüfen
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT discord_id FROM admin_whitelist WHERE discord_id = ?',
|
||||||
|
[profile.id]
|
||||||
|
);
|
||||||
|
const isAdmin = rows.length > 0;
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: profile.id,
|
||||||
|
username: profile.username,
|
||||||
|
avatar: profile.avatar
|
||||||
|
? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
|
||||||
|
: null,
|
||||||
|
isAdmin,
|
||||||
|
};
|
||||||
|
return done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err, null);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
passport.serializeUser((user, done) => done(null, user));
|
||||||
|
passport.deserializeUser(async (obj, done) => {
|
||||||
|
// Admin-Status bei jedem Request neu prüfen (live Whitelist)
|
||||||
|
try {
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT discord_id FROM admin_whitelist WHERE discord_id = ?',
|
||||||
|
[obj.id]
|
||||||
|
);
|
||||||
|
obj.isAdmin = rows.length > 0;
|
||||||
|
done(null, obj);
|
||||||
|
} catch (e) {
|
||||||
|
done(null, obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
90
schema.sql
Normal file
90
schema.sql
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════
|
||||||
|
-- Blair Dashboard — MySQL Schema
|
||||||
|
-- Ausführen mit: mysql -u root -p < schema.sql
|
||||||
|
-- ODER: Im phpMyAdmin / DBeaver importieren
|
||||||
|
-- ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS blair_dashboard
|
||||||
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE blair_dashboard;
|
||||||
|
|
||||||
|
-- Dedizierter DB-User (sicherer als root)
|
||||||
|
-- Passe Passwort an und führe das als root aus:
|
||||||
|
-- CREATE USER 'blair_user'@'localhost' IDENTIFIED BY 'SICHERES_PASSWORT';
|
||||||
|
-- GRANT ALL PRIVILEGES ON blair_dashboard.* TO 'blair_user'@'localhost';
|
||||||
|
-- FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- ─── Sessions ───────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||||
|
expires INT(11) UNSIGNED NOT NULL,
|
||||||
|
data MEDIUMTEXT,
|
||||||
|
INDEX idx_expires (expires)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Admin Whitelist ────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_whitelist (
|
||||||
|
discord_id VARCHAR(30) NOT NULL PRIMARY KEY,
|
||||||
|
discord_username VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
added_by VARCHAR(100) NOT NULL DEFAULT 'system'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Ghosts ─────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS ghosts (
|
||||||
|
id VARCHAR(80) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
evidence JSON NOT NULL DEFAULT '[]',
|
||||||
|
sanity TINYINT NOT NULL DEFAULT 50,
|
||||||
|
hunt VARCHAR(20) NOT NULL DEFAULT 'mid',
|
||||||
|
sanity_special VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
tip TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
tells JSON NOT NULL DEFAULT '[]',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
updated_by VARCHAR(100) NOT NULL DEFAULT 'system',
|
||||||
|
INDEX idx_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Submissions ────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ghost_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
type VARCHAR(20) NOT NULL DEFAULT 'tip',
|
||||||
|
username VARCHAR(100) NOT NULL DEFAULT 'Anonym',
|
||||||
|
discord_id VARCHAR(30) DEFAULT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
admin_note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reviewed_at DATETIME DEFAULT NULL,
|
||||||
|
reviewed_by VARCHAR(100) DEFAULT NULL,
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_discord_id (discord_id)
|
||||||
|
) 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"}]'),
|
||||||
|
('demon','Demon','["Freezing","Writing","SpiritBox"]',90,'high','Hunts at 90% — most aggressive','Wird schneller nach jedem Kill · Crucifix Range 2.25× · 1/30 Chance Crucifix ignorieren','The most dangerous ghost — hunts near-constantly and gains speed after every kill.','[{"icon":"⚡","text":"<strong>90% sanity threshold</strong> — can hunt within seconds"},{"icon":"💨","text":"<strong>Gains speed</strong> after every successful kill"},{"icon":"✝️","text":"Crucifix range +40 studs AND 60s cooldown post-burn"},{"icon":"🎲","text":"1/30 chance it <strong>roars and ignores the crucifix</strong>"}]'),
|
||||||
|
('faejkur','Faejkur','["EMF","Freezing","Writing"]',50,'mid','Standard 50%','Mimt Sounds eine Oktave tiefer · Fake Footsteps im Hunt','Copies ambient sounds and replays them one octave lower.','[{"icon":"🎵","text":"All mimicked sounds play at <strong>one octave lower</strong>"},{"icon":"👣","text":"Fake footsteps during hunts"},{"icon":"😂","text":"Parabolic Microphone picks up a unique <strong>Faejkur laugh</strong>"},{"icon":"📡","text":"Mimicked EMF: smooth single tone instead of choppy beeps"}]'),
|
||||||
|
('harrow','Harrow','["Writing","Orbs","SLS"]',50,'mid','+10% per player in its room (stacks)','Kann nicht roamen · Schnell im Raum, langsam außerhalb','The only ghost permanently bound to its favorite room.','[{"icon":"📍","text":"<strong>Cannot roam</strong> — permanently glued to its favorite room"},{"icon":"🏃","text":"Revenant-fast <strong>inside</strong>, very slow <strong>far from</strong> its room"},{"icon":"🧂","text":"Salt outside the favorite room = not Harrow"},{"icon":"😭","text":"Cannot perform Crying Event outside its room"}]'),
|
||||||
|
('jiangshi','Jiangshi','["Freezing","UV","SLS"]',50,'mid','Standard 50% · Added May 2026','3× repetitive actions · skips letters on Spirit Board · skips 2nd salt footstep','Everything happens in threes. Any interaction triggers three times consecutively.','[{"icon":"3️⃣","text":"Any interaction <strong>repeats exactly 3 times</strong>"},{"icon":"📋","text":"Spirit Board skips letters (KITCHEN → K T C H E N)"},{"icon":"🧂","text":"May skip exactly the <strong>2nd footstep</strong> in salt"},{"icon":"👣","text":"Occasional hop during hunts — skipped footstep audio"}]'),
|
||||||
|
('krasue','Krasue','["EMF","Freezing","UV"]',50,'mid','Standard 50%','Schwimmender Kopf near Kerzen · Kerzen verlangsamen sie','The only ghost with a unique visual model — a floating head.','[{"icon":"💀","text":"Unique <strong>floating head model</strong> — 10% base + 10% per lit candle"},{"icon":"🕯️","text":"Each lit candle <strong>slows head form by 1 speed</strong> (max −5)"},{"icon":"🕯️","text":"Stack candles to nullify its LOS speed boost"},{"icon":"✝️","text":"Crucifix in ghost room documented to block appearance change"}]'),
|
||||||
|
('lament','Lament','["EMF","Orbs","SpiritBox"]',50,'mid','Standard 50% · No LOS speed','Kann Hunt Fake beenden → Lights stop, silent, NV works but hunt still active','Extremely deceptive — near the end of a hunt it goes completely silent.','[{"icon":"🔇","text":"<strong>Near hunt end: goes silent</strong> — no heartbeat, lights stop"},{"icon":"💡","text":"Lights stop BUT doors remain locked — <strong>still in a hunt!</strong>"},{"icon":"🔄","text":"<strong>No LOS speed boost</strong> — fully loopable"},{"icon":"⚡","text":"Very rare: drains all nearby players stamina during manifestation"}]'),
|
||||||
|
('mare','Mare','["Freezing","SpiritBox","SLS"]',50,'mid','35% when lit; 50% in darkness','Kein Lightflickers · Kann nicht jagen wenn Raumlicht an','The darkness-dependent ghost. Cannot hunt while its room light is on.','[{"icon":"💡","text":"<strong>Cannot hunt</strong> while its room ceiling light is on"},{"icon":"🕯️","text":"Blows out candles within <strong>90 studs</strong> (vs. 40 for others)"},{"icon":"💥","text":"5% chance to <strong>shatter the light bulb</strong> when turning it off"},{"icon":"⚡","text":"EMF spike when light goes OFF — flip the switch to test"}]'),
|
||||||
|
('nook','Nook','["EMF","Freezing","Orbs"]',50,'mid','25% when in highest-item room','Kann Raum wechseln · Klaut Items · Favorite Room = meisten Items','The roam king — its favorite room is wherever the most items are.','[{"icon":"🏃","text":"Roams most — favors room with <strong>most items</strong>"},{"icon":"💨","text":"Can make nearby objects <strong>completely vanish</strong>"},{"icon":"🌡️","text":"Multiple cold rooms = Nook or Yama"},{"icon":"📦","text":"<strong>Lure tactic:</strong> pile items in a room to redirect its favorite room"}]'),
|
||||||
|
('oni','Oni','["Writing","UV","SLS"]',50,'mid','Standard 50% · Weakens permanently','Opens & closes doors during hunt · fast salt steps · Cannot Sing','Starts very dangerous but permanently weakens each time players use incense, crucifixes, or salt.','[{"icon":"📉","text":"<strong>Permanently slows</strong> each time stunned by incense or salt"},{"icon":"🧂","text":"Salt footstep speed <strong>audibly decreases</strong> as it weakens"},{"icon":"🚪","text":"Opens and closes doors during hunts"},{"icon":"🎵","text":"<strong>Cannot perform Singing Event</strong>"}]'),
|
||||||
|
('phantom','Phantom','["SLS","UV","Orbs"]',50,'mid','Standard 50% · Added May 2026','Invisible on Video Camera · Photo flash stuns 3s','Camera-shy and invisible to video feeds.','[{"icon":"📷","text":"Photo flash <strong>stuns for 3 seconds</strong> (once per 2 min)"},{"icon":"📹","text":"<strong>Invisible on Video Cameras/CCTV</strong>"},{"icon":"👻","text":"Photo during Flash/Fake Hunt events = completely disappears"},{"icon":"🎥","text":"Does not disrupt CCTV footage at all"}]'),
|
||||||
|
('poltergeist','Poltergeist','["UV","Orbs","SpiritBox"]',50,'mid','Standard 50%','Aggresiv mit Türen/Items · Poltsplosion: bis zu 10 Items gleichzeitig','The chaos thrower — can launch up to 10 items simultaneously.','[{"icon":"💥","text":"<strong>Poltsplosion</strong> — throws up to 10 items simultaneously"},{"icon":"🔌","text":"<strong>Only ghost</strong> that consistently interacts with electronics during hunts"},{"icon":"📚","text":"Can throw Ghost Writing Books"},{"icon":"📦","text":"Test: pile items in ghost room — if they explode outward = Poltergeist"}]'),
|
||||||
|
('revenant','Revenant','["EMF","Writing","UV"]',50,'mid','Standard 50%','Normale Footsteps (Salt) = No Revenant · slow → LOS = very fast','The most extreme speed swing in the game.','[{"icon":"👁️","text":"<strong>Extremely slow</strong> without LOS, <strong>blisteringly fast</strong> on LOS"},{"icon":"🏃","text":"Incense stun reverts to base slow speed"},{"icon":"💡","text":"Performs Light Event more often than most ghosts"},{"icon":"🧂","text":"Fast salt-step audio (shared with Demon, Harrow)"}]'),
|
||||||
|
('shade','Shade','["EMF","Writing","SLS"]',35,'low','35% — lowest in game · Very passive','Red Lights Event kann nicht durch Shade · Schnelle Interaktion = Kein Shade','Almost never hunts. Activity drops dramatically with players nearby.','[{"icon":"👥","text":"Activity drops <strong>~80%</strong> when any player is in its room"},{"icon":"🚫","text":"<strong>Cannot</strong> do: Physical Manifestation, Fake Hunt, Flash, Red Lights, Singing"},{"icon":"🔮","text":"Appears <strong>translucent</strong> from Summoning Circle, won\'t hunt"},{"icon":"⚡","text":"Incense stun lasts ~6 seconds"}]'),
|
||||||
|
('spirit','Spirit','["Writing","UV","SpiritBox"]',50,'mid','Standard 50% · Incense vulnerable','Incense stun 6s (vs 2s) · 3 min hunt block after cleanse','Average baseline ghost but uniquely vulnerable to incense.','[{"icon":"🌿","text":"Incense stun lasts <strong>6 seconds</strong> (vs 2s for others)"},{"icon":"🛡️","text":"Cleansing ghost room blocks hunts for <strong>3 minutes</strong>"},{"icon":"⛔","text":"Ghost halts completely before regaining speed after incense"},{"icon":"🧘","text":"Less likely to throw the Incense Burner item"}]'),
|
||||||
|
('strigoi','Strigoi','["EMF","UV","Orbs"]',50,'mid','Standard 50% · Water interaction','UV 4 Finger · turning off water can start a hunt · invisible near water','Water-powered — near active water sources it can turn completely invisible.','[{"icon":"🖐️","text":"<strong>4-fingered handprint</strong> on UV — unique to Strigoi"},{"icon":"💧","text":"Near running water: can <strong>fade completely invisible</strong>"},{"icon":"🚰","text":"Turn off activated tap: <strong>1/8 chance to instantly trigger a hunt</strong>"},{"icon":"👁️","text":"Slowest blink rate in game away from water"}]'),
|
||||||
|
('vuult','Vuult','["EMF","Orbs","SLS"]',50,'variable','Variable 0–100% based on Vuultage charge (0–16+)','Vuultage charge · Each point = +10% sanity threshold · Smashes EMF at 16','Its threat is determined by Vuultage charge (0–16+).','[{"icon":"⚡","text":"<strong>Vuultage:</strong> +2 per nearby electric device, +5 generator on, +10 thunderstorm"},{"icon":"📈","text":"Each charge point <strong>raises hunt threshold by 10%</strong>"},{"icon":"💥","text":"At 16+ charge: can <strong>smash your EMF Reader</strong> during hunt"},{"icon":"💡","text":"Performs Light Events frequently, tampers with breaker early"}]'),
|
||||||
|
('wraith','Wraith','["Freezing","Orbs","SLS"]',50,'mid','Standard 50% · Keine Steps im Salz','Keine Steps im Salz · Teleportiert zu Spielern','Never steps in salt — the single most reliable identification tell.','[{"icon":"🧂","text":"<strong>NEVER steps in salt</strong> — 100% confirmation"},{"icon":"🌀","text":"Can <strong>teleport</strong> to any random player — triggers EMF 2"},{"icon":"👻","text":"Floating animation during Flash Events"},{"icon":"📡","text":"SLS rig may <strong>hover</strong> rather than walk"}]'),
|
||||||
|
('yama','Yama','["Writing","SpiritBox","SLS"]',50,'mid','Standard 50% · Incense = 2 min locked','Roams constantly · incense sticks it 2 min · Spirit Box loud roar','Changes its favorite room every time it roams — roams constantly.','[{"icon":"🗣️","text":"<strong>Spirit Box roar/growl</strong> instead of whisper — unique Yama response"},{"icon":"🚶","text":"Changes favorite room <strong>every time it roams</strong>"},{"icon":"📡","text":"SLS rig can <strong>wander outside</strong> the ghost room"},{"icon":"⏸️","text":"Incense <strong>locks Yama in place for 2 full minutes</strong>"}]'),
|
||||||
|
('yurei','Yurei','["Freezing","UV","SpiritBox"]',50,'mid','Standard 50% · Mostly blind','Macht Auto nicht an · takes time to even step on salt','Almost blind but hyper-aware of sound.','[{"icon":"👁️","text":"<strong>Mostly blind</strong> — stationary players with no electronics may be ignored"},{"icon":"👂","text":"<strong>Exceptional hearing</strong> — any movement or electronic = pursuit"},{"icon":"🔄","text":"<strong>No LOS speed boost</strong> — fully loopable"},{"icon":"🚗","text":"Does not interact with phones or vehicles"}]'),
|
||||||
|
('zozo','ZoZo','["EMF","UV","SpiritBox"]',80,'high','80% threshold — 2nd most aggressive','Verflucht Ouija Board · Slows when you look · rages when you stare','Staring slows it — but stare too long and it rages to Revenant-tier speed.','[{"icon":"👀","text":"<strong>Slows when you look at it</strong> — rage state if you stare too long"},{"icon":"😡","text":"Rage state: Revenant-tier speed for ~10 seconds"},{"icon":"📋","text":"50% chance Spirit Board turns <strong>red and spells ZoZo</strong>"},{"icon":"🚫","text":"Cannot perform Crying or Singing Events"}]');
|
||||||
86
server.js
Normal file
86
server.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const session = require('express-session');
|
||||||
|
const passport = require('passport');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('./db');
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const apiRoutes = require('./routes/api');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// ── Security headers ──────────────────────────────────
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com", "fonts.gstatic.com"],
|
||||||
|
fontSrc: ["'self'", "fonts.gstatic.com", "fonts.googleapis.com"],
|
||||||
|
imgSrc: ["'self'", "data:", "cdn.discordapp.com"],
|
||||||
|
connectSrc: ["'self'", "discord.com"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Body parsers ──────────────────────────────────────
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// ── Session with MySQL store ──────────────────────────
|
||||||
|
const MySQLStore = require('connect-mysql-session')(session);
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
store: new MySQLStore({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: parseInt(process.env.DB_PORT) || 3306,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
}),
|
||||||
|
cookie: {
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Passport ──────────────────────────────────────────
|
||||||
|
require('./middleware/passport')(passport);
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
|
// ── Static files ──────────────────────────────────────
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/api', apiRoutes);
|
||||||
|
app.use('/admin', adminRoutes);
|
||||||
|
|
||||||
|
// ── SPA fallback ──────────────────────────────────────
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`\n🎃 Blair Dashboard läuft auf http://localhost:${PORT}`);
|
||||||
|
console.log(` Modus: ${process.env.NODE_ENV || 'development'}\n`);
|
||||||
|
|
||||||
|
// Owner automatisch als Admin eintragen (falls noch nicht vorhanden)
|
||||||
|
if (process.env.OWNER_DISCORD_ID) {
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
'INSERT IGNORE INTO admin_whitelist (discord_id, discord_username, added_by) VALUES (?, ?, ?)',
|
||||||
|
[process.env.OWNER_DISCORD_ID, process.env.OWNER_DISCORD_NAME || 'Owner', 'system']
|
||||||
|
);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user