diff --git a/api_v1_channel_permissions.php b/api_v1_channel_permissions.php new file mode 100644 index 0000000..078e7f6 --- /dev/null +++ b/api_v1_channel_permissions.php @@ -0,0 +1,66 @@ +prepare(" + SELECT cp.*, r.name as role_name, r.color as role_color + FROM channel_permissions cp + JOIN roles r ON cp.role_id = r.id + WHERE cp.channel_id = ? + "); + $stmt->execute([$channel_id]); + echo json_encode(['success' => true, 'permissions' => $stmt->fetchAll()]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $channel_id = $data['channel_id'] ?? 0; + $role_id = $data['role_id'] ?? 0; + $allow = $data['allow'] ?? 0; + $deny = $data['deny'] ?? 0; + + // Check if user is owner of the server + $stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN channels c ON s.id = c.server_id WHERE c.id = ?"); + $stmt->execute([$channel_id]); + $server = $stmt->fetch(); + + if ($server && $server['owner_id'] == $user_id) { + $stmt = db()->prepare(" + INSERT INTO channel_permissions (channel_id, role_id, allow_permissions, deny_permissions) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE allow_permissions = VALUES(allow_permissions), deny_permissions = VALUES(deny_permissions) + "); + $stmt->execute([$channel_id, $role_id, $allow, $deny]); + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + $channel_id = $data['channel_id'] ?? 0; + $role_id = $data['role_id'] ?? 0; + + // Check if user is owner + $stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN channels c ON s.id = c.server_id WHERE c.id = ?"); + $stmt->execute([$channel_id]); + $server = $stmt->fetch(); + + if ($server && $server['owner_id'] == $user_id) { + $stmt = db()->prepare("DELETE FROM channel_permissions WHERE channel_id = ? AND role_id = ?"); + $stmt->execute([$channel_id, $role_id]); + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + } + exit; +} diff --git a/api_v1_channels.php b/api_v1_channels.php index 4850062..2ca5dc1 100644 --- a/api_v1_channels.php +++ b/api_v1_channels.php @@ -24,6 +24,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $channel_id = $_POST['channel_id'] ?? 0; $name = $_POST['name'] ?? ''; $allow_file_sharing = isset($_POST['allow_file_sharing']) ? 1 : 0; + $theme_color = $_POST['theme_color'] ?? null; + if ($theme_color === '') $theme_color = null; // Check if user is owner of the server $stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN channels c ON s.id = c.server_id WHERE c.id = ?"); @@ -32,8 +34,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($server && $server['owner_id'] == $user_id) { $name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $name)); - $stmt = db()->prepare("UPDATE channels SET name = ?, allow_file_sharing = ? WHERE id = ?"); - $stmt->execute([$name, $allow_file_sharing, $channel_id]); + $stmt = db()->prepare("UPDATE channels SET name = ?, allow_file_sharing = ?, theme_color = ? WHERE id = ?"); + $stmt->execute([$name, $allow_file_sharing, $theme_color, $channel_id]); } header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id); exit; @@ -67,9 +69,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Basic sanitization for channel name $name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $name)); $allow_file_sharing = isset($_POST['allow_file_sharing']) ? 1 : 0; + $theme_color = $_POST['theme_color'] ?? null; + if ($theme_color === '') $theme_color = null; - $stmt = db()->prepare("INSERT INTO channels (server_id, name, type, allow_file_sharing) VALUES (?, ?, ?, ?)"); - $stmt->execute([$server_id, $name, $type, $allow_file_sharing]); + $stmt = db()->prepare("INSERT INTO channels (server_id, name, type, allow_file_sharing, theme_color) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$server_id, $name, $type, $allow_file_sharing, $theme_color]); $channel_id = db()->lastInsertId(); header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id); diff --git a/api_v1_messages.php b/api_v1_messages.php index 07b3b7c..cd3922a 100644 --- a/api_v1_messages.php +++ b/api_v1_messages.php @@ -3,6 +3,7 @@ header('Content-Type: application/json'); require_once 'auth/session.php'; require_once 'includes/opengraph.php'; require_once 'includes/ai_filtering.php'; +require_once 'includes/permissions.php'; // Check for Bot token in headers $headers = getallheaders(); @@ -31,17 +32,59 @@ if ($bot_token) { exit; } +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $channel_id = $_GET['channel_id'] ?? 0; + $pinned = isset($_GET['pinned']) && $_GET['pinned'] == 1; + + if ($pinned) { + try { + $stmt = db()->prepare(" + SELECT m.*, u.username, u.avatar_url + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? AND m.is_pinned = 1 + ORDER BY m.created_at DESC + "); + $stmt->execute([$channel_id]); + $msgs = $stmt->fetchAll(); + + foreach ($msgs as &$m) { + $m['time'] = date('H:i', strtotime($m['created_at'])); + $m['metadata'] = $m['metadata'] ? json_decode($m['metadata']) : null; + } + + echo json_encode(['success' => true, 'messages' => $msgs]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; + } +} + if ($_SERVER['REQUEST_METHOD'] === 'PUT') { $data = json_decode(file_get_contents('php://input'), true); $message_id = $data['id'] ?? 0; $content = $data['content'] ?? ''; - - if (empty($content)) { - echo json_encode(['success' => false, 'error' => 'Content cannot be empty']); - exit; - } + $action = $data['action'] ?? 'edit'; try { + if ($action === 'pin') { + $stmt = db()->prepare("UPDATE messages SET is_pinned = 1 WHERE id = ?"); + $stmt->execute([$message_id]); + echo json_encode(['success' => true]); + exit; + } + if ($action === 'unpin') { + $stmt = db()->prepare("UPDATE messages SET is_pinned = 0 WHERE id = ?"); + $stmt->execute([$message_id]); + echo json_encode(['success' => true]); + exit; + } + + if (empty($content)) { + echo json_encode(['success' => false, 'error' => 'Content cannot be empty']); + exit; + } $stmt = db()->prepare("UPDATE messages SET content = ? WHERE id = ? AND user_id = ?"); $stmt->execute([$content, $message_id, $user_id]); @@ -115,6 +158,12 @@ if (empty($content) && empty($attachment_url)) { exit; } +// Check granular permissions +if (!Permissions::canSendInChannel($user_id, $channel_id)) { + echo json_encode(['success' => false, 'error' => 'You do not have permission to send messages in this channel.']); + exit; +} + if (!empty($content)) { $moderation = moderateContent($content); if (!$moderation['is_safe']) { diff --git a/api_v1_search.php b/api_v1_search.php index 02c5fc4..5ecddb9 100644 --- a/api_v1_search.php +++ b/api_v1_search.php @@ -5,6 +5,7 @@ requireLogin(); $user_id = $_SESSION['user_id']; $query = $_GET['q'] ?? ''; +$type = $_GET['type'] ?? 'messages'; // messages or users $channel_id = $_GET['channel_id'] ?? 0; if (empty($query)) { @@ -13,31 +14,42 @@ if (empty($query)) { } try { - $sql = "SELECT m.*, u.username, u.avatar_url - FROM messages m - JOIN users u ON m.user_id = u.id - WHERE m.content LIKE ? "; - $params = ["%" . $query . "%"]; - - if ($channel_id > 0) { - $sql .= " AND m.channel_id = ?"; - $params[] = $channel_id; + if ($type === 'users') { + $stmt = db()->prepare(" + SELECT id, username, avatar_url, status + FROM users + WHERE username LIKE ? + LIMIT 20 + "); + $stmt->execute(["%" . $query . "%"]); + $results = $stmt->fetchAll(); } else { - // Search in all channels user has access to - $sql .= " AND m.channel_id IN ( - SELECT c.id FROM channels c - LEFT JOIN server_members sm ON c.server_id = sm.server_id - LEFT JOIN channel_members cm ON c.id = cm.channel_id - WHERE sm.user_id = ? OR cm.user_id = ? - )"; - $params[] = $user_id; - $params[] = $user_id; - } + $sql = "SELECT m.*, u.username, u.avatar_url + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.content LIKE ? "; + $params = ["%" . $query . "%"]; - $sql .= " ORDER BY m.created_at DESC LIMIT 50"; - $stmt = db()->prepare($sql); - $stmt->execute($params); - $results = $stmt->fetchAll(); + if ($channel_id > 0) { + $sql .= " AND m.channel_id = ?"; + $params[] = $channel_id; + } else { + // Search in all channels user has access to + $sql .= " AND m.channel_id IN ( + SELECT c.id FROM channels c + LEFT JOIN server_members sm ON c.server_id = sm.server_id + LEFT JOIN channel_members cm ON c.id = cm.channel_id + WHERE sm.user_id = ? OR cm.user_id = ? + )"; + $params[] = $user_id; + $params[] = $user_id; + } + + $sql .= " ORDER BY m.created_at DESC LIMIT 50"; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $results = $stmt->fetchAll(); + } echo json_encode(['success' => true, 'results' => $results]); } catch (Exception $e) { diff --git a/api_v1_stats.php b/api_v1_stats.php new file mode 100644 index 0000000..dd8196e --- /dev/null +++ b/api_v1_stats.php @@ -0,0 +1,74 @@ + false, 'error' => 'Server ID required']); + exit; +} + +$user_id = $_SESSION['user_id']; + +// Check if user is member of the server +$stmt = db()->prepare("SELECT 1 FROM server_members WHERE server_id = ? AND user_id = ?"); +$stmt->execute([$server_id, $user_id]); +if (!$stmt->fetch()) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; +} + +try { + // Total members + $stmt = db()->prepare("SELECT COUNT(*) as count FROM server_members WHERE server_id = ?"); + $stmt->execute([$server_id]); + $total_members = $stmt->fetch()['count']; + + // Total messages in all channels of this server + $stmt = db()->prepare(" + SELECT COUNT(*) as count + FROM messages m + JOIN channels c ON m.channel_id = c.id + WHERE c.server_id = ? + "); + $stmt->execute([$server_id]); + $total_messages = $stmt->fetch()['count']; + + // Messages per day (last 7 days) + $stmt = db()->prepare(" + SELECT DATE(m.created_at) as date, COUNT(*) as count + FROM messages m + JOIN channels c ON m.channel_id = c.id + WHERE c.server_id = ? AND m.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + GROUP BY DATE(m.created_at) + ORDER BY date ASC + "); + $stmt->execute([$server_id]); + $history = $stmt->fetchAll(); + + // Top active users + $stmt = db()->prepare(" + SELECT u.username, COUNT(*) as message_count + FROM messages m + JOIN channels c ON m.channel_id = c.id + JOIN users u ON m.user_id = u.id + WHERE c.server_id = ? + GROUP BY m.user_id + ORDER BY message_count DESC + LIMIT 5 + "); + $stmt->execute([$server_id]); + $top_users = $stmt->fetchAll(); + + echo json_encode([ + 'success' => true, + 'stats' => [ + 'total_members' => $total_members, + 'total_messages' => $total_messages, + 'history' => $history, + 'top_users' => $top_users + ] + ]); +} catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api_v1_user.php b/api_v1_user.php index d762118..87938af 100644 --- a/api_v1_user.php +++ b/api_v1_user.php @@ -11,10 +11,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = $_POST['username'] ?? $user['username']; $avatar_url = $_POST['avatar_url'] ?? $user['avatar_url']; + $dnd_mode = isset($_POST['dnd_mode']) ? (int)$_POST['dnd_mode'] : (int)($user['dnd_mode'] ?? 0); + $theme = $_POST['theme'] ?? $user['theme'] ?? 'dark'; try { - $stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ? WHERE id = ?"); - $stmt->execute([$username, $avatar_url, $user['id']]); + $stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ?, dnd_mode = ?, theme = ? WHERE id = ?"); + $stmt->execute([$username, $avatar_url, $dnd_mode, $theme, $user['id']]); $_SESSION['username'] = $username; // Update session if stored (though getCurrentUser fetches from DB) diff --git a/assets/css/discord.css b/assets/css/discord.css index b4cc879..37eb8fa 100644 --- a/assets/css/discord.css +++ b/assets/css/discord.css @@ -10,6 +10,75 @@ --active: #3f4147; } +[data-theme="light"] { + --bg-servers: #e3e5e8; + --bg-channels: #f2f3f5; + --bg-chat: #ffffff; + --bg-members: #f2f3f5; + --text-primary: #313338; + --text-muted: #5c5e66; + --hover: #e8e9eb; + --active: #dbdee1; +} + +[data-theme="light"] .chat-input-wrapper { + background-color: #ebedef; +} + +[data-theme="light"] .message-item:hover { + background-color: rgba(0,0,0,0.02); +} + +[data-theme="light"] .user-panel { + background-color: #ebedef; +} + +[data-theme="light"] .form-control { + background-color: #ffffff; + border: 1px solid #dbdee1; +} + +[data-theme="light"] .modal-content { + background-color: #ffffff; + color: #313338; +} + +[data-theme="light"] .modal-header, [data-theme="light"] .modal-footer { + border-color: #dbdee1; +} + +[data-theme="light"] .btn-close { + filter: invert(1); +} + +[data-theme="light"] .search-input { + background-color: #dbdee1; +} + +[data-theme="light"] .search-results-dropdown { + background-color: #ffffff; +} + +[data-theme="light"] .channel-category { + color: #5c5e66; +} + +[data-theme="light"] .dm-status-indicator { + border-color: #f2f3f5; +} + +[data-theme="light"] hr { + border-color: #dbdee1 !important; +} + +[data-theme="light"] .upload-progress-container { + background-color: #f2f3f5; +} + +[data-theme="light"] .rich-embed { + background: rgba(0,0,0,0.05) !important; +} + body { margin: 0; padding: 0; @@ -663,3 +732,56 @@ body { .channel-settings-btn:hover { color: var(--text-normal) !important; } + +/* Progress Bar */ +.upload-progress-container { + padding: 8px 16px; + background-color: rgba(0,0,0,0.1); + border-top: 1px solid rgba(255,255,255,0.05); +} + +.progress-bar { + transition: width 0.1s linear; +} + +/* Mentions */ +.mention { + background-color: rgba(88, 101, 242, 0.3); + color: #fff; + font-weight: 500; + padding: 0 2px; + border-radius: 3px; + cursor: pointer; +} + +.mention:hover { + background-color: var(--blurple); +} + +.message-item.mentioned { + background-color: rgba(250, 166, 26, 0.05); + border-left: 2px solid #faa61a; +} + +/* Pinned Messages */ +.message-item.pinned { + background-color: rgba(88, 101, 242, 0.05); + border-left: 2px solid var(--blurple); +} + +.pinned-badge { + font-size: 0.7em; + color: var(--blurple); + background-color: rgba(88, 101, 242, 0.1); + padding: 1px 6px; + border-radius: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: 600; + text-transform: uppercase; +} + +.action-btn.pin.active { + color: var(--blurple); +} diff --git a/assets/js/main.js b/assets/js/main.js index 473a319..2e14fd1 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -14,6 +14,11 @@ document.addEventListener('DOMContentLoaded', () => { const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1; let typingTimeout; + // Notification Permission + if ("Notification" in window && Notification.permission === "default") { + Notification.requestPermission(); + } + // WebSocket for real-time let ws; let voiceHandler; @@ -40,6 +45,16 @@ document.addEventListener('DOMContentLoaded', () => { if (data.channel_id == currentChannel) { appendMessage(data); messagesList.scrollTop = messagesList.scrollHeight; + + // Desktop Notifications for mentions + if (data.content.includes(`@${window.currentUsername}`) && data.user_id != window.currentUserId) { + if (Notification.permission === "granted" && !window.isDndMode) { + new Notification(`Mention in #${window.currentChannelName}`, { + body: `${data.username}: ${data.content}`, + icon: data.avatar_url || '' + }); + } + } } } else if (msg.type === 'typing') { if (msg.channel_id == currentChannel && msg.user_id != window.currentUserId) { @@ -89,7 +104,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); - chatForm.addEventListener('submit', async (e) => { + chatForm.addEventListener('submit', (e) => { e.preventDefault(); const content = chatInput.value.trim(); const file = fileUpload.files[0]; @@ -99,35 +114,64 @@ document.addEventListener('DOMContentLoaded', () => { const formData = new FormData(); formData.append('content', content); formData.append('channel_id', currentChannel); + + const progressContainer = document.getElementById('upload-progress-container'); + const progressBar = document.getElementById('upload-progress-bar'); + const progressPercent = document.getElementById('upload-percentage'); + const progressFilename = document.getElementById('upload-filename'); + if (file) { formData.append('file', file); fileUpload.value = ''; // Clear file input + + // Show progress bar + progressContainer.style.display = 'block'; + progressFilename.textContent = `Uploading: ${file.name}`; + progressBar.style.width = '0%'; + progressPercent.textContent = '0%'; } - try { - const response = await fetch('api_v1_messages.php', { - method: 'POST', - body: formData - }); + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'api_v1_messages.php', true); - const result = await response.json(); - if (result.success) { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'message', - data: JSON.stringify({ - ...result.message, - channel_id: currentChannel - }) - })); + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable && file) { + const percent = Math.round((ev.loaded / ev.total) * 100); + progressBar.style.width = percent + '%'; + progressPercent.textContent = percent + '%'; + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + const result = JSON.parse(xhr.responseText); + if (result.success) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'message', + data: JSON.stringify({ + ...result.message, + channel_id: currentChannel + }) + })); + } else { + appendMessage(result.message); + messagesList.scrollTop = messagesList.scrollHeight; + } } else { - appendMessage(result.message); - messagesList.scrollTop = messagesList.scrollHeight; + alert(result.error || 'Failed to send message'); } } - } catch (err) { - console.error('Failed to send message:', err); - } + progressContainer.style.display = 'none'; + }; + + xhr.onerror = () => { + console.error('XHR Error'); + progressContainer.style.display = 'none'; + alert('An error occurred during the upload.'); + }; + + xhr.send(formData); }); // Handle Reaction Clicks @@ -305,6 +349,61 @@ document.addEventListener('DOMContentLoaded', () => { } return; } + + const pinBtn = e.target.closest('.action-btn.pin'); + if (pinBtn) { + const msgId = pinBtn.dataset.id; + const isPinned = pinBtn.dataset.pinned == '1'; + const action = isPinned ? 'unpin' : 'pin'; + + const resp = await fetch('api_v1_messages.php', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: msgId, action: action }) + }); + const result = await resp.json(); + if (result.success) { + location.reload(); // Simplest way to reflect changes across UI + } + return; + } + + const pinnedMessagesBtn = document.getElementById('pinned-messages-btn'); + if (e.target.closest('#pinned-messages-btn')) { + const container = document.getElementById('pinned-messages-container'); + container.innerHTML = '
Loading pinned messages...
'; + const modal = new bootstrap.Modal(document.getElementById('pinnedMessagesModal')); + modal.show(); + + const resp = await fetch(`api_v1_messages.php?channel_id=${currentChannel}&pinned=1`); + const data = await resp.json(); + if (data.success && data.messages.length > 0) { + container.innerHTML = ''; + data.messages.forEach(msg => { + const div = document.createElement('div'); + div.className = 'message-item p-2 border-bottom border-secondary'; + div.style.backgroundColor = 'transparent'; + div.innerHTML = ` +
+
+
+
+ ${escapeHTML(msg.username)} + ${msg.time} +
+
+ ${escapeHTML(msg.content).replace(/\n/g, '
')} +
+
+
+ `; + container.appendChild(div); + }); + } else { + container.innerHTML = '
No pinned messages in this channel.
'; + } + return; + } // Start DM const dmBtn = e.target.closest('.start-dm-btn'); @@ -322,31 +421,50 @@ document.addEventListener('DOMContentLoaded', () => { // Global Search const searchInput = document.getElementById('global-search'); + const searchType = document.getElementById('search-type'); const searchResults = document.getElementById('search-results'); - searchInput.addEventListener('input', async () => { + searchInput?.addEventListener('input', async () => { const q = searchInput.value.trim(); + const type = searchType.value; if (q.length < 2) { searchResults.style.display = 'none'; return; } - const resp = await fetch(`api_v1_search.php?q=${encodeURIComponent(q)}&channel_id=${currentChannel}`); + const resp = await fetch(`api_v1_search.php?q=${encodeURIComponent(q)}&type=${type}&channel_id=${currentChannel}`); const data = await resp.json(); if (data.success && data.results.length > 0) { searchResults.innerHTML = ''; data.results.forEach(res => { const item = document.createElement('div'); - item.className = 'search-result-item'; - item.innerHTML = ` -
${res.username}
-
${res.content}
- `; - item.onclick = () => { - // Logic to scroll to message would go here - searchResults.style.display = 'none'; - }; + item.className = 'search-result-item d-flex align-items-center gap-2'; + if (type === 'users') { + item.innerHTML = ` +
+
+
${res.username}
+
Click to start conversation
+
+ `; + item.onclick = () => { + const formData = new FormData(); + formData.append('user_id', res.id); + fetch('api_v1_dms.php', { method: 'POST', body: formData }) + .then(r => r.json()) + .then(resDM => { + if (resDM.success) window.location.href = `?server_id=dms&channel_id=${resDM.channel_id}`; + }); + }; + } else { + item.innerHTML = ` +
+
${res.username}
+
${res.content}
+
+ `; + } searchResults.appendChild(item); }); searchResults.style.display = 'block'; @@ -356,6 +474,110 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // Channel Permissions Management + const channelPermissionsTabBtn = document.getElementById('channel-permissions-tab-btn'); + const channelPermissionsList = document.getElementById('channel-permissions-list'); + const addPermRoleList = document.getElementById('add-permission-role-list'); + + channelPermissionsTabBtn?.addEventListener('click', async () => { + const channelId = document.getElementById('edit-channel-id').value; + loadChannelPermissions(channelId); + loadRolesForPermissions(channelId); + }); + + async function loadChannelPermissions(channelId) { + channelPermissionsList.innerHTML = '
Loading permissions...
'; + const resp = await fetch(`api_v1_channel_permissions.php?channel_id=${channelId}`); + const data = await resp.json(); + if (data.success) { + renderChannelPermissions(channelId, data.permissions); + } + } + + async function loadRolesForPermissions(channelId) { + addPermRoleList.innerHTML = ''; + const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`); + const data = await resp.json(); + if (data.success) { + data.roles.forEach(role => { + const li = document.createElement('li'); + li.innerHTML = ` +
+ ${role.name} +
`; + li.onclick = async (e) => { + e.preventDefault(); + await fetch('api_v1_channel_permissions.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel_id: channelId, role_id: role.id, allow: 0, deny: 0 }) + }); + loadChannelPermissions(channelId); + }; + addPermRoleList.appendChild(li); + }); + } + } + + function renderChannelPermissions(channelId, permissions) { + channelPermissionsList.innerHTML = ''; + if (permissions.length === 0) { + channelPermissionsList.innerHTML = '
No role overrides.
'; + return; + } + permissions.forEach(p => { + const item = document.createElement('div'); + item.className = 'list-group-item bg-transparent text-white border-secondary p-2'; + item.innerHTML = ` +
+
+
+ ${p.role_name} +
+ +
+
+ +
+ `; + channelPermissionsList.appendChild(item); + }); + } + + channelPermissionsList?.addEventListener('click', async (e) => { + const channelId = document.getElementById('edit-channel-id').value; + if (e.target.classList.contains('remove-perm-btn')) { + const roleId = e.target.dataset.roleId; + await fetch('api_v1_channel_permissions.php', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel_id: channelId, role_id: roleId }) + }); + loadChannelPermissions(channelId); + } + }); + + channelPermissionsList?.addEventListener('change', async (e) => { + if (e.target.classList.contains('perm-select')) { + const channelId = document.getElementById('edit-channel-id').value; + const roleId = e.target.dataset.roleId; + const val = e.target.value; + let allow = 0, deny = 0; + if (val === 'allow') allow = 1; + if (val === 'deny') deny = 1; + + await fetch('api_v1_channel_permissions.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel_id: channelId, role_id: roleId, allow, deny }) + }); + } + }); + document.addEventListener('click', (e) => { if (!e.target.closest('.search-container')) { searchResults.style.display = 'none'; @@ -370,6 +592,7 @@ document.addEventListener('DOMContentLoaded', () => { modal.querySelector('#edit-channel-id').value = btn.dataset.id; modal.querySelector('#edit-channel-name').value = btn.dataset.name; modal.querySelector('#edit-channel-files').checked = btn.dataset.files == '1'; + modal.querySelector('#edit-channel-theme').value = btn.dataset.theme || '#5865f2'; modal.querySelector('#delete-channel-id').value = btn.dataset.id; }); }); @@ -532,6 +755,49 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // Stats Management + const statsTabBtn = document.getElementById('stats-tab-btn'); + statsTabBtn?.addEventListener('click', loadStats); + + async function loadStats() { + try { + const resp = await fetch(`api_v1_stats.php?server_id=${activeServerId}`); + const data = await resp.json(); + if (data.success) { + document.getElementById('stat-members').textContent = data.stats.total_members; + document.getElementById('stat-messages').textContent = data.stats.total_messages; + + const topUsersList = document.getElementById('top-users-list'); + topUsersList.innerHTML = ''; + data.stats.top_users.forEach(user => { + const item = document.createElement('div'); + item.className = 'd-flex justify-content-between align-items-center mb-1 p-2 bg-dark rounded'; + item.innerHTML = `${user.username}${user.message_count} msgs`; + topUsersList.appendChild(item); + }); + + const activity = document.getElementById('activity-chart-placeholder'); + activity.innerHTML = ''; + data.stats.history.forEach(day => { + const bar = document.createElement('div'); + bar.className = 'd-flex align-items-center mb-1'; + const percent = Math.min(100, (day.count / 100) * 100); // Normalize to 100 for visual + bar.innerHTML = ` +
${day.date}
+
+
+
+
${day.count}
+ `; + activity.appendChild(bar); + }); + if (data.stats.history.length === 0) { + activity.innerHTML = '
No activity in the last 7 days.
'; + } + } + } catch (e) { console.error(e); } + } + // Server Settings const searchServerIconBtn = document.getElementById('search-server-icon-btn'); const serverIconResults = document.getElementById('server-icon-search-results'); @@ -564,67 +830,153 @@ document.addEventListener('DOMContentLoaded', () => { serverIconResults.innerHTML = '
Error fetching icons
'; } }); + + // User Settings - Avatar Search + const avatarSearchBtn = document.getElementById('search-avatar-btn'); + const avatarSearchQuery = document.getElementById('avatar-search-query'); + const avatarResults = document.getElementById('avatar-results'); + const avatarPreview = document.getElementById('settings-avatar-preview'); + const avatarUrlInput = document.getElementById('settings-avatar-url'); + + avatarSearchBtn?.addEventListener('click', async () => { + const q = avatarSearchQuery.value.trim(); + if (!q) return; + avatarResults.innerHTML = '
Searching...
'; + try { + const resp = await fetch(`api/pexels.php?action=search&query=${encodeURIComponent(q)}`); + const data = await resp.json(); + avatarResults.innerHTML = ''; + data.forEach(photo => { + const img = document.createElement('img'); + img.src = photo.url; + img.className = 'avatar-pick'; + img.style.width = '60px'; + img.style.height = '60px'; + img.style.cursor = 'pointer'; + img.onclick = () => { + avatarUrlInput.value = photo.url; + avatarPreview.style.backgroundImage = `url('${photo.url}')`; + }; + avatarResults.appendChild(img); + }); + } catch (e) { console.error(e); } + }); + + // User Settings - Save + const saveSettingsBtn = document.getElementById('save-settings-btn'); + saveSettingsBtn?.addEventListener('click', async () => { + const form = document.getElementById('user-settings-form'); + const formData = new FormData(form); + const dndMode = document.getElementById('dnd-switch').checked ? '1' : '0'; + formData.append('dnd_mode', dndMode); + + const theme = form.querySelector('input[name="theme"]:checked').value; + document.body.setAttribute('data-theme', theme); + + const resp = await fetch('api_v1_user.php', { + method: 'POST', + body: formData + }); + const result = await resp.json(); + if (result.success) { + location.reload(); + } else { + alert(result.error || 'Failed to save settings'); + } + }); }); -function appendMessage(msg) { - const messagesList = document.getElementById('messages-list'); - const div = document.createElement('div'); - div.className = 'message-item'; - div.dataset.id = msg.id; - const avatarStyle = msg.avatar_url ? `background-image: url('${msg.avatar_url}');` : ''; - - let attachmentHtml = ''; - if (msg.attachment_url) { - const ext = msg.attachment_url.split('.').pop().toLowerCase(); - if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) { - attachmentHtml = `
Attachment
`; - } else { - attachmentHtml = `
${msg.attachment_url.split('/').pop()}
`; - } + function escapeHTML(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; } - let embedHtml = ''; - if (msg.metadata) { - const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata; - embedHtml = ` -
- ${meta.site_name ? `
${meta.site_name}
` : ''} - ${meta.title ? `${meta.title}` : ''} - ${meta.description ? `
${meta.description}
` : ''} - ${meta.image ? `
` : ''} + function appendMessage(msg) { + const messagesList = document.getElementById('messages-list'); + const div = document.createElement('div'); + div.className = 'message-item'; + div.dataset.id = msg.id; + const avatarStyle = msg.avatar_url ? `background-image: url('${msg.avatar_url}');` : ''; + + let attachmentHtml = ''; + if (msg.attachment_url) { + const ext = msg.attachment_url.split('.').pop().toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) { + attachmentHtml = `
Attachment
`; + } else { + attachmentHtml = `
${msg.attachment_url.split('/').pop()}
`; + } + } + + let embedHtml = ''; + if (msg.metadata) { + const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata; + embedHtml = ` +
+ ${meta.site_name ? `
${escapeHTML(meta.site_name)}
` : ''} + ${meta.title ? `${escapeHTML(meta.title)}` : ''} + ${meta.description ? `
${escapeHTML(meta.description)}
` : ''} + ${meta.image ? `
` : ''} +
+ `; + } + + const isMe = msg.user_id == window.currentUserId || msg.username == window.currentUsername; + // Check if user is server owner (could be passed in window object) + const isOwner = window.isServerOwner || false; + + const pinHtml = ` + + + + `; + + const actionsHtml = (isMe || isOwner) ? ` +
+ ${pinHtml} + ${isMe ? ` + + + + + + + ` : ''} +
+ ` : ''; + + const pinnedBadge = msg.is_pinned ? ` + + + Pinned + + ` : ''; + + const mentionRegex = new RegExp(`@${window.currentUsername}\\b`, 'g'); + if (msg.content.match(mentionRegex)) { + div.classList.add('mentioned'); + } + if (msg.is_pinned) div.classList.add('pinned'); + + div.innerHTML = ` +
+
+
+ ${escapeHTML(msg.username)} + ${msg.time} + ${pinnedBadge} + ${actionsHtml} +
+
+ ${escapeHTML(msg.content).replace(/\n/g, '
').replace(mentionRegex, `@${window.currentUsername}`)} + ${attachmentHtml} + ${embedHtml} +
+
+ + +
`; + messagesList.appendChild(div); } - - const isMe = msg.user_id == window.currentUserId || msg.username == window.currentUsername; - const actionsHtml = isMe ? ` -
- - - - - - -
- ` : ''; - - div.innerHTML = ` -
-
-
- ${msg.username} - ${msg.time} - ${actionsHtml} -
-
- ${msg.content.replace(/\n/g, '
')} - ${attachmentHtml} - ${embedHtml} -
-
- + -
-
- `; - messagesList.appendChild(div); -} diff --git a/db/migrations/20260215_granular_roles_and_themes.sql b/db/migrations/20260215_granular_roles_and_themes.sql new file mode 100644 index 0000000..f54c331 --- /dev/null +++ b/db/migrations/20260215_granular_roles_and_themes.sql @@ -0,0 +1,12 @@ +-- Migration: Add channel permissions and user theme preference +CREATE TABLE IF NOT EXISTS channel_permissions ( + channel_id INT NOT NULL, + role_id INT NOT NULL, + allow_permissions INT DEFAULT 0, + deny_permissions INT DEFAULT 0, + PRIMARY KEY (channel_id, role_id), + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE +); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS theme VARCHAR(20) DEFAULT 'dark'; diff --git a/includes/permissions.php b/includes/permissions.php index 5ec6fbd..9e467f5 100644 --- a/includes/permissions.php +++ b/includes/permissions.php @@ -9,6 +9,11 @@ class Permissions { const ADMINISTRATOR = 32; public static function hasPermission($user_id, $server_id, $permission) { + $stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?"); + $stmt->execute([$server_id]); + $server = $stmt->fetch(); + if ($server && $server['owner_id'] == $user_id) return true; + $stmt = db()->prepare(" SELECT SUM(r.permissions) as total_perms FROM roles r @@ -22,4 +27,35 @@ class Permissions { if ($perms & self::ADMINISTRATOR) return true; return ($perms & $permission) === $permission; } + + public static function canSendInChannel($user_id, $channel_id) { + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $c = $stmt->fetch(); + if (!$c) return false; + $server_id = $c['server_id']; + + // Check if owner + $stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?"); + $stmt->execute([$server_id]); + $s = $stmt->fetch(); + if ($s && $s['owner_id'] == $user_id) return true; + + // Check overrides + $stmt = db()->prepare(" + SELECT cp.allow_permissions, cp.deny_permissions + FROM channel_permissions cp + JOIN user_roles ur ON cp.role_id = ur.role_id + WHERE ur.user_id = ? AND cp.channel_id = ? + "); + $stmt->execute([$user_id, $channel_id]); + $overrides = $stmt->fetchAll(); + + foreach($overrides as $o) { + if ($o['deny_permissions'] & 1) return false; // Bit 1 for SEND_MESSAGES in overrides + if ($o['allow_permissions'] & 1) return true; + } + + return self::hasPermission($user_id, $server_id, self::SEND_MESSAGES); + } } diff --git a/index.php b/index.php index dd2cf6f..2e02b6e 100644 --- a/index.php +++ b/index.php @@ -31,6 +31,7 @@ if ($is_dm_view) { $dm_channels = $stmt->fetchAll(); $active_channel_id = $_GET['channel_id'] ?? ($dm_channels[0]['id'] ?? 0); + $channel_theme = null; // DMs don't have custom themes for now if ($active_channel_id) { // Fetch DM messages @@ -67,6 +68,16 @@ if ($is_dm_view) { $channels = $stmt->fetchAll(); $active_channel_id = $_GET['channel_id'] ?? ($channels[0]['id'] ?? 1); + // Fetch active channel details for theme + $active_channel = null; + foreach($channels as $c) { + if($c['id'] == $active_channel_id) { + $active_channel = $c; + break; + } + } + $channel_theme = $active_channel['theme_color'] ?? null; + // Fetch messages $stmt = db()->prepare(" SELECT m.*, u.username, u.avatar_url @@ -115,8 +126,30 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + + - +
@@ -190,7 +223,8 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; data-bs-toggle="modal" data-bs-target="#editChannelModal" data-id="" data-name="" - data-files=""> + data-files="" + data-theme=""> @@ -211,7 +245,8 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; data-bs-toggle="modal" data-bs-target="#editChannelModal" data-id="" data-name="" - data-files=""> + data-files="" + data-theme=""> @@ -245,11 +280,22 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
- + -
- -
+
+ +
+
+ + +
+
+
@@ -259,26 +305,44 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';

This is the start of the # channel.

- -
+ +
">
- + + + + Pinned + + +
- - - - - + + + + + + + + + +
- + @' . htmlspecialchars($user['username']) . '', $msg_content); + echo nl2br($msg_content); + ?>
+
@@ -406,6 +479,23 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+
+ +
+ > + +
Mute all desktop notifications.
+
+
+ +
+ > + + > + +
+
+
@@ -447,6 +537,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; +
@@ -511,6 +604,33 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+
+
+
+
+
+
Members
+
-
+
+
+
+
+
Messages
+
-
+
+
+
+
Top Active Users
+
+ +
+
Activity (Last 7 Days)
+
+ + Loading activity... +
+
+
@@ -585,6 +705,10 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+
+ + +
- +
+
+ + +