36c488ca1e
Rebuilt embed-example.html with inline, full-screen, and popup examples. Fixed host-page/frame scrolling and contained reply navigation. New logins now transactionally replace prior sessions; displaced clients return to login. Added per-user session/IP locking with automatic SQLite migration. Added modular Spam Control dashboard for locks, honeypot, and CAPTCHA settings. Moved CAPTCHA controls out of Plans & Platform.
1989 lines
99 KiB
PHP
1989 lines
99 KiB
PHP
<?php
|
||
/**
|
||
* CyberChat Admin Panel
|
||
* Place in the cyberchat root directory.
|
||
* Protect with a strong ADMIN_PASSWORD below, or restrict via .htaccess / server config.
|
||
*
|
||
* ⚠ SECURITY: Change ADMIN_PASSWORD before deploying. Keep this file out of public reach
|
||
* if possible (e.g. IP-restrict via .htaccess or move above web root and symlink).
|
||
*/
|
||
|
||
define('ROOT_DIR', __DIR__);
|
||
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
||
define('ADMIN_VERSION', '2.2.0');
|
||
require_once ROOT_DIR . '/bootstrap.php';
|
||
require_once ROOT_DIR . '/lib/admin_platform.php';
|
||
require_once ROOT_DIR . '/lib/admin_spam.php';
|
||
|
||
// ─── CHANGE THIS PASSWORD ────────────────────────────────────────────────────
|
||
define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123'));
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
session_name('cyberchat_admin');
|
||
session_start();
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function cfg(): array {
|
||
static $c = null;
|
||
if ($c === null) $c = getConfig();
|
||
return $c;
|
||
}
|
||
|
||
function cfgVal(string $path, mixed $default = null): mixed {
|
||
$c = cfg(); $keys = explode('.', $path); $v = $c;
|
||
foreach ($keys as $k) { if (!is_array($v) || !array_key_exists($k, $v)) return $default; $v = $v[$k]; }
|
||
return $v;
|
||
}
|
||
|
||
function db(): PDO {
|
||
return getDB();
|
||
}
|
||
|
||
function ensureAdminMessageColumns(PDO $d): void {
|
||
$columns = [];
|
||
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
|
||
foreach ([
|
||
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||
'voice_file' => 'TEXT',
|
||
'voice_mime' => 'TEXT',
|
||
'voice_duration' => 'REAL',
|
||
'voice_bitrate_kbps' => 'INTEGER',
|
||
'reply_to_id' => 'INTEGER',
|
||
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
|
||
] as $name => $definition) {
|
||
if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition");
|
||
}
|
||
}
|
||
|
||
function archiveDB(string $year, string $month, string $day): ?PDO {
|
||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
||
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
|
||
if (!file_exists($path)) return null;
|
||
$a = new PDO('sqlite:' . $path);
|
||
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||
ensureAdminMessageColumns($a);
|
||
removeLegacyGroupSchema($a, false);
|
||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
|
||
return $a;
|
||
}
|
||
|
||
function createArchiveDB(string $year, string $month, string $day): PDO {
|
||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
||
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
|
||
$dir = dirname($path);
|
||
ensureWritableDirectory($dir, 'Archive');
|
||
|
||
$a = new PDO('sqlite:' . $path);
|
||
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||
$a->exec("CREATE TABLE IF NOT EXISTS messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
username TEXT NOT NULL,
|
||
color TEXT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
message_type TEXT NOT NULL DEFAULT 'text',
|
||
voice_file TEXT,
|
||
voice_mime TEXT,
|
||
voice_duration REAL,
|
||
voice_bitrate_kbps INTEGER,
|
||
reply_to_id INTEGER,
|
||
reply_depth INTEGER NOT NULL DEFAULT 0,
|
||
created_at INTEGER NOT NULL,
|
||
day_key TEXT NOT NULL
|
||
)");
|
||
ensureAdminMessageColumns($a);
|
||
removeLegacyGroupSchema($a, false);
|
||
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT
|
||
)");
|
||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
|
||
return $a;
|
||
}
|
||
|
||
function esc(mixed $v): string { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); }
|
||
function fmtTime(int $ts): string { return date('Y-m-d H:i:s', $ts); }
|
||
function ago(int $ts): string {
|
||
$d = time() - $ts; if ($d < 60) return $d . 's ago';
|
||
if ($d < 3600) return floor($d/60) . 'm ago';
|
||
if ($d < 86400) return floor($d/3600) . 'h ago';
|
||
return floor($d/86400) . 'd ago';
|
||
}
|
||
|
||
function flash(string $msg, string $type = 'ok'): void {
|
||
$_SESSION['flash'] = ['msg' => $msg, 'type' => $type];
|
||
}
|
||
function getFlash(): ?array {
|
||
$f = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $f;
|
||
}
|
||
|
||
function redirect(string $qs = ''): never {
|
||
header('Location: admin.php' . ($qs ? '?' . $qs : '')); exit;
|
||
}
|
||
|
||
function csrfToken(): string {
|
||
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||
return $_SESSION['csrf'];
|
||
}
|
||
function csrfCheck(): void {
|
||
if (($_POST['csrf'] ?? '') !== ($_SESSION['csrf'] ?? '')) {
|
||
flash('Invalid CSRF token.', 'err'); redirect();
|
||
}
|
||
}
|
||
|
||
function deleteRecording(?string $filename): void {
|
||
if (!$filename || basename($filename) !== $filename) return;
|
||
$dir = storagePath((string)cfgVal('voice.upload_dir', 'uploads/voice'));
|
||
$path = $dir . '/' . $filename;
|
||
if (is_file($path)) unlink($path);
|
||
}
|
||
|
||
function deleteRecordingsForRows(array $rows): void {
|
||
foreach ($rows as $row) deleteRecording($row['voice_file'] ?? null);
|
||
}
|
||
|
||
function voiceFileUrl(?string $filename): string {
|
||
if (!$filename || basename($filename) !== $filename) return '';
|
||
return trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($filename);
|
||
}
|
||
|
||
function voiceFileSize(?string $filename): ?int {
|
||
if (!$filename || basename($filename) !== $filename) return null;
|
||
$path = storagePath((string)cfgVal('voice.upload_dir', 'uploads/voice')) . '/' . $filename;
|
||
return is_file($path) ? filesize($path) : null;
|
||
}
|
||
|
||
function refreshArchiveMeta(PDO $archive): void {
|
||
$archive->exec("CREATE TABLE IF NOT EXISTS archive_meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT
|
||
)");
|
||
$count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
||
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
|
||
$meta->execute(['archived_at', (string)time()]);
|
||
$meta->execute(['message_count', (string)$count]);
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
$isLoggedIn = !empty($_SESSION['admin_auth']);
|
||
|
||
if (isset($_POST['admin_login'])) {
|
||
if ($_POST['admin_pass'] === ADMIN_PASSWORD) {
|
||
$_SESSION['admin_auth'] = true;
|
||
$_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||
redirect();
|
||
} else {
|
||
$loginError = 'Incorrect password.';
|
||
}
|
||
}
|
||
|
||
if (isset($_POST['admin_logout'])) {
|
||
session_destroy(); redirect();
|
||
}
|
||
|
||
// ── POST actions (require auth) ───────────────────────────────────────────────
|
||
if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
csrfCheck();
|
||
$act = $_POST['action'] ?? '';
|
||
|
||
if (in_array($act, ['save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
|
||
try {
|
||
$message = handleAdminPlatformAction($act);
|
||
flash($message ?: 'Platform action completed.');
|
||
} catch (Throwable $e) {
|
||
flash($e->getMessage(), 'err');
|
||
}
|
||
redirect('tab=platform');
|
||
}
|
||
|
||
if (in_array($act, ['save_spam_settings','lock_user_session','unlock_user_session'], true)) {
|
||
try {
|
||
$message = handleAdminSpamAction($act);
|
||
flash($message ?: 'Spam control action completed.');
|
||
} catch (Throwable $e) {
|
||
flash($e->getMessage(), 'err');
|
||
}
|
||
redirect('tab=spam');
|
||
}
|
||
|
||
// ── Messages ──────────────────────────────────────────────────────────────
|
||
if ($act === 'archive_voice_clip') {
|
||
$id = (int)($_POST['msg_id'] ?? 0);
|
||
$live = db();
|
||
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
|
||
$stmt->execute([$id]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) {
|
||
flash('Voice clip not found.', 'err');
|
||
redirect($_POST['return_qs'] ?? 'tab=voice');
|
||
}
|
||
|
||
$day = (string)$row['day_key'];
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) {
|
||
flash('Voice clip has an invalid archive date.', 'err');
|
||
redirect($_POST['return_qs'] ?? 'tab=voice');
|
||
}
|
||
|
||
[$y, $m, $d] = explode('-', $day);
|
||
try {
|
||
$adb = createArchiveDB($y, $m, $d);
|
||
$adb->beginTransaction();
|
||
try {
|
||
$existing = $adb->prepare("SELECT voice_file FROM messages WHERE id = ?");
|
||
$existing->execute([$id]);
|
||
$existingFile = $existing->fetchColumn();
|
||
if ($existingFile !== false && $existingFile !== $row['voice_file']) {
|
||
throw new RuntimeException('Archive already contains a different message with this ID.');
|
||
}
|
||
if ($existingFile === false) {
|
||
$insert = $adb->prepare("INSERT INTO messages
|
||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
||
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||
$insert->execute([
|
||
$row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'],
|
||
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
|
||
$row['voice_bitrate_kbps'], $row['reply_to_id'], $row['reply_depth'],
|
||
$row['created_at'], $row['day_key']
|
||
]);
|
||
}
|
||
refreshArchiveMeta($adb);
|
||
$adb->commit();
|
||
} catch (Throwable $e) {
|
||
if ($adb->inTransaction()) $adb->rollBack();
|
||
throw $e;
|
||
}
|
||
$delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
|
||
$delete->execute([$id]);
|
||
if ($delete->rowCount() !== 1) {
|
||
throw new RuntimeException('The live clip changed before it could be removed.');
|
||
}
|
||
flash("Voice clip #$id archived to $day.");
|
||
} catch (Throwable $e) {
|
||
flash('Could not archive voice clip: ' . $e->getMessage(), 'err');
|
||
}
|
||
redirect($_POST['return_qs'] ?? 'tab=voice');
|
||
}
|
||
|
||
if ($act === 'delete_message') {
|
||
$id = (int)$_POST['msg_id'];
|
||
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
|
||
if ($src === 'live') {
|
||
$live = db();
|
||
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ?");
|
||
$row->execute([$id]);
|
||
deleteRecordingsForRows($row->fetchAll());
|
||
$live->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
|
||
flash("Message #$id deleted.");
|
||
} else {
|
||
[$_, $day] = explode(':', $src, 2);
|
||
[$y,$m,$d] = explode('-', $day);
|
||
$adb = archiveDB($y, $m, $d);
|
||
if ($adb) {
|
||
$row = $adb->prepare("SELECT voice_file FROM messages WHERE id = ?");
|
||
$row->execute([$id]);
|
||
deleteRecordingsForRows($row->fetchAll());
|
||
$adb->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
|
||
refreshArchiveMeta($adb);
|
||
flash("Archive message #$id deleted.");
|
||
}
|
||
}
|
||
redirect($_POST['return_qs'] ?? '');
|
||
}
|
||
|
||
if ($act === 'edit_message') {
|
||
$id = (int)$_POST['msg_id'];
|
||
$txt = trim($_POST['msg_text']);
|
||
$username = trim($_POST['msg_username'] ?? '');
|
||
$src = $_POST['msg_src'] ?? 'live';
|
||
if ($txt === '') { flash('Message cannot be empty.', 'err'); redirect($_POST['return_qs'] ?? ''); }
|
||
if ($username === '' || !preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) {
|
||
flash('A valid displayed sender is required.', 'err'); redirect($_POST['return_qs'] ?? '');
|
||
}
|
||
if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) {
|
||
flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? '');
|
||
}
|
||
if ($src === 'live') {
|
||
$live = db();
|
||
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")
|
||
->execute([$txt, $username, $id]);
|
||
flash("Message #$id updated.");
|
||
} else {
|
||
[$_, $day] = explode(':', $src, 2);
|
||
[$y,$m,$d] = explode('-', $day);
|
||
$adb = archiveDB($y, $m, $d);
|
||
if ($adb) {
|
||
$adb->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->execute([$txt, $username, $id]);
|
||
flash("Archive message #$id updated.");
|
||
}
|
||
}
|
||
redirect($_POST['return_qs'] ?? '');
|
||
}
|
||
|
||
if ($act === 'delete_messages_bulk') {
|
||
$ids = array_map('intval', $_POST['msg_ids'] ?? []);
|
||
if ($ids) {
|
||
$live = db();
|
||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||
$rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
|
||
$rows->execute($ids);
|
||
deleteRecordingsForRows($rows->fetchAll());
|
||
$live->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
|
||
flash(count($ids) . ' message(s) deleted.');
|
||
}
|
||
redirect($_POST['return_qs'] ?? '');
|
||
}
|
||
|
||
if ($act === 'delete_day_live') {
|
||
$day = $_POST['day_key'];
|
||
$live = db();
|
||
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
|
||
$rows->execute([$day]);
|
||
deleteRecordingsForRows($rows->fetchAll());
|
||
$st = $live->prepare("DELETE FROM messages WHERE day_key = ?");
|
||
$st->execute([$day]);
|
||
flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
|
||
redirect('tab=messages');
|
||
}
|
||
|
||
if ($act === 'delete_archive_day') {
|
||
$day = $_POST['day'];
|
||
[$y,$m,$d] = explode('-', $day);
|
||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
||
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$y,$m,$d], $tpl));
|
||
if (file_exists($path)) {
|
||
$adb = archiveDB($y, $m, $d);
|
||
if ($adb) deleteRecordingsForRows($adb->query("SELECT voice_file FROM messages")->fetchAll());
|
||
$adb = null;
|
||
unlink($path);
|
||
flash("Archive for $day deleted.");
|
||
}
|
||
else flash("Archive file not found.", 'err');
|
||
redirect('tab=archives');
|
||
}
|
||
|
||
if ($act === 'clear_all_live') {
|
||
$live = db();
|
||
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages")->fetchAll());
|
||
$live->exec("DELETE FROM messages");
|
||
flash("All live messages cleared.");
|
||
redirect('tab=messages');
|
||
}
|
||
|
||
// ── Users ─────────────────────────────────────────────────────────────────
|
||
if ($act === 'edit_user') {
|
||
$id = (int)$_POST['user_id'];
|
||
$username = trim($_POST['username']);
|
||
$color = trim($_POST['color']);
|
||
$newpass = trim($_POST['new_password']);
|
||
$manualPlanId = max(0, (int)($_POST['manual_plan_id'] ?? 0));
|
||
$errors = [];
|
||
|
||
if ($username === '') $errors[] = 'Username required.';
|
||
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Username: letters, numbers, _ and - only.';
|
||
if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.';
|
||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.';
|
||
if ($manualPlanId > 0 && !planById($manualPlanId)) $errors[] = 'Invalid tier override.';
|
||
|
||
if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
|
||
|
||
// Check username collision
|
||
$existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?");
|
||
$existing->execute([$username, $id]);
|
||
if ($existing->fetchColumn()) {
|
||
flash('Username already taken.', 'err'); redirect('tab=users');
|
||
}
|
||
|
||
db()->prepare("UPDATE users SET username = ?, color = ?, manual_plan_id = ? WHERE id = ?")
|
||
->execute([$username, $color, $manualPlanId ?: null, $id]);
|
||
// Also update username in messages (denormalised)
|
||
db()->prepare("UPDATE messages SET username = ?, color = ? WHERE user_id = ?")->execute([$username, $color, $id]);
|
||
|
||
if ($newpass !== '') {
|
||
$hash = password_hash($newpass, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]);
|
||
db()->prepare("UPDATE users SET password_hash = ? WHERE id = ?")->execute([$hash, $id]);
|
||
}
|
||
flash("User #$id updated, including tier assignment.");
|
||
redirect('tab=users');
|
||
}
|
||
|
||
if ($act === 'delete_user') {
|
||
$id = (int)$_POST['user_id'];
|
||
// Cascade deletes sessions; messages are kept (username preserved in row)
|
||
db()->prepare("DELETE FROM users WHERE id = ?")->execute([$id]);
|
||
flash("User #$id deleted. Their messages are preserved.");
|
||
redirect('tab=users');
|
||
}
|
||
|
||
if ($act === 'kick_user') {
|
||
$id = (int)$_POST['user_id'];
|
||
db()->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$id]);
|
||
flash("User #$id sessions terminated (kicked).");
|
||
redirect('tab=users');
|
||
}
|
||
|
||
if ($act === 'create_user') {
|
||
$username = trim($_POST['username']);
|
||
$password = trim($_POST['password']);
|
||
$color = trim($_POST['color']);
|
||
$palette = cfgVal('colors.user_palette', ['#00ff9f']);
|
||
if (!$color) $color = $palette[array_rand($palette)];
|
||
$errors = [];
|
||
if ($username === '') $errors[] = 'Username required.';
|
||
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Invalid username characters.';
|
||
if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.';
|
||
if (strlen($password) < (int)cfgVal('security.min_password_length', 4)) $errors[] = 'Password too short.';
|
||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.';
|
||
if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
|
||
try {
|
||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]);
|
||
$freeId = (int)db()->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn();
|
||
db()->prepare("INSERT INTO users (username,password_hash,color,plan_id,created_at) VALUES (?,?,?,?,?)")
|
||
->execute([$username, $hash, $color, $freeId ?: null, time()]);
|
||
flash("User '$username' created.");
|
||
} catch (Exception $e) { flash('Username already exists.', 'err'); }
|
||
redirect('tab=users');
|
||
}
|
||
|
||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||
if ($act === 'delete_session') {
|
||
db()->prepare("DELETE FROM sessions WHERE id = ?")->execute([$_POST['session_id']]);
|
||
flash("Session terminated.");
|
||
redirect('tab=sessions');
|
||
}
|
||
if ($act === 'clear_expired_sessions') {
|
||
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
|
||
$st = db()->prepare("DELETE FROM sessions WHERE last_active < ?"); $st->execute([time() - $timeout]);
|
||
flash($st->rowCount() . " expired session(s) cleared.");
|
||
redirect('tab=sessions');
|
||
}
|
||
if ($act === 'clear_all_sessions') {
|
||
db()->exec("DELETE FROM sessions");
|
||
flash("All sessions cleared — everyone is logged out.");
|
||
redirect('tab=sessions');
|
||
}
|
||
|
||
// ── Config ────────────────────────────────────────────────────────────────
|
||
if ($act === 'save_config') {
|
||
$raw = $_POST['config_json'] ?? '';
|
||
$parsed = json_decode($raw, true);
|
||
if ($parsed === null) { flash('Invalid JSON: ' . json_last_error_msg(), 'err'); redirect('tab=config'); }
|
||
file_put_contents(CONFIG_FILE, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||
flash('config.json saved.');
|
||
redirect('tab=config');
|
||
}
|
||
}
|
||
|
||
// ── Data fetching ─────────────────────────────────────────────────────────────
|
||
$tab = $_GET['tab'] ?? 'dashboard';
|
||
|
||
$stats = [];
|
||
if ($isLoggedIn) {
|
||
try {
|
||
$live = db();
|
||
$stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
|
||
$stats['messages'] = $live->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
||
$stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
|
||
$stats['sessions'] = $live->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
|
||
$stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn();
|
||
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
|
||
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
|
||
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
|
||
}
|
||
|
||
// Archive list
|
||
$archives = [];
|
||
if ($isLoggedIn) {
|
||
$archDir = storagePath((string)cfgVal('archive.archive_dir', 'archive'));
|
||
if (is_dir($archDir)) {
|
||
foreach (glob($archDir . '/*/*/*.sqlite') as $f) {
|
||
if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) {
|
||
$day = "{$m[1]}-{$m[2]}-{$m[3]}";
|
||
$sz = filesize($f);
|
||
$cnt = 0;
|
||
try {
|
||
$x = archiveDB($m[1], $m[2], $m[3]);
|
||
if ($x) $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
||
} catch(Exception $e) {}
|
||
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
|
||
}
|
||
}
|
||
usort($archives, fn($a,$b) => strcmp($b['day'], $a['day']));
|
||
}
|
||
}
|
||
|
||
$flash = getFlash();
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CyberChat Admin</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ─── Reset ──────────────────────────────────────────────────────────── */
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg0:#05080d;--bg1:#080c12;--bg2:#0c1118;--bg3:#101820;
|
||
--bd:#182030;--bd2:#1e2e40;
|
||
--g:#00ff9f;--b:#00b8ff;--p:#ff2d78;--o:#ff9f00;--r:#ff3c3c;--v:#bf5fff;
|
||
--t0:#c8e6ff;--t1:#4d6a88;--t2:#1e2e40;
|
||
--mono:'Share Tech Mono',monospace;
|
||
--ui:'Rajdhani',sans-serif;
|
||
--disp:'Orbitron',monospace;
|
||
}
|
||
html,body{height:100%;font-family:var(--ui);background:var(--bg0);color:var(--t0);font-size:14px}
|
||
::-webkit-scrollbar{width:5px;height:5px}
|
||
::-webkit-scrollbar-track{background:var(--bg1)}
|
||
::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px}
|
||
a{color:var(--b);text-decoration:none}
|
||
a:hover{color:var(--g)}
|
||
button,input,select,textarea{font-family:inherit;font-size:inherit}
|
||
|
||
/* ─── Layout ─────────────────────────────────────────────────────────── */
|
||
.layout{display:flex;min-height:100vh}
|
||
.sidebar{
|
||
width:220px;flex-shrink:0;
|
||
background:var(--bg1);
|
||
border-right:1px solid var(--bd);
|
||
display:flex;flex-direction:column;
|
||
position:sticky;top:0;height:100vh;overflow-y:auto;
|
||
}
|
||
.main{flex:1;min-width:0;overflow-x:hidden}
|
||
|
||
/* ─── Sidebar ────────────────────────────────────────────────────────── */
|
||
.sb-logo{
|
||
padding:18px 16px 14px;
|
||
border-bottom:1px solid var(--bd);
|
||
}
|
||
.sb-logo-text{
|
||
font-family:var(--disp);font-size:11px;font-weight:700;letter-spacing:3px;
|
||
background:linear-gradient(90deg,var(--g),var(--b));
|
||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
|
||
}
|
||
.sb-logo-sub{font-family:var(--mono);font-size:8px;color:var(--t1);letter-spacing:2px;margin-top:2px}
|
||
|
||
.sb-nav{padding:10px 0;flex:1}
|
||
.sb-section{
|
||
font-family:var(--mono);font-size:8px;color:var(--t1);
|
||
letter-spacing:2px;padding:12px 16px 4px;
|
||
}
|
||
.sb-link{
|
||
display:flex;align-items:center;gap:8px;
|
||
padding:8px 16px;font-size:13px;font-weight:600;
|
||
color:var(--t1);text-decoration:none;letter-spacing:.5px;
|
||
border-left:2px solid transparent;
|
||
transition:all .12s;
|
||
}
|
||
.sb-link:hover{color:var(--t0);background:rgba(0,184,255,.05);border-left-color:var(--b)}
|
||
.sb-link.active{color:var(--g);background:rgba(0,255,159,.06);border-left-color:var(--g)}
|
||
.sb-link .ico{font-size:14px;width:16px;text-align:center}
|
||
|
||
.sb-footer{padding:12px 16px;border-top:1px solid var(--bd)}
|
||
.sb-version{font-family:var(--mono);font-size:9px;color:var(--t2);letter-spacing:1px}
|
||
|
||
/* ─── Topbar ─────────────────────────────────────────────────────────── */
|
||
.topbar{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:0 24px;height:48px;
|
||
background:var(--bg1);border-bottom:1px solid var(--bd);
|
||
position:sticky;top:0;z-index:100;
|
||
}
|
||
.topbar-title{font-family:var(--mono);font-size:10px;letter-spacing:2px;color:var(--t1)}
|
||
.topbar-right{display:flex;align-items:center;gap:12px}
|
||
.btn-logout{
|
||
background:none;border:1px solid var(--r);color:var(--r);
|
||
padding:4px 10px;border-radius:3px;font-family:var(--mono);font-size:9px;
|
||
letter-spacing:1px;cursor:pointer;transition:all .12s;
|
||
}
|
||
.btn-logout:hover{background:var(--r);color:#000}
|
||
|
||
/* ─── Content ────────────────────────────────────────────────────────── */
|
||
.content{padding:24px;max-width:1300px}
|
||
|
||
/* ─── Flash ──────────────────────────────────────────────────────────── */
|
||
.alert{
|
||
font-family:var(--mono);font-size:11px;letter-spacing:.5px;
|
||
padding:9px 14px;border-radius:3px;margin-bottom:20px;
|
||
border:1px solid;display:flex;align-items:center;gap:8px;
|
||
}
|
||
.alert.ok {border-color:var(--g);color:var(--g);background:rgba(0,255,159,.06)}
|
||
.alert.err{border-color:var(--r);color:var(--r);background:rgba(255,60,60,.06)}
|
||
.alert.warn{border-color:var(--o);color:var(--o);background:rgba(255,159,0,.06)}
|
||
|
||
/* ─── Page header ────────────────────────────────────────────────────── */
|
||
.page-head{margin-bottom:20px}
|
||
.page-head h1{font-family:var(--disp);font-size:14px;letter-spacing:3px;color:var(--t0);margin-bottom:4px}
|
||
.page-head p{font-family:var(--mono);font-size:10px;color:var(--t1);letter-spacing:1px}
|
||
|
||
/* ─── Stat cards ─────────────────────────────────────────────────────── */
|
||
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:24px}
|
||
.stat-card{
|
||
background:var(--bg2);border:1px solid var(--bd);border-radius:4px;
|
||
padding:14px 16px;position:relative;overflow:hidden;
|
||
}
|
||
.stat-card::after{
|
||
content:'';position:absolute;bottom:0;left:0;right:0;height:2px;
|
||
}
|
||
.stat-card.green::after{background:var(--g)}
|
||
.stat-card.blue::after{background:var(--b)}
|
||
.stat-card.pink::after{background:var(--p)}
|
||
.stat-card.orange::after{background:var(--o)}
|
||
.stat-card.violet::after{background:var(--v)}
|
||
.stat-num{font-family:var(--disp);font-size:22px;font-weight:700;line-height:1}
|
||
.stat-card.green .stat-num{color:var(--g);text-shadow:0 0 12px rgba(0,255,159,.4)}
|
||
.stat-card.blue .stat-num{color:var(--b);text-shadow:0 0 12px rgba(0,184,255,.4)}
|
||
.stat-card.pink .stat-num{color:var(--p);text-shadow:0 0 12px rgba(255,45,120,.4)}
|
||
.stat-card.orange .stat-num{color:var(--o);text-shadow:0 0 12px rgba(255,159,0,.4)}
|
||
.stat-card.violet .stat-num{color:var(--v);text-shadow:0 0 12px rgba(191,95,255,.4)}
|
||
.stat-label{font-family:var(--mono);font-size:9px;color:var(--t1);letter-spacing:1.5px;margin-top:6px;text-transform:uppercase}
|
||
|
||
/* ─── Panel ──────────────────────────────────────────────────────────── */
|
||
.panel{background:var(--bg1);border:1px solid var(--bd);border-radius:4px;overflow:hidden;margin-bottom:20px}
|
||
.panel-head{
|
||
display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;
|
||
padding:10px 16px;border-bottom:1px solid var(--bd);
|
||
background:rgba(255,255,255,.02);
|
||
}
|
||
.panel-title{font-family:var(--mono);font-size:10px;color:var(--t1);letter-spacing:2px}
|
||
.panel-body{padding:16px}
|
||
.panel-body.np{padding:0}
|
||
|
||
/* ─── Table ──────────────────────────────────────────────────────────── */
|
||
.tbl-wrap{overflow-x:auto}
|
||
table{width:100%;border-collapse:collapse;font-size:12px}
|
||
th{
|
||
font-family:var(--mono);font-size:9px;color:var(--t1);
|
||
letter-spacing:1.5px;text-transform:uppercase;
|
||
padding:8px 12px;border-bottom:1px solid var(--bd);
|
||
text-align:left;white-space:nowrap;background:rgba(0,0,0,.2);
|
||
}
|
||
td{
|
||
padding:8px 12px;border-bottom:1px solid rgba(24,32,48,.7);
|
||
vertical-align:middle;color:var(--t0);
|
||
}
|
||
tr:last-child td{border-bottom:none}
|
||
tr:hover td{background:rgba(255,255,255,.02)}
|
||
td.mono{font-family:var(--mono);font-size:11px}
|
||
td.dim{color:var(--t1);font-family:var(--mono);font-size:10px}
|
||
td.actions{white-space:nowrap;width:1px}
|
||
|
||
/* ─── Buttons ────────────────────────────────────────────────────────── */
|
||
.btn{
|
||
display:inline-flex;align-items:center;gap:5px;
|
||
padding:5px 12px;border-radius:3px;border:1px solid;
|
||
font-family:var(--mono);font-size:10px;letter-spacing:1px;
|
||
cursor:pointer;transition:all .12s;text-transform:uppercase;
|
||
text-decoration:none;white-space:nowrap;
|
||
}
|
||
.btn-sm{padding:3px 8px;font-size:9px}
|
||
.btn-g{border-color:var(--g);color:var(--g);background:none} .btn-g:hover{background:var(--g);color:#000}
|
||
.btn-b{border-color:var(--b);color:var(--b);background:none} .btn-b:hover{background:var(--b);color:#000}
|
||
.btn-r{border-color:var(--r);color:var(--r);background:none} .btn-r:hover{background:var(--r);color:#000}
|
||
.btn-o{border-color:var(--o);color:var(--o);background:none} .btn-o:hover{background:var(--o);color:#000}
|
||
.btn-v{border-color:var(--v);color:var(--v);background:none} .btn-v:hover{background:var(--v);color:#000}
|
||
.btn-t1{border-color:var(--t1);color:var(--t1);background:none} .btn-t1:hover{border-color:var(--t0);color:var(--t0)}
|
||
.btn:disabled{opacity:.35;cursor:not-allowed;pointer-events:none}
|
||
.btn-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
|
||
/* ─── Forms ──────────────────────────────────────────────────────────── */
|
||
.field{margin-bottom:14px}
|
||
.field label{display:block;font-family:var(--mono);font-size:9px;color:var(--t1);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:5px}
|
||
.field input,.field select,.field textarea{
|
||
width:100%;background:rgba(0,0,0,.3);border:1px solid var(--bd2);border-radius:3px;
|
||
color:var(--t0);padding:7px 10px;outline:none;font-family:var(--mono);font-size:12px;
|
||
transition:border-color .12s,box-shadow .12s;
|
||
}
|
||
.field input:focus,.field select:focus,.field textarea:focus{
|
||
border-color:var(--b);box-shadow:0 0 0 2px rgba(0,184,255,.12);
|
||
}
|
||
.field textarea{resize:vertical;min-height:80px}
|
||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||
.form-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
|
||
@media(max-width:600px){.form-row,.form-row3{grid-template-columns:1fr}}
|
||
.color-preview{
|
||
width:20px;height:20px;border-radius:50%;border:2px solid var(--bd2);
|
||
display:inline-block;vertical-align:middle;margin-left:8px;flex-shrink:0;
|
||
}
|
||
|
||
/* ─── Badge ──────────────────────────────────────────────────────────── */
|
||
.badge{
|
||
display:inline-block;padding:2px 7px;border-radius:2px;
|
||
font-family:var(--mono);font-size:9px;letter-spacing:1px;
|
||
}
|
||
.badge-g{background:rgba(0,255,159,.12);color:var(--g);border:1px solid rgba(0,255,159,.25)}
|
||
.badge-r{background:rgba(255,60,60,.12);color:var(--r);border:1px solid rgba(255,60,60,.25)}
|
||
.badge-b{background:rgba(0,184,255,.12);color:var(--b);border:1px solid rgba(0,184,255,.25)}
|
||
.badge-o{background:rgba(255,159,0,.12);color:var(--o);border:1px solid rgba(255,159,0,.25)}
|
||
|
||
/* ─── Message text preview ───────────────────────────────────────────── */
|
||
.msg-preview{
|
||
max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||
font-family:var(--mono);font-size:11px;color:var(--t0);
|
||
}
|
||
.admin-audio{
|
||
display:block;width:280px;max-width:100%;height:34px;
|
||
color-scheme:dark;accent-color:var(--g);
|
||
border:1px solid rgba(0,255,159,.38);border-radius:2px;
|
||
background:linear-gradient(90deg,rgba(0,255,159,.08),rgba(0,184,255,.04)),var(--bg2);
|
||
box-shadow:inset 0 0 14px rgba(0,0,0,.55),0 0 8px rgba(0,255,159,.12);
|
||
}
|
||
.admin-audio::-webkit-media-controls-enclosure{border-radius:0;background:transparent}
|
||
.admin-audio::-webkit-media-controls-panel{background:linear-gradient(90deg,#07120f,#08111a)}
|
||
.admin-audio::-webkit-media-controls-current-time-display,
|
||
.admin-audio::-webkit-media-controls-time-remaining-display{color:var(--g);font-family:var(--mono);font-size:9px}
|
||
|
||
/* ─── Modal ──────────────────────────────────────────────────────────── */
|
||
.modal-bg{
|
||
display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);
|
||
z-index:1000;align-items:center;justify-content:center;padding:20px;
|
||
}
|
||
.modal-bg.open{display:flex}
|
||
.modal{
|
||
background:var(--bg2);border:1px solid var(--bd2);border-radius:5px;
|
||
width:100%;max-width:520px;max-height:90vh;overflow-y:auto;
|
||
box-shadow:0 0 60px rgba(0,0,0,.8);
|
||
animation:mIn .18s ease;
|
||
}
|
||
@keyframes mIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
|
||
.modal-head{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:12px 16px;border-bottom:1px solid var(--bd);
|
||
}
|
||
.modal-head-title{font-family:var(--mono);font-size:10px;color:var(--b);letter-spacing:2px}
|
||
.modal-close{background:none;border:none;color:var(--t1);font-size:18px;cursor:pointer;line-height:1;padding:0 2px}
|
||
.modal-close:hover{color:var(--r)}
|
||
.modal-body{padding:18px 16px}
|
||
.modal-footer{padding:10px 16px;border-top:1px solid var(--bd);display:flex;gap:8px;justify-content:flex-end}
|
||
|
||
/* ─── Search bar ─────────────────────────────────────────────────────── */
|
||
.search-bar{
|
||
display:flex;align-items:center;gap:8px;flex-wrap:wrap;
|
||
margin-bottom:14px;
|
||
}
|
||
.search-bar input,.search-bar select{
|
||
background:rgba(0,0,0,.3);border:1px solid var(--bd2);border-radius:3px;
|
||
color:var(--t0);padding:5px 10px;outline:none;font-family:var(--mono);font-size:11px;
|
||
}
|
||
.search-bar input:focus,.search-bar select:focus{border-color:var(--b)}
|
||
.search-bar input[type=text]{min-width:180px}
|
||
|
||
/* ─── Login screen ───────────────────────────────────────────────────── */
|
||
.login-wrap{
|
||
min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||
background:var(--bg0);
|
||
background-image:radial-gradient(ellipse 60% 50% at 50% 30%, rgba(0,184,255,.05) 0%, transparent 70%);
|
||
}
|
||
.login-box{
|
||
width:320px;background:var(--bg1);border:1px solid var(--bd);border-radius:5px;
|
||
overflow:hidden;
|
||
}
|
||
.login-box-head{
|
||
padding:20px;border-bottom:1px solid var(--bd);text-align:center;
|
||
background:linear-gradient(135deg, rgba(0,184,255,.05), rgba(0,255,159,.03));
|
||
}
|
||
.login-logo{font-family:var(--disp);font-size:13px;letter-spacing:3px;background:linear-gradient(90deg,var(--g),var(--b));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||
.login-sub{font-family:var(--mono);font-size:9px;color:var(--t1);letter-spacing:2px;margin-top:3px}
|
||
.login-body{padding:20px}
|
||
.login-err{font-family:var(--mono);font-size:10px;color:var(--r);margin-bottom:12px;text-align:center}
|
||
|
||
/* ─── Config editor ──────────────────────────────────────────────────── */
|
||
.config-editor{
|
||
width:100%;min-height:400px;background:rgba(0,0,0,.4);border:1px solid var(--bd2);
|
||
border-radius:3px;color:var(--g);font-family:var(--mono);font-size:12px;
|
||
padding:14px;outline:none;resize:vertical;line-height:1.7;
|
||
transition:border-color .12s;
|
||
}
|
||
.config-editor:focus{border-color:var(--b)}
|
||
|
||
/* ─── Checkbox ───────────────────────────────────────────────────────── */
|
||
.cb-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;cursor:pointer}
|
||
.cb-row input[type=checkbox]{accent-color:var(--g);width:14px;height:14px;cursor:pointer}
|
||
.cb-row span{font-size:12px;color:var(--t1)}
|
||
|
||
/* ─── Danger zone ────────────────────────────────────────────────────── */
|
||
.danger-zone{border:1px solid rgba(255,60,60,.3);border-radius:4px;padding:14px 16px;margin-top:20px}
|
||
.danger-zone-title{font-family:var(--mono);font-size:9px;color:var(--r);letter-spacing:2px;margin-bottom:12px}
|
||
|
||
/* ─── Responsive sidebar ─────────────────────────────────────────────── */
|
||
@media(max-width:700px){
|
||
.sidebar{width:54px}
|
||
.sb-logo-text,.sb-logo-sub,.sb-section,.sb-link span,.sb-version{display:none}
|
||
.sb-link{padding:10px 0;justify-content:center}
|
||
.sb-link .ico{width:auto}
|
||
}
|
||
|
||
/* ─── Empty state ────────────────────────────────────────────────────── */
|
||
.empty{
|
||
text-align:center;padding:40px 20px;
|
||
font-family:var(--mono);font-size:11px;color:var(--t2);letter-spacing:1.5px;
|
||
}
|
||
|
||
/* ─── Select-all row ─────────────────────────────────────────────────── */
|
||
.bulk-bar{
|
||
display:none;align-items:center;gap:10px;flex-wrap:wrap;
|
||
padding:8px 12px;background:rgba(0,184,255,.06);
|
||
border-bottom:1px solid var(--b);
|
||
font-family:var(--mono);font-size:10px;color:var(--b);
|
||
}
|
||
.bulk-bar.visible{display:flex}
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<?php if (!$isLoggedIn): ?>
|
||
<!-- ════ LOGIN ══════════════════════════════════════════════════════════════ -->
|
||
<div class="login-wrap">
|
||
<div class="login-box">
|
||
<div class="login-box-head">
|
||
<div class="login-logo">CYBERCHAT</div>
|
||
<div class="login-sub">// ADMIN PANEL</div>
|
||
</div>
|
||
<div class="login-body">
|
||
<?php if (!empty($loginError)): ?>
|
||
<div class="login-err">⚠ <?= esc($loginError) ?></div>
|
||
<?php endif; ?>
|
||
<form method="post" autocomplete="off">
|
||
<div class="field">
|
||
<label>Admin Password</label>
|
||
<input type="password" name="admin_pass" autofocus autocomplete="current-password"
|
||
placeholder="enter password…">
|
||
</div>
|
||
<button type="submit" name="admin_login" class="btn btn-g" style="width:100%;justify-content:center">
|
||
AUTHENTICATE
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php else: /* ════ MAIN APP ════════════════════════════════════════════════ */ ?>
|
||
|
||
<div class="layout">
|
||
|
||
<!-- ── Sidebar ─────────────────────────────────────────────────────────── -->
|
||
<aside class="sidebar">
|
||
<div class="sb-logo">
|
||
<div class="sb-logo-text">CYBERCHAT</div>
|
||
<div class="sb-logo-sub">// ADMIN</div>
|
||
</div>
|
||
<nav class="sb-nav">
|
||
<div class="sb-section">OVERVIEW</div>
|
||
<a class="sb-link <?= $tab==='dashboard'?'active':'' ?>" href="?tab=dashboard">
|
||
<span class="ico">◈</span><span>Dashboard</span>
|
||
</a>
|
||
|
||
<div class="sb-section">CONTENT</div>
|
||
<a class="sb-link <?= $tab==='messages'?'active':'' ?>" href="?tab=messages">
|
||
<span class="ico">✉</span><span>Messages</span>
|
||
</a>
|
||
<a class="sb-link <?= $tab==='voice'?'active':'' ?>" href="?tab=voice">
|
||
<span class="ico">◖</span><span>Voice Clips</span>
|
||
</a>
|
||
<a class="sb-link <?= $tab==='archives'?'active':'' ?>" href="?tab=archives">
|
||
<span class="ico">🗄</span><span>Archives</span>
|
||
</a>
|
||
|
||
<div class="sb-section">ACCOUNTS</div>
|
||
<a class="sb-link <?= $tab==='users'?'active':'' ?>" href="?tab=users">
|
||
<span class="ico">◉</span><span>Users</span>
|
||
</a>
|
||
<a class="sb-link <?= $tab==='sessions'?'active':'' ?>" href="?tab=sessions">
|
||
<span class="ico">⚡</span><span>Sessions</span>
|
||
</a>
|
||
<a class="sb-link <?= $tab==='spam'?'active':'' ?>" href="?tab=spam">
|
||
<span class="ico">!</span><span>Spam Control</span>
|
||
</a>
|
||
|
||
<a class="sb-link <?= $tab==='platform'?'active':'' ?>" href="?tab=platform">
|
||
<span class="ico">◆</span><span>Plans & Platform</span>
|
||
</a>
|
||
|
||
<div class="sb-section">SYSTEM</div>
|
||
<a class="sb-link <?= $tab==='config'?'active':'' ?>" href="?tab=config">
|
||
<span class="ico">⚙</span><span>Config</span>
|
||
</a>
|
||
</nav>
|
||
<div class="sb-footer">
|
||
<div class="sb-version">v<?= ADMIN_VERSION ?></div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ── Main ─────────────────────────────────────────────────────────────── -->
|
||
<div class="main">
|
||
<div class="topbar">
|
||
<span class="topbar-title">// <?= strtoupper(esc($tab)) ?></span>
|
||
<div class="topbar-right">
|
||
<form method="post" style="margin:0">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<button type="submit" name="admin_logout" class="btn-logout">⏻ LOGOUT</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
|
||
<?php if ($flash): ?>
|
||
<div class="alert <?= esc($flash['type']) ?>">
|
||
<?= $flash['type']==='ok' ? '✓' : '⚠' ?> <?= esc($flash['msg']) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* ════════════════════════════════ DASHBOARD ══════════════════ */ ?>
|
||
<?php if ($tab === 'dashboard'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>DASHBOARD</h1>
|
||
<p>// system overview</p>
|
||
</div>
|
||
|
||
<div class="stat-grid">
|
||
<div class="stat-card green">
|
||
<div class="stat-num"><?= esc($stats['users']) ?></div>
|
||
<div class="stat-label">Users</div>
|
||
</div>
|
||
<div class="stat-card blue">
|
||
<div class="stat-num"><?= esc($stats['messages']) ?></div>
|
||
<div class="stat-label">Live Messages</div>
|
||
</div>
|
||
<div class="stat-card violet">
|
||
<div class="stat-num"><?= esc($stats['voice']) ?></div>
|
||
<div class="stat-label">Live Voice Clips</div>
|
||
</div>
|
||
<div class="stat-card pink">
|
||
<div class="stat-num"><?= esc($stats['today']) ?></div>
|
||
<div class="stat-label">Today</div>
|
||
</div>
|
||
<div class="stat-card orange">
|
||
<div class="stat-num"><?= esc($stats['sessions']) ?></div>
|
||
<div class="stat-label">Sessions</div>
|
||
</div>
|
||
<div class="stat-card violet">
|
||
<div class="stat-num"><?= esc($stats['active']) ?></div>
|
||
<div class="stat-label">Active Now</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
<!-- Recent messages -->
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// RECENT MESSAGES</span>
|
||
<a class="btn btn-sm btn-b" href="?tab=messages">VIEW ALL</a>
|
||
</div>
|
||
<div class="panel-body np">
|
||
<?php
|
||
$live = db();
|
||
$recent = $live->query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id
|
||
ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
|
||
if (empty($recent)): ?>
|
||
<div class="empty">NO MESSAGES YET</div>
|
||
<?php else: ?>
|
||
<table>
|
||
<thead><tr><th>User</th><th>Message</th><th>Time</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($recent as $r): ?>
|
||
<tr>
|
||
<td style="color:<?= esc($r['color']) ?>;font-family:var(--mono);font-size:11px"><?= esc($r['username']) ?></td>
|
||
<td><div class="msg-preview"><?= ($r['message_type'] ?? 'text') === 'voice' ? 'VOICE CLIP · ' . esc(round((float)$r['voice_duration'], 1)) . 's' : esc($r['message']) ?></div></td>
|
||
<td class="dim"><?= ago((int)$r['created_at']) ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent users -->
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// RECENT USERS</span>
|
||
<a class="btn btn-sm btn-g" href="?tab=users">VIEW ALL</a>
|
||
</div>
|
||
<div class="panel-body np">
|
||
<?php
|
||
$recUsers = db()->query("SELECT * FROM users ORDER BY created_at DESC LIMIT 8")->fetchAll();
|
||
if (empty($recUsers)): ?>
|
||
<div class="empty">NO USERS YET</div>
|
||
<?php else: ?>
|
||
<table>
|
||
<thead><tr><th>Username</th><th>Color</th><th>Joined</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($recUsers as $u): ?>
|
||
<tr>
|
||
<td style="color:<?= esc($u['color']) ?>;font-family:var(--mono)"><?= esc($u['username']) ?></td>
|
||
<td><span style="background:<?= esc($u['color']) ?>;width:14px;height:14px;display:inline-block;border-radius:50%;"></span></td>
|
||
<td class="dim"><?= ago((int)$u['created_at']) ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel" style="margin-top:16px">
|
||
<div class="panel-head"><span class="panel-title">// QUICK ACTIONS</span></div>
|
||
<div class="panel-body">
|
||
<div class="btn-group">
|
||
<a class="btn btn-b" href="?tab=messages">Browse Messages</a>
|
||
<a class="btn btn-v" href="?tab=voice">Manage Voice Clips</a>
|
||
<a class="btn btn-g" href="?tab=users">Manage Users</a>
|
||
<a class="btn btn-v" href="?tab=sessions">Active Sessions</a>
|
||
<a class="btn btn-o" href="?tab=archives">Archives</a>
|
||
<a class="btn btn-t1" href="?tab=config">Edit Config</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ════════════════════════════════ MESSAGES ═══════════════════ */ ?>
|
||
<?php elseif ($tab === 'messages'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>LIVE MESSAGES</h1>
|
||
<p>// today's active chat — <?= esc(date('Y-m-d')) ?></p>
|
||
</div>
|
||
|
||
<?php
|
||
// Filters
|
||
$fUser = trim($_GET['fu'] ?? '');
|
||
$fText = trim($_GET['ft'] ?? '');
|
||
$fDay = trim($_GET['fd'] ?? '');
|
||
$fType = trim($_GET['type'] ?? '');
|
||
|
||
$live = db();
|
||
$where = []; $params = [];
|
||
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
|
||
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
|
||
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
|
||
if (in_array($fType, ['text','voice'], true)) { $where[] = "message_type = ?"; $params[] = $fType; }
|
||
$sql = "SELECT * FROM messages" . ($where ? " WHERE ".implode(" AND ", $where) : "") . " ORDER BY created_at DESC LIMIT 500";
|
||
$msgs = $live->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
|
||
|
||
// Days available
|
||
$days = $live->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
|
||
?>
|
||
|
||
<div class="search-bar">
|
||
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="hidden" name="tab" value="messages">
|
||
<input type="text" name="fu" value="<?= esc($fUser) ?>" placeholder="Filter by user…">
|
||
<input type="text" name="ft" value="<?= esc($fText) ?>" placeholder="Filter by text…">
|
||
<select name="fd">
|
||
<option value="">All days</option>
|
||
<?php foreach ($days as $d): ?>
|
||
<option value="<?= esc($d) ?>" <?= $fDay===$d?'selected':'' ?>><?= esc($d) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<select name="type">
|
||
<option value="">All types</option>
|
||
<option value="text" <?= $fType==='text'?'selected':'' ?>>Text</option>
|
||
<option value="voice" <?= $fType==='voice'?'selected':'' ?>>Voice</option>
|
||
</select>
|
||
<button type="submit" class="btn btn-b btn-sm">FILTER</button>
|
||
<a class="btn btn-t1 btn-sm" href="?tab=messages">RESET</a>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// <?= count($msgs) ?> MESSAGE(S)</span>
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm btn-b" onclick="toggleSelectAll()">SELECT ALL</button>
|
||
<form method="post" id="bulk-form" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_messages_bulk">
|
||
<input type="hidden" name="return_qs" value="tab=messages&fu=<?= urlencode($fUser) ?>&ft=<?= urlencode($fText) ?>&fd=<?= urlencode($fDay) ?>&type=<?= urlencode($fType) ?>">
|
||
<div id="bulk-ids"></div>
|
||
<button type="submit" class="btn btn-sm btn-r" id="bulk-delete-btn" style="display:none"
|
||
onclick="return confirm('Delete selected messages?')">
|
||
DELETE SELECTED
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<?php if (!empty($days)): ?>
|
||
<div class="bulk-bar" id="bulk-bar">
|
||
<span id="bulk-count">0 selected</span>
|
||
<button class="btn btn-sm btn-r" onclick="submitBulk()" >DELETE SELECTED</button>
|
||
<button class="btn btn-sm btn-t1" onclick="clearSelection()">CLEAR</button>
|
||
</div>
|
||
<?php endif; ?>
|
||
<div class="panel-body np">
|
||
<?php if (empty($msgs)): ?>
|
||
<div class="empty">NO MESSAGES FOUND</div>
|
||
<?php else: ?>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th><input type="checkbox" id="cb-all" onchange="cbAllChange(this)" title="Select all"></th>
|
||
<th>ID</th><th>Day</th><th>User</th><th>Message</th><th>Time</th><th>Actions</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<?php foreach ($msgs as $msg): ?>
|
||
<tr id="row-<?= $msg['id'] ?>">
|
||
<td><input type="checkbox" class="msg-cb" value="<?= $msg['id'] ?>" onchange="cbChange()"></td>
|
||
<td class="mono dim">#<?= $msg['id'] ?></td>
|
||
<td class="dim"><?= esc($msg['day_key']) ?></td>
|
||
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($msg['username']) ?></td>
|
||
<td>
|
||
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
|
||
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s
|
||
<?= !empty($msg['voice_bitrate_kbps']) ? ' · ' . (int)$msg['voice_bitrate_kbps'] . ' kbps' : '' ?></div>
|
||
<audio class="admin-audio" controls preload="metadata"
|
||
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||
<?php else: ?>
|
||
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
||
<?php endif; ?>
|
||
<?php if (!empty($msg['reply_to_id'])): ?><div class="dim">Reply #<?= (int)$msg['reply_to_id'] ?> · depth <?= (int)$msg['reply_depth'] ?></div><?php endif; ?>
|
||
</td>
|
||
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
|
||
<td class="actions">
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm btn-b"
|
||
onclick='openEditMsg(<?= json_encode((int)$msg['id']) ?>,<?= json_encode('live') ?>,<?= json_encode($msg['message'], JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_TAG|JSON_HEX_QUOT) ?>,<?= json_encode($msg['username']) ?>,<?= json_encode('tab=messages') ?>)'>
|
||
EDIT
|
||
</button>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_message">
|
||
<input type="hidden" name="msg_id" value="<?= $msg['id'] ?>">
|
||
<input type="hidden" name="msg_src" value="live">
|
||
<input type="hidden" name="return_qs" value="tab=messages&fu=<?= urlencode($fUser) ?>&ft=<?= urlencode($fText) ?>&fd=<?= urlencode($fDay) ?>&type=<?= urlencode($fType) ?>">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Delete this message?')">DEL</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Danger zone -->
|
||
<div class="danger-zone">
|
||
<div class="danger-zone-title">// DANGER ZONE</div>
|
||
<div class="btn-group">
|
||
<?php if ($fDay): ?>
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_day_live">
|
||
<input type="hidden" name="day_key" value="<?= esc($fDay) ?>">
|
||
<button type="submit" class="btn btn-r"
|
||
onclick="return confirm('Delete ALL messages for <?= esc($fDay) ?>?')">
|
||
DELETE ALL FOR <?= esc($fDay) ?>
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="clear_all_live">
|
||
<button type="submit" class="btn btn-r"
|
||
onclick="return confirm('DELETE ALL live messages? This cannot be undone.')">
|
||
CLEAR ALL LIVE MESSAGES
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ════════════════════════════════ VOICE CLIPS ════════════════ */ ?>
|
||
<?php elseif ($tab === 'voice'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>VOICE CLIPS</h1>
|
||
<p>// play, archive, and delete walkie-talkie recordings</p>
|
||
</div>
|
||
|
||
<?php
|
||
$vUser = trim($_GET['vu'] ?? '');
|
||
$vDay = trim($_GET['vd'] ?? '');
|
||
$vSource = trim($_GET['vs'] ?? 'all');
|
||
if (!in_array($vSource, ['all', 'live', 'archive'], true)) $vSource = 'all';
|
||
if ($vDay !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $vDay)) $vDay = '';
|
||
|
||
$voiceRows = [];
|
||
if ($vSource !== 'archive') {
|
||
$live = db();
|
||
$vWhere = ["message_type = 'voice'"];
|
||
$vParams = [];
|
||
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
||
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
|
||
$vStmt = db()->prepare("SELECT * FROM messages WHERE " . implode(' AND ', $vWhere) . " ORDER BY created_at DESC LIMIT 500");
|
||
$vStmt->execute($vParams);
|
||
foreach ($vStmt->fetchAll() as $row) {
|
||
$row['_source'] = 'live';
|
||
$voiceRows[] = $row;
|
||
}
|
||
}
|
||
|
||
if ($vSource !== 'live') {
|
||
foreach ($archives as $arc) {
|
||
if ($vDay !== '' && $arc['day'] !== $vDay) continue;
|
||
try {
|
||
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
|
||
if (!$vAdb) continue;
|
||
$vSql = "SELECT * FROM messages WHERE message_type = 'voice'";
|
||
$vParams = [];
|
||
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
||
$vSql .= ' ORDER BY created_at DESC LIMIT 500';
|
||
$vStmt = $vAdb->prepare($vSql);
|
||
$vStmt->execute($vParams);
|
||
foreach ($vStmt->fetchAll() as $row) {
|
||
$row['_source'] = 'archive';
|
||
$voiceRows[] = $row;
|
||
}
|
||
} catch (Throwable $e) {
|
||
// Keep the rest of the archive list usable if one file is damaged.
|
||
}
|
||
}
|
||
}
|
||
|
||
usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at']));
|
||
$voiceRows = array_slice($voiceRows, 0, 500);
|
||
$live = db();
|
||
$voiceDays = array_unique(array_merge(
|
||
$live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
|
||
array_column($archives, 'day')
|
||
));
|
||
rsort($voiceDays);
|
||
$voiceReturn = http_build_query(['tab'=>'voice', 'vu'=>$vUser, 'vd'=>$vDay, 'vs'=>$vSource]);
|
||
?>
|
||
|
||
<div class="search-bar">
|
||
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="hidden" name="tab" value="voice">
|
||
<input type="text" name="vu" value="<?= esc($vUser) ?>" placeholder="Filter by user…">
|
||
<select name="vd">
|
||
<option value="">All days</option>
|
||
<?php foreach ($voiceDays as $day): ?>
|
||
<option value="<?= esc($day) ?>" <?= $vDay===$day?'selected':'' ?>><?= esc($day) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<select name="vs">
|
||
<option value="all" <?= $vSource==='all'?'selected':'' ?>>Live + archived</option>
|
||
<option value="live" <?= $vSource==='live'?'selected':'' ?>>Live only</option>
|
||
<option value="archive" <?= $vSource==='archive'?'selected':'' ?>>Archived only</option>
|
||
</select>
|
||
<button type="submit" class="btn btn-b btn-sm">FILTER</button>
|
||
<a class="btn btn-t1 btn-sm" href="?tab=voice">RESET</a>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// <?= count($voiceRows) ?> VOICE CLIP(S)</span>
|
||
<span class="dim" style="font-family:var(--mono);font-size:9px">MAX 500 RESULTS</span>
|
||
</div>
|
||
<div class="panel-body np">
|
||
<?php if (empty($voiceRows)): ?>
|
||
<div class="empty">NO VOICE CLIPS FOUND</div>
|
||
<?php else: ?>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr><th>ID</th><th>Source</th><th>Day</th><th>User</th><th>Recording</th><th>File</th><th>Time</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($voiceRows as $clip):
|
||
$clipUrl = voiceFileUrl($clip['voice_file'] ?? null);
|
||
$clipSize = voiceFileSize($clip['voice_file'] ?? null);
|
||
$clipSource = $clip['_source'];
|
||
$clipSrcValue = $clipSource === 'live' ? 'live' : 'archive:' . $clip['day_key'];
|
||
?>
|
||
<tr>
|
||
<td class="mono dim">#<?= (int)$clip['id'] ?></td>
|
||
<td><span class="badge <?= $clipSource === 'live' ? 'badge-g' : 'badge-o' ?>"><?= strtoupper(esc($clipSource)) ?></span></td>
|
||
<td class="dim"><?= esc($clip['day_key']) ?></td>
|
||
<td style="color:<?= esc($clip['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($clip['username']) ?></td>
|
||
<td>
|
||
<?php if ($clipUrl !== '' && $clipSize !== null): ?>
|
||
<audio class="admin-audio" controls preload="metadata" src="<?= esc($clipUrl) ?>"></audio>
|
||
<?php else: ?>
|
||
<span class="badge badge-r">RECORDING MISSING</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td class="dim" style="white-space:nowrap">
|
||
<?= esc(strtoupper(pathinfo((string)$clip['voice_file'], PATHINFO_EXTENSION))) ?>
|
||
· <?= esc(round((float)$clip['voice_duration'], 1)) ?>s
|
||
<?php if ($clipSize !== null): ?>· <?= number_format($clipSize / 1024, 1) ?> KB<?php endif; ?>
|
||
</td>
|
||
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$clip['created_at']) ?></td>
|
||
<td class="actions">
|
||
<div class="btn-group">
|
||
<?php if ($clipSource === 'live'): ?>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="archive_voice_clip">
|
||
<input type="hidden" name="msg_id" value="<?= (int)$clip['id'] ?>">
|
||
<input type="hidden" name="return_qs" value="<?= esc($voiceReturn) ?>">
|
||
<button type="submit" class="btn btn-sm btn-o"
|
||
onclick="return confirm('Archive this voice clip and remove it from the live chat?')">ARCHIVE</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_message">
|
||
<input type="hidden" name="msg_id" value="<?= (int)$clip['id'] ?>">
|
||
<input type="hidden" name="msg_src" value="<?= esc($clipSrcValue) ?>">
|
||
<input type="hidden" name="return_qs" value="<?= esc($voiceReturn) ?>">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Delete this voice clip and its recording file?')">DELETE</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ════════════════════════════════ ARCHIVES ════════════════════ */ ?>
|
||
<?php elseif ($tab === 'archives'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>ARCHIVES</h1>
|
||
<p>// daily chat logs — <?= count($archives) ?> archive(s)</p>
|
||
</div>
|
||
|
||
<?php
|
||
$viewDay = $_GET['day'] ?? '';
|
||
$archMsgs = [];
|
||
if ($viewDay && preg_match('/^\d{4}-\d{2}-\d{2}$/', $viewDay)) {
|
||
[$y,$m,$d] = explode('-', $viewDay);
|
||
$adb = archiveDB($y, $m, $d);
|
||
if ($adb) {
|
||
$fAUser = trim($_GET['au'] ?? '');
|
||
$fAText = trim($_GET['at'] ?? '');
|
||
$awhere = []; $aparams = [];
|
||
if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; }
|
||
if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; }
|
||
$asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500";
|
||
$astmt = $adb->prepare($asql); $astmt->execute($aparams);
|
||
$archMsgs = $astmt->fetchAll();
|
||
}
|
||
}
|
||
?>
|
||
|
||
<?php if ($viewDay): ?>
|
||
<!-- ── Archive day detail ── -->
|
||
<div style="margin-bottom:14px">
|
||
<a class="btn btn-t1 btn-sm" href="?tab=archives">◂ BACK TO LIST</a>
|
||
</div>
|
||
<div class="page-head">
|
||
<h1><?= esc($viewDay) ?></h1>
|
||
<p>// archive day view</p>
|
||
</div>
|
||
|
||
<div class="search-bar">
|
||
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="hidden" name="tab" value="archives">
|
||
<input type="hidden" name="day" value="<?= esc($viewDay) ?>">
|
||
<input type="text" name="au" value="<?= esc($_GET['au']??'') ?>" placeholder="Filter by user…">
|
||
<input type="text" name="at" value="<?= esc($_GET['at']??'') ?>" placeholder="Filter by text…">
|
||
<button type="submit" class="btn btn-b btn-sm">FILTER</button>
|
||
<a class="btn btn-t1 btn-sm" href="?tab=archives&day=<?= urlencode($viewDay) ?>">RESET</a>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// <?= count($archMsgs) ?> MESSAGE(S)</span>
|
||
</div>
|
||
<div class="panel-body np">
|
||
<?php if (empty($archMsgs)): ?>
|
||
<div class="empty">NO MESSAGES FOUND</div>
|
||
<?php else: ?>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr><th>ID</th><th>User</th><th>Message</th><th>Time</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($archMsgs as $msg): ?>
|
||
<tr>
|
||
<td class="mono dim">#<?= $msg['id'] ?></td>
|
||
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px"><?= esc($msg['username']) ?></td>
|
||
<td>
|
||
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
|
||
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s
|
||
<?= !empty($msg['voice_bitrate_kbps']) ? ' · ' . (int)$msg['voice_bitrate_kbps'] . ' kbps' : '' ?></div>
|
||
<audio class="admin-audio" controls preload="metadata"
|
||
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||
<?php else: ?>
|
||
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
||
<?php endif; ?>
|
||
<?php if (!empty($msg['reply_to_id'])): ?><div class="dim">Reply #<?= (int)$msg['reply_to_id'] ?> · depth <?= (int)$msg['reply_depth'] ?></div><?php endif; ?>
|
||
</td>
|
||
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
|
||
<td class="actions">
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm btn-b"
|
||
onclick='openEditMsg(<?= json_encode((int)$msg['id']) ?>,<?= json_encode('archive:' . $viewDay) ?>,<?= json_encode($msg['message'], JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_TAG|JSON_HEX_QUOT) ?>,<?= json_encode($msg['username']) ?>,<?= json_encode('tab=archives&day=' . urlencode($viewDay)) ?>)'>
|
||
EDIT
|
||
</button>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_message">
|
||
<input type="hidden" name="msg_id" value="<?= $msg['id'] ?>">
|
||
<input type="hidden" name="msg_src" value="archive:<?= esc($viewDay) ?>">
|
||
<input type="hidden" name="return_qs" value="tab=archives&day=<?= urlencode($viewDay) ?>">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Delete this message?')">DEL</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="danger-zone">
|
||
<div class="danger-zone-title">// DANGER ZONE</div>
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_archive_day">
|
||
<input type="hidden" name="day" value="<?= esc($viewDay) ?>">
|
||
<button type="submit" class="btn btn-r"
|
||
onclick="return confirm('Permanently delete the entire archive for <?= esc($viewDay) ?>?')">
|
||
DELETE ENTIRE ARCHIVE: <?= esc($viewDay) ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<?php else: ?>
|
||
<!-- ── Archive list ── -->
|
||
<div class="panel">
|
||
<div class="panel-head"><span class="panel-title">// ARCHIVED DAYS</span></div>
|
||
<div class="panel-body np">
|
||
<?php if (empty($archives)): ?>
|
||
<div class="empty">NO ARCHIVES YET</div>
|
||
<?php else: ?>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr><th>Date</th><th>Messages</th><th>File Size</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($archives as $arc): ?>
|
||
<tr>
|
||
<td class="mono"><?= esc($arc['day']) ?></td>
|
||
<td><span class="badge badge-b"><?= $arc['count'] ?> msgs</span></td>
|
||
<td class="dim"><?= number_format($arc['size'] / 1024, 1) ?> KB</td>
|
||
<td class="actions">
|
||
<div class="btn-group">
|
||
<a class="btn btn-sm btn-b" href="?tab=archives&day=<?= urlencode($arc['day']) ?>">BROWSE</a>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_archive_day">
|
||
<input type="hidden" name="day" value="<?= esc($arc['day']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Permanently delete archive for <?= esc($arc['day']) ?>?')">
|
||
DELETE
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* ════════════════════════════════ USERS ══════════════════════ */ ?>
|
||
<?php elseif ($tab === 'users'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>USERS</h1>
|
||
<p>// manage accounts and credentials</p>
|
||
</div>
|
||
|
||
<!-- Create user -->
|
||
<div class="panel" style="margin-bottom:20px">
|
||
<div class="panel-head"><span class="panel-title">// CREATE USER</span></div>
|
||
<div class="panel-body">
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="create_user">
|
||
<div class="form-row3">
|
||
<div class="field">
|
||
<label>Username (max <?= cfgVal('chat.max_username_length',12) ?> chars)</label>
|
||
<input type="text" name="username" maxlength="<?= cfgVal('chat.max_username_length',12) ?>"
|
||
placeholder="callsign…" autocomplete="off">
|
||
</div>
|
||
<div class="field">
|
||
<label>Password (min <?= cfgVal('security.min_password_length',4) ?> chars)</label>
|
||
<input type="password" name="password" autocomplete="new-password" placeholder="passphrase…">
|
||
</div>
|
||
<div class="field">
|
||
<label>Color <small style="color:var(--t2)">(leave blank = random)</small></label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<input type="text" name="color" id="new-color-txt" placeholder="#00ff9f"
|
||
maxlength="7" style="flex:1" oninput="updateSwatch('new-color-swatch',this.value)">
|
||
<input type="color" id="new-color-pick" value="#00ff9f"
|
||
style="width:32px;height:32px;padding:2px;background:none;border:1px solid var(--bd2);border-radius:3px;cursor:pointer"
|
||
oninput="syncColor('new-color-txt',this.value);updateSwatch('new-color-swatch',this.value)">
|
||
<span class="color-preview" id="new-color-swatch" style="background:#00ff9f"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-g">CREATE USER</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<?php
|
||
$live = db();
|
||
$allUsers = $live->query("
|
||
SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name,
|
||
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count,
|
||
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
|
||
FROM users u
|
||
LEFT JOIN plans p ON p.id=u.plan_id
|
||
LEFT JOIN plans mp ON mp.id=u.manual_plan_id
|
||
ORDER BY u.created_at DESC
|
||
")->fetchAll();
|
||
?>
|
||
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// <?= count($allUsers) ?> USER(S)</span>
|
||
</div>
|
||
<div class="panel-body np">
|
||
<?php if (empty($allUsers)): ?>
|
||
<div class="empty">NO USERS YET</div>
|
||
<?php else: ?>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>ID</th><th>Username</th><th>Tier</th><th>Color</th><th>Messages</th>
|
||
<th>Sessions</th><th>Last Seen</th><th>Joined</th><th>Actions</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<?php foreach ($allUsers as $u): ?>
|
||
<tr>
|
||
<td class="mono dim">#<?= $u['id'] ?></td>
|
||
<td style="color:<?= esc($u['color']) ?>;font-family:var(--mono);font-weight:600">
|
||
<?= esc($u['username']) ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge <?= $u['manual_plan_id'] ? 'badge-o' : 'badge-b' ?>">
|
||
<?= esc($u['manual_plan_name'] ?: $u['billing_plan_name'] ?: 'Free') ?>
|
||
</span>
|
||
<div class="dim"><?= $u['manual_plan_id'] ? 'ADMIN' : 'BILLING' ?></div>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<span style="background:<?= esc($u['color']) ?>;width:14px;height:14px;border-radius:50%;display:inline-block;flex-shrink:0"></span>
|
||
<span class="mono" style="font-size:10px;color:var(--t1)"><?= esc($u['color']) ?></span>
|
||
</div>
|
||
</td>
|
||
<td><span class="badge badge-b"><?= $u['msg_count'] ?></span></td>
|
||
<td><span class="badge <?= $u['sess_count'] > 0 ? 'badge-g' : 'badge-r' ?>"><?= $u['sess_count'] ?></span></td>
|
||
<td class="dim"><?= $u['last_seen'] ? ago((int)$u['last_seen']) : 'Never' ?></td>
|
||
<td class="dim"><?= fmtTime((int)$u['created_at']) ?></td>
|
||
<td class="actions">
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm btn-g"
|
||
onclick='openEditUser(<?= json_encode((int)$u['id']) ?>,<?= json_encode($u['username']) ?>,<?= json_encode($u['color']) ?>,<?= json_encode((int)($u['manual_plan_id'] ?? 0)) ?>)'>
|
||
EDIT
|
||
</button>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="kick_user">
|
||
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-o"
|
||
onclick="return confirm('Kick <?= esc($u['username']) ?> (terminate all sessions)?')">KICK</button>
|
||
</form>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_user">
|
||
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Delete user <?= esc($u['username']) ?>? Their messages are kept.')">DEL</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ════════════════════════════════ SESSIONS ════════════════════ */ ?>
|
||
<?php elseif ($tab === 'sessions'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>SESSIONS</h1>
|
||
<p>// active logins and session management</p>
|
||
</div>
|
||
|
||
<?php
|
||
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
|
||
$allSess = db()->query("
|
||
SELECT s.*, u.username, u.color
|
||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||
ORDER BY s.last_active DESC
|
||
")->fetchAll();
|
||
?>
|
||
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// <?= count($allSess) ?> SESSION(S)</span>
|
||
<div class="btn-group">
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="clear_expired_sessions">
|
||
<button type="submit" class="btn btn-sm btn-o">CLEAR EXPIRED</button>
|
||
</form>
|
||
<form method="post" style="display:inline">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="clear_all_sessions">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Log out ALL users?')">CLEAR ALL</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body np">
|
||
<?php if (empty($allSess)): ?>
|
||
<div class="empty">NO SESSIONS</div>
|
||
<?php else: ?>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Token</th><th>User</th><th>IP</th><th>Status</th>
|
||
<th>Last Active</th><th>Created</th><th>Actions</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<?php foreach ($allSess as $s):
|
||
$expired = (time() - (int)$s['last_active']) > $timeout;
|
||
?>
|
||
<tr>
|
||
<td class="mono dim" style="font-size:10px"><?= substr(esc($s['id']),0,16) ?>…</td>
|
||
<td style="color:<?= esc($s['color']) ?>;font-family:var(--mono)"><?= esc($s['username']) ?></td>
|
||
<td class="mono"><?= esc($s['ip']) ?></td>
|
||
<td>
|
||
<span class="badge <?= $expired ? 'badge-r' : 'badge-g' ?>">
|
||
<?= $expired ? 'EXPIRED' : 'ACTIVE' ?>
|
||
</span>
|
||
</td>
|
||
<td class="dim"><?= ago((int)$s['last_active']) ?></td>
|
||
<td class="dim"><?= fmtTime((int)$s['created_at']) ?></td>
|
||
<td class="actions">
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="delete_session">
|
||
<input type="hidden" name="session_id" value="<?= esc($s['id']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-r"
|
||
onclick="return confirm('Terminate this session?')">KILL</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?>
|
||
<?php elseif ($tab === 'spam'): ?>
|
||
<?php renderAdminSpamTab(); ?>
|
||
|
||
<?php elseif ($tab === 'platform'): ?>
|
||
<?php renderAdminPlatformTab(); ?>
|
||
|
||
<?php elseif ($tab === 'config'): ?>
|
||
|
||
<div class="page-head">
|
||
<h1>CONFIGURATION</h1>
|
||
<p>// edit config.json live — changes take effect immediately</p>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">// config.json</span>
|
||
<span class="dim" style="font-family:var(--mono);font-size:9px;color:var(--t1)"><?= esc(CONFIG_FILE) ?></span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="save_config">
|
||
<div class="field">
|
||
<label>JSON — edit directly</label>
|
||
<textarea class="config-editor" name="config_json" rows="30" spellcheck="false"><?= esc(json_encode(cfg(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) ?></textarea>
|
||
</div>
|
||
<div class="btn-group">
|
||
<button type="submit" class="btn btn-g"
|
||
onclick="return validateJSON(this.form.config_json.value)">
|
||
SAVE CONFIG
|
||
</button>
|
||
<button type="button" class="btn btn-t1" onclick="formatJSON()">FORMAT JSON</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div style="margin-top:20px">
|
||
<div class="panel-title" style="margin-bottom:10px">// QUICK REFERENCE</div>
|
||
<table style="font-size:11px">
|
||
<thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
|
||
<tbody>
|
||
<tr><td class="mono">chat.max_message_length</td><td class="dim">integer</td><td>Max chars per message</td></tr>
|
||
<tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr>
|
||
<tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr>
|
||
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
|
||
<tr><td class="mono">chat.poll_max_backoff_ms</td><td class="dim">integer</td><td>Maximum delay after repeated poll failures</td></tr>
|
||
<tr><td class="mono">chat.max_rendered_messages</td><td class="dim">integer</td><td>Maximum chat rows retained in the browser</td></tr>
|
||
<tr><td class="mono">chat.replies_enabled</td><td class="dim">bool</td><td>Enable reply-to and threaded display</td></tr>
|
||
<tr><td class="mono">chat.reply_max_depth</td><td class="dim">integer</td><td>Maximum nested reply depth</td></tr>
|
||
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
|
||
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
|
||
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
|
||
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
|
||
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
|
||
<tr><td class="mono">voice.transcoding.enabled</td><td class="dim">bool</td><td>Use FFmpeg for uploaded voice clips</td></tr>
|
||
<tr><td class="mono">voice.transcoding.ffmpeg_path</td><td class="dim">string</td><td>FFmpeg executable name or absolute path</td></tr>
|
||
<tr><td class="mono">voice.transcoding.profile</td><td class="dim">string</td><td>m4a_aac or mp4_h264_aac</td></tr>
|
||
<tr><td class="mono">voice.transcoding.audio_bitrate_kbps</td><td class="dim">integer</td><td>AAC output bitrate from 48 to 320 kbps</td></tr>
|
||
<tr><td class="mono">voice.transcoding.timeout_seconds</td><td class="dim">integer</td><td>FFmpeg upload conversion timeout</td></tr>
|
||
<tr><td class="mono">voice.transcoding.fallback_to_source</td><td class="dim">bool</td><td>Retain WebM/MP4 source when conversion fails</td></tr>
|
||
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
|
||
<tr><td class="mono">security.captcha.*</td><td class="dim">object</td><td>Registration/login honeypot, internal, and Google CAPTCHA settings</td></tr>
|
||
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
|
||
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (10–14)</td></tr>
|
||
<tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr>
|
||
<tr><td class="mono">colors.user_palette</td><td class="dim">array</td><td>Hex colors for new users</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php endif; ?>
|
||
|
||
</div><!-- /content -->
|
||
</div><!-- /main -->
|
||
</div><!-- /layout -->
|
||
|
||
<!-- ── Edit Message Modal ─────────────────────────────────────────────────── -->
|
||
<div class="modal-bg" id="modal-edit-msg">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<span class="modal-head-title">// EDIT MESSAGE</span>
|
||
<button class="modal-close" onclick="closeModal('modal-edit-msg')">✕</button>
|
||
</div>
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="edit_message">
|
||
<input type="hidden" name="msg_id" id="edit-msg-id">
|
||
<input type="hidden" name="msg_src" id="edit-msg-src">
|
||
<input type="hidden" name="return_qs" id="edit-msg-rqs">
|
||
<div class="modal-body">
|
||
<div class="field">
|
||
<label>Displayed Sender</label>
|
||
<input type="text" name="msg_username" id="edit-msg-username"
|
||
maxlength="<?= cfgVal('chat.max_username_length',12) ?>" autocomplete="off">
|
||
</div>
|
||
<div class="field">
|
||
<label>Message Text</label>
|
||
<textarea name="msg_text" id="edit-msg-text" rows="4" maxlength="500"
|
||
placeholder="Enter message…"></textarea>
|
||
</div>
|
||
<div style="font-family:var(--mono);font-size:9px;color:var(--t2)">
|
||
Max <?= cfgVal('chat.max_message_length',500) ?> characters
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-t1" onclick="closeModal('modal-edit-msg')">CANCEL</button>
|
||
<button type="submit" class="btn btn-g">SAVE CHANGES</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Edit User Modal ────────────────────────────────────────────────────── -->
|
||
<div class="modal-bg" id="modal-edit-user">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<span class="modal-head-title">// EDIT USER</span>
|
||
<button class="modal-close" onclick="closeModal('modal-edit-user')">✕</button>
|
||
</div>
|
||
<form method="post">
|
||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
||
<input type="hidden" name="action" value="edit_user">
|
||
<input type="hidden" name="user_id" id="edit-user-id">
|
||
<div class="modal-body">
|
||
<div class="field">
|
||
<label>Username (max <?= cfgVal('chat.max_username_length',12) ?> chars)</label>
|
||
<input type="text" name="username" id="edit-user-name"
|
||
maxlength="<?= cfgVal('chat.max_username_length',12) ?>" autocomplete="off">
|
||
</div>
|
||
<div class="field">
|
||
<label>Chat Color</label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<input type="text" name="color" id="edit-color-txt" maxlength="7"
|
||
placeholder="#00ff9f" style="flex:1"
|
||
oninput="updateSwatch('edit-color-swatch',this.value)">
|
||
<input type="color" id="edit-color-pick"
|
||
style="width:32px;height:32px;padding:2px;background:none;border:1px solid var(--bd2);border-radius:3px;cursor:pointer"
|
||
oninput="syncColor('edit-color-txt',this.value);updateSwatch('edit-color-swatch',this.value)">
|
||
<span class="color-preview" id="edit-color-swatch"></span>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>New Password <small style="color:var(--t2);text-transform:none">(leave blank to keep current)</small></label>
|
||
<input type="password" name="new_password" autocomplete="new-password" placeholder="leave blank to keep…">
|
||
</div>
|
||
<div class="field">
|
||
<label>Tier Override</label>
|
||
<select name="manual_plan_id" id="edit-user-plan">
|
||
<option value="0">Follow Stripe / billing status</option>
|
||
<?php foreach (plans(false) as $plan): ?>
|
||
<option value="<?= (int)$plan['id'] ?>"><?= esc($plan['name']) ?> (admin assigned)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<div class="dim" style="margin-top:5px">An admin tier grants its features without payment and takes precedence over Stripe.</div>
|
||
</div>
|
||
<div class="alert warn" style="font-size:10px;margin-top:4px;margin-bottom:0">
|
||
⚠ Changing username also updates it in all their messages.
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-t1" onclick="closeModal('modal-edit-user')">CANCEL</button>
|
||
<button type="submit" class="btn btn-g">SAVE CHANGES</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<?php endif; /* end isLoggedIn */ ?>
|
||
|
||
<script>
|
||
// ── Modal ──────────────────────────────────────────────────────────────────
|
||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||
|
||
document.querySelectorAll('.modal-bg').forEach(function(bg) {
|
||
bg.addEventListener('click', function(e) { if (e.target === bg) bg.classList.remove('open'); });
|
||
});
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') document.querySelectorAll('.modal-bg.open').forEach(function(m){ m.classList.remove('open'); });
|
||
});
|
||
|
||
// ── Edit message ───────────────────────────────────────────────────────────
|
||
function openEditMsg(id, src, text, username, rqs) {
|
||
document.getElementById('edit-msg-id').value = id;
|
||
document.getElementById('edit-msg-src').value = src;
|
||
document.getElementById('edit-msg-text').value = text;
|
||
document.getElementById('edit-msg-username').value = username;
|
||
document.getElementById('edit-msg-rqs').value = rqs;
|
||
openModal('modal-edit-msg');
|
||
setTimeout(function(){ document.getElementById('edit-msg-text').focus(); }, 80);
|
||
}
|
||
|
||
// ── Edit user ──────────────────────────────────────────────────────────────
|
||
function openEditUser(id, username, color, manualPlanId) {
|
||
document.getElementById('edit-user-id').value = id;
|
||
document.getElementById('edit-user-name').value = username;
|
||
document.getElementById('edit-color-txt').value = color;
|
||
document.getElementById('edit-color-pick').value = color;
|
||
document.getElementById('edit-user-plan').value = String(manualPlanId || 0);
|
||
updateSwatch('edit-color-swatch', color);
|
||
openModal('modal-edit-user');
|
||
}
|
||
|
||
// ── Color helpers ──────────────────────────────────────────────────────────
|
||
function updateSwatch(swatchId, color) {
|
||
var el = document.getElementById(swatchId);
|
||
if (el && /^#[0-9a-fA-F]{6}$/.test(color)) el.style.background = color;
|
||
}
|
||
function syncColor(txtId, color) {
|
||
var el = document.getElementById(txtId);
|
||
if (el) el.value = color;
|
||
}
|
||
|
||
// ── Bulk select ────────────────────────────────────────────────────────────
|
||
function cbChange() {
|
||
var cbs = document.querySelectorAll('.msg-cb');
|
||
var checked = Array.from(cbs).filter(function(c){ return c.checked; });
|
||
var bar = document.getElementById('bulk-bar');
|
||
var cnt = document.getElementById('bulk-count');
|
||
if (bar) bar.classList.toggle('visible', checked.length > 0);
|
||
if (cnt) cnt.textContent = checked.length + ' selected';
|
||
}
|
||
|
||
function cbAllChange(master) {
|
||
document.querySelectorAll('.msg-cb').forEach(function(c){ c.checked = master.checked; });
|
||
cbChange();
|
||
}
|
||
|
||
function toggleSelectAll() {
|
||
var cbs = document.querySelectorAll('.msg-cb');
|
||
var allChecked = Array.from(cbs).every(function(c){ return c.checked; });
|
||
cbs.forEach(function(c){ c.checked = !allChecked; });
|
||
var master = document.getElementById('cb-all');
|
||
if (master) master.checked = !allChecked;
|
||
cbChange();
|
||
}
|
||
|
||
function clearSelection() {
|
||
document.querySelectorAll('.msg-cb').forEach(function(c){ c.checked = false; });
|
||
var master = document.getElementById('cb-all');
|
||
if (master) master.checked = false;
|
||
cbChange();
|
||
}
|
||
|
||
function submitBulk() {
|
||
var checked = Array.from(document.querySelectorAll('.msg-cb:checked')).map(function(c){ return c.value; });
|
||
if (!checked.length) return;
|
||
if (!confirm('Delete ' + checked.length + ' message(s)?')) return;
|
||
var form = document.getElementById('bulk-form');
|
||
var div = document.getElementById('bulk-ids');
|
||
div.innerHTML = '';
|
||
checked.forEach(function(id) {
|
||
var inp = document.createElement('input');
|
||
inp.type = 'hidden'; inp.name = 'msg_ids[]'; inp.value = id;
|
||
div.appendChild(inp);
|
||
});
|
||
form.submit();
|
||
}
|
||
|
||
// ── Config JSON helpers ────────────────────────────────────────────────────
|
||
function validateJSON(str) {
|
||
try { JSON.parse(str); return true; }
|
||
catch(e) { alert('Invalid JSON: ' + e.message); return false; }
|
||
}
|
||
|
||
function formatJSON() {
|
||
var ta = document.querySelector('.config-editor');
|
||
if (!ta) return;
|
||
try {
|
||
ta.value = JSON.stringify(JSON.parse(ta.value), null, 2);
|
||
} catch(e) { alert('Cannot format: ' + e.message); }
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|