| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const JSONBIN_KEY = '$2a$10$Hurr28g4Cy7NWpA/Abd6YOzkBLuC8PdAOPQR34g4pEkA24LpXo7NK'; |
| const JSONBIN_BIN = '69976e43ae596e708f382b97'; |
|
|
| |
| const OWNER_NAME = 'Noah'; |
| const OWNER_PASS = 'Noah100419!'; |
| const POLL_MS = 3000; |
|
|
| |
| 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; } |
| } |
|
|
| |
| 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 = {}; |
|
|
| |
| |
| |
| |
| |
| 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: {} |
| }); |
|
|
| |
| 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() { |
| |
| if (heartbeatTimer) return; |
| heartbeatTimer = setTimeout(() => { |
| heartbeatTimer = null; |
| apiSave(); |
| }, 6000); |
| } |
|
|
| |
| 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) { |
| |
| |
| |
| 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 }; |
| } |
| 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() { |
| |
| 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' |
| }, |
| 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(); } |
| } |
| } |
|
|
| |
| 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 = {}; |
| } |
|
|
| |
| 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(); |
| } |
|
|
| 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; |
| } |
|
|
| |
| 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]; } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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=''; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| function startPolling() { |
| pollTimer = setInterval(pollTick, POLL_MS); |
| } |
|
|
| async function pollTick() { |
| if (!currentUser) return; |
|
|
| |
| setSyncStatus('sync'); |
| await apiLoad(true); |
| setSyncStatus('ok'); |
|
|
| |
| const me = DB.users[currentUser.username.toLowerCase()]; |
| if (me) { |
| currentUser = {...me}; |
| updateTopbar(); |
| userLanguage = me.settings?.lang||'de'; |
| translationEnabled = me.settings?.translate||false; |
| } |
|
|
| |
| |
| DB.online[currentUser.username] = Date.now(); |
| |
| scheduleHeartbeatSave(); |
|
|
| |
| 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(); |
|
|
| |
| 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(); |
|
|
| |
| if (currentDM) renderDMMessages(currentDM); |
| else { |
| renderChannelMessages(currentChannel); |
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| 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]; |
| } |
| } |
| } |
|
|
| |
| voicePollConnect(); |
|
|
| |
| if (voiceActive && voiceChannel && DB.voice?.[voiceChannel]?.[currentUser.username]) { |
| DB.voice[voiceChannel][currentUser.username].ts = now; |
| } |
|
|
| buildChannelList(); |
| } |
|
|
| |
| function setSyncStatus(state) { } |
|
|
| 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]) { |
| |
| const name = vcNames[vcId] || vcId; |
| joinVoice(vcId, name); |
| break; |
| } |
| } |
| } |
|
|
| |
| 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]||''; |
| } |
|
|
| |
| 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); |
| } |
| } |
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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>'); |
| } |
|
|
| |
| 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&¤tDM) { |
| 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'; } |
|
|
| |
| 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'; |
| } |
|
|
| |
| |
| |
| let myPeer = null; |
| let localStream = null; |
| let activeCalls = {}; |
| let activeAudios = {}; |
|
|
| |
| |
| function makePeerId(username, vcId) { |
| |
| 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(); |
|
|
| |
| 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); |
|
|
| |
| if (myPeer && !myPeer.destroyed) { |
| myPeer.destroy(); |
| myPeer = null; |
| } |
|
|
| |
| myPeer = new Peer(peerId, { |
| host: 'peerjs-server.netlify.app', |
| 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); |
| |
| if (!DB.voice[vcId]) DB.voice[vcId] = {}; |
| DB.voice[vcId][currentUser.username] = { |
| name: currentUser.username, |
| peerId: id, |
| muted: false, |
| ts: Date.now() |
| }; |
| apiSave(); |
|
|
| |
| const others = Object.entries(DB.voice[vcId] || {}) |
| .filter(([uname]) => uname !== currentUser.username); |
| for (const [uname, data] of others) { |
| if (data.peerId) callPeer(data.peerId); |
| } |
|
|
| |
| 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)'); |
| }); |
|
|
| |
| myPeer.on('call', (call) => { |
| call.answer(localStream); |
| handleCallStream(call); |
| }); |
|
|
| myPeer.on('error', (err) => { |
| |
| if (err.type === 'unavailable-id') { |
| console.warn('[Voice] Peer-ID besetzt, versuche disconnect...'); |
| |
| 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; |
|
|
| |
| for (const pid of Object.keys(activeCalls)) cleanupPeer(pid); |
| activeCalls = {}; |
|
|
| |
| if (localStream) { |
| localStream.getTracks().forEach(t => t.stop()); |
| localStream = null; |
| } |
|
|
| |
| if (myPeer && !myPeer.destroyed) { |
| myPeer.destroy(); |
| myPeer = null; |
| } |
|
|
| |
| 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); |
| |
| 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); |
| |
| for (const audio of Object.values(activeAudios)) { |
| audio.muted = deafened; |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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); |
|
|
| |
| 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()}); |
| } |
|
|
| |
| 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>`); |
| } |
|
|
| |
| 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)'); |
| } |
|
|
| |
| 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)'); |
| } |
|
|
| |
| 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>`); |
| } |
|
|
| |
| 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;} |
| } |
|
|
| |
| 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>`); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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>`); |
| } |
|
|
| |
| 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(''); |
| } |
| } |
|
|
| |
| 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');} |
| 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);} |
|
|
|
|
|
|
| |
| window.addEventListener('DOMContentLoaded', async () => { |
| |
| document.getElementById('auth-screen').style.display = 'flex'; |
| document.getElementById('app').classList.remove('visible'); |
| document.getElementById('banned-screen').style.display = 'none'; |
|
|
| |
| 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); |
|
|
| |
| await apiLoad(); |
|
|
| |
| 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(); |
| } |
|
|
| |
| loadEl.remove(); |
|
|
| |
| 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); |
| launchApp(); |
| return; |
| } else { |
| saveSession(null); |
| } |
| } |
|
|
| |
| refreshCaptcha('login'); |
| refreshCaptcha('reg'); |
| }); |