ccdbc47642
Replaced polling with a single queued cursor worker, timeout recovery, backoff, deduplication, chronological insertion, and capped DOM history in cyberchat-v4.js. Removed Groups UI, API, configuration, tier features, admin controls, and archive exposure. Legacy group records remain stored but inaccessible. Added configurable polling limits in config.json.
1963 lines
97 KiB
PHP
1963 lines
97 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', '1.0.0');
|
||
require_once ROOT_DIR . '/bootstrap.php';
|
||
require_once ROOT_DIR . '/lib/admin_platform.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 = json_decode(file_get_contents(CONFIG_FILE), true) ?? [];
|
||
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',
|
||
] as $name => $definition) {
|
||
if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition");
|
||
}
|
||
}
|
||
|
||
function adminPublicMessagePredicate(PDO $d, string $alias = ''): string {
|
||
$prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
|
||
return isset(tableColumns($d, 'messages')['group_id']) ? "{$prefix}group_id IS NULL" : '1=1';
|
||
}
|
||
|
||
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);
|
||
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,
|
||
created_at INTEGER NOT NULL,
|
||
day_key TEXT NOT NULL
|
||
)");
|
||
ensureAdminMessageColumns($a);
|
||
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT
|
||
)");
|
||
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 WHERE " . adminPublicMessagePredicate($archive)
|
||
)->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');
|
||
}
|
||
|
||
// ── 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' AND "
|
||
. adminPublicMessagePredicate($live));
|
||
$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,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['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' AND "
|
||
. adminPublicMessagePredicate($live));
|
||
$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();
|
||
$public = adminPublicMessagePredicate($live);
|
||
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
|
||
$row->execute([$id]);
|
||
deleteRecordingsForRows($row->fetchAll());
|
||
$live->prepare("DELETE FROM messages WHERE id = ? AND $public")->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 = ? AND "
|
||
. adminPublicMessagePredicate($live))->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();
|
||
$public = adminPublicMessagePredicate($live);
|
||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||
$rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph) AND $public");
|
||
$rows->execute($ids);
|
||
deleteRecordingsForRows($rows->fetchAll());
|
||
$live->prepare("DELETE FROM messages WHERE id IN ($ph) AND $public")->execute($ids);
|
||
flash(count($ids) . ' message(s) deleted.');
|
||
}
|
||
redirect($_POST['return_qs'] ?? '');
|
||
}
|
||
|
||
if ($act === 'delete_day_live') {
|
||
$day = $_POST['day_key'];
|
||
$live = db();
|
||
$public = adminPublicMessagePredicate($live);
|
||
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ? AND $public");
|
||
$rows->execute([$day]);
|
||
deleteRecordingsForRows($rows->fetchAll());
|
||
$st = $live->prepare("DELETE FROM messages WHERE day_key = ? AND $public");
|
||
$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();
|
||
$public = adminPublicMessagePredicate($live);
|
||
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages WHERE $public")->fetchAll());
|
||
$live->exec("DELETE FROM messages WHERE $public");
|
||
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();
|
||
$public = adminPublicMessagePredicate($live);
|
||
$stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
|
||
$stats['messages'] = $live->query("SELECT COUNT(*) FROM messages WHERE $public")->fetchColumn();
|
||
$stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice' AND $public")->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') . "' AND $public")->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 = new PDO('sqlite:'.$f);
|
||
$cnt = $x->query("SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($x))->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==='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
|
||
WHERE " . adminPublicMessagePredicate($live, 'm') . " 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 = [adminPublicMessagePredicate($live)]; $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 WHERE "
|
||
. adminPublicMessagePredicate($live) . " 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</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; ?>
|
||
</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'", adminPublicMessagePredicate($live)];
|
||
$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' AND "
|
||
. adminPublicMessagePredicate($vAdb);
|
||
$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' AND "
|
||
. adminPublicMessagePredicate($live) . " 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 = [adminPublicMessagePredicate($adb)]; $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</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; ?>
|
||
</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 AND "
|
||
. adminPublicMessagePredicate($live, 'm') . ") 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 === '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.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">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</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>
|