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 = `
+