Chat / app.js
noah33565's picture
Update app.js
0dff6da verified
raw
history blame
76.3 kB
// ═══════════════════════════════════════════════════════════════
// NOAHSCHAT v4 — JSONBin.io Backend
// ═══════════════════════════════════════════════════════════════
//
// SETUP (5 Minuten, kostenlos):
// 1. https://jsonbin.io → kostenlos registrieren
// 2. Dashboard → "API Keys" → Master Key kopieren
// 3. "+ Create Bin" → leeres JSON {} → Create → Bin ID kopieren
// (du brauchst NUR EINEN Bin für alles!)
// 4. JSONBIN_KEY und JSONBIN_BIN unten eintragen → fertig!
// ─────────────────────────────────────────────────────────────
const JSONBIN_KEY = '$2a$10$Hurr28g4Cy7NWpA/Abd6YOzkBLuC8PdAOPQR34g4pEkA24LpXo7NK'; // ← eintragen
const JSONBIN_BIN = '69976e43ae596e708f382b97'; // ← eintragen
// ─── CONFIG ───────────────────────────────────────────────────
const OWNER_NAME = 'Noah';
const OWNER_PASS = 'Noah100419!';
const POLL_MS = 3000; // Polling-Intervall in ms
// ─── SESSION PERSISTENCE ──────────────────────────────────────
function saveSession(user) {
if (user) localStorage.setItem('nc_session', JSON.stringify({ username: user.username, ts: Date.now() }));
else localStorage.removeItem('nc_session');
}
function loadSession() {
try {
const s = localStorage.getItem('nc_session');
if (!s) return null;
const parsed = JSON.parse(s);
if (Date.now() - parsed.ts > 7 * 86400000) { localStorage.removeItem('nc_session'); return null; }
return parsed;
} catch { return null; }
}
// ─── STATE ────────────────────────────────────────────────────
let currentUser = null;
let currentChannel = 'allgemein';
let currentDM = null;
let pollTimer = null;
let spamTs = [];
let captchaData = {};
let pendingSpamMsg = null;
let appConfig = {};
let voiceActive = false;
let voiceChannel = null;
let micMuted = false;
let deafened = false;
let translationEnabled = false;
let userLanguage = 'de';
let unreadCounts = {};
let lastMsgCounts = {};
// ─── IN-MEMORY DB CACHE ───────────────────────────────────────
// Die komplette DB liegt immer im RAM.
// Schreiben: sofort im Cache sichtbar, async zu JSONBin gesendet.
// Lesen: aus Cache (kein Extra-Request nötig).
// Polling lädt alle POLL_MS die DB neu von JSONBin.
let DB = null;
let isSaving = false;
let saveQueued = false;
const DB_DEFAULT = () => ({
users: {},
msgs: {},
dms: {},
online: {},
voice: {},
tickets: {},
pins: [],
moderation: { bans:{}, banned_fps:{}, kicked:{}, timeouts:{} },
config: {}
});
// ─── FINGERPRINT ──────────────────────────────────────────────
function getFingerprint() {
const s = navigator.userAgent + screen.width + screen.height + screen.colorDepth
+ (Intl.DateTimeFormat().resolvedOptions().timeZone||'') + navigator.language;
let h = 0;
for (let i = 0; i < s.length; i++) { h = ((h<<5)-h) + s.charCodeAt(i); h = h&h; }
return Math.abs(h).toString(36);
}
const MY_FP = getFingerprint();
let heartbeatTimer = null;
function scheduleHeartbeatSave() {
// Nur alle 6 Sekunden wirklich speichern (Polling ist 3s, also jeden 2. Tick)
if (heartbeatTimer) return;
heartbeatTimer = setTimeout(() => {
heartbeatTimer = null;
apiSave();
}, 6000);
}
// ─── JSONBIN API ──────────────────────────────────────────────
const isConfigured = () =>
!JSONBIN_KEY.includes('DEIN') && !JSONBIN_BIN.includes('DEINE');
async function apiLoad(isMerge = false) {
if (!isConfigured()) {
const v = localStorage.getItem('nc_db');
DB = v ? JSON.parse(v) : DB_DEFAULT();
ensureFields();
return;
}
try {
const r = await fetch(`https://api.jsonbin.io/v3/b/${JSONBIN_BIN}/latest`, {
headers: { 'X-Master-Key': JSONBIN_KEY, 'X-Bin-Meta': 'false' }
});
if (!r.ok) throw new Error(r.status);
const remote = await r.json();
if (isMerge && DB) {
// Smart merge: Remote-Daten haben Vorrang, aber lokale Änderungen die
// noch nicht gepusht wurden (pending saves) bleiben erhalten.
// Nachrichten: Union beider Sets (remote + lokal), remote gewinnt bei Konflikt
const mergedMsgs = {};
const allChannels = new Set([...Object.keys(DB.msgs || {}), ...Object.keys(remote.msgs || {})]);
for (const ch of allChannels) {
const localCh = DB.msgs?.[ch] || {};
const remoteCh = remote.msgs?.[ch] || {};
mergedMsgs[ch] = { ...localCh, ...remoteCh }; // Remote überschreibt lokal bei gleichem Key
}
const mergedDms = {};
const allDms = new Set([...Object.keys(DB.dms || {}), ...Object.keys(remote.dms || {})]);
for (const dm of allDms) {
const localDm = DB.dms?.[dm] || {};
const remoteDm = remote.dms?.[dm] || {};
mergedDms[dm] = { ...localDm, ...remoteDm };
}
DB = { ...remote, msgs: mergedMsgs, dms: mergedDms };
} else {
DB = remote;
}
ensureFields();
localStorage.setItem('nc_db', JSON.stringify(DB));
} catch(e) {
console.warn('Load fehlgeschlagen:', e);
const v = localStorage.getItem('nc_db');
if (v) DB = JSON.parse(v);
else DB = DB_DEFAULT();
ensureFields();
}
}
async function apiSave() {
// Immer lokal speichern
localStorage.setItem('nc_db', JSON.stringify(DB));
if (!isConfigured()) return;
if (isSaving) { saveQueued = true; return; }
isSaving = true;
try {
const r = await fetch(`https://api.jsonbin.io/v3/b/${JSONBIN_BIN}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Master-Key': JSONBIN_KEY,
'X-Bin-Versioning': 'false' // Kein Versionsverlauf → schneller
},
body: JSON.stringify(DB)
});
if (!r.ok) console.warn('Save Fehler:', r.status);
} catch(e) {
console.warn('Save Exception:', e);
} finally {
isSaving = false;
if (saveQueued) { saveQueued = false; apiSave(); }
}
}
// Fehlende Felder ergänzen falls DB älter ist
function ensureFields() {
const def = DB_DEFAULT();
for (const k of Object.keys(def)) {
if (DB[k] === undefined) DB[k] = def[k];
}
if (!DB.moderation.bans) DB.moderation.bans = {};
if (!DB.moderation.banned_fps) DB.moderation.banned_fps = {};
if (!DB.moderation.kicked) DB.moderation.kicked = {};
if (!DB.moderation.timeouts) DB.moderation.timeouts = {};
}
// Hilfsfunktionen: Pfad-Zugriff (z.B. "users/noah")
function dbGet(path) {
const parts = path.split('/').filter(Boolean);
let o = DB;
for (const p of parts) { if (o==null) return undefined; o = o[p]; }
return o;
}
function dbSet(path, val) {
const parts = path.split('/').filter(Boolean);
let o = DB;
for (let i = 0; i < parts.length-1; i++) {
if (o[parts[i]] == null) o[parts[i]] = {};
o = o[parts[i]];
}
o[parts[parts.length-1]] = val;
apiSave(); // Async, blockiert nicht
}
function dbDelete(path) {
const parts = path.split('/').filter(Boolean);
let o = DB;
for (let i = 0; i < parts.length-1; i++) {
if (o[parts[i]] == null) return;
o = o[parts[i]];
}
delete o[parts[parts.length-1]];
apiSave();
}
function dbPush(path, val) {
const key = Date.now().toString(36) + Math.random().toString(36).slice(2,5);
val._key = key;
dbSet(path+'/'+key, val);
return key;
}
// ─── ROLE HELPERS ─────────────────────────────────────────────
const ROLE_PRIORITY = { owner:5, admin:4, mod:3, vip:2, member:1 };
const ROLE_BADGE = { owner:'👑', admin:'🔰', mod:'⭐', vip:'💎', member:'' };
const ROLE_LABELS = { owner:'Owner', admin:'Admin', mod:'Moderator', vip:'VIP', member:'Mitglied' };
const ROLE_COLORS = { owner:'name-color-1', admin:'name-color-0', mod:'name-color-2', vip:'name-color-4', member:'' };
function hasRole(r) { return currentUser && ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY[r]; }
// ─── CAPTCHA ──────────────────────────────────────────────────
function genCaptcha() {
const a=Math.floor(Math.random()*18)+2, b=Math.floor(Math.random()*18)+2;
if (Math.random()>.4) return {q:`${a} + ${b} = ?`, ans:a+b};
const big=Math.max(a,b), sm=Math.min(a,b);
return {q:`${big}${sm} = ?`, ans:big-sm};
}
function refreshCaptcha(id) {
const d=genCaptcha(); captchaData[id]=d;
const q=document.getElementById(id+'-q'); if(q) q.textContent=d.q;
const a=document.getElementById(id+'-a'); if(a) a.value='';
}
function verifyCaptcha(id) {
return captchaData[id] && parseInt(document.getElementById(id+'-a')?.value)===captchaData[id].ans;
}
// ─── AUTH TABS ────────────────────────────────────────────────
function switchTab(tab) {
['login','register'].forEach((t,i)=>{
document.getElementById('tab-'+t).style.display=t===tab?'':'none';
document.querySelectorAll('.auth-tab')[i].classList.toggle('active',t===tab);
});
document.getElementById('auth-error').textContent='';
}
// ─── REGISTER ─────────────────────────────────────────────────
async function doRegister() {
const u=document.getElementById('reg-user').value.trim();
const p=document.getElementById('reg-pass').value;
const p2=document.getElementById('reg-pass2').value;
const err=t=>document.getElementById('auth-error').textContent=t;
if (u.length<2) return err('Username mind. 2 Zeichen');
if (u.length>20) return err('Username max. 20 Zeichen');
if (!/^[a-zA-Z0-9_]+$/.test(u)) return err('Nur Buchstaben, Zahlen, _');
if (p.length<4) return err('Passwort mind. 4 Zeichen');
if (p!==p2) return err('Passwörter stimmen nicht überein');
if (!verifyCaptcha('reg')) { refreshCaptcha('reg'); return err('❌ Falsches Captcha!'); }
if (DB.moderation.bans?.[u.toLowerCase()]) return err('Dieser Account ist gesperrt.');
if (DB.moderation.banned_fps?.[MY_FP]) return showBanned();
if (DB.users[u.toLowerCase()]) return err('Username bereits vergeben');
dbSet(`users/${u.toLowerCase()}`, {
username:u, password:encode(p),
color:Math.floor(Math.random()*5), role:'member', joined:Date.now(),
settings:{lang:'de', translate:false, notif:true}
});
refreshCaptcha('reg');
showAuthSuccess('✓ Account erstellt! Du kannst dich jetzt einloggen.');
setTimeout(()=>{ switchTab('login'); document.getElementById('login-user').value=u; }, 1300);
}
// ─── LOGIN ────────────────────────────────────────────────────
async function doLogin() {
const u=document.getElementById('login-user').value.trim();
const p=document.getElementById('login-pass').value;
const err=t=>document.getElementById('auth-error').textContent=t;
if (!verifyCaptcha('login')) { refreshCaptcha('login'); return err('❌ Falsches Captcha!'); }
if (DB.moderation.banned_fps?.[MY_FP]) return showBanned();
const entry=DB.users[u.toLowerCase()];
if (!entry) return err('Falscher Username oder Passwort');
if (decode(entry.password)!==p)return err('Falscher Username oder Passwort');
if (DB.moderation.bans?.[u.toLowerCase()]) return err('🚫 Du bist von NoahsChat gebannt.');
currentUser={...entry};
saveSession(currentUser);
refreshCaptcha('login');
dbSet(`users/${u.toLowerCase()}/_fp`, MY_FP);
dbSet(`online/${entry.username}`, Date.now());
userLanguage=entry.settings?.lang||'de';
translationEnabled=entry.settings?.translate||false;
launchApp();
}
// ─── LOGOUT ───────────────────────────────────────────────────
async function doLogout() {
if (!currentUser) return;
saveSession(null);
if (voiceActive) await leaveVoice();
dbDelete(`online/${currentUser.username}`);
if (voiceActive) leaveVoice();
clearInterval(pollTimer);
currentUser=null;
document.getElementById('app').classList.remove('visible');
document.getElementById('auth-screen').style.display='flex';
document.getElementById('login-pass').value='';
refreshCaptcha('login');
}
// ─── LAUNCH APP ───────────────────────────────────────────────
async function launchApp() {
document.getElementById('auth-screen').style.display='none';
document.getElementById('app').classList.add('visible');
appConfig = DB.config || {};
if (!appConfig.channels) {
appConfig.channels = window.DEFAULT_CHANNELS||[];
appConfig.automod = window.DEFAULT_AUTOMOD||{};
dbSet('config', appConfig);
}
updateTopbar();
buildChannelList();
restoreVoiceState();
openChannel('allgemein');
startPolling();
}
// ─── POLLING ──────────────────────────────────────────────────
function startPolling() {
pollTimer = setInterval(pollTick, POLL_MS);
}
async function pollTick() {
if (!currentUser) return;
// DB neu laden → bekommt Änderungen anderer User (mit Merge, damit lokale Msgs nicht verloren gehen)
setSyncStatus('sync');
await apiLoad(true);
setSyncStatus('ok');
// Eigene Daten aktualisieren (Rolle könnte geändert worden sein)
const me = DB.users[currentUser.username.toLowerCase()];
if (me) {
currentUser = {...me};
updateTopbar();
userLanguage = me.settings?.lang||'de';
translationEnabled = me.settings?.translate||false;
}
// Heartbeat — NUR den eigenen Online-Eintrag setzen, NICHT die komplette DB speichern
// Direkt im Objekt setzen und gezielt speichern um Race-Conditions zu vermeiden
DB.online[currentUser.username] = Date.now();
// Heartbeat-only save: kleines Debounce damit nicht zu viele Requests entstehen
scheduleHeartbeatSave();
// Moderation-Checks
const mod = DB.moderation;
if (mod.bans?.[currentUser.username.toLowerCase()] || mod.banned_fps?.[MY_FP]) {
clearInterval(pollTimer); showBanned(); return;
}
const kick = mod.kicked?.[currentUser.username.toLowerCase()];
if (kick && Date.now()-kick.ts < 60000) { clearInterval(pollTimer); showKickedModal(); return; }
updateTimeoutBar();
// Online-User-Liste
const now = Date.now();
const onlineNames = Object.entries(DB.online)
.filter(([,ts])=>now-ts<12000).map(([u])=>u);
document.getElementById('online-count').textContent = onlineNames.length;
renderUserList(onlineNames);
updateDMList();
// Nachrichten anzeigen
if (currentDM) renderDMMessages(currentDM);
else {
renderChannelMessages(currentChannel);
// Unread-Badges für andere Channels
for (const ch of (appConfig.channels||[])) {
if (ch.id===currentChannel) continue;
const msgs = getMsgs(ch.id);
const key = 'ch_'+ch.id;
const prev = lastMsgCounts[key]||0;
if (msgs.length>prev) {
unreadCounts[key] = (unreadCounts[key]||0) + (msgs.length-prev);
updateBadge('badge-'+ch.id, unreadCounts[key]);
lastMsgCounts[key] = msgs.length;
}
}
}
// DM Unread-Badges
for (const entry of Object.values(DB.users)) {
if (entry.username===currentUser.username) continue;
if (currentDM===entry.username) continue;
const key = 'dm_'+entry.username.toLowerCase();
const msgs = getDMMsgs(getDMKey(currentUser.username, entry.username));
const prev = lastMsgCounts[key]||0;
if (msgs.length>prev) {
unreadCounts[key] = (unreadCounts[key]||0) + (msgs.length-prev);
lastMsgCounts[key] = msgs.length;
updateDMList();
}
}
// Abgelaufene Voice-Einträge aufräumen (20s Timeout)
for (const [vcId, parts] of Object.entries(DB.voice||{})) {
for (const [uname, data] of Object.entries(parts||{})) {
if (now-(data.ts||0) > 20000) {
delete DB.voice[vcId][uname];
}
}
}
// Voice: neue User im Channel erkennen und anrufen
voicePollConnect();
// Voice-Heartbeat: eigenen Eintrag aktuell halten
if (voiceActive && voiceChannel && DB.voice?.[voiceChannel]?.[currentUser.username]) {
DB.voice[voiceChannel][currentUser.username].ts = now;
}
buildChannelList();
}
// ─── SYNC STATUS INDICATOR ────────────────────────────────────
function setSyncStatus(state) { /* deaktiviert – keine störende Animation */ }
function restoreVoiceState() {
const vcNames = {'voice-allgemein':'Allgemein','voice-gaming':'Gaming','voice-musik':'Musik'};
for (const [vcId, parts] of Object.entries(DB.voice||{})) {
if (parts?.[currentUser.username]) {
// Nach Reload: Voice neu joinen (stellt PeerJS-Verbindung wieder her)
const name = vcNames[vcId] || vcId;
joinVoice(vcId, name);
break;
}
}
}
// ─── TOPBAR ───────────────────────────────────────────────────
function updateTopbar() {
const av=document.getElementById('topbar-avatar');
av.textContent=currentUser.username[0].toUpperCase();
av.className=`user-avatar-sm av-color-${currentUser.color}`;
document.getElementById('topbar-username').textContent=currentUser.username;
document.getElementById('topbar-role-badge').textContent=ROLE_BADGE[currentUser.role]||'';
}
// ─── SIDEBAR ──────────────────────────────────────────────────
function buildChannelList() {
const channels=appConfig.channels||window.DEFAULT_CHANNELS||[];
const list=document.getElementById('channel-list');
list.innerHTML='';
const cats={info:'Info',chat:'Text-Channels',fun:'Community',staff:'Staff'};
const grouped={};
for (const ch of channels) {
const cat=ch.category||'chat';
if (!grouped[cat]) grouped[cat]=[];
grouped[cat].push(ch);
}
for (const [catId,label] of Object.entries(cats)) {
const chs=grouped[catId]; if (!chs) continue;
if (catId==='staff'&&!hasRole('mod')) continue;
const catEl=document.createElement('div');
catEl.className='channel-category';
catEl.textContent='— '+label;
list.appendChild(catEl);
for (const ch of chs) {
if (ch.adminOnly&&!hasRole('admin')) {
if (catId==='staff') continue;
const div=document.createElement('div');
div.className='channel-item ch-locked';
div.innerHTML=`<span class="channel-icon">${ch.icon}</span><span class="channel-name">${ch.name}</span><span style="font-size:.65rem;color:var(--muted)">🔒</span>`;
list.appendChild(div); continue;
}
if (ch.modOnly&&!hasRole('mod')) continue;
const div=document.createElement('div');
div.className='channel-item'+(ch.id===currentChannel&&!currentDM?' active':'');
div.dataset.channel=ch.id;
div.innerHTML=`<span class="channel-icon">${ch.icon}</span><span class="channel-name">${ch.name}</span><span class="unread-badge" id="badge-${ch.id}" style="display:none"></span>`;
div.onclick=()=>openChannel(ch.id);
list.appendChild(div);
}
}
// Voice Channels
const vcCat=document.createElement('div');
vcCat.className='channel-category';
vcCat.textContent='— Voice Channels';
list.appendChild(vcCat);
for (const vc of [{id:'voice-allgemein',name:'Allgemein',icon:'🔊'},{id:'voice-gaming',name:'Gaming',icon:'🎮'},{id:'voice-musik',name:'Musik',icon:'🎵'}]) {
const isActive=voiceActive&&voiceChannel===vc.id;
const div=document.createElement('div');
div.className='channel-item'+(isActive?' active':'');
div.innerHTML=`<span class="channel-icon">${vc.icon}</span><span class="channel-name">${vc.name}</span>`;
div.onclick=()=>joinVoice(vc.id,vc.name);
list.appendChild(div);
for (const p of Object.values(DB.voice?.[vc.id]||{})) {
const pe=document.createElement('div');
pe.style.cssText='padding:3px 14px 3px 32px;font-size:.78rem;color:var(--green);display:flex;gap:6px;align-items:center;pointer-events:none';
pe.innerHTML=`<span>🎙</span><span>${escHtml(p.name)}</span>${p.muted?'<span style="color:var(--muted)">🔇</span>':''}`;
list.appendChild(pe);
}
}
}
// ─── CHANNELS ─────────────────────────────────────────────────
function openChannel(chId) {
currentChannel=chId; currentDM=null;
document.querySelectorAll('.channel-item').forEach(el=>el.classList.remove('active'));
document.querySelector(`[data-channel="${chId}"]`)?.classList.add('active');
document.querySelectorAll('.dm-item').forEach(el=>el.classList.remove('active'));
const ch=(appConfig.channels||[]).find(c=>c.id===chId)||{name:chId,icon:'💬',sub:''};
document.getElementById('chat-title').textContent=ch.icon+' '+ch.name;
document.getElementById('chat-sub').textContent=ch.sub||'';
document.getElementById('topbar-channel-name').textContent='# '+ch.name;
unreadCounts['ch_'+chId]=0; updateBadge('badge-'+chId,0);
const locked=(ch.adminOnly&&!hasRole('admin'))||(ch.readOnly&&!hasRole('admin'))||(ch.modOnly&&!hasRole('mod'));
document.getElementById('msg-input').disabled=locked;
document.getElementById('send-btn').disabled=locked;
document.getElementById('msg-input').placeholder=locked?'🔒 Du kannst hier nicht schreiben':'Nachricht schreiben...';
renderChannelMessages(chId);
}
function getMsgs(chId) {
return Object.values(DB.msgs[chId]||{}).sort((a,b)=>a.ts-b.ts);
}
function renderChannelMessages(chId) {
const msgs=getMsgs(chId);
lastMsgCounts['ch_'+chId]=msgs.length;
renderMessages(msgs);
}
// ─── DMs ──────────────────────────────────────────────────────
function getDMKey(a,b) { return [a.toLowerCase(),b.toLowerCase()].sort().join('__'); }
function getDMMsgs(key) {
return Object.values(DB.dms[key]||{}).sort((a,b)=>a.ts-b.ts);
}
function openDM(username) {
currentDM=username; currentChannel=null;
document.querySelectorAll('.channel-item').forEach(el=>el.classList.remove('active'));
document.querySelectorAll('.dm-item').forEach(el=>{
el.classList.toggle('active',el.dataset.user===username.toLowerCase());
});
document.getElementById('chat-title').textContent='@ '+username;
document.getElementById('chat-sub').textContent='Direktnachricht – privat';
document.getElementById('topbar-channel-name').textContent='@ '+username;
document.getElementById('msg-input').disabled=false;
document.getElementById('send-btn').disabled=false;
document.getElementById('msg-input').placeholder=`Nachricht an @${username}...`;
unreadCounts['dm_'+username.toLowerCase()]=0;
updateDMList();
renderDMMessages(username);
}
function renderDMMessages(username) {
const key=getDMKey(currentUser.username,username);
const msgs=getDMMsgs(key);
lastMsgCounts['dm_'+username.toLowerCase()]=msgs.length;
renderMessages(msgs,true);
}
// ─── RENDER MESSAGES ──────────────────────────────────────────
function renderMessages(msgs, isDM=false) {
const c=document.getElementById('messages');
const atBottom=c.scrollHeight-c.scrollTop<=c.clientHeight+80;
if (!msgs.length) {
c.innerHTML='<div class="empty-chat"><div class="empty-chat-icon">💬</div><p>Noch keine Nachrichten. Schreib was!</p></div>';
return;
}
let html='', lastDay='', lastAuthor='', lastTime=0;
for (let i=0; i<msgs.length; i++) {
const m=msgs[i];
if (m.system) { html+=`<div class="msg-system">⚙ ${escHtml(m.text)}</div>`; lastAuthor=''; continue; }
if (m.automod){ html+=`<div class="msg-automod">🤖 AutoMod: ${escHtml(m.text)}</div>`; lastAuthor=''; continue; }
const date=new Date(m.ts);
const day=date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'});
const time=date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
if (day!==lastDay) { html+=`<div class="day-divider">${day}</div>`; lastDay=day; lastAuthor=''; }
const color=m.color??0;
const consec=m.author===lastAuthor&&(m.ts-lastTime)<5*60000;
lastAuthor=m.author; lastTime=m.ts;
const initials=(m.author||'?')[0].toUpperCase();
const role=m.role||'member';
const badge=ROLE_BADGE[role]||'';
const nc=ROLE_COLORS[role]||`name-color-${color}`;
const canDel=hasRole('mod')||m.author===currentUser?.username;
const canPin=hasRole('mod')&&!isDM;
const safeT=escHtml((m.text||'').substring(0,50));
html+=`<div class="msg ${consec?'consecutive':''}" data-key="${m._key||''}">
<div class="msg-avatar av-color-${color}" onclick="onAvatarClick(event,'${escHtml(m.author)}')">${initials}</div>
<div class="msg-content">
<div class="msg-header">
<span class="msg-name ${nc}" onclick="onAvatarClick(event,'${escHtml(m.author)}')">${escHtml(m.author)}${badge?` <span class="msg-role-badge">${badge}</span>`:''}</span>
<span class="msg-time">${time}</span>
<div class="msg-actions">
${canPin?`<button class="msg-action-btn" onclick="pinMsg('${escHtml(m.author)}','${safeT}')">📌</button>`:''}
${canDel?`<button class="msg-action-btn danger" onclick="deleteMsg('${m._key||''}',${isDM})">🗑</button>`:''}
</div>
</div>
<div class="msg-text">${formatMsg(m.text)}</div>
</div>
</div>`;
}
c.innerHTML=html;
if (atBottom) c.scrollTop=c.scrollHeight;
}
function formatMsg(text) {
return escHtml(text)
.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.*?)\*/g,'<em>$1</em>')
.replace(/`(.*?)`/g,'<code style="background:rgba(0,212,255,0.08);padding:1px 5px;border-radius:2px;font-family:monospace">$1</code>')
.replace(/(https?:\/\/[^\s]+)/g,'<a href="$1" target="_blank" rel="noopener">$1</a>');
}
// ─── SEND ─────────────────────────────────────────────────────
async function sendMessage() {
const input=document.getElementById('msg-input');
const text=input.value.trim();
if (!text||!currentUser) return;
const mod=DB.moderation;
const to=mod.timeouts?.[currentUser.username.toLowerCase()];
if (to&&to.until>Date.now()) { showToast('Du bist stummgeschaltet!','var(--warn)'); return; }
if (!currentDM) {
const ch=(appConfig.channels||[]).find(c=>c.id===currentChannel);
if (ch?.adminOnly&&!hasRole('admin')) return;
if (ch?.readOnly&&!hasRole('admin')) return;
if (ch?.modOnly&&!hasRole('mod')) return;
const am=autoModCheck(text);
if (am.blocked) { showToast('🤖 AutoMod: '+am.reason,'var(--warn)'); input.value=''; input.style.height=''; return; }
}
const now=Date.now();
spamTs=spamTs.filter(t=>now-t<(appConfig.automod?.spamWindow||10000));
spamTs.push(now);
if (spamTs.length>(appConfig.automod?.spamThreshold||5)) {
pendingSpamMsg=text; input.value=''; input.style.height='';
showSpamCaptchaModal(); return;
}
input.value=''; input.style.height='';
const msg={author:currentUser.username,color:currentUser.color,role:currentUser.role,text,ts:Date.now()};
if (currentDM) {
const key=getDMKey(currentUser.username,currentDM);
if (!DB.dms[key]) DB.dms[key]={};
dbPush(`dms/${key}`,msg);
renderDMMessages(currentDM);
} else {
if (!DB.msgs[currentChannel]) DB.msgs[currentChannel]={};
dbPush(`msgs/${currentChannel}`,msg);
renderChannelMessages(currentChannel);
document.getElementById('messages').scrollTop=9999999;
}
}
function autoModCheck(text) {
const am=appConfig.automod||{};
if (!am.enabled) return {blocked:false};
if (am.maxMsgLength&&text.length>am.maxMsgLength) return {blocked:true,reason:`Zu lang (max. ${am.maxMsgLength})`};
const lower=text.toLowerCase();
for (const w of (am.bannedWords||[])) { if(lower.includes(w.toLowerCase())) return {blocked:true,reason:'Verbotenes Wort'}; }
if (am.capsFilter&&text.length>10) {
const caps=(text.match(/[A-ZÄÖÜ]/g)||[]).length;
if (caps/text.length>(am.capsThreshold||0.7)) return {blocked:true,reason:'Zu viele Großbuchstaben'};
}
return {blocked:false};
}
function deleteMsg(key,isDM=false) {
if (!key) return;
if (isDM&&currentDM) {
const dmKey=getDMKey(currentUser.username,currentDM);
const m=DB.dms[dmKey]?.[key];
if (!m||(m.author!==currentUser.username&&!hasRole('mod'))) return;
dbDelete(`dms/${dmKey}/${key}`);
renderDMMessages(currentDM);
} else {
const m=DB.msgs[currentChannel]?.[key];
if (!m||(m.author!==currentUser.username&&!hasRole('mod'))) return;
dbDelete(`msgs/${currentChannel}/${key}`);
renderChannelMessages(currentChannel);
}
}
function pinMsg(author,text) {
if (!Array.isArray(DB.pins)) DB.pins=[];
DB.pins.push({author,text,by:currentUser.username,ts:Date.now(),channel:currentChannel});
if (DB.pins.length>50) DB.pins.shift();
apiSave();
showToast('📌 Nachricht angepinnt','var(--green)');
}
function handleKey(e) { if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();} }
function autoResize(el) { el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,120)+'px'; }
// ─── TIMEOUT BAR ──────────────────────────────────────────────
function updateTimeoutBar() {
const t=DB.moderation.timeouts?.[currentUser.username.toLowerCase()];
const bar=document.getElementById('timeout-bar');
if (t&&t.until>Date.now()) {
bar.style.display='';
document.getElementById('timeout-until').textContent=
new Date(t.until).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
document.getElementById('msg-input').disabled=true;
document.getElementById('send-btn').disabled=true;
} else {
bar.style.display='none';
if (!currentDM) {
const ch=(appConfig.channels||[]).find(c=>c.id===currentChannel);
const locked=(ch?.adminOnly&&!hasRole('admin'))||(ch?.readOnly&&!hasRole('admin'));
document.getElementById('msg-input').disabled=!!locked;
document.getElementById('send-btn').disabled=!!locked;
}
}
}
function updateBadge(id,count) {
const el=document.getElementById(id); if (!el) return;
if (count>0){el.style.display='';el.textContent=count>99?'99+':count;}
else el.style.display='none';
}
// ═══════════════════════════════════════════════════════════════
// REAL VOICE — WebRTC via PeerJS
// ═══════════════════════════════════════════════════════════════
let myPeer = null; // PeerJS-Instanz
let localStream = null; // Mikrofon-Stream
let activeCalls = {}; // { peerId: Call }
let activeAudios = {}; // { peerId: <audio> }
// Peer-ID aus Username + Channel deterministisch ableiten
// (damit man nach Reload reconnecten kann)
function makePeerId(username, vcId) {
// PeerJS IDs dürfen nur alphanumeric + - _ sein
const safe = (username + '__' + vcId).replace(/[^a-zA-Z0-9_-]/g, '_');
return 'nc__' + safe;
}
async function joinVoice(vcId, vcName) {
if (voiceActive && voiceChannel === vcId) { leaveVoice(); return; }
if (voiceActive) await leaveVoice();
// Mikrofon anfordern
try {
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
} catch(e) {
showToast('🎙 Mikrofon-Zugriff verweigert!', 'var(--danger)');
return;
}
voiceActive = true;
voiceChannel = vcId;
micMuted = false;
deafened = false;
const peerId = makePeerId(currentUser.username, vcId);
// Alten Peer aufräumen falls vorhanden
if (myPeer && !myPeer.destroyed) {
myPeer.destroy();
myPeer = null;
}
// PeerJS initialisieren
myPeer = new Peer(peerId, {
host: 'peerjs-server.netlify.app', // kostenloser public PeerJS server
secure: true,
path: '/',
debug: 0,
config: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]
}
});
myPeer.on('open', (id) => {
console.log('[Voice] Peer offen:', id);
// In DB eintragen
if (!DB.voice[vcId]) DB.voice[vcId] = {};
DB.voice[vcId][currentUser.username] = {
name: currentUser.username,
peerId: id,
muted: false,
ts: Date.now()
};
apiSave();
// Mit allen anderen im Channel verbinden
const others = Object.entries(DB.voice[vcId] || {})
.filter(([uname]) => uname !== currentUser.username);
for (const [uname, data] of others) {
if (data.peerId) callPeer(data.peerId);
}
// Panel anzeigen
document.getElementById('voice-panel').classList.add('active');
document.getElementById('voice-channel-name').textContent = '🔊 ' + vcName;
document.getElementById('voice-mute-btn').textContent = '🎙 Stumm';
document.getElementById('voice-deaf-btn').textContent = '🔔 Taub';
document.getElementById('voice-mute-btn').classList.remove('active');
document.getElementById('voice-deaf-btn').classList.remove('active');
buildChannelList();
showToast(`🔊 Beigetreten: ${vcName}`, 'var(--green)');
});
// Eingehende Anrufe entgegennehmen
myPeer.on('call', (call) => {
call.answer(localStream);
handleCallStream(call);
});
myPeer.on('error', (err) => {
// ID bereits vergeben → mit Suffix nochmal versuchen
if (err.type === 'unavailable-id') {
console.warn('[Voice] Peer-ID besetzt, versuche disconnect...');
// Alten Eintrag löschen und nochmal joinen
if (DB.voice?.[vcId]?.[currentUser.username]) {
delete DB.voice[vcId][currentUser.username];
apiSave();
}
setTimeout(() => joinVoice(vcId, vcName), 1500);
} else {
console.warn('[Voice] Peer-Fehler:', err.type, err.message);
showToast('🔊 Voice-Fehler: ' + err.type, 'var(--warn)');
}
});
myPeer.on('disconnected', () => {
if (voiceActive) myPeer.reconnect();
});
}
function callPeer(remotePeerId) {
if (!localStream || !myPeer || activeCalls[remotePeerId]) return;
console.log('[Voice] Rufe an:', remotePeerId);
const call = myPeer.call(remotePeerId, localStream);
if (!call) return;
handleCallStream(call);
}
function handleCallStream(call) {
activeCalls[call.peer] = call;
call.on('stream', (remoteStream) => {
if (activeAudios[call.peer]) {
activeAudios[call.peer].srcObject = remoteStream;
return;
}
const audio = document.createElement('audio');
audio.autoplay = true;
audio.srcObject = remoteStream;
document.body.appendChild(audio);
activeAudios[call.peer] = audio;
console.log('[Voice] Stream von:', call.peer);
});
call.on('close', () => {
cleanupPeer(call.peer);
});
call.on('error', (e) => {
console.warn('[Voice] Call-Fehler:', e);
cleanupPeer(call.peer);
});
}
function cleanupPeer(peerId) {
if (activeCalls[peerId]) { try { activeCalls[peerId].close(); } catch {} delete activeCalls[peerId]; }
if (activeAudios[peerId]) { activeAudios[peerId].remove(); delete activeAudios[peerId]; }
}
async function leaveVoice() {
if (!voiceActive) return;
const ch = voiceChannel;
voiceActive = false;
voiceChannel = null;
// Alle Calls beenden
for (const pid of Object.keys(activeCalls)) cleanupPeer(pid);
activeCalls = {};
// Mikrofon stoppen
if (localStream) {
localStream.getTracks().forEach(t => t.stop());
localStream = null;
}
// Peer zerstören
if (myPeer && !myPeer.destroyed) {
myPeer.destroy();
myPeer = null;
}
// DB-Eintrag entfernen
if (DB.voice?.[ch]?.[currentUser.username]) {
delete DB.voice[ch][currentUser.username];
apiSave();
}
document.getElementById('voice-panel').classList.remove('active');
buildChannelList();
showToast('Voice verlassen', 'var(--muted)');
}
function toggleMute() {
micMuted = !micMuted;
const btn = document.getElementById('voice-mute-btn');
btn.textContent = micMuted ? '🔇 Stumm' : '🎙 Stumm';
btn.classList.toggle('active', micMuted);
// Mikrofon-Track stumm/laut schalten
if (localStream) {
localStream.getAudioTracks().forEach(t => { t.enabled = !micMuted; });
}
if (voiceActive && DB.voice?.[voiceChannel]?.[currentUser.username]) {
DB.voice[voiceChannel][currentUser.username].muted = micMuted;
DB.voice[voiceChannel][currentUser.username].ts = Date.now();
apiSave();
}
}
function toggleDeafen() {
deafened = !deafened;
const btn = document.getElementById('voice-deaf-btn');
btn.textContent = deafened ? '🔕 Taub' : '🔔 Taub';
btn.classList.toggle('active', deafened);
// Alle eingehenden Audios stumm/laut schalten
for (const audio of Object.values(activeAudios)) {
audio.muted = deafened;
}
}
// Im Poll-Tick: neue User im Channel erkennen und anrufen
function voicePollConnect() {
if (!voiceActive || !voiceChannel || !myPeer || myPeer.disconnected) return;
const others = Object.entries(DB.voice[voiceChannel] || {})
.filter(([uname]) => uname !== currentUser.username);
for (const [uname, data] of others) {
if (data.peerId && !activeCalls[data.peerId]) {
callPeer(data.peerId);
}
}
}
// ─── USER LIST ────────────────────────────────────────────────
function renderUserList(onlineNames) {
const listEl=document.getElementById('user-list');
listEl.innerHTML='';
const groups={owner:[],admin:[],mod:[],vip:[],member:[]};
for (const u of Object.values(DB.users)) {
if (groups[u.role||'member']) groups[u.role||'member'].push(u);
else groups.member.push(u);
}
for (const [roleId,users] of Object.entries(groups)) {
if (!users.length) continue;
const hdr=document.createElement('div');
hdr.className='role-group-header';
hdr.innerHTML=`<span>${ROLE_LABELS[roleId]}</span><span style="color:var(--muted);font-size:.7rem">${users.length}</span>`;
listEl.appendChild(hdr);
for (const u of users) {
const isMe=u.username===currentUser?.username;
const isOnline=onlineNames.includes(u.username);
const div=document.createElement('div');
div.className='user-item'+(isMe?' me':'')+(!isOnline?' offline':'');
div.innerHTML=`<div class="${isOnline?'online-dot':'offline-dot'}"></div>
<div class="user-avatar-sm av-color-${u.color??0}">${u.username[0].toUpperCase()}</div>
<span>${escHtml(u.username)}${ROLE_BADGE[u.role]?' '+ROLE_BADGE[u.role]:''}</span>`;
if (!isMe) div.onclick=e=>{e.stopPropagation();showUserCtx(e,u.username);};
listEl.appendChild(div);
}
}
}
function updateDMList() {
const dmEl=document.getElementById('dm-list');
dmEl.innerHTML='';
for (const u of Object.values(DB.users)) {
if (u.username===currentUser?.username) continue;
const unread=unreadCounts['dm_'+u.username.toLowerCase()]||0;
const div=document.createElement('div');
div.className=`channel-item dm-item ${currentDM===u.username?'active':''}`;
div.dataset.user=u.username.toLowerCase();
div.innerHTML=`<span class="channel-icon">@</span><span class="channel-name">${escHtml(u.username)}</span>${unread>0?`<span class="unread-badge">${unread}</span>`:''}`;
div.onclick=()=>openDM(u.username);
dmEl.appendChild(div);
}
}
// ─── CONTEXT MENU ─────────────────────────────────────────────
function onAvatarClick(e,username) {
e.preventDefault(); e.stopPropagation();
hideCtx();
if (username===currentUser?.username) return;
showUserCtx(e,username);
}
function showUserCtx(e,username) {
e.preventDefault(); e.stopPropagation();
hideCtx();
const menu=document.getElementById('context-menu');
menu.innerHTML='';
const h=document.createElement('div'); h.className='ctx-header'; h.textContent=username; menu.appendChild(h);
addCtxItem(menu,'💬 Direktnachricht','',()=>{hideCtx();openDM(username);});
addCtxItem(menu,'👤 Profil','',()=>{hideCtx();showProfileModal(username);});
if (hasRole('mod')) {
addCtxDivider(menu);
addCtxItem(menu,'👢 Kicken','warn',()=>{hideCtx();openKickModal(username);});
addCtxItem(menu,'⏱ Timeout','warn',()=>{hideCtx();openTimeoutModal(username);});
addCtxItem(menu,'🗑 Nachrichten löschen','warn',()=>{hideCtx();clearUserMsgs(username);});
}
if (hasRole('admin')) {
addCtxItem(menu,'🚫 Bannen','danger',()=>{hideCtx();openBanModal(username);});
addCtxDivider(menu);
addCtxItem(menu,'🎖 Rolle ändern','',()=>{hideCtx();openRoleModal(username);});
}
menu.style.left=Math.min(e.clientX,window.innerWidth-210)+'px';
menu.style.top=Math.min(e.clientY,window.innerHeight-200)+'px';
menu.style.display='';
}
function addCtxItem(menu,label,cls,fn){const i=document.createElement('div');i.className='ctx-item'+(cls?' '+cls:'');i.textContent=label;i.onclick=fn;menu.appendChild(i);}
function addCtxDivider(menu){const d=document.createElement('div');d.className='ctx-divider';menu.appendChild(d);}
function hideCtx(){document.getElementById('context-menu').style.display='none';}
document.addEventListener('click',hideCtx);
// ─── MODERATION ───────────────────────────────────────────────
function openKickModal(u) {
showModal(`<div class="modal-title">👢 Kick: ${escHtml(u)}</div>
<div class="modal-sub">Der User wird aus dem Chat geworfen.</div>
<div class="modal-field"><label>Grund (optional)</label><input type="text" id="m-reason" placeholder="z.B. Regelverstoß"></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn warn-btn" onclick="doKick('${escHtml(u)}')">Kicken</button>
</div>`);
}
function doKick(u) {
const reason=document.getElementById('m-reason')?.value||'';
if (!DB.moderation.kicked) DB.moderation.kicked={};
DB.moderation.kicked[u.toLowerCase()]={by:currentUser.username,ts:Date.now(),reason};
apiSave();
sysMsg(`${u} wurde von ${currentUser.username} gekickt.${reason?' Grund: '+reason:''}`);
clearModal(); showToast(`${u} gekickt`,'var(--warn)');
}
function openTimeoutModal(u) {
showModal(`<div class="modal-title">⏱ Timeout: ${escHtml(u)}</div>
<div class="modal-sub">Stummschalten für eine bestimmte Zeit.</div>
<div class="modal-field"><label>Dauer</label>
<select id="m-dur">
<option value="60000">1 Minute</option><option value="300000">5 Minuten</option>
<option value="600000">10 Minuten</option><option value="1800000">30 Minuten</option>
<option value="3600000">1 Stunde</option><option value="86400000">24 Stunden</option>
</select></div>
<div class="modal-field"><label>Grund</label><input type="text" id="m-reason" placeholder="z.B. Spam"></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn warn-btn" onclick="doTimeout('${escHtml(u)}')">Timeout</button>
</div>`);
}
function doTimeout(u) {
const dur=parseInt(document.getElementById('m-dur').value);
const reason=document.getElementById('m-reason')?.value||'';
if (!DB.moderation.timeouts) DB.moderation.timeouts={};
DB.moderation.timeouts[u.toLowerCase()]={until:Date.now()+dur,by:currentUser.username,reason};
apiSave();
sysMsg(`${u} stummgeschaltet für ${Math.round(dur/60000)} Min.${reason?' Grund: '+reason:''}`);
clearModal(); showToast(`${u} stummgeschaltet`,'var(--warn)');
}
function openBanModal(u) {
showModal(`<div class="modal-title" style="color:var(--danger)">🚫 Ban: ${escHtml(u)}</div>
<div class="modal-sub">Permanenter Bann.</div>
<div class="modal-field"><label>Grund</label><input type="text" id="m-reason" placeholder="Banngrund (Pflicht)"></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn danger-btn" onclick="doBan('${escHtml(u)}')">BANNEN</button>
</div>`);
}
function doBan(u) {
const reason=document.getElementById('m-reason')?.value||'';
if (!reason) { showToast('Bitte einen Grund angeben!','var(--danger)'); return; }
if (!DB.moderation.bans) DB.moderation.bans={};
DB.moderation.bans[u.toLowerCase()]={by:currentUser.username,ts:Date.now(),reason};
const fp=DB.users[u.toLowerCase()]?._fp;
if (fp) { if (!DB.moderation.banned_fps) DB.moderation.banned_fps={}; DB.moderation.banned_fps[fp]=true; }
apiSave();
sysMsg(`${u} wurde von ${currentUser.username} gebannt. Grund: ${reason}`);
clearModal(); showToast(`${u} gebannt`,'var(--danger)');
}
function openRoleModal(u) {
showModal(`<div class="modal-title">🎖 Rolle: ${escHtml(u)}</div>
<div class="modal-sub">Rolle des Users ändern.</div>
<div class="modal-field"><label>Neue Rolle</label>
<select id="m-role">
<option value="member">Mitglied</option><option value="vip">💎 VIP</option>
<option value="mod">⭐ Moderator</option>
${currentUser.role==='owner'?'<option value="admin">🔰 Admin</option>':''}
</select></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn primary" onclick="doSetRole('${escHtml(u)}')">Speichern</button>
</div>`);
}
function doSetRole(u) {
const newRole=document.getElementById('m-role').value;
const target=DB.users[u.toLowerCase()]; if (!target) return clearModal();
if (target.role==='owner'){showToast('Owner-Rolle kann nicht geändert werden','var(--danger)');return clearModal();}
if (currentUser.role!=='owner'&&ROLE_PRIORITY[target.role]>=ROLE_PRIORITY[currentUser.role]){
showToast('Keine Berechtigung','var(--danger)');return clearModal();
}
DB.users[u.toLowerCase()].role=newRole;
apiSave();
sysMsg(`${u} → Rolle "${ROLE_LABELS[newRole]}" (von ${currentUser.username})`);
clearModal(); showToast(`${u}${ROLE_LABELS[newRole]}`,'var(--green)');
}
function clearUserMsgs(u) {
if (!hasRole('mod')) return;
const ch=DB.msgs[currentChannel]||{};
for (const [k,m] of Object.entries(ch)) { if (m.author===u) delete DB.msgs[currentChannel][k]; }
apiSave();
renderChannelMessages(currentChannel);
showToast(`Nachrichten von ${u} gelöscht`,'var(--warn)');
}
function sysMsg(text) {
if (!currentChannel) return;
if (!DB.msgs[currentChannel]) DB.msgs[currentChannel]={};
dbPush(`msgs/${currentChannel}`,{system:true,text,ts:Date.now()});
}
// ─── PROFILE ──────────────────────────────────────────────────
function showProfileModal(username) {
const u=DB.users[username.toLowerCase()]; if (!u) return;
showModal(`<div class="modal-title">👤 ${escHtml(u.username)} ${ROLE_BADGE[u.role]||''}</div>
<div class="modal-sub">Rolle: ${ROLE_LABELS[u.role]||'Mitglied'} · Beigetreten: ${new Date(u.joined||Date.now()).toLocaleDateString('de-DE')}</div>
<div style="text-align:center;margin:14px 0">
<div class="user-avatar-sm av-color-${u.color}" style="width:60px;height:60px;font-size:1.5rem;margin:0 auto 12px">${u.username[0].toUpperCase()}</div>
</div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Schließen</button>
<button class="modal-btn primary" onclick="clearModal();openDM('${escHtml(u.username)}')">💬 DM senden</button>
</div>`);
}
// ─── SETTINGS ─────────────────────────────────────────────────
const _st={};
function openSettings() {
const s=currentUser.settings||{};
showModal(`<div class="modal-title">⚙️ Einstellungen</div>
<div class="modal-tabs">
<button class="modal-tab active" onclick="switchSettingsTab('konto',this)">Konto</button>
<button class="modal-tab" onclick="switchSettingsTab('darstellung',this)">Darstellung</button>
${hasRole('mod')?'<button class="modal-tab" onclick="switchSettingsTab(\'automod\',this)">AutoMod</button>':''}
${hasRole('admin')?'<button class="modal-tab" onclick="switchSettingsTab(\'tickets\',this)">Tickets</button>':''}
</div>
<div id="settings-tab-konto">
<div class="settings-section"><h4>Konto</h4>
<div class="modal-field"><label>Neuer Username</label><input type="text" id="s-user" placeholder="${escHtml(currentUser.username)}"></div>
<div class="modal-field"><label>Neues Passwort</label><input type="password" id="s-pass" placeholder="Leer = keine Änderung"></div>
<div class="modal-field"><label>Passwort bestätigen</label><input type="password" id="s-pass2"></div>
</div>
<div class="settings-section"><h4>Benachrichtigungen</h4>
<div class="toggle-row"><label>DM-Benachrichtigungen</label>
<div class="toggle ${s.notif!==false?'on':''}" id="toggle-notif" onclick="toggleST('notif')"></div>
</div>
</div>
</div>
<div id="settings-tab-darstellung" style="display:none">
<div class="settings-section"><h4>Avatar-Farbe</h4>
<div style="display:flex;gap:10px;margin:10px 0">
${[0,1,2,3,4].map(i=>`<div class="user-avatar-sm av-color-${i}" style="width:36px;height:36px;cursor:pointer${currentUser.color===i?';box-shadow:0 0 8px currentColor':''}" onclick="setAvatarColor(${i})">${currentUser.username[0].toUpperCase()}</div>`).join('')}
</div>
</div>
</div>
${hasRole('mod')?`<div id="settings-tab-automod" style="display:none">
<div class="settings-section"><h4>AutoMod</h4>
<div class="toggle-row"><label>AutoMod aktiv</label><div class="toggle ${appConfig.automod?.enabled?'on':''}" id="toggle-am" onclick="toggleST('am')"></div></div>
<div class="modal-field"><label>Max. Nachrichtenlänge</label><input type="number" id="s-maxlen" value="${appConfig.automod?.maxMsgLength||800}"></div>
<div class="modal-field"><label>Spam-Grenze (Nachrichten/10s)</label><input type="number" id="s-spam" value="${appConfig.automod?.spamThreshold||5}"></div>
<div class="modal-field"><label>Verbotene Wörter</label>
<div id="bw-tags">${(appConfig.automod?.bannedWords||[]).map(w=>`<span class="tag">${escHtml(w)}<button class="tag-remove" onclick="removeBW('${escHtml(w)}')">×</button></span>`).join('')}</div>
<div style="display:flex;gap:6px;margin-top:6px">
<input class="tag-input" type="text" id="new-bw" placeholder="Wort hinzufügen" style="flex:1">
<button class="modal-btn primary" style="flex:none;width:auto;padding:5px 12px;font-size:.7rem" onclick="addBW()">+</button>
</div>
</div>
<div class="toggle-row"><label>Caps-Filter</label><div class="toggle ${appConfig.automod?.capsFilter?'on':''}" id="toggle-caps" onclick="toggleST('caps')"></div></div>
<div class="toggle-row"><label>Link-Filter</label><div class="toggle ${appConfig.automod?.linkFilter?'on':''}" id="toggle-links" onclick="toggleST('links')"></div></div>
</div>
</div>`:''}
${hasRole('admin')?`<div id="settings-tab-tickets" style="display:none">
<div class="settings-section"><h4>Ticket-System</h4><div id="admin-tickets-list">Wird geladen...</div></div>
</div>`:''}
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Schließen</button>
<button class="modal-btn primary" onclick="saveSettings()">Speichern</button>
</div>`,true);
if (hasRole('admin')) setTimeout(loadAdminTickets,100);
}
function toggleST(id){const el=document.getElementById('toggle-'+id);if(!el)return;const on=!el.classList.contains('on');el.classList.toggle('on',on);_st[id]=on;}
function switchSettingsTab(tab,btn){
document.querySelectorAll('[id^="settings-tab-"]').forEach(el=>el.style.display='none');
document.querySelectorAll('.modal-tab').forEach(el=>el.classList.remove('active'));
const el=document.getElementById('settings-tab-'+tab);if(el)el.style.display='';
if(btn)btn.classList.add('active');
if(tab==='tickets')loadAdminTickets();
}
function addBW(){const i=document.getElementById('new-bw');const w=i.value.trim().toLowerCase();if(!w)return;if(!appConfig.automod)appConfig.automod={};if(!appConfig.automod.bannedWords)appConfig.automod.bannedWords=[];if(!appConfig.automod.bannedWords.includes(w)){appConfig.automod.bannedWords.push(w);document.getElementById('bw-tags').insertAdjacentHTML('beforeend',`<span class="tag">${escHtml(w)}<button class="tag-remove" onclick="removeBW('${escHtml(w)}')">×</button></span>`);}i.value='';}
function removeBW(w){if(!appConfig.automod?.bannedWords)return;appConfig.automod.bannedWords=appConfig.automod.bannedWords.filter(x=>x!==w);const el=document.getElementById('bw-tags');if(el)el.innerHTML=appConfig.automod.bannedWords.map(x=>`<span class="tag">${escHtml(x)}<button class="tag-remove" onclick="removeBW('${escHtml(x)}')">×</button></span>`).join('');}
function setAvatarColor(c){currentUser.color=c;DB.users[currentUser.username.toLowerCase()].color=c;apiSave();updateTopbar();}
function saveSettings(){
const me=DB.users[currentUser.username.toLowerCase()];if(!me)return clearModal();
const newU=document.getElementById('s-user')?.value.trim();
if(newU&&newU!==currentUser.username){
if(newU.length<2||newU.length>20){showToast('Username: 2-20 Zeichen','var(--danger)');return;}
if(!/^[a-zA-Z0-9_]+$/.test(newU)){showToast('Nur Buchstaben, Zahlen, _','var(--danger)');return;}
if(DB.users[newU.toLowerCase()]&&newU.toLowerCase()!==currentUser.username.toLowerCase()){showToast('Username bereits vergeben','var(--danger)');return;}
delete DB.users[currentUser.username.toLowerCase()];
me.username=newU;
DB.users[newU.toLowerCase()]=me;
currentUser.username=newU;
}
const np=document.getElementById('s-pass')?.value,np2=document.getElementById('s-pass2')?.value;
if(np){if(np.length<4){showToast('Passwort mind. 4 Zeichen','var(--danger)');return;}if(np!==np2){showToast('Passwörter stimmen nicht überein','var(--danger)');return;}me.password=encode(np);}
if(!me.settings)me.settings={};
if('notif' in _st)me.settings.notif=_st.notif;
if(hasRole('mod')&&appConfig.automod){
const ml=document.getElementById('s-maxlen'),sp=document.getElementById('s-spam');
if(ml)appConfig.automod.maxMsgLength=parseInt(ml.value)||800;
if(sp)appConfig.automod.spamThreshold=parseInt(sp.value)||5;
if('am' in _st)appConfig.automod.enabled=_st.am;
if('caps' in _st)appConfig.automod.capsFilter=_st.caps;
if('links' in _st)appConfig.automod.linkFilter=_st.links;
DB.config.automod=appConfig.automod;
}
DB.users[me.username.toLowerCase()]=me;
apiSave();
currentUser={...me};
updateTopbar();clearModal();
showToast('✓ Einstellungen gespeichert','var(--green)');
}
// ─── TICKETS ──────────────────────────────────────────────────
function openCreateTicket(){
showModal(`<div class="modal-title">🎫 Ticket erstellen</div>
<div class="modal-sub">Wende dich an das Team!</div>
<div class="modal-field"><label>Kategorie</label>
<select id="t-cat"><option value="support">❓ Support</option><option value="report">🚨 Meldung</option><option value="appeal">⚖️ Ban-Appeal</option><option value="other">💭 Sonstiges</option></select></div>
<div class="modal-field"><label>Betreff</label><input type="text" id="t-title" maxlength="80"></div>
<div class="modal-field"><label>Beschreibung</label><textarea id="t-desc" rows="4"></textarea></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn primary" onclick="submitTicket()">Erstellen</button>
</div>`);
}
function submitTicket(){
const cat=document.getElementById('t-cat')?.value;
const title=document.getElementById('t-title')?.value.trim();
const desc=document.getElementById('t-desc')?.value.trim();
if(!title||!desc){showToast('Bitte alle Felder ausfüllen','var(--danger)');return;}
const id='TKT-'+String(Date.now()).slice(-5);
if(!DB.tickets)DB.tickets={};
dbPush('tickets',{id,cat,title,desc,author:currentUser.username,status:'open',ts:Date.now()});
clearModal();showToast(`🎫 Ticket ${id} erstellt`,'var(--green)');
}
function loadAdminTickets(){
const el=document.getElementById('admin-tickets-list');if(!el)return;
const tickets=Object.values(DB.tickets||{}).sort((a,b)=>b.ts-a.ts);
if(!tickets.length){el.innerHTML='<div style="color:var(--muted);text-align:center;padding:20px;font-size:.85rem">Keine Tickets</div>';return;}
el.innerHTML=tickets.map(t=>`<div class="ticket-item ${t.status}" onclick="openTicketDetail('${escHtml(t._key||t.id)}')">
<div class="ticket-meta">${t.id} · ${t.cat} · ${new Date(t.ts).toLocaleDateString('de-DE')} · ${t.author}</div>
<div class="ticket-title">${escHtml(t.title)}</div>
<div style="font-size:.75rem;color:${t.status==='open'?'var(--green)':'var(--muted)'}">● ${t.status==='open'?'Offen':'Geschlossen'}</div>
</div>`).join('');
}
function openTicketDetail(key){
const t=DB.tickets[key];if(!t)return;
const btnHtml = hasRole('mod')
? `<div class="modal-btns"><button class="modal-btn secondary" onclick="clearModal()">Schließen</button><button class="modal-btn ${t.status==='open'?'danger-btn':'success-btn'}" onclick="toggleTicket('${key}')">${t.status==='open'?'Ticket schließen':'Ticket öffnen'}</button></div>`
: `<div class="modal-btns"><button class="modal-btn secondary" onclick="clearModal()">Schließen</button></div>`;
showModal(`<div class="modal-title">🎫 ${escHtml(t.id)}: ${escHtml(t.title)}</div><div class="modal-sub">Von ${escHtml(t.author)} · ${new Date(t.ts).toLocaleDateString('de-DE')} · ${t.status}</div><div style="background:rgba(0,0,0,0.3);padding:12px;border:1px solid var(--border);font-size:.9rem;margin-bottom:14px;border-radius:2px">${escHtml(t.desc)}</div>${btnHtml}`, true);
}
function toggleTicket(key){
const t=DB.tickets[key];if(!t)return;
t.status=t.status==='open'?'closed':'open';
apiSave();clearModal();
showToast(`Ticket ${t.id} ${t.status==='open'?'geöffnet':'geschlossen'}`,'var(--accent)');
}
// ─── SEARCH & PINS ────────────────────────────────────────────
async function openSearchModal(){
showModal(`<div class="modal-title">🔍 Nachrichten durchsuchen</div>
<div class="modal-field"><label>Suchbegriff</label>
<input type="text" id="search-q" placeholder="Suche im aktuellen Channel..." oninput="liveSearch(this.value)">
</div>
<div id="search-results" style="max-height:300px;overflow-y:auto;margin-top:10px"></div>
<div class="modal-btns"><button class="modal-btn secondary" onclick="clearModal()">Schließen</button></div>`);
}
function liveSearch(query){
const res=document.getElementById('search-results');
if(!query||query.length<2){res.innerHTML='';return;}
const msgs=getMsgs(currentChannel).filter(m=>!m.system&&m.text?.toLowerCase().includes(query.toLowerCase()));
if(!msgs.length){res.innerHTML='<div style="color:var(--muted);text-align:center;padding:12px;font-size:.85rem">Keine Ergebnisse</div>';return;}
res.innerHTML=msgs.slice(-20).map(m=>`<div style="padding:8px 0;border-bottom:1px solid var(--border)">
<div style="font-size:.72rem;color:var(--muted);font-family:Orbitron,monospace">${m.author} · ${new Date(m.ts).toLocaleDateString('de-DE')}</div>
<div style="font-size:.9rem;color:var(--text)">${escHtml(m.text).replace(new RegExp(escHtml(query),'gi'),s=>`<mark style="background:rgba(0,212,255,0.2);color:var(--accent)">${s}</mark>`)}</div>
</div>`).join('');
}
async function openPinsModal(){
const pins=Array.isArray(DB.pins)?DB.pins.filter(p=>p.channel===currentChannel):[];
showModal(`<div class="modal-title">📌 Angepinnte Nachrichten</div>
<div style="max-height:350px;overflow-y:auto">
${!pins.length?'<div style="color:var(--muted);text-align:center;padding:20px;font-size:.85rem">Keine angepinnten Nachrichten</div>':
pins.map(p=>`<div style="padding:10px 0;border-bottom:1px solid var(--border)">
<div style="font-size:.7rem;color:var(--muted);font-family:Orbitron,monospace">📌 ${p.author} · angeheftet von ${p.by}</div>
<div style="font-size:.9rem;color:var(--text);margin-top:4px">${escHtml(p.text)}</div>
</div>`).join('')}
</div>
<div class="modal-btns"><button class="modal-btn secondary" onclick="clearModal()">Schließen</button></div>`);
}
// ─── SPAM CAPTCHA ─────────────────────────────────────────────
function showSpamCaptchaModal(){
const d=genCaptcha();captchaData.spam=d;
showModal(`<div class="modal-title">🤖 Anti-Spam Prüfung</div>
<div class="modal-sub">Du schreibst zu schnell. Löse das Captcha.</div>
<div class="captcha-modal-question" id="spam-q">${d.q}</div>
<div class="modal-field"><label>Antwort</label><input type="number" id="spam-a" placeholder="?" onkeydown="if(event.key==='Enter')spamSubmit()"></div>
<div class="auth-error" id="spam-err"></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn primary" onclick="spamSubmit()">Bestätigen</button>
</div>`);
}
async function spamSubmit(){
const ans=parseInt(document.getElementById('spam-a')?.value);
if(ans!==captchaData.spam?.ans){
const d=genCaptcha();captchaData.spam=d;
const q=document.getElementById('spam-q');if(q)q.textContent=d.q;
const e=document.getElementById('spam-err');if(e)e.textContent='❌ Falsche Antwort!';
return;
}
clearModal();spamTs=[];
if(pendingSpamMsg){await sendMessage();pendingSpamMsg=null;}
}
// ─── MODAL SYSTEM ─────────────────────────────────────────────
function showModal(html,wide=false){
clearModal();
const o=document.createElement('div');
o.className='modal-overlay';o.id='active-modal';
o.innerHTML=`<div class="modal${wide?' wide':''}">${html}</div>`;
o.onclick=e=>{if(e.target===o)clearModal();};
document.getElementById('modal-container').appendChild(o);
}
function clearModal(){document.getElementById('active-modal')?.remove();}
function showKickedModal(){
showModal(`<div class="modal-title">👢 Du wurdest gekickt</div>
<div class="modal-sub">Ein Moderator hat dich aus dem Chat entfernt.</div>
<div class="modal-btns"><button class="modal-btn primary" onclick="doLogout();clearModal()">Zum Login</button></div>`);
}
// ─── EMOJI REACTIONS (NEW) ────────────────────────────────────
function addReaction(msgKey, emoji, isDM=false) {
const path = isDM ? `dms/${getDMKey(currentUser.username, currentDM)}/${msgKey}` : `msgs_${currentChannel}/${msgKey}`;
const msgs = isDM ? getDMMsgs(getDMKey(currentUser.username, currentDM)) : getMsgs(currentChannel);
const msg = msgs?.find(m => m._key === msgKey || m.id === msgKey);
if (!msg) return;
if (!msg.reactions) msg.reactions = {};
if (!msg.reactions[emoji]) msg.reactions[emoji] = [];
if (!msg.reactions[emoji].includes(currentUser.username)) {
msg.reactions[emoji].push(currentUser.username);
}
apiSave();
if (isDM) renderDMMessages(currentDM);
else renderChannelMessages(currentChannel);
}
function removeReaction(msgKey, emoji, isDM=false) {
const msgs = isDM ? getDMMsgs(getDMKey(currentUser.username, currentDM)) : getMsgs(currentChannel);
const msg = msgs?.find(m => m._key === msgKey || m.id === msgKey);
if (!msg || !msg.reactions?.[emoji]) return;
msg.reactions[emoji] = msg.reactions[emoji].filter(u => u !== currentUser.username);
if (!msg.reactions[emoji].length) delete msg.reactions[emoji];
apiSave();
if (isDM) renderDMMessages(currentDM);
else renderChannelMessages(currentChannel);
}
function showReactionPicker(msgKey, isDM=false) {
const x = event.clientX, y = event.clientY;
const picker = document.createElement('div');
picker.className = 'emoji-picker';
picker.style.left = x + 'px';
picker.style.top = y + 'px';
picker.innerHTML = QUICK_EMOJIS.map(em =>
`<button class="emoji-btn" onclick="addReaction('${msgKey}','${em}',${isDM});this.parentElement.remove()">${em}</button>`
).join('');
document.body.appendChild(picker);
setTimeout(() => picker.remove(), 5000);
}
// ─── MESSAGE EDITING (NEW) ────────────────────────────────────
function editMessage(msgKey, isDM=false) {
const msgs = isDM ? getDMMsgs(getDMKey(currentUser.username, currentDM)) : getMsgs(currentChannel);
const msg = msgs?.find(m => m._key === msgKey || m.id === msgKey);
if (!msg || msg.author !== currentUser.username) return showToast('Nur eigene Nachrichten editierbar','var(--danger)');
showModal(`
<div class="modal-title">✏️ Nachricht bearbeiten</div>
<div class="modal-field">
<textarea id="edit-text" rows="3">${escHtml(msg.text)}</textarea>
</div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Abbrechen</button>
<button class="modal-btn primary" onclick="submitEdit('${msgKey}',${isDM})">Speichern</button>
</div>`);
}
function submitEdit(msgKey, isDM=false) {
const newText = document.getElementById('edit-text')?.value.trim();
if (!newText) { showToast('Nachricht kann nicht leer sein','var(--danger)'); return; }
const msgs = isDM ? getDMMsgs(getDMKey(currentUser.username, currentDM)) : getMsgs(currentChannel);
const msg = msgs?.find(m => m._key === msgKey || m.id === msgKey);
if (!msg) return;
msg.text = newText;
msg.edited = Date.now();
apiSave();
clearModal();
showToast('✓ Nachricht bearbeitet','var(--green)');
if (isDM) renderDMMessages(currentDM);
else renderChannelMessages(currentChannel);
}
// ─── USER STATISTICS (NEW) ────────────────────────────────────
function getUserStats(username) {
let msgCount = 0, lastMsg = 0, totalWords = 0;
for (const [chId, msgs] of Object.entries(DB.msgs || {})) {
if (Array.isArray(msgs)) {
msgs.forEach(m => {
if (m.author === username) {
msgCount++;
lastMsg = Math.max(lastMsg, m.ts || 0);
totalWords += (m.text || '').split(/\s+/).length;
}
});
}
}
return { msgCount, lastMsg, totalWords, avgWords: msgCount > 0 ? Math.round(totalWords / msgCount) : 0 };
}
function showUserStats() {
const stats = getUserStats(currentUser.username);
const joined = new Date(currentUser.joined || Date.now());
const daysActive = Math.floor((Date.now() - currentUser.joined) / 86400000);
showModal(`
<div class="modal-title">📊 Meine Statistiken</div>
<div style="background:rgba(0,200,255,0.1);padding:14px;border-radius:4px;margin:12px 0">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;text-align:center">
<div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:4px">NACHRICHTEN</div>
<div style="font-size:1.4rem;font-weight:700">${stats.msgCount}</div>
</div>
<div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:4px">AVG WÖRTER</div>
<div style="font-size:1.4rem;font-weight:700">${stats.avgWords}</div>
</div>
<div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:4px">TAGE AKTIV</div>
<div style="font-size:1.4rem;font-weight:700">${daysActive}</div>
</div>
<div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:4px">BEIGETRETEN</div>
<div style="font-size:.9rem">${joined.toLocaleDateString('de-DE')}</div>
</div>
</div>
</div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Schließen</button>
</div>`);
}
// ─── ADVANCED SEARCH (NEW) ────────────────────────────────────
function openAdvancedSearch() {
showModal(`
<div class="modal-title">🔍 Erweiterte Suche</div>
<div class="modal-field">
<label>Suchtext</label>
<input type="text" id="adv-search-text" placeholder="Suche...">
</div>
<div class="modal-field">
<label>Von Benutzer</label>
<input type="text" id="adv-search-author" placeholder="Optional">
</div>
<div class="modal-field">
<label>Zeitraum</label>
<select id="adv-search-time">
<option value="">Alle</option>
<option value="3600000">Letzte Stunde</option>
<option value="86400000">Letzte 24h</option>
<option value="604800000">Letzte Woche</option>
</select>
</div>
<div id="adv-search-results" style="max-height:250px;overflow-y:auto;margin:12px 0"></div>
<div class="modal-btns">
<button class="modal-btn secondary" onclick="clearModal()">Schließen</button>
<button class="modal-btn primary" onclick="doAdvancedSearch()">Suchen</button>
</div>`, true);
}
function doAdvancedSearch() {
const text = document.getElementById('adv-search-text')?.value.toLowerCase() || '';
const author = document.getElementById('adv-search-author')?.value.toLowerCase() || '';
const timespan = parseInt(document.getElementById('adv-search-time')?.value) || 0;
const now = Date.now();
let results = [];
const msgs = getMsgs(currentChannel) || [];
msgs.forEach(m => {
if (m.system) return;
const textMatch = !text || (m.text || '').toLowerCase().includes(text);
const authorMatch = !author || (m.author || '').toLowerCase().includes(author);
const timeMatch = !timespan || (now - (m.ts || 0)) <= timespan;
if (textMatch && authorMatch && timeMatch) results.push(m);
});
const resultsEl = document.getElementById('adv-search-results');
if (!results.length) {
resultsEl.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px">Keine Ergebnisse</div>';
} else {
resultsEl.innerHTML = results.slice(-50).map(m => `
<div style="padding:8px 0;border-bottom:1px solid var(--border)">
<div style="font-size:.7rem;color:var(--muted);font-family:Orbitron,monospace">${m.author} · ${new Date(m.ts).toLocaleString('de-DE')}</div>
<div style="font-size:.9rem;color:var(--text)">${escHtml(m.text)}</div>
</div>
`).join('');
}
}
// ─── HELPERS ──────────────────────────────────────────────────
function encode(s){try{return btoa(unescape(encodeURIComponent(s)));}catch{return btoa(s);}}
function decode(s){try{return decodeURIComponent(escape(atob(s)));}catch{try{return atob(s);}catch{return '';}}}
function escHtml(str){return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#x27;');}
function showBanned(){document.getElementById('banned-screen').style.display='flex';document.getElementById('auth-screen').style.display='none';document.getElementById('app').classList.remove('visible');}
function showAuthSuccess(msg){const el=document.getElementById('auth-error');el.style.color='var(--green)';el.textContent=msg;setTimeout(()=>{el.style.color='';el.textContent='';},3000);}
function showToast(msg,color='var(--accent)'){const t=document.createElement('div');t.className='toast';t.style.borderColor=color;t.style.color=color;t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),3000);}
// ─── APP INIT ─────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', async () => {
// Auth-Screen anzeigen, App & Banned-Screen verstecken
document.getElementById('auth-screen').style.display = 'flex';
document.getElementById('app').classList.remove('visible');
document.getElementById('banned-screen').style.display = 'none';
// Loading-Overlay anzeigen
const loadEl = document.createElement('div');
loadEl.id = 'init-loading';
loadEl.style.cssText = 'position:fixed;inset:0;background:var(--bg,#0a0e14);display:flex;align-items:center;justify-content:center;z-index:99999;flex-direction:column;gap:12px;font-family:Orbitron,monospace;';
loadEl.innerHTML = '<div style="font-size:1.4rem;color:#00d4ff;letter-spacing:.2em">NoahsChat</div><div style="font-size:.7rem;color:#4a5568;letter-spacing:.15em">VERBINDE...</div>';
document.body.appendChild(loadEl);
// DB laden
await apiLoad();
// Owner-Account anlegen falls noch nicht vorhanden
if (!DB.users[OWNER_NAME.toLowerCase()]) {
DB.users[OWNER_NAME.toLowerCase()] = {
username: OWNER_NAME,
password: encode(OWNER_PASS),
color: 0,
role: 'owner',
joined: Date.now(),
settings: { lang: 'de', translate: false, notif: true }
};
apiSave();
}
// Loading entfernen
loadEl.remove();
// Session wiederherstellen (Auto-Login nach Reload)
const session = loadSession();
if (session) {
const entry = DB.users[session.username.toLowerCase()];
if (entry && !DB.moderation?.bans?.[session.username.toLowerCase()] && !DB.moderation?.banned_fps?.[MY_FP]) {
currentUser = { ...entry };
userLanguage = entry.settings?.lang || 'de';
translationEnabled = entry.settings?.translate || false;
saveSession(currentUser); // Timestamp erneuern
launchApp();
return; // Auth-Screen nicht zeigen
} else {
saveSession(null); // Ungültige Session löschen
}
}
// Captchas immer initialisieren (auch falls Session nicht klappt)
refreshCaptcha('login');
refreshCaptcha('reg');
});