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 = '