- Voice enhancements, user management, payment method
This commit is contained in:
+227
-5
@@ -656,9 +656,7 @@ input, button { font-family: inherit; }
|
||||
}
|
||||
|
||||
.cc-voice-message audio {
|
||||
width: min(280px, 100%);
|
||||
height: 30px;
|
||||
accent-color: var(--g);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cc-msg-voice-playing {
|
||||
@@ -858,7 +856,174 @@ input, button { font-family: inherit; }
|
||||
}
|
||||
|
||||
.cc-voice-review[hidden] { display: none; }
|
||||
.cc-voice-review audio { width: 170px; height: 28px; accent-color: var(--g); }
|
||||
.cc-voice-review audio { display: none; }
|
||||
.cc-voice-review .cc-cyber-audio { width: 230px; min-width: 200px; }
|
||||
|
||||
/* Cyberpunk audio console */
|
||||
.cc-cyber-audio {
|
||||
--cc-audio-progress: 0%;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 26px 20px minmax(78px, 1fr) auto 34px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: min(100%, 310px);
|
||||
min-width: 230px;
|
||||
height: 34px;
|
||||
padding: 3px 6px 3px 4px;
|
||||
color: var(--g);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0,255,159,.07), transparent 38%),
|
||||
var(--bg2);
|
||||
border: 1px solid rgba(0,255,159,.32);
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 12px rgba(0,0,0,.55), 0 0 8px rgba(0,255,159,.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cc-cyber-audio::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(0deg, transparent 0 3px, rgba(0,255,159,.025) 3px 4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cc-cyber-audio.playing {
|
||||
border-color: var(--g);
|
||||
box-shadow: inset 0 0 14px rgba(0,255,159,.08), 0 0 10px rgba(0,255,159,.22);
|
||||
}
|
||||
|
||||
.cc-audio-play,
|
||||
.cc-audio-mute {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 24px;
|
||||
color: var(--g);
|
||||
background: rgba(0,255,159,.04);
|
||||
border: 1px solid rgba(0,255,159,.42);
|
||||
border-radius: 1px;
|
||||
font: 7px var(--mono);
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.cc-audio-play { width: 26px; }
|
||||
.cc-audio-play:hover,
|
||||
.cc-audio-mute:hover,
|
||||
.cc-audio-mute.active {
|
||||
color: #020806;
|
||||
background: var(--g);
|
||||
box-shadow: var(--glowg);
|
||||
}
|
||||
.cc-audio-play:focus-visible,
|
||||
.cc-audio-mute:focus-visible,
|
||||
.cc-audio-seek:focus-visible {
|
||||
outline: 1px solid var(--b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cc-audio-play-icon {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: auto;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
border-left: 8px solid currentColor;
|
||||
}
|
||||
|
||||
.cc-cyber-audio.playing .cc-audio-play-icon {
|
||||
width: 7px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-left: 3px solid currentColor;
|
||||
border-right: 3px solid currentColor;
|
||||
}
|
||||
|
||||
.cc-audio-signal {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cc-audio-signal i {
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: 4px;
|
||||
background: var(--b);
|
||||
box-shadow: 0 0 4px rgba(0,184,255,.65);
|
||||
}
|
||||
|
||||
.cc-cyber-audio.playing .cc-audio-signal i {
|
||||
animation: ccAudioSignal .7s ease-in-out infinite alternate;
|
||||
}
|
||||
.cc-cyber-audio.playing .cc-audio-signal i:nth-child(2) { animation-delay: -.4s; }
|
||||
.cc-cyber-audio.playing .cc-audio-signal i:nth-child(3) { animation-delay: -.2s; }
|
||||
.cc-cyber-audio.playing .cc-audio-signal i:nth-child(4) { animation-delay: -.55s; }
|
||||
|
||||
@keyframes ccAudioSignal {
|
||||
from { height: 4px; opacity: .45; }
|
||||
to { height: 15px; opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cc-cyber-audio.playing .cc-audio-signal i { animation: none; height: 10px; }
|
||||
}
|
||||
|
||||
.cc-audio-seek {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cc-audio-seek::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--g) var(--cc-audio-progress), var(--bd) var(--cc-audio-progress));
|
||||
box-shadow: 0 0 5px rgba(0,255,159,.2);
|
||||
}
|
||||
.cc-audio-seek::-moz-range-track { height: 3px; background: var(--bd); }
|
||||
.cc-audio-seek::-moz-range-progress { height: 3px; background: var(--g); }
|
||||
.cc-audio-seek::-webkit-slider-thumb {
|
||||
width: 7px;
|
||||
height: 13px;
|
||||
margin-top: -5px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--g);
|
||||
box-shadow: 0 0 7px rgba(0,255,159,.8);
|
||||
}
|
||||
.cc-audio-seek::-moz-range-thumb {
|
||||
width: 7px;
|
||||
height: 13px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--g);
|
||||
}
|
||||
|
||||
.cc-audio-time {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 70px;
|
||||
color: var(--t1);
|
||||
font: 8px var(--mono);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-audio-mute { width: 34px; }
|
||||
|
||||
.cc-voice-action {
|
||||
height: 27px;
|
||||
@@ -964,7 +1129,7 @@ input, button { font-family: inherit; }
|
||||
.cc-voice-queue { order: 3; }
|
||||
.cc-voice-autoplay { margin-left: 0; }
|
||||
.cc-voice-review { width: 100%; }
|
||||
.cc-voice-review audio { flex: 1; min-width: 100px; }
|
||||
.cc-voice-review .cc-cyber-audio { flex: 1; min-width: 220px; }
|
||||
.cc-voice-message { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||
}
|
||||
|
||||
@@ -984,3 +1149,60 @@ html, body {
|
||||
#app {
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
/* v4 application shell */
|
||||
.cc-theme-text { width: auto; padding: 0 8px; font-family: var(--mono); font-size: 8px; }
|
||||
.cc-auth-notice { margin: 12px 20px 0; padding: 8px; border: 1px solid var(--o); color: var(--o); font-family: var(--mono); font-size: 10px; }
|
||||
.cc-app-nav { display: grid; grid-template-columns: repeat(4, 1fr); background: var(--bgp); border-bottom: 1px solid var(--bd); flex-shrink: 0; }
|
||||
.cc-app-nav button { padding: 8px 4px; border: 0; border-right: 1px solid var(--bd); background: transparent; color: var(--t1); font: 9px var(--mono); letter-spacing: 1px; }
|
||||
.cc-app-nav button:last-child { border-right: 0; }
|
||||
.cc-app-nav button.active { color: #000; background: var(--g); }
|
||||
.cc-view { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; }
|
||||
.cc-plan-badge { padding: 2px 6px; border: 1px solid var(--v); color: var(--v); border-radius: 2px; font: 8px var(--mono); text-transform: uppercase; }
|
||||
.cc-compact-select { max-width: 220px; padding: 4px 7px; background: var(--bgi); color: var(--t0); border: 1px solid var(--bd); font: 10px var(--mono); }
|
||||
.cc-recording-indicator { padding: 4px 12px; color: var(--p); background: rgba(255,45,120,.07); border-bottom: 1px solid rgba(255,45,120,.2); font: 10px var(--mono); }
|
||||
.cc-page { flex: 1; overflow-y: auto; padding: 18px; }
|
||||
.cc-page-head, .cc-card-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.cc-page-head { margin-bottom: 16px; }
|
||||
.cc-page h2 { color: var(--b); font: 700 13px var(--disp); letter-spacing: 2px; }
|
||||
.cc-page h3 { color: var(--g); font: 10px var(--mono); letter-spacing: 1.5px; margin-bottom: 10px; }
|
||||
.cc-page h4 { color: var(--v); font: 700 12px var(--disp); letter-spacing: 1px; }
|
||||
.cc-page p { color: var(--t1); font: 10px/1.5 var(--mono); margin-top: 4px; }
|
||||
.cc-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||
.cc-card { padding: 14px; margin-bottom: 12px; background: var(--bg1); border: 1px solid var(--bd); border-radius: var(--r2); }
|
||||
.cc-form-grid { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto; gap: 8px; align-items: center; }
|
||||
.cc-form-grid.compact { grid-template-columns: minmax(120px, 1fr) auto auto; margin-top: 10px; }
|
||||
.cc-inline-btn { display: inline-flex; justify-content: center; align-items: center; min-height: 31px; padding: 5px 10px; color: var(--b); border: 1px solid var(--b); border-radius: var(--r1); background: transparent; font: 9px var(--mono); text-decoration: none; }
|
||||
.cc-inline-btn.primary { color: var(--g); border-color: var(--g); }
|
||||
.cc-inline-btn.danger { color: var(--r); border-color: var(--r); }
|
||||
.cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.cc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.cc-member-list { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.cc-member-list span { padding: 4px 7px; border: 1px solid var(--bd); border-radius: 2px; font: 10px var(--mono); }
|
||||
.cc-member-list button { color: var(--r); background: none; border: 0; margin-left: 4px; }
|
||||
.cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); }
|
||||
.cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; }
|
||||
.cc-archive-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cc-archive-row { display: grid; grid-template-columns: 145px 55px 1fr; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--bd); background: var(--bg1); font: 11px var(--mono); }
|
||||
.cc-archive-row time { color: var(--t1); font-size: 9px; }
|
||||
.cc-archive-row audio { display: none; }
|
||||
.cc-type-tag { color: var(--o); font-size: 8px; text-transform: uppercase; }
|
||||
.cc-switch { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); }
|
||||
.cc-switch input { accent-color: var(--g); }
|
||||
.cc-feature-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 7px; }
|
||||
.cc-feature-list span { padding: 7px; border: 1px solid var(--bd); color: var(--t1); font: 9px var(--mono); text-transform: uppercase; }
|
||||
.cc-feature-list b { display: block; color: var(--t0); margin-bottom: 3px; }
|
||||
.cc-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.cc-plan { padding: 12px; border: 1px solid var(--bd); background: var(--bg2); }
|
||||
.cc-price { margin: 8px 0; color: var(--g); font: 700 18px var(--disp); }
|
||||
.cc-price small { color: var(--t1); font: 9px var(--mono); }
|
||||
.cc-billing-card, .cc-feature-card { margin-top: 12px; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.cc-app-nav button { font-size: 8px; letter-spacing: 0; }
|
||||
.cc-form-grid, .cc-form-grid.compact { grid-template-columns: 1fr; }
|
||||
.cc-archive-row { grid-template-columns: 1fr; }
|
||||
.cc-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; }
|
||||
.cc-compact-select { max-width: 145px; }
|
||||
.cc-page { padding: 12px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
(function(window, document) {
|
||||
'use strict';
|
||||
|
||||
const initialView = new URLSearchParams(location.search).get('view');
|
||||
const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || '';
|
||||
const state = {
|
||||
config: {}, user: null, groups: [], billing: null,
|
||||
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
||||
groupId: null, lastId: 0, pollTimer: null, polling: false,
|
||||
loginChallenge: initialChallenge,
|
||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||
chunks: [], blob: null, duration: 0, url: '', autoSend: false, sending: false }
|
||||
};
|
||||
let root;
|
||||
const $ = (s, c) => (c || root).querySelector(s);
|
||||
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
|
||||
const esc = value => String(value == null ? '' : value)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
function base() {
|
||||
if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, '');
|
||||
const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-v4.js'));
|
||||
return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.js.*$/, '') : '';
|
||||
}
|
||||
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
|
||||
function publicUrl(path) {
|
||||
if (!path) return '';
|
||||
return /^(https?:)?\/\//.test(path) || path.startsWith('/') ? path : base() + '/' + path;
|
||||
}
|
||||
function form(data) {
|
||||
const body = new FormData();
|
||||
Object.entries(data).forEach(([key, value]) => body.append(key, value));
|
||||
return { method: 'POST', body };
|
||||
}
|
||||
async function api(file, options, query = '') {
|
||||
const response = await fetch(apiUrl(file, query), { credentials: 'include', ...(options || {}) });
|
||||
const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' }));
|
||||
if (!response.ok && !data.error) data.error = 'Request failed';
|
||||
return data;
|
||||
}
|
||||
function feature(key, fallback = false) {
|
||||
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
||||
? state.user.features[key] : fallback;
|
||||
}
|
||||
function toast(message, type = 'error') {
|
||||
const area = $('#cc-toasts');
|
||||
if (!area) return;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cc-toast ' + type;
|
||||
item.textContent = message;
|
||||
area.appendChild(item);
|
||||
setTimeout(() => { item.classList.add('cc-toast-fade'); setTimeout(() => item.remove(), 350); }, 2800);
|
||||
}
|
||||
function duration(seconds) {
|
||||
const total = Math.max(0, Math.ceil(Number(seconds) || 0));
|
||||
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
||||
}
|
||||
function audioClock(seconds) {
|
||||
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
||||
}
|
||||
function clock(timestamp) {
|
||||
return new Date(Number(timestamp) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function money(cents, currency) {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(cents / 100);
|
||||
}
|
||||
function linkify(text) {
|
||||
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
}
|
||||
function audioPlayer(src = '', id = '') {
|
||||
return `<span class="cc-cyber-audio">
|
||||
<audio ${id ? `id="${id}"` : ''} preload="metadata" ${src ? `src="${esc(src)}"` : ''}></audio>
|
||||
<button class="cc-audio-play" type="button" aria-label="Play audio"><span class="cc-audio-play-icon"></span></button>
|
||||
<span class="cc-audio-signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||||
<input class="cc-audio-seek" type="range" min="0" max="1000" value="0" aria-label="Audio position">
|
||||
<span class="cc-audio-time">0:00 / --:--</span>
|
||||
<button class="cc-audio-mute" type="button" aria-label="Mute audio">VOL</button>
|
||||
</span>`;
|
||||
}
|
||||
function bindAudioPlayers(scope = root) {
|
||||
$$('.cc-cyber-audio', scope).forEach(player => {
|
||||
if (player.dataset.bound) return;
|
||||
player.dataset.bound = '1';
|
||||
const audio = $('audio', player);
|
||||
const play = $('.cc-audio-play', player);
|
||||
const seek = $('.cc-audio-seek', player);
|
||||
const time = $('.cc-audio-time', player);
|
||||
const mute = $('.cc-audio-mute', player);
|
||||
const update = () => {
|
||||
const current = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0;
|
||||
seek.value = total ? Math.round((current / total) * 1000) : 0;
|
||||
seek.style.setProperty('--cc-audio-progress', `${seek.value / 10}%`);
|
||||
time.textContent = `${audioClock(current)} / ${total ? duration(total) : '--:--'}`;
|
||||
};
|
||||
play.addEventListener('click', () => audio.paused ? audio.play().catch(() => toast('Audio playback failed')) : audio.pause());
|
||||
seek.addEventListener('input', () => {
|
||||
if (Number.isFinite(audio.duration)) audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
|
||||
update();
|
||||
});
|
||||
mute.addEventListener('click', () => {
|
||||
audio.muted = !audio.muted;
|
||||
mute.textContent = audio.muted ? 'MUTE' : 'VOL';
|
||||
mute.classList.toggle('active', audio.muted);
|
||||
});
|
||||
audio.addEventListener('play', () => {
|
||||
$$('.cc-cyber-audio audio').forEach(other => { if (other !== audio) other.pause(); });
|
||||
player.classList.add('playing');
|
||||
play.setAttribute('aria-label', 'Pause audio');
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
player.classList.remove('playing');
|
||||
play.setAttribute('aria-label', 'Play audio');
|
||||
});
|
||||
audio.addEventListener('ended', () => { player.classList.remove('playing'); update(); });
|
||||
audio.addEventListener('loadedmetadata', update);
|
||||
audio.addEventListener('durationchange', update);
|
||||
audio.addEventListener('timeupdate', update);
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
async function init(selector) {
|
||||
root = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
||||
if (!root) return;
|
||||
root.id = 'cyberchat-root';
|
||||
state.config = await api('config.php').catch(() => ({
|
||||
chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 },
|
||||
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
||||
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
||||
}));
|
||||
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
|
||||
shell();
|
||||
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
|
||||
const check = await api('auth.php', null, 'action=check').catch(() => ({ authenticated: false }));
|
||||
if (check.authenticated) {
|
||||
state.user = check.user;
|
||||
await enter();
|
||||
} else auth(state.loginChallenge ? '2fa' : 'register');
|
||||
}
|
||||
|
||||
function shell() {
|
||||
root.innerHTML = `
|
||||
<div class="cc-toast-area" id="cc-toasts"></div>
|
||||
<header class="cc-header">
|
||||
<div class="cc-logo"><span class="cc-logo-glyph">+</span><div>
|
||||
<div class="cc-logo-text">${esc(state.config.ui?.app_title || 'CYBERCHAT')}</div>
|
||||
<div class="cc-logo-sub">${esc(state.config.ui?.app_subtitle || 'SECURE CHANNEL')}</div>
|
||||
</div></div>
|
||||
<div class="cc-header-center"><span class="cc-conn-dot" id="cc-conn-dot"></span>
|
||||
<span class="cc-online-num" id="cc-online-count">--</span><span class="cc-online-label">online</span></div>
|
||||
<div class="cc-header-right"><button class="cc-theme-btn cc-theme-text" id="cc-theme">THEME</button></div>
|
||||
</header><div class="cc-body" id="cc-body"></div>`;
|
||||
$('#cc-theme').addEventListener('click', () => {
|
||||
const light = !document.body.classList.contains('cc-light');
|
||||
document.body.classList.toggle('cc-light', light);
|
||||
try { localStorage.setItem('cc_theme', light ? 'light' : 'dark'); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function auth(tab) {
|
||||
stopPoll();
|
||||
const maxUser = state.config.chat?.max_username_length || 12;
|
||||
const minPass = state.config.security?.min_password_length || 4;
|
||||
$('#cc-body').innerHTML = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
|
||||
<div class="cc-auth-brand"><div class="cc-auth-brand-line"></div><span>IDENTIFY YOURSELF</span><div class="cc-auth-brand-line"></div></div>
|
||||
${state.verifyToken ? '<div class="cc-auth-notice">Log in to finish email verification.</div>' : ''}
|
||||
${state.loginChallenge ? '<div class="cc-auth-notice">Enter the code from your login email.</div>' : ''}
|
||||
<div class="cc-auth-tabs"><button class="cc-auth-tab ${tab === 'register' ? 'active' : ''}" data-tab="register">REGISTER</button>
|
||||
<button class="cc-auth-tab ${tab === 'login' ? 'active' : ''}" data-tab="login">LOGIN</button></div>
|
||||
<div class="cc-auth-form ${tab === 'register' ? 'active' : ''}" data-form="register">
|
||||
<p class="cc-form-hint">Email is optional for free registration.</p>
|
||||
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
|
||||
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
|
||||
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
|
||||
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
|
||||
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
|
||||
<p class="cc-form-hint">Welcome back.</p>
|
||||
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
|
||||
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
|
||||
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
|
||||
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
|
||||
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
|
||||
<button class="cc-btn cc-btn-login" id="two-submit">VERIFY CODE</button></div>
|
||||
<div class="cc-auth-error" id="auth-error"></div></div></div>`;
|
||||
$$('[data-tab]').forEach(button => button.addEventListener('click', () => switchAuth(button.dataset.tab)));
|
||||
$('#register').addEventListener('click', register);
|
||||
$('#login').addEventListener('click', login);
|
||||
if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
||||
}
|
||||
function switchAuth(name) {
|
||||
$$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name));
|
||||
$$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name));
|
||||
authError('');
|
||||
}
|
||||
function authError(message) {
|
||||
const item = $('#auth-error');
|
||||
item.textContent = message;
|
||||
item.classList.toggle('visible', !!message);
|
||||
}
|
||||
async function register() {
|
||||
const username = $('#reg-user').value.trim();
|
||||
const password = $('#reg-pass').value;
|
||||
if (!username || !password) return authError('Username and password are required');
|
||||
if (password !== $('#reg-pass2').value) return authError('Passphrases do not match');
|
||||
const result = await api('auth.php', form({ action: 'register', username, password }));
|
||||
if (result.error) return authError(result.error);
|
||||
state.user = result.user;
|
||||
await enter();
|
||||
}
|
||||
async function login() {
|
||||
const result = await api('auth.php', form({
|
||||
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
||||
}));
|
||||
if (result.error) return authError(result.error);
|
||||
if (result.requires_2fa) {
|
||||
state.loginChallenge = result.challenge;
|
||||
switchAuth('2fa');
|
||||
$('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
||||
return;
|
||||
}
|
||||
state.user = result.user;
|
||||
await enter();
|
||||
}
|
||||
async function verify2fa(challenge) {
|
||||
const result = await api('auth.php', form({ action: 'verify_2fa', challenge, code: $('#two-code').value.trim() }));
|
||||
if (result.error) return authError(result.error);
|
||||
state.loginChallenge = '';
|
||||
history.replaceState({}, '', location.pathname);
|
||||
state.user = result.user;
|
||||
await enter();
|
||||
}
|
||||
|
||||
async function enter() {
|
||||
const [groups, billing] = await Promise.all([
|
||||
api('groups.php', null, 'action=list').catch(() => ({ groups: [] })),
|
||||
api('billing.php', null, 'action=status').catch(() => null)
|
||||
]);
|
||||
state.groups = groups.groups || [];
|
||||
state.billing = billing;
|
||||
app();
|
||||
if (state.verifyToken) {
|
||||
state.view = 'account';
|
||||
renderView();
|
||||
await verifyEmail(state.verifyToken, '');
|
||||
state.verifyToken = '';
|
||||
history.replaceState({}, '', location.pathname);
|
||||
}
|
||||
}
|
||||
function app() {
|
||||
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
|
||||
<button data-view="chat">CHAT</button><button data-view="groups">GROUPS</button>
|
||||
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
|
||||
</nav><main class="cc-view" id="cc-view"></main>`;
|
||||
$$('[data-view]').forEach(button => button.addEventListener('click', () => {
|
||||
state.view = button.dataset.view;
|
||||
renderView();
|
||||
}));
|
||||
renderView();
|
||||
}
|
||||
function renderView() {
|
||||
stopPoll();
|
||||
stopVoice(true);
|
||||
$$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view));
|
||||
if (state.view === 'chat') chat();
|
||||
else if (state.view === 'groups') groupsView();
|
||||
else if (state.view === 'archive') archiveView();
|
||||
else accountView();
|
||||
}
|
||||
|
||||
function groupOptions() {
|
||||
return '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
|
||||
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${esc(group.name)} (${group.member_count})</option>`
|
||||
).join('');
|
||||
}
|
||||
function chat() {
|
||||
const max = state.config.chat?.max_message_length || 500;
|
||||
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
||||
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
|
||||
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
|
||||
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div>
|
||||
<select class="cc-compact-select" id="group-select">${groupOptions()}</select></div>
|
||||
<div class="cc-recording-indicator" id="recording" hidden></div>
|
||||
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
|
||||
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
|
||||
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span>
|
||||
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
|
||||
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
|
||||
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
|
||||
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
|
||||
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
|
||||
$('#group-select').addEventListener('change', event => {
|
||||
state.groupId = event.target.value ? Number(event.target.value) : null;
|
||||
state.lastId = 0;
|
||||
chat();
|
||||
});
|
||||
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
|
||||
$('#message').addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
||||
});
|
||||
$('#send').addEventListener('click', sendText);
|
||||
$('#record')?.addEventListener('click', toggleVoice);
|
||||
$('#voice-send')?.addEventListener('click', sendVoice);
|
||||
$('#voice-discard')?.addEventListener('click', discardVoice);
|
||||
$('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; });
|
||||
bindAudioPlayers($('#cc-view'));
|
||||
state.lastId = 0;
|
||||
startPoll();
|
||||
}
|
||||
async function sendText() {
|
||||
const input = $('#message');
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
input.value = '';
|
||||
const result = await api('messages.php', form({ action: 'send', message, group_id: state.groupId || '' }));
|
||||
if (result.error) { input.value = message; return toast(result.error); }
|
||||
appendMessage(result.message);
|
||||
state.lastId = Math.max(state.lastId, result.message.id);
|
||||
}
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
poll();
|
||||
state.pollTimer = setInterval(poll, state.config.chat?.poll_interval_ms || 2000);
|
||||
}
|
||||
function stopPoll() {
|
||||
if (state.pollTimer) clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
state.polling = false;
|
||||
}
|
||||
async function poll() {
|
||||
if (state.polling || state.view !== 'chat') return;
|
||||
state.polling = true;
|
||||
const query = new URLSearchParams({ action: 'poll', since: state.lastId, group_id: state.groupId || '' });
|
||||
const result = await api('messages.php', null, query.toString()).catch(() => null);
|
||||
if (result && !result.error) {
|
||||
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
||||
$('#cc-online-count').textContent = result.online;
|
||||
if (state.lastId === 0) renderMessages(result.messages || []);
|
||||
else (result.messages || []).forEach(appendMessage);
|
||||
if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id;
|
||||
const recording = $('#recording');
|
||||
recording.hidden = !result.recording?.length;
|
||||
recording.textContent = result.recording?.length
|
||||
? result.recording.map(user => user.username).join(', ') + ' is recording...' : '';
|
||||
} else $('#cc-conn-dot').className = 'cc-conn-dot offline';
|
||||
state.polling = false;
|
||||
}
|
||||
function renderMessages(messages) {
|
||||
const list = $('#messages');
|
||||
list.innerHTML = messages.length ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
||||
messages.forEach(appendMessage);
|
||||
if (messages.length) state.lastId = messages[messages.length - 1].id;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
function appendMessage(message) {
|
||||
const list = $('#messages');
|
||||
if (!list || list.querySelector(`[data-id="${message.id}"]`)) return;
|
||||
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new';
|
||||
item.dataset.id = message.id;
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
||||
: linkify(esc(message.message));
|
||||
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
||||
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
||||
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
||||
list.appendChild(item);
|
||||
bindAudioPlayers(item);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
async function recording(active) {
|
||||
if (!feature('recording_status')) return;
|
||||
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0', group_id: state.groupId || '' })).catch(() => {});
|
||||
}
|
||||
async function toggleVoice() {
|
||||
if (state.voice.recorder) return stopVoice(false);
|
||||
discardVoice();
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type));
|
||||
if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); }
|
||||
state.voice.stream = stream;
|
||||
state.voice.chunks = [];
|
||||
state.voice.started = Date.now();
|
||||
const recorder = new MediaRecorder(stream, { mimeType: mime });
|
||||
recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
|
||||
recorder.addEventListener('stop', () => {
|
||||
if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000);
|
||||
}, { once: true });
|
||||
recorder.start(250);
|
||||
state.voice.recorder = recorder;
|
||||
$('#record').classList.add('recording');
|
||||
$('#record-label').textContent = 'STOP';
|
||||
recording(true);
|
||||
state.voice.heartbeat = setInterval(() => recording(true), 4000);
|
||||
state.voice.timer = setInterval(voiceTimer, 200);
|
||||
voiceTimer();
|
||||
} catch (e) { toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone'); }
|
||||
}
|
||||
function voiceTimer() {
|
||||
const max = state.config.voice?.max_duration_seconds || 60;
|
||||
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max);
|
||||
if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`;
|
||||
if (elapsed >= max) stopVoice(false);
|
||||
}
|
||||
function stopVoice(discard) {
|
||||
clearInterval(state.voice.timer);
|
||||
clearInterval(state.voice.heartbeat);
|
||||
state.voice.timer = state.voice.heartbeat = null;
|
||||
recording(false);
|
||||
if (state.voice.recorder && state.voice.recorder.state !== 'inactive') {
|
||||
state.voice.recorder._discard = discard;
|
||||
state.voice.recorder.stop();
|
||||
}
|
||||
state.voice.recorder = null;
|
||||
state.voice.stream?.getTracks().forEach(track => track.stop());
|
||||
state.voice.stream = null;
|
||||
$('#record')?.classList.remove('recording');
|
||||
if ($('#record-label')) $('#record-label').textContent = 'RECORD';
|
||||
}
|
||||
function finishVoice(blob, length) {
|
||||
if (!blob.size || length < 0.25) return toast('Voice clip was too short');
|
||||
state.voice.blob = blob;
|
||||
state.voice.duration = length;
|
||||
if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice();
|
||||
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
||||
state.voice.url = URL.createObjectURL(blob);
|
||||
$('#voice-preview').src = state.voice.url;
|
||||
$('#voice-preview').load();
|
||||
$('#voice-review').hidden = false;
|
||||
$('#voice-status').textContent = 'READY ' + duration(length);
|
||||
}
|
||||
function discardVoice() {
|
||||
if (state.voice.recorder) stopVoice(true);
|
||||
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
||||
state.voice.url = '';
|
||||
state.voice.blob = null;
|
||||
state.voice.duration = 0;
|
||||
if ($('#voice-review')) $('#voice-review').hidden = true;
|
||||
}
|
||||
async function sendVoice() {
|
||||
if (!state.voice.blob || state.voice.sending) return;
|
||||
state.voice.sending = true;
|
||||
const body = new FormData();
|
||||
body.append('action', 'send_voice');
|
||||
body.append('group_id', state.groupId || '');
|
||||
body.append('duration', state.voice.duration.toFixed(2));
|
||||
body.append('voice', state.voice.blob, 'voice.webm');
|
||||
const result = await api('messages.php', { method: 'POST', body });
|
||||
state.voice.sending = false;
|
||||
if (result.error) return toast(result.error);
|
||||
appendMessage(result.message);
|
||||
state.lastId = Math.max(state.lastId, result.message.id);
|
||||
discardVoice();
|
||||
}
|
||||
|
||||
function groupsView() {
|
||||
const limit = Number(feature('group_member_limit', 2));
|
||||
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>GROUPS</h2>
|
||||
<p>Up to ${limit} people per group, including you.</p></div></div>
|
||||
<section class="cc-card"><h3>CREATE GROUP</h3><div class="cc-form-grid">
|
||||
<input class="cc-input" id="group-name" maxlength="40" placeholder="Group name">
|
||||
<input class="cc-input" id="group-members" placeholder="Member callsigns, comma separated">
|
||||
<button class="cc-inline-btn primary" id="group-create">CREATE</button></div></section>
|
||||
<div class="cc-card-grid">${state.groups.length ? state.groups.map(groupCard).join('') : '<div class="cc-empty-card">No private groups yet.</div>'}</div></div>`;
|
||||
$('#group-create').addEventListener('click', createGroup);
|
||||
$$('[data-open]').forEach(button => button.addEventListener('click', () => {
|
||||
state.groupId = Number(button.dataset.open);
|
||||
state.view = 'chat';
|
||||
renderView();
|
||||
}));
|
||||
$$('[data-delete]').forEach(button => button.addEventListener('click', () => deleteGroup(Number(button.dataset.delete))));
|
||||
$$('[data-add]').forEach(button => button.addEventListener('click', () => addMember(Number(button.dataset.add))));
|
||||
$$('[data-remove]').forEach(button => button.addEventListener('click', () => {
|
||||
const [groupId, userId] = button.dataset.remove.split(':').map(Number);
|
||||
removeMember(groupId, userId);
|
||||
}));
|
||||
}
|
||||
function groupCard(group) {
|
||||
const owner = group.owner_id === Number(state.user.id);
|
||||
return `<section class="cc-card"><div class="cc-card-title"><h3>${esc(group.name)}</h3>
|
||||
<button class="cc-inline-btn" data-open="${group.id}">OPEN CHAT</button></div>
|
||||
<div class="cc-member-list">${group.members.map(member => `<span style="color:${esc(member.color)}">${esc(member.username)}
|
||||
${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` <button data-remove="${group.id}:${member.id}">x</button>` : ''}</span>`).join('')}</div>
|
||||
${owner ? `<div class="cc-form-grid compact"><input class="cc-input" id="add-${group.id}" placeholder="Callsign">
|
||||
<button class="cc-inline-btn" data-add="${group.id}">ADD</button><button class="cc-inline-btn danger" data-delete="${group.id}">DELETE</button></div>` : ''}</section>`;
|
||||
}
|
||||
async function createGroup() {
|
||||
const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.groups = result.groups;
|
||||
groupsView();
|
||||
}
|
||||
async function addMember(groupId) {
|
||||
const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.groups = result.groups;
|
||||
groupsView();
|
||||
}
|
||||
async function removeMember(groupId, userId) {
|
||||
const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.groups = result.groups;
|
||||
groupsView();
|
||||
}
|
||||
async function deleteGroup(groupId) {
|
||||
if (!confirm('Delete this group and its messages?')) return;
|
||||
const result = await api('groups.php', form({ action: 'delete', group_id: groupId }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.groups = result.groups;
|
||||
if (state.groupId === groupId) state.groupId = null;
|
||||
groupsView();
|
||||
}
|
||||
|
||||
async function archiveView(search = '') {
|
||||
$('#cc-view').innerHTML = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
|
||||
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
|
||||
if (result.error) return $('#cc-view').innerHTML = `<div class="cc-empty-card">${esc(result.error)}</div>`;
|
||||
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>MY ARCHIVE</h2>
|
||||
<p>Your own messages for ${result.retention_days} days. ${result.voice_included ? 'Voice included.' : 'Text only on this plan.'}</p></div>
|
||||
${result.can_export ? `<div class="cc-actions"><a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=json')}">JSON</a>
|
||||
<a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=csv')}">CSV</a></div>` : ''}</div>
|
||||
<div class="cc-search-row"><input class="cc-input" id="archive-search" value="${esc(search)}" placeholder="Search your messages">
|
||||
<button class="cc-inline-btn primary" id="archive-submit">SEARCH</button></div>
|
||||
<div class="cc-archive-list">${result.messages.length ? result.messages.map(archiveRow).join('') : '<div class="cc-empty-card">No matching messages.</div>'}</div></div>`;
|
||||
bindAudioPlayers($('#cc-view'));
|
||||
$('#archive-submit').addEventListener('click', () => archiveView($('#archive-search').value.trim()));
|
||||
}
|
||||
function archiveRow(message) {
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? audioPlayer(publicUrl(message.voice_url)) : esc(message.message);
|
||||
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
|
||||
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
|
||||
}
|
||||
|
||||
async function accountView() {
|
||||
if (!state.billing) state.billing = await api('billing.php', null, 'action=status').catch(() => null);
|
||||
const billing = state.billing;
|
||||
const showBilling = billing && (billing.subscriptions_enabled || billing.can_manage);
|
||||
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>ACCOUNT</h2>
|
||||
<p>${esc(state.user.username)} / ${esc(state.user.plan.name)}</p></div><button class="cc-inline-btn danger" id="logout">LOG OUT</button></div>
|
||||
<div class="cc-card-grid"><section class="cc-card"><h3>EMAIL VERIFICATION</h3>
|
||||
<p>${state.user.email_verified ? `Verified: ${esc(state.user.email)}` : 'Email is only required before premium activation.'}</p>
|
||||
<div class="cc-form-grid"><input class="cc-input" id="email" type="email" value="${esc(state.user.email || '')}" placeholder="you@example.com">
|
||||
<button class="cc-inline-btn primary" id="email-send">SEND VERIFICATION</button></div>
|
||||
<div class="cc-form-grid compact"><input class="cc-input" id="email-code" maxlength="6" placeholder="Six-digit code">
|
||||
<button class="cc-inline-btn" id="email-verify">VERIFY</button></div></section>
|
||||
<section class="cc-card"><h3>IDENTITY & SECURITY</h3>
|
||||
${feature('username_color') ? `<div class="cc-form-grid compact"><input type="color" id="color" value="${esc(state.user.color)}">
|
||||
<button class="cc-inline-btn" id="color-save">SAVE COLOR</button></div>` : '<p>Custom username color is not included in this plan.</p>'}
|
||||
${feature('email_2fa') ? `<label class="cc-switch"><input type="checkbox" id="two-toggle" ${state.user.two_factor_enabled ? 'checked' : ''}> EMAIL TWO-FACTOR AUTHENTICATION</label>` : '<p>Email two-factor authentication is not included in this plan.</p>'}
|
||||
</section></div>
|
||||
${showBilling ? billingHtml(billing) : ''}
|
||||
<section class="cc-card cc-feature-card"><h3>CURRENT FEATURES</h3><div class="cc-feature-list">
|
||||
${Object.entries(state.user.features).map(([key, value]) => `<span><b>${esc(key.replace(/_/g, ' '))}</b> ${esc(String(value))}</span>`).join('')}</div></section>
|
||||
</div>`;
|
||||
$('#logout').addEventListener('click', () => logout(true));
|
||||
$('#email-send').addEventListener('click', requestEmail);
|
||||
$('#email-verify').addEventListener('click', () => verifyEmail('', $('#email-code').value.trim()));
|
||||
$('#color-save')?.addEventListener('click', saveColor);
|
||||
$('#two-toggle')?.addEventListener('change', saveTwoFactor);
|
||||
$$('[data-plan]').forEach(button => button.addEventListener('click', () => checkout(Number(button.dataset.plan))));
|
||||
$('#billing-manage')?.addEventListener('click', portal);
|
||||
$('#billing-cancel')?.addEventListener('click', cancelSubscription);
|
||||
}
|
||||
function billingHtml(billing) {
|
||||
const alreadySubscribed = ['active', 'trialing', 'past_due'].includes(state.user.subscription.status);
|
||||
const plans = billing.subscriptions_enabled && !alreadySubscribed ? billing.plans : [];
|
||||
return `<section class="cc-card cc-billing-card"><div class="cc-card-title"><div><h3>SUBSCRIPTION</h3>
|
||||
<p>Status: ${esc(state.user.subscription.status)}${state.user.subscription.cancel_at_period_end ? ' / cancels at period end' : ''}</p></div>
|
||||
<div class="cc-actions">${billing.can_manage ? '<button class="cc-inline-btn" id="billing-manage">MANAGE IN STRIPE</button>' : ''}
|
||||
${state.user.subscription.manageable && !state.user.subscription.cancel_at_period_end ? '<button class="cc-inline-btn danger" id="billing-cancel">CANCEL</button>' : ''}</div></div>
|
||||
${plans.length ? `<div class="cc-plan-grid">${plans.map(plan => `<article class="cc-plan"><h4>${esc(plan.name)}</h4>
|
||||
<div class="cc-price">${money(plan.price_cents, plan.currency)}<small>/${esc(plan.interval)}</small></div>
|
||||
<p>${esc(plan.description)}</p><button class="cc-inline-btn primary" data-plan="${plan.id}" ${plan.checkout_ready ? '' : 'disabled'}>SELECT PLAN</button></article>`).join('')}</div>` : ''}
|
||||
${billing.subscriptions_enabled && alreadySubscribed ? '<p>Use the Stripe portal to change plans or payment details.</p>' : ''}
|
||||
${!billing.subscriptions_enabled && billing.can_manage ? '<p>New subscriptions are hidden. Your existing subscription remains manageable.</p>' : ''}</section>`;
|
||||
}
|
||||
async function requestEmail() {
|
||||
const result = await api('account.php', form({ action: 'request_email_verification', email: $('#email').value.trim() }));
|
||||
toast(result.error || result.message, result.error ? 'error' : 'success');
|
||||
}
|
||||
async function verifyEmail(token, code) {
|
||||
const result = await api('account.php', form({ action: 'verify_email', token, code }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.user = result.user;
|
||||
state.billing = await api('billing.php', null, 'action=status').catch(() => state.billing);
|
||||
toast('Email verified', 'success');
|
||||
accountView();
|
||||
}
|
||||
async function saveColor() {
|
||||
const result = await api('account.php', form({ action: 'set_color', color: $('#color').value }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.user = result.user;
|
||||
toast('Color updated', 'success');
|
||||
accountView();
|
||||
}
|
||||
async function saveTwoFactor(event) {
|
||||
const result = await api('account.php', form({ action: 'set_2fa', enabled: event.target.checked ? '1' : '0' }));
|
||||
if (result.error) { event.target.checked = !event.target.checked; return toast(result.error); }
|
||||
state.user = result.user;
|
||||
toast('Two-factor setting updated', 'success');
|
||||
}
|
||||
async function checkout(planId) {
|
||||
if (!state.user.email_verified) return toast('Verify an email before premium checkout');
|
||||
const result = await api('billing.php', form({ action: 'checkout', plan_id: planId }));
|
||||
if (result.error) return toast(result.error);
|
||||
location.href = result.url;
|
||||
}
|
||||
async function portal() {
|
||||
const result = await api('billing.php', form({ action: 'portal' }));
|
||||
if (result.error) return toast(result.error);
|
||||
location.href = result.url;
|
||||
}
|
||||
async function cancelSubscription() {
|
||||
if (!confirm('Cancel this subscription at the end of its billing period?')) return;
|
||||
const result = await api('billing.php', form({ action: 'cancel' }));
|
||||
if (result.error) return toast(result.error);
|
||||
state.user = result.user;
|
||||
state.billing = await api('billing.php', null, 'action=status');
|
||||
accountView();
|
||||
}
|
||||
async function logout(request = true) {
|
||||
stopPoll();
|
||||
stopVoice(true);
|
||||
if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {});
|
||||
state.user = null;
|
||||
state.groups = [];
|
||||
state.billing = null;
|
||||
state.groupId = null;
|
||||
auth('login');
|
||||
}
|
||||
|
||||
window.CyberChat = { init };
|
||||
})(window, document);
|
||||
Reference in New Issue
Block a user