diff --git a/api/pexels.php b/api/pexels.php new file mode 100644 index 0000000..ce51ef0 --- /dev/null +++ b/api/pexels.php @@ -0,0 +1,26 @@ + 'Failed to fetch images']); + exit; + } + + $results = []; + foreach ($data['photos'] as $photo) { + $results[] = [ + 'id' => $photo['id'], + 'url' => $photo['src']['medium'], + 'photographer' => $photo['photographer'] + ]; + } + echo json_encode($results); + exit; +} diff --git a/api_v1_dms.php b/api_v1_dms.php new file mode 100644 index 0000000..fea2583 --- /dev/null +++ b/api_v1_dms.php @@ -0,0 +1,67 @@ + false, 'error' => 'You cannot message yourself']); + exit; + } + + try { + // Check if DM channel already exists between these two users + $stmt = db()->prepare(" + SELECT c.id + FROM channels c + JOIN channel_members cm1 ON c.id = cm1.channel_id + JOIN channel_members cm2 ON c.id = cm2.channel_id + WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id = ? + "); + $stmt->execute([$current_user_id, $target_user_id]); + $existing = $stmt->fetch(); + + if ($existing) { + echo json_encode(['success' => true, 'channel_id' => $existing['id']]); + exit; + } + + // Create new DM channel + $stmt = db()->prepare("INSERT INTO channels (server_id, name, type) VALUES (NULL, 'dm', 'dm')"); + $stmt->execute(); + $channel_id = db()->lastInsertId(); + + // Add both users to the channel + $stmt = db()->prepare("INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?), (?, ?)"); + $stmt->execute([$channel_id, $current_user_id, $channel_id, $target_user_id]); + + echo json_encode(['success' => true, 'channel_id' => $channel_id]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + // Fetch all DM channels for current user + try { + $stmt = db()->prepare(" + SELECT c.id, u.username as other_user, u.avatar_url, u.status, u.id as other_user_id + FROM channels c + JOIN channel_members cm1 ON c.id = cm1.channel_id + JOIN channel_members cm2 ON c.id = cm2.channel_id + JOIN users u ON cm2.user_id = u.id + WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id != ? + "); + $stmt->execute([$current_user_id, $current_user_id]); + $dms = $stmt->fetchAll(); + + echo json_encode(['success' => true, 'dms' => $dms]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} diff --git a/api_v1_messages.php b/api_v1_messages.php index 418c59b..ba12ee9 100644 --- a/api_v1_messages.php +++ b/api_v1_messages.php @@ -1,6 +1,7 @@ false, 'error' => 'Empty content']); + if (empty($content)) { + echo json_encode(['success' => false, 'error' => 'Content cannot be empty']); + exit; + } + + try { + $stmt = db()->prepare("UPDATE messages SET content = ? WHERE id = ? AND user_id = ?"); + $stmt->execute([$content, $message_id, $user_id]); + + if ($stmt->rowCount() > 0) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Message not found or unauthorized']); + } + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } exit; } +if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + $data = json_decode(file_get_contents('php://input'), true); + $message_id = $data['id'] ?? 0; + + try { + $stmt = db()->prepare("DELETE FROM messages WHERE id = ? AND user_id = ?"); + $stmt->execute([$message_id, $user_id]); + + if ($stmt->rowCount() > 0) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Message not found or unauthorized']); + } + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; +} + +$content = ''; +$channel_id = 0; +$attachment_url = null; + +if (strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') !== false) { + $data = json_decode(file_get_contents('php://input'), true); + $content = $data['content'] ?? ''; + $channel_id = $data['channel_id'] ?? 0; +} else { + $content = $_POST['content'] ?? ''; + $channel_id = $_POST['channel_id'] ?? 0; + + if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) { + $upload_dir = 'assets/uploads/'; + if (!is_dir($upload_dir)) mkdir($upload_dir, 0775, true); + + $filename = time() . '_' . basename($_FILES['file']['name']); + $target_file = $upload_dir . $filename; + + if (move_uploaded_file($_FILES['file']['tmp_name'], $target_file)) { + $attachment_url = $target_file; + } + } +} + +if (empty($content) && empty($attachment_url)) { + echo json_encode(['success' => false, 'error' => 'Empty content and no attachment']); + exit; +} + +$metadata = null; +if (!empty($content)) { + $urls = extractUrls($content); + if (!empty($urls)) { + // Fetch OG data for the first URL + $ogData = fetchOpenGraphData($urls[0]); + if ($ogData) { + $metadata = json_encode($ogData); + } + } +} + try { - $stmt = db()->prepare("INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)"); - $stmt->execute([$channel_id, $user_id, $content]); + $stmt = db()->prepare("INSERT INTO messages (channel_id, user_id, content, attachment_url, metadata) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$channel_id, $user_id, $content, $attachment_url, $metadata]); $last_id = db()->lastInsertId(); // Fetch message with username for the response - $stmt = db()->prepare("SELECT m.*, u.username FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?"); + $stmt = db()->prepare("SELECT m.*, u.username, u.avatar_url FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?"); $stmt->execute([$last_id]); $msg = $stmt->fetch(); echo json_encode([ 'success' => true, 'message' => [ + 'id' => $msg['id'], + 'user_id' => $msg['user_id'], 'username' => $msg['username'], - 'content' => htmlspecialchars($msg['content']), + 'avatar_url' => $msg['avatar_url'], + 'content' => $msg['content'], + 'attachment_url' => $msg['attachment_url'], + 'metadata' => $msg['metadata'] ? json_decode($msg['metadata']) : null, 'time' => date('H:i', strtotime($msg['created_at'])) ] ]); diff --git a/api_v1_reactions.php b/api_v1_reactions.php new file mode 100644 index 0000000..bdc827d --- /dev/null +++ b/api_v1_reactions.php @@ -0,0 +1,58 @@ + false, 'error' => 'Unauthorized']); + exit; +} + +$user_id = $_SESSION['user_id']; +$data = json_decode(file_get_contents('php://input'), true); +$message_id = $data['message_id'] ?? 0; +$emoji = $data['emoji'] ?? ''; +$action = $data['action'] ?? 'toggle'; // 'toggle', 'add', 'remove' + +if (!$message_id || !$emoji) { + echo json_encode(['success' => false, 'error' => 'Missing message_id or emoji']); + exit; +} + +try { + if ($action === 'toggle') { + $stmt = db()->prepare("SELECT id FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?"); + $stmt->execute([$message_id, $user_id, $emoji]); + if ($stmt->fetch()) { + $stmt = db()->prepare("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?"); + $stmt->execute([$message_id, $user_id, $emoji]); + $res_action = 'removed'; + } else { + $stmt = db()->prepare("INSERT INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)"); + $stmt->execute([$message_id, $user_id, $emoji]); + $res_action = 'added'; + } + } elseif ($action === 'add') { + $stmt = db()->prepare("INSERT IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)"); + $stmt->execute([$message_id, $user_id, $emoji]); + $res_action = 'added'; + } else { + $stmt = db()->prepare("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?"); + $stmt->execute([$message_id, $user_id, $emoji]); + $res_action = 'removed'; + } + + // Get updated reactions for this message + $stmt = db()->prepare("SELECT emoji, COUNT(*) as count, GROUP_CONCAT(user_id) as users FROM message_reactions WHERE message_id = ? GROUP BY emoji"); + $stmt->execute([$message_id]); + $reactions = $stmt->fetchAll(); + + echo json_encode([ + 'success' => true, + 'action' => $res_action, + 'message_id' => $message_id, + 'reactions' => $reactions + ]); +} catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api_v1_roles.php b/api_v1_roles.php new file mode 100644 index 0000000..66a209e --- /dev/null +++ b/api_v1_roles.php @@ -0,0 +1,86 @@ +prepare("SELECT * FROM roles WHERE server_id = ? ORDER BY position DESC"); + $stmt->execute([$server_id]); + echo json_encode(['success' => true, 'roles' => $stmt->fetchAll()]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $server_id = $data['server_id'] ?? 0; + $action = $data['action'] ?? 'create'; + + // Check if user is owner of server + $stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?"); + $stmt->execute([$server_id]); + $server = $stmt->fetch(); + if (!$server || $server['owner_id'] != $user_id) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + if ($action === 'create') { + $name = $data['name'] ?? 'New Role'; + $color = $data['color'] ?? '#99aab5'; + $stmt = db()->prepare("INSERT INTO roles (server_id, name, color) VALUES (?, ?, ?)"); + $stmt->execute([$server_id, $name, $color]); + echo json_encode(['success' => true, 'role_id' => db()->lastInsertId()]); + } elseif ($action === 'assign') { + $target_user_id = $data['user_id'] ?? 0; + $role_id = $data['role_id'] ?? 0; + $stmt = db()->prepare("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)"); + $stmt->execute([$target_user_id, $role_id]); + echo json_encode(['success' => true]); + } elseif ($action === 'unassign') { + $target_user_id = $data['user_id'] ?? 0; + $role_id = $data['role_id'] ?? 0; + $stmt = db()->prepare("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?"); + $stmt->execute([$target_user_id, $role_id]); + echo json_encode(['success' => true]); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'PUT') { + $role_id = $data['id'] ?? 0; + $name = $data['name'] ?? ''; + $color = $data['color'] ?? ''; + $permissions = $data['permissions'] ?? null; + + // Check server ownership via role + $stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN roles r ON s.id = r.server_id WHERE r.id = ?"); + $stmt->execute([$role_id]); + $server = $stmt->fetch(); + + if ($server && $server['owner_id'] == $user_id) { + $stmt = db()->prepare("UPDATE roles SET name = ?, color = ?, permissions = ? WHERE id = ?"); + $stmt->execute([$name, $color, $permissions, $role_id]); + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + $role_id = $data['id'] ?? 0; + $stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN roles r ON s.id = r.server_id WHERE r.id = ?"); + $stmt->execute([$role_id]); + $server = $stmt->fetch(); + + if ($server && $server['owner_id'] == $user_id) { + $stmt = db()->prepare("DELETE FROM roles WHERE id = ?"); + $stmt->execute([$role_id]); + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + } + exit; +} diff --git a/api_v1_search.php b/api_v1_search.php new file mode 100644 index 0000000..02c5fc4 --- /dev/null +++ b/api_v1_search.php @@ -0,0 +1,45 @@ + true, 'results' => []]); + exit; +} + +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; + } 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) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api_v1_servers.php b/api_v1_servers.php index 3d450bb..ae7bff5 100644 --- a/api_v1_servers.php +++ b/api_v1_servers.php @@ -22,7 +22,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } + if ($action === 'update') { + $server_id = $_POST['server_id'] ?? 0; + $name = $_POST['name'] ?? ''; + $icon_url = $_POST['icon_url'] ?? ''; + + $stmt = db()->prepare("UPDATE servers SET name = ?, icon_url = ? WHERE id = ? AND owner_id = ?"); + $stmt->execute([$name, $icon_url, $server_id, $user_id]); + header('Location: index.php?server_id=' . $server_id); + exit; + } + + if ($action === 'delete') { + $server_id = $_POST['server_id'] ?? 0; + $stmt = db()->prepare("DELETE FROM servers WHERE id = ? AND owner_id = ?"); + $stmt->execute([$server_id, $user_id]); + header('Location: index.php'); + exit; + } + $name = $_POST['name'] ?? ''; + $icon_url = $_POST['icon_url'] ?? ''; if ($name) { try { @@ -31,8 +51,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Create server $invite_code = substr(strtoupper(md5(uniqid())), 0, 8); - $stmt = $db->prepare("INSERT INTO servers (name, owner_id, invite_code) VALUES (?, ?, ?)"); - $stmt->execute([$name, $user_id, $invite_code]); + $stmt = $db->prepare("INSERT INTO servers (name, owner_id, invite_code, icon_url) VALUES (?, ?, ?, ?)"); + $stmt->execute([$name, $user_id, $invite_code, $icon_url]); $server_id = $db->lastInsertId(); // Add owner as member diff --git a/api_v1_user.php b/api_v1_user.php new file mode 100644 index 0000000..d762118 --- /dev/null +++ b/api_v1_user.php @@ -0,0 +1,28 @@ + false, 'error' => 'Unauthorized']); + exit; + } + + $username = $_POST['username'] ?? $user['username']; + $avatar_url = $_POST['avatar_url'] ?? $user['avatar_url']; + + try { + $stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ? WHERE id = ?"); + $stmt->execute([$username, $avatar_url, $user['id']]); + + $_SESSION['username'] = $username; // Update session if stored (though getCurrentUser fetches from DB) + + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; +} + +echo json_encode(['success' => false, 'error' => 'Invalid request']); diff --git a/assets/css/discord.css b/assets/css/discord.css index 9e9d1f0..1b3c936 100644 --- a/assets/css/discord.css +++ b/assets/css/discord.css @@ -237,6 +237,12 @@ body { .message-item { display: flex; gap: 16px; + padding: 4px 0; + transition: background-color 0.1s; +} + +.message-item:hover { + background-color: rgba(255, 255, 255, 0.02); } .message-avatar { @@ -245,6 +251,35 @@ body { background-color: #4e5058; border-radius: 50%; flex-shrink: 0; + background-size: cover; + background-position: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.typing-indicator { + padding: 0 16px 8px 16px; + font-size: 0.75em; + color: var(--text-muted); + height: 20px; + font-style: italic; +} + +.avatar-pick { + width: 60px; + height: 60px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.2s; +} + +.avatar-pick:hover { + border-color: var(--blurple); +} + +.avatar-pick.selected { + border-color: var(--blurple); + box-shadow: 0 0 10px var(--blurple); } .message-content { @@ -299,8 +334,296 @@ body { display: none; /* Hidden on mobile/small screens */ } -@media (min-width: 1024px) { - .members-sidebar { - display: block; +/* Reactions */ +.message-reactions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.reaction-badge { + background-color: #2b2d31; + border: 1px solid transparent; + border-radius: 8px; + padding: 2px 6px; + font-size: 0.8em; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.1s; +} + +.reaction-badge:hover { + border-color: #5865f2; + background-color: #35373c; +} + +.reaction-badge.active { + background-color: rgba(88, 101, 242, 0.15); + border-color: #5865f2; +} + +.reaction-badge .count { + color: #5865f2; + font-weight: bold; +} + +.reaction-badge.active .count { + color: white; +} + +.add-reaction-btn { + opacity: 0; + cursor: pointer; + color: var(--text-muted); + font-size: 1.2em; + line-height: 1; + padding: 0 4px; + transition: opacity 0.2s; +} + +.message-item:hover .add-reaction-btn, +.message-item:hover .message-actions-menu { + opacity: 1; +} + +.message-actions-menu { + opacity: 0; + display: flex; + gap: 8px; + margin-left: auto; + transition: opacity 0.2s; +} + +.action-btn { + color: var(--text-muted); + cursor: pointer; + padding: 2px; +} + +.action-btn:hover { + color: var(--text-primary); +} + +.action-btn.delete:hover { + color: #f23f42; +} + +/* Search bar */ +.search-container { + margin-left: auto; + position: relative; + width: 200px; +} + +.search-input { + background-color: var(--bg-servers); + border: none; + border-radius: 4px; + padding: 4px 8px; + color: var(--text-primary); + font-size: 0.85em; + width: 100%; +} + +.search-results-dropdown { + position: absolute; + top: 100%; + right: 0; + width: 300px; + background-color: var(--bg-channels); + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0,0,0,0.5); + z-index: 1000; + max-height: 400px; + overflow-y: auto; + display: none; +} + +.search-result-item { + padding: 8px; + border-bottom: 1px solid var(--bg-servers); + cursor: pointer; +} + +.search-result-item:hover { + background-color: var(--hover); +} + +.search-result-author { + font-weight: bold; + font-size: 0.85em; +} + +.search-result-text { + font-size: 0.8em; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* DM Specific */ +.dm-user-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + border-radius: 4px; + cursor: pointer; + text-decoration: none; + color: var(--text-muted); +} + +.dm-user-item:hover { + background-color: var(--hover); + color: var(--text-primary); +} + +.dm-user-item.active { + background-color: var(--active); + color: var(--text-primary); +} + +.dm-status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--bg-channels); +} + +.dm-status-online { background-color: #23a559; } +.dm-status-offline { background-color: #80848e; } + +.emoji-picker { + position: fixed; + background-color: #1e1f22; + border: 1px solid #313338; + border-radius: 8px; + padding: 8px; + display: flex; + gap: 8px; + box-shadow: 0 4px 15px rgba(0,0,0,0.5); + z-index: 10000; +} + +.emoji-picker span { + font-size: 1.5em; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background 0.1s; +} + +.emoji-picker span:hover { + background-color: #35373c; +} + +/* File Upload */ +.upload-btn-label { + margin-right: 12px; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; +} + +.upload-btn-label:hover { + color: var(--text-primary); +} + +.message-img-preview { + max-width: 100%; + max-height: 300px; + object-fit: contain; + background-color: #2b2d31; +} + +.attachment-link { + transition: background 0.2s; +} + +.attachment-link:hover { + background-color: #3f4147 !important; +} + +/* Rich Embeds */ +.rich-embed { + transition: transform 0.2s; +} + +.rich-embed:hover { + transform: scale(1.01); +} + +.embed-title { + color: #00a8fc !important; +} + +.embed-title:hover { + text-decoration: underline !important; +} + +.embed-image img { + border: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Voice active state */ +.voice-item.active { + background-color: rgba(35, 165, 89, 0.1); + color: #23a559 !important; +} + +.voice-user { + padding: 2px 4px; + border-radius: 4px; +} + +.voice-user .message-avatar { + background-color: var(--bg-servers); + border: 1px solid rgba(255,255,255,0.1); +} + +/* Roles Management */ +#roles-list .list-group-item:hover { + background-color: rgba(255, 255, 255, 0.05) !important; +} + +.nav-tabs .nav-link.active { + border-bottom: 2px solid var(--blurple) !important; + color: white !important; +} + +.nav-tabs .nav-link { + font-size: 0.9em; + font-weight: 500; + padding: 12px; +} + +.presence-indicator { + box-shadow: 0 0 2px rgba(0,0,0,0.5); +} + +/* Mobile & Transitions */ +@media (max-width: 768px) { + .servers-sidebar { + width: 60px; + } + .channels-sidebar { + width: 200px; + } + .server-icon { + width: 40px; + height: 40px; } } + +.message-item { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/assets/js/main.js b/assets/js/main.js index 8564681..651f0cc 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,85 +1,478 @@ document.addEventListener('DOMContentLoaded', () => { + const fileUpload = document.getElementById('file-upload'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); const messagesList = document.getElementById('messages-list'); + const typingIndicator = document.getElementById('typing-indicator'); + + // Emoji list for reactions + const EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥', '✅', '🚀']; // Scroll to bottom messagesList.scrollTop = messagesList.scrollHeight; + const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1; + let typingTimeout; + // WebSocket for real-time let ws; - try { - ws = new WebSocket('ws://' + window.location.hostname + ':8080'); - ws.onmessage = (e) => { - const msg = JSON.parse(e.data); - if (msg.type === 'message') { - const data = JSON.parse(msg.data); - // Simple broadcast, we check if it belongs to current channel - const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1; - if (data.channel_id == currentChannel) { - appendMessage(data); - messagesList.scrollTop = messagesList.scrollHeight; - } + let voiceHandler; + + function connectWS() { + try { + ws = new WebSocket('ws://' + window.location.hostname + ':8080'); + + if (typeof VoiceChannel !== 'undefined') { + voiceHandler = new VoiceChannel(ws); } - }; - } catch (e) { - console.warn('WebSocket connection failed, falling back to REST only.'); + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + + // Voice signaling + if (msg.type && msg.type.startsWith('voice_')) { + if (voiceHandler) voiceHandler.handleSignaling(msg); + return; + } + + if (msg.type === 'message') { + const data = JSON.parse(msg.data); + if (data.channel_id == currentChannel) { + appendMessage(data); + messagesList.scrollTop = messagesList.scrollHeight; + } + } else if (msg.type === 'typing') { + if (msg.channel_id == currentChannel && msg.user_id != window.currentUserId) { + showTyping(msg.username); + } + } else if (msg.type === 'reaction') { + updateReactionUI(msg.message_id, msg.reactions); + } else if (msg.type === 'message_edit') { + const el = document.querySelector(`.message-item[data-id="${msg.message_id}"] .message-text`); + if (el) el.innerHTML = msg.content.replace(/\n/g, '
'); + } else if (msg.type === 'message_delete') { + document.querySelector(`.message-item[data-id="${msg.message_id}"]`)?.remove(); + } else if (msg.type === 'presence') { + updatePresenceUI(msg.user_id, msg.status); + } + }; + ws.onopen = () => { + ws.send(JSON.stringify({ + type: 'presence', + user_id: window.currentUserId, + status: 'online' + })); + }; + ws.onclose = () => setTimeout(connectWS, 3000); + } catch (e) { + console.warn('WebSocket connection failed.'); + } } + connectWS(); + + function showTyping(username) { + typingIndicator.textContent = `${username} is typing...`; + clearTimeout(typingTimeout); + typingTimeout = setTimeout(() => { + typingIndicator.textContent = ''; + }, 3000); + } + + chatInput.addEventListener('input', () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'typing', + channel_id: currentChannel, + user_id: window.currentUserId, + username: window.currentUsername + })); + } + }); chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const content = chatInput.value.trim(); - if (!content) return; + const file = fileUpload.files[0]; + if (!content && !file) return; chatInput.value = ''; - - const channel_id = new URLSearchParams(window.location.search).get('channel_id') || 1; + const formData = new FormData(); + formData.append('content', content); + formData.append('channel_id', currentChannel); + if (file) { + formData.append('file', file); + fileUpload.value = ''; // Clear file input + } try { const response = await fetch('api_v1_messages.php', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: content, - channel_id: channel_id - }) + body: formData }); const result = await response.json(); if (result.success) { - // If WS is connected, we might want to let WS handle the UI update - // But for simplicity, we append here and also send to WS if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'message', data: JSON.stringify({ ...result.message, - channel_id: channel_id + channel_id: currentChannel }) })); } else { appendMessage(result.message); messagesList.scrollTop = messagesList.scrollHeight; } - } else { - alert('Error: ' + result.error); } } catch (err) { console.error('Failed to send message:', err); } }); - // Voice - const voiceHandler = new VoiceChannel(ws); - document.querySelectorAll('.voice-item').forEach(item => { - item.addEventListener('click', () => { - const cid = item.dataset.channelId; - voiceHandler.join(cid); - - // UI Update - document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active')); - item.classList.add('active'); + // Handle Reaction Clicks + document.addEventListener('click', (e) => { + const badge = e.target.closest('.reaction-badge'); + if (badge) { + const msgId = badge.parentElement.dataset.messageId; + const emoji = badge.dataset.emoji; + toggleReaction(msgId, emoji); + return; + } + + const addBtn = e.target.closest('.add-reaction-btn'); + if (addBtn) { + const msgId = addBtn.parentElement.dataset.messageId; + showEmojiPicker(addBtn, msgId); + return; + } + + // Close picker if click outside + if (!e.target.closest('.emoji-picker')) { + const picker = document.querySelector('.emoji-picker'); + if (picker) picker.remove(); + } + }); + + async function toggleReaction(messageId, emoji) { + try { + const resp = await fetch('api_v1_reactions.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message_id: messageId, emoji: emoji }) + }); + const result = await resp.json(); + if (result.success) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'reaction', + message_id: messageId, + reactions: result.reactions + })); + } + updateReactionUI(messageId, result.reactions); + } + } catch (e) { console.error(e); } + } + + function showEmojiPicker(anchor, messageId) { + document.querySelector('.emoji-picker')?.remove(); + const picker = document.createElement('div'); + picker.className = 'emoji-picker'; + EMOJIS.forEach(emoji => { + const span = document.createElement('span'); + span.textContent = emoji; + span.onclick = () => { + toggleReaction(messageId, emoji); + picker.remove(); + }; + picker.appendChild(span); }); + document.body.appendChild(picker); + const rect = anchor.getBoundingClientRect(); + picker.style.top = `${rect.top - picker.offsetHeight - 5}px`; + picker.style.left = `${rect.left}px`; + } + + function updateReactionUI(messageId, reactions) { + const container = document.querySelector(`.message-reactions[data-message-id="${messageId}"]`); + if (!container) return; + + const addBtn = container.querySelector('.add-reaction-btn'); + container.innerHTML = ''; + reactions.forEach(r => { + const badge = document.createElement('span'); + const userList = r.users.split(','); + const active = userList.includes(String(window.currentUserId)); + badge.className = `reaction-badge ${active ? 'active' : ''}`; + badge.dataset.emoji = r.emoji; + badge.innerHTML = `${r.emoji} ${r.count}`; + container.appendChild(badge); + }); + container.appendChild(addBtn); + } + + function updatePresenceUI(userId, status) { + const memberItem = document.querySelector(`.start-dm-btn[data-user-id="${userId}"] .message-avatar`); + if (memberItem) { + let indicator = memberItem.querySelector('.presence-indicator'); + if (!indicator) { + indicator = document.createElement('div'); + indicator.className = 'presence-indicator'; + memberItem.appendChild(indicator); + } + indicator.style.position = 'absolute'; + indicator.style.bottom = '0'; + indicator.style.right = '0'; + indicator.style.width = '10px'; + indicator.style.height = '10px'; + indicator.style.borderRadius = '50%'; + indicator.style.border = '2px solid var(--bg-members)'; + indicator.style.backgroundColor = status === 'online' ? '#23a559' : '#80848e'; + } + } + + // Voice + if (voiceHandler) { + document.querySelectorAll('.voice-item').forEach(item => { + item.addEventListener('click', () => { + const cid = item.dataset.channelId; + if (voiceHandler.currentChannelId == cid) { + voiceHandler.leave(); + item.classList.remove('active'); + } else { + voiceHandler.join(cid); + document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active')); + item.classList.add('active'); + } + }); + }); + } + + // Message Actions (Edit/Delete) + document.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.action-btn.edit'); + if (editBtn) { + const msgId = editBtn.dataset.id; + const msgItem = editBtn.closest('.message-item'); + const textEl = msgItem.querySelector('.message-text'); + const originalContent = textEl.innerText; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'form-control bg-dark text-white'; + input.value = originalContent; + + textEl.innerHTML = ''; + textEl.appendChild(input); + input.focus(); + + input.onkeydown = async (ev) => { + if (ev.key === 'Enter') { + const newContent = input.value.trim(); + if (newContent && newContent !== originalContent) { + const resp = await fetch('api_v1_messages.php', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: msgId, content: newContent }) + }); + if ((await resp.json()).success) { + textEl.innerHTML = newContent.replace(/\n/g, '
'); + ws?.send(JSON.stringify({ type: 'message_edit', message_id: msgId, content: newContent })); + } + } else { + textEl.innerHTML = originalContent.replace(/\n/g, '
'); + } + } else if (ev.key === 'Escape') { + textEl.innerHTML = originalContent.replace(/\n/g, '
'); + } + }; + return; + } + + const deleteBtn = e.target.closest('.action-btn.delete'); + if (deleteBtn) { + if (!confirm('Delete this message?')) return; + const msgId = deleteBtn.dataset.id; + const resp = await fetch('api_v1_messages.php', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: msgId }) + }); + if ((await resp.json()).success) { + deleteBtn.closest('.message-item').remove(); + ws?.send(JSON.stringify({ type: 'message_delete', message_id: msgId })); + } + return; + } + + // Start DM + const dmBtn = e.target.closest('.start-dm-btn'); + if (dmBtn) { + const userId = dmBtn.dataset.userId; + const formData = new FormData(); + formData.append('user_id', userId); + const resp = await fetch('api_v1_dms.php', { method: 'POST', body: formData }); + const result = await resp.json(); + if (result.success) { + window.location.href = `?server_id=dms&channel_id=${result.channel_id}`; + } + } + }); + + // Global Search + const searchInput = document.getElementById('global-search'); + const searchResults = document.getElementById('search-results'); + + searchInput.addEventListener('input', async () => { + const q = searchInput.value.trim(); + if (q.length < 2) { + searchResults.style.display = 'none'; + return; + } + + const resp = await fetch(`api_v1_search.php?q=${encodeURIComponent(q)}&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'; + }; + searchResults.appendChild(item); + }); + searchResults.style.display = 'block'; + } else { + searchResults.innerHTML = '
No results found
'; + searchResults.style.display = 'block'; + } + }); + + document.addEventListener('click', (e) => { + if (!e.target.closest('.search-container')) { + searchResults.style.display = 'none'; + } + }); + + // Roles Management + const rolesTabBtn = document.getElementById('roles-tab-btn'); + const rolesList = document.getElementById('roles-list'); + const addRoleBtn = document.getElementById('add-role-btn'); + const activeServerId = new URLSearchParams(window.location.search).get('server_id') || 1; + + rolesTabBtn?.addEventListener('click', loadRoles); + + async function loadRoles() { + rolesList.innerHTML = '
Loading roles...
'; + try { + const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`); + const data = await resp.json(); + if (data.success) { + renderRoles(data.roles); + } + } catch (e) { console.error(e); } + } + + function renderRoles(roles) { + rolesList.innerHTML = ''; + roles.forEach(role => { + const item = document.createElement('div'); + item.className = 'list-group-item bg-transparent text-white border-secondary d-flex justify-content-between align-items-center p-2'; + item.innerHTML = ` +
+
+ ${role.name} +
+
+ + +
+ `; + rolesList.appendChild(item); + }); + } + + addRoleBtn?.addEventListener('click', async () => { + const name = prompt('Role name:'); + if (!name) return; + const color = prompt('Role color (hex):', '#99aab5'); + + try { + const resp = await fetch('api_v1_roles.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'create', server_id: activeServerId, name, color }) + }); + if ((await resp.json()).success) loadRoles(); + } catch (e) { console.error(e); } + }); + + rolesList?.addEventListener('click', async (e) => { + if (e.target.classList.contains('delete-role-btn')) { + if (!confirm('Delete this role?')) return; + const roleId = e.target.dataset.id; + const resp = await fetch('api_v1_roles.php', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: roleId }) + }); + if ((await resp.json()).success) loadRoles(); + } + + if (e.target.classList.contains('edit-role-btn')) { + const roleId = e.target.dataset.id; + const name = prompt('New name:'); + const color = prompt('New color (hex):'); + if (!name || !color) return; + + const resp = await fetch('api_v1_roles.php', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: roleId, name, color, permissions: 0 }) + }); + if ((await resp.json()).success) loadRoles(); + } + }); + + // Server Settings + const searchServerIconBtn = document.getElementById('search-server-icon-btn'); + const serverIconResults = document.getElementById('server-icon-search-results'); + const serverIconPreview = document.getElementById('server-icon-preview'); + const serverIconUrlInput = document.getElementById('server-icon-url'); + + searchServerIconBtn?.addEventListener('click', async () => { + const query = prompt('Search for a server icon:', 'abstract'); + if (!query) return; + + serverIconResults.innerHTML = '
Searching...
'; + try { + const resp = await fetch(`api/pexels.php?action=search&query=${encodeURIComponent(query)}`); + const data = await resp.json(); + serverIconResults.innerHTML = ''; + data.forEach(photo => { + const img = document.createElement('img'); + img.src = photo.url; + img.className = 'avatar-pick'; + img.style.width = '50px'; + img.style.height = '50px'; + img.onclick = () => { + serverIconUrlInput.value = photo.url; + serverIconPreview.style.backgroundImage = `url('${photo.url}')`; + serverIconResults.innerHTML = ''; + }; + serverIconResults.appendChild(img); + }); + } catch (e) { + serverIconResults.innerHTML = '
Error fetching icons
'; + } }); }); @@ -87,61 +480,61 @@ 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 ? `
${meta.site_name}
` : ''} + ${meta.title ? `${meta.title}` : ''} + ${meta.description ? `
${meta.description}
` : ''} + ${meta.image ? `
` : ''} +
+ `; + } + + 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); } - - }); -}); - - - const result = await response.json(); - if (result.success) { - // If WS is connected, we might want to let WS handle the UI update - // But for simplicity, we append here and also send to WS - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - ...result.message, - channel_id: channel_id - })); - } else { - appendMessage(result.message); - messagesList.scrollTop = messagesList.scrollHeight; - } - } else { - alert('Error: ' + result.error); - } - } catch (err) { - console.error('Failed to send message:', err); - } - }); - - function appendMessage(msg) { - const div = document.createElement('div'); - div.className = 'message-item'; - div.innerHTML = ` -
-
-
- ${msg.username} - ${msg.time} -
-
- ${msg.content.replace(/\n/g, '
')} -
-
- `; - messagesList.appendChild(div); - } -}); diff --git a/assets/js/voice.js b/assets/js/voice.js index 8a0d47f..40060ec 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -1,30 +1,198 @@ -// Placeholder for WebRTC Voice Logic class VoiceChannel { constructor(ws) { this.ws = ws; this.localStream = null; - this.peers = {}; + this.peers = {}; // userId -> RTCPeerConnection + this.participants = {}; // userId -> username + this.currentChannelId = null; } async join(channelId) { + if (this.currentChannelId === channelId) return; + if (this.currentChannelId) this.leave(); + console.log('Joining voice channel:', channelId); + this.currentChannelId = channelId; + try { - this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + this.ws.send(JSON.stringify({ type: 'voice_join', - channel_id: channelId + channel_id: channelId, + user_id: window.currentUserId, + username: window.currentUsername })); - // Signalization would happen here via WS + + this.updateVoiceUI(); } catch (e) { console.error('Failed to get local stream:', e); alert('Could not access microphone.'); + this.currentChannelId = null; } } leave() { + if (!this.currentChannelId) return; + + this.ws.send(JSON.stringify({ + type: 'voice_leave', + channel_id: this.currentChannelId, + user_id: window.currentUserId + })); + if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; } - this.ws.send(JSON.stringify({ type: 'voice_leave' })); + + Object.values(this.peers).forEach(pc => pc.close()); + this.peers = {}; + this.participants = {}; + this.currentChannelId = null; + this.updateVoiceUI(); + } + + async handleSignaling(data) { + const { type, from, to, offer, answer, candidate, channel_id, username } = data; + + if (channel_id != this.currentChannelId) return; + if (to && to != window.currentUserId) return; + + switch (type) { + case 'voice_join': + if (from != window.currentUserId) { + this.participants[from] = username || `User ${from}`; + this.createPeerConnection(from, true); + this.updateVoiceUI(); + } + break; + case 'voice_offer': + this.participants[from] = username || `User ${from}`; + await this.handleOffer(from, offer); + this.updateVoiceUI(); + break; + case 'voice_answer': + await this.handleAnswer(from, answer); + break; + case 'voice_ice_candidate': + await this.handleCandidate(from, candidate); + break; + case 'voice_leave': + if (this.peers[from]) { + this.peers[from].close(); + delete this.peers[from]; + } + delete this.participants[from]; + this.updateVoiceUI(); + break; + } + } + + createPeerConnection(userId, isOfferor) { + if (this.peers[userId]) return this.peers[userId]; + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + this.peers[userId] = pc; + + this.localStream.getTracks().forEach(track => { + pc.addTrack(track, this.localStream); + }); + + pc.onicecandidate = (event) => { + if (event.candidate) { + this.ws.send(JSON.stringify({ + type: 'voice_ice_candidate', + to: userId, + from: window.currentUserId, + candidate: event.candidate, + channel_id: this.currentChannelId + })); + } + }; + + pc.ontrack = (event) => { + const remoteAudio = new Audio(); + remoteAudio.srcObject = event.streams[0]; + remoteAudio.play(); + }; + + if (isOfferor) { + pc.createOffer().then(offer => { + return pc.setLocalDescription(offer); + }).then(() => { + this.ws.send(JSON.stringify({ + type: 'voice_offer', + to: userId, + from: window.currentUserId, + username: window.currentUsername, + offer: pc.localDescription, + channel_id: this.currentChannelId + })); + }); + } + + return pc; + } + + async handleOffer(from, offer) { + const pc = this.createPeerConnection(from, false); + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + this.ws.send(JSON.stringify({ + type: 'voice_answer', + to: from, + from: window.currentUserId, + answer: pc.localDescription, + channel_id: this.currentChannelId + })); + } + + async handleAnswer(from, answer) { + const pc = this.peers[from]; + if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer)); + } + + async handleCandidate(from, candidate) { + const pc = this.peers[from]; + if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } + + updateVoiceUI() { + document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = ''); + + if (this.currentChannelId) { + const channelEl = document.querySelector(`.voice-item[data-channel-id="${this.currentChannelId}"]`); + if (channelEl) { + let listEl = channelEl.querySelector('.voice-users-list'); + if (!listEl) { + listEl = document.createElement('div'); + listEl.className = 'voice-users-list ms-3'; + channelEl.appendChild(listEl); + } + + // Me + this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername); + + // Others + Object.entries(this.participants).forEach(([uid, name]) => { + this.addVoiceUserToUI(listEl, uid, name); + }); + } + } + } + + addVoiceUserToUI(container, userId, username) { + const userEl = document.createElement('div'); + userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1'; + userEl.innerHTML = ` +
+ ${username} + `; + container.appendChild(userEl); } } diff --git a/db/migrations/20260215_add_attachments_and_reactions.sql b/db/migrations/20260215_add_attachments_and_reactions.sql new file mode 100644 index 0000000..5dc5bf7 --- /dev/null +++ b/db/migrations/20260215_add_attachments_and_reactions.sql @@ -0,0 +1,13 @@ +-- Migration to add attachments and reactions +ALTER TABLE messages ADD COLUMN attachment_url VARCHAR(255) AFTER content; + +CREATE TABLE IF NOT EXISTS message_reactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + message_id INT NOT NULL, + user_id INT NOT NULL, + emoji VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY (message_id, user_id, emoji), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/db/migrations/20260215_dms_and_edits.sql b/db/migrations/20260215_dms_and_edits.sql new file mode 100644 index 0000000..c8de0fd --- /dev/null +++ b/db/migrations/20260215_dms_and_edits.sql @@ -0,0 +1,26 @@ +-- Migration to support DMs and Message Editing +ALTER TABLE messages ADD COLUMN updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP; + +-- Support for DMs in channels table +ALTER TABLE channels MODIFY COLUMN server_id INT NULL; +ALTER TABLE channels MODIFY COLUMN type ENUM('text', 'voice', 'dm') DEFAULT 'text'; + +-- Track members in channels (especially for DMs) +CREATE TABLE IF NOT EXISTS channel_members ( + channel_id INT NOT NULL, + user_id INT NOT NULL, + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (channel_id, user_id), + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Notifications: Track last read message per channel per user +CREATE TABLE IF NOT EXISTS channel_last_read ( + channel_id INT NOT NULL, + user_id INT NOT NULL, + last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (channel_id, user_id), + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/includes/opengraph.php b/includes/opengraph.php new file mode 100644 index 0000000..a241cef --- /dev/null +++ b/includes/opengraph.php @@ -0,0 +1,54 @@ +loadHTML($html); + $metas = $doc->getElementsByTagName('meta'); + + $data = [ + 'title' => '', + 'description' => '', + 'image' => '', + 'url' => $url, + 'site_name' => '' + ]; + + // Try title tag if og:title is missing + $titles = $doc->getElementsByTagName('title'); + if ($titles->length > 0) { + $data['title'] = $titles->item(0)->nodeValue; + } + + foreach ($metas as $meta) { + $property = $meta->getAttribute('property'); + $name = $meta->getAttribute('name'); + $content = $meta->getAttribute('content'); + + if ($property === 'og:title' || $name === 'twitter:title') $data['title'] = $content; + if ($property === 'og:description' || $name === 'description' || $name === 'twitter:description') $data['description'] = $content; + if ($property === 'og:image' || $name === 'twitter:image') $data['image'] = $content; + if ($property === 'og:site_name') $data['site_name'] = $content; + } + + // Filter out empty results + if (empty($data['title']) && empty($data['description'])) return null; + + return $data; +} + +function extractUrls($text) { + $pattern = '/https?:\/\/[^\s<]+/'; + preg_match_all($pattern, $text, $matches); + return $matches[0]; +} diff --git a/includes/pexels.php b/includes/pexels.php new file mode 100644 index 0000000..589849b --- /dev/null +++ b/includes/pexels.php @@ -0,0 +1,28 @@ + 0 ? $k : 'Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18'; +} + +function pexels_get($url) { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ 'Authorization: '. pexels_key() ], + CURLOPT_TIMEOUT => 15, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300 && $resp) return json_decode($resp, true); + return null; +} + +function download_to($srcUrl, $destPath) { + $data = file_get_contents($srcUrl); + if ($data === false) return false; + if (!is_dir(dirname($destPath))) mkdir(dirname($destPath), 0775, true); + return file_put_contents($destPath, $data) !== false; +} diff --git a/index.php b/index.php index 51c3e3a..bb488e7 100644 --- a/index.php +++ b/index.php @@ -14,41 +14,84 @@ $stmt = db()->prepare(" "); $stmt->execute([$current_user_id]); $servers = $stmt->fetchAll(); -$active_server_id = $_GET['server_id'] ?? ($servers[0]['id'] ?? 1); +$is_dm_view = (isset($_GET['server_id']) && $_GET['server_id'] == 'dms') || !isset($_GET['server_id']) && empty($servers); -// If no servers found, we might want to show a default or an empty state -// For now, let's assume the seed data or the first server works +if ($is_dm_view) { + $active_server_id = 'dms'; + // Fetch DM channels + $stmt = db()->prepare(" + SELECT c.id, u.username as other_user, u.avatar_url, u.status, u.id as other_user_id + FROM channels c + JOIN channel_members cm1 ON c.id = cm1.channel_id + JOIN channel_members cm2 ON c.id = cm2.channel_id + JOIN users u ON cm2.user_id = u.id + WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id != ? + "); + $stmt->execute([$current_user_id, $current_user_id]); + $dm_channels = $stmt->fetchAll(); + + $active_channel_id = $_GET['channel_id'] ?? ($dm_channels[0]['id'] ?? 0); + + if ($active_channel_id) { + // Fetch DM messages + $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 = ? + ORDER BY m.created_at ASC + LIMIT 50 + "); + $stmt->execute([$active_channel_id]); + $messages = $stmt->fetchAll(); + + $current_channel_name = 'Direct Message'; + foreach($dm_channels as $dm) { + if ($dm['id'] == $active_channel_id) { + $current_channel_name = $dm['other_user']; + break; + } + } + } else { + $messages = []; + $current_channel_name = 'Direct Messages'; + } + $channels = []; + $members = []; // Members list is different for DMs or hidden +} else { + $active_server_id = $_GET['server_id'] ?? ($servers[0]['id'] ?? 1); -// Fetch channels -$stmt = db()->prepare("SELECT * FROM channels WHERE server_id = ?"); -$stmt->execute([$active_server_id]); -$channels = $stmt->fetchAll(); -$active_channel_id = $_GET['channel_id'] ?? ($channels[0]['id'] ?? 1); + // Fetch channels + $stmt = db()->prepare("SELECT * FROM channels WHERE server_id = ?"); + $stmt->execute([$active_server_id]); + $channels = $stmt->fetchAll(); + $active_channel_id = $_GET['channel_id'] ?? ($channels[0]['id'] ?? 1); -// Fetch messages -$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 = ? - ORDER BY m.created_at ASC - LIMIT 50 -"); -$stmt->execute([$active_channel_id]); -$messages = $stmt->fetchAll(); + // Fetch messages + $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 = ? + ORDER BY m.created_at ASC + LIMIT 50 + "); + $stmt->execute([$active_channel_id]); + $messages = $stmt->fetchAll(); -$current_channel_name = 'general'; -foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name']; + $current_channel_name = 'general'; + foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name']; -// Fetch members -$stmt = db()->prepare(" - SELECT u.username, u.avatar_url, u.status - FROM users u - JOIN server_members sm ON u.id = sm.user_id - WHERE sm.server_id = ? -"); -$stmt->execute([$active_server_id]); -$members = $stmt->fetchAll(); + // Fetch members + $stmt = db()->prepare(" + SELECT u.id, u.username, u.avatar_url, u.status + FROM users u + JOIN server_members sm ON u.id = sm.user_id + WHERE sm.server_id = ? + "); + $stmt->execute([$active_server_id]); + $members = $stmt->fetchAll(); +} // SEO & Env tags $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Discord-like messaging app built with PHP'; @@ -78,15 +121,16 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
- +
- + title="" + style=""> + @@ -98,36 +142,64 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+ + + +
-
- Text Channels - + -
- -
- - - - -
- Voice Channels - + -
- -
- + + + +
"> +
+
+ +
+ + +
+ Text Channels + +
- + + + + + + +
+ Voice Channels + + +
+ +
+ +
+ +
-