diff --git a/api_v1_channels.php b/api_v1_channels.php index 9c14b53..62b2dbf 100644 --- a/api_v1_channels.php +++ b/api_v1_channels.php @@ -1,6 +1,7 @@ prepare("SELECT s.owner_id FROM servers s JOIN channels c ON s.id = c.server_id WHERE c.id = ?"); + // Check if user has permission to manage channels + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); $stmt->execute([$channel_id]); - $server = $stmt->fetch(); - - if ($server && $server['owner_id'] == $user_id) { + $chan = $stmt->fetch(); + + if ($chan && Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) { $name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $name)); - $stmt = db()->prepare("UPDATE channels SET name = ?, allow_file_sharing = ?, theme_color = ?, message_limit = ? WHERE id = ?"); - $stmt->execute([$name, $allow_file_sharing, $theme_color, $message_limit, $channel_id]); + $stmt = db()->prepare("UPDATE channels SET name = ?, type = ?, status = ?, allow_file_sharing = ?, theme_color = ?, message_limit = ? WHERE id = ?"); + $stmt->execute([$name, $type, $status, $allow_file_sharing, $theme_color, $message_limit, $channel_id]); } header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id); exit; @@ -44,16 +47,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($action === 'delete') { $channel_id = $_POST['channel_id'] ?? 0; - // Check if user is owner - $stmt = db()->prepare("SELECT s.owner_id, s.id as server_id FROM servers s JOIN channels c ON s.id = c.server_id WHERE c.id = ?"); + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); $stmt->execute([$channel_id]); - $server = $stmt->fetch(); + $chan = $stmt->fetch(); - if ($server && $server['owner_id'] == $user_id) { + if ($chan && Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) { $stmt = db()->prepare("DELETE FROM channels WHERE id = ?"); $stmt->execute([$channel_id]); } - header('Location: index.php?server_id=' . ($server['server_id'] ?? '')); + header('Location: index.php?server_id=' . ($chan['server_id'] ?? '')); exit; } @@ -61,11 +63,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $type = $_POST['type'] ?? 'text'; $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() && $name) { + // Check if user has permission to manage channels + if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS) && $name) { try { // Basic sanitization for channel name $name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $name)); diff --git a/api_v1_clear_channel.php b/api_v1_clear_channel.php index 2e84faa..a9aec57 100644 --- a/api_v1_clear_channel.php +++ b/api_v1_clear_channel.php @@ -32,8 +32,8 @@ $stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?"); $stmt->execute([$server_id]); $server = $stmt->fetch(); -if ($server["owner_id"] != $_SESSION["user_id"]) { - echo json_encode(["success" => false, "error" => "Only the server owner can clear history"]); +if (!Permissions::hasPermission($_SESSION["user_id"], $server_id, Permissions::MANAGE_CHANNELS)) { + echo json_encode(["success" => false, "error" => "Only moderators or admins can clear history"]); exit; } diff --git a/api_v1_messages.php b/api_v1_messages.php index 33d85bb..b24c250 100644 --- a/api_v1_messages.php +++ b/api_v1_messages.php @@ -38,14 +38,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { if ($pinned) { try { + // Get server_id for the channel + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $server_id = $stmt->fetchColumn(); + $stmt = db()->prepare(" - SELECT m.*, u.username, u.avatar_url + SELECT m.*, u.username, u.avatar_url, + (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color 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]); + $stmt->execute([$server_id ?: 0, $channel_id]); $msgs = $stmt->fetchAll(); foreach ($msgs as &$m) { @@ -120,15 +126,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { $content = ''; $channel_id = 0; +$thread_id = null; $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; + $thread_id = !empty($data['thread_id']) ? (int)$data['thread_id'] : null; } else { $content = $_POST['content'] ?? ''; $channel_id = $_POST['channel_id'] ?? 0; + $thread_id = !empty($_POST['thread_id']) ? (int)$_POST['thread_id'] : null; // Check if file sharing is allowed in this channel $stmt = db()->prepare("SELECT allow_file_sharing FROM channels WHERE id = ?"); @@ -185,8 +194,8 @@ if (!empty($content)) { } try { - $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]); + $stmt = db()->prepare("INSERT INTO messages (channel_id, thread_id, user_id, content, attachment_url, metadata) VALUES (?, ?, ?, ?, ?, ?)"); + $stmt->execute([$channel_id, $thread_id, $user_id, $content, $attachment_url, $metadata]); $last_id = db()->lastInsertId(); // Enforce message limit if set @@ -211,9 +220,20 @@ try { $stmt->execute([$channel_id, $channel_id, $limit]); } - // Fetch message with username for the response - $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]); + // Get server_id for the channel + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $server_id = $stmt->fetchColumn(); + + // Fetch message with username and role color for the response + $stmt = db()->prepare(" + SELECT m.*, u.username, u.avatar_url, + (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.id = ? + "); + $stmt->execute([$server_id ?: 0, $last_id]); $msg = $stmt->fetch(); echo json_encode([ @@ -223,6 +243,7 @@ try { 'user_id' => $msg['user_id'], 'username' => $msg['username'], 'avatar_url' => $msg['avatar_url'], + 'role_color' => $msg['role_color'], 'content' => $msg['content'], 'attachment_url' => $msg['attachment_url'], 'metadata' => $msg['metadata'] ? json_decode($msg['metadata']) : null, diff --git a/api_v1_roles.php b/api_v1_roles.php index 66a209e..20862b9 100644 --- a/api_v1_roles.php +++ b/api_v1_roles.php @@ -1,5 +1,7 @@ false, 'error' => 'Missing server_id']); + exit; + } + + // Verify user is in server + $stmt = db()->prepare("SELECT * FROM server_members WHERE server_id = ? AND user_id = ?"); + $stmt->execute([$server_id, $user_id]); + if (!$stmt->fetch()) { + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; + } + $stmt = db()->prepare("SELECT * FROM roles WHERE server_id = ? ORDER BY position DESC"); $stmt->execute([$server_id]); - echo json_encode(['success' => true, 'roles' => $stmt->fetchAll()]); + $roles = $stmt->fetchAll(); + + // Fetch members and their roles + $stmt = db()->prepare(" + SELECT u.id, u.username, u.avatar_url, + GROUP_CONCAT(r.id) as role_ids, + GROUP_CONCAT(r.name) as role_names, + (SELECT r2.color FROM roles r2 JOIN user_roles ur2 ON r2.id = ur2.role_id WHERE ur2.user_id = u.id AND r2.server_id = ? ORDER BY r2.position DESC LIMIT 1) as role_color + FROM users u + JOIN server_members sm ON u.id = sm.user_id + LEFT JOIN user_roles ur ON u.id = ur.user_id + LEFT JOIN roles r ON ur.role_id = r.id AND r.server_id = ? + WHERE sm.server_id = ? + GROUP BY u.id + "); + $stmt->execute([$server_id, $server_id, $server_id]); + $members = $stmt->fetchAll(); + + echo json_encode([ + 'success' => true, + 'roles' => $roles, + 'members' => $members, + 'permissions_list' => [ + ['value' => 1, 'name' => 'View Channels'], + ['value' => 2, 'name' => 'Send Messages'], + ['value' => 4, 'name' => 'Manage Messages'], + ['value' => 8, 'name' => 'Manage Channels'], + ['value' => 16, 'name' => 'Manage Server'], + ['value' => 32, 'name' => 'Administrator'] + ] + ]); exit; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $data['action'] ?? ''; $server_id = $data['server_id'] ?? 0; - $action = $data['action'] ?? 'create'; - // Check if user is owner of server + // Permissions check: Owner or MANAGE_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) { + + $is_owner = ($server && $server['owner_id'] == $user_id); + $can_manage = Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_SERVER) || Permissions::hasPermission($user_id, $server_id, Permissions::ADMINISTRATOR); + + if (!$is_owner && !$can_manage) { echo json_encode(['success' => false, 'error' => 'Unauthorized']); exit; } @@ -29,58 +78,47 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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]); + $perms = $data['permissions'] ?? 0; + + $stmt = db()->prepare("INSERT INTO roles (server_id, name, color, permissions) VALUES (?, ?, ?, ?)"); + $stmt->execute([$server_id, $name, $color, $perms]); echo json_encode(['success' => true, 'role_id' => db()->lastInsertId()]); + } elseif ($action === 'update') { + $role_id = $data['id'] ?? 0; + $name = $data['name'] ?? ''; + $color = $data['color'] ?? ''; + $perms = $data['permissions'] ?? 0; + + $stmt = db()->prepare("UPDATE roles SET name = ?, color = ?, permissions = ? WHERE id = ? AND server_id = ?"); + $stmt->execute([$name, $color, $perms, $role_id, $server_id]); + echo json_encode(['success' => true]); + } elseif ($action === 'delete') { + $role_id = $data['id'] ?? 0; + $stmt = db()->prepare("DELETE FROM roles WHERE id = ? AND server_id = ?"); + $stmt->execute([$role_id, $server_id]); + echo json_encode(['success' => true]); } elseif ($action === 'assign') { $target_user_id = $data['user_id'] ?? 0; $role_id = $data['role_id'] ?? 0; + + // Verify role belongs to server + $stmt = db()->prepare("SELECT id FROM roles WHERE id = ? AND server_id = ?"); + $stmt->execute([$role_id, $server_id]); + if (!$stmt->fetch()) { + echo json_encode(['success' => false, 'error' => 'Invalid role']); + exit; + } + $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]); + + $stmt = db()->prepare("DELETE ur FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ? AND ur.role_id = ? AND r.server_id = ?"); + $stmt->execute([$target_user_id, $role_id, $server_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_rss.php b/api_v1_rss.php new file mode 100644 index 0000000..5871032 --- /dev/null +++ b/api_v1_rss.php @@ -0,0 +1,116 @@ +prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $chan = $stmt->fetch(); + + if (!$chan || !Permissions::hasPermission($user['id'], $chan['server_id'], Permissions::MANAGE_CHANNELS)) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + if ($action === 'add') { + $url = $_POST['url'] ?? ''; + if (!filter_var($url, FILTER_VALIDATE_URL)) { + echo json_encode(['success' => false, 'error' => 'Invalid URL']); + exit; + } + $stmt = db()->prepare("INSERT INTO channel_rss_feeds (channel_id, url) VALUES (?, ?)"); + $stmt->execute([$channel_id, $url]); + echo json_encode(['success' => true]); + exit; + } + + if ($action === 'delete') { + $feed_id = $_POST['feed_id'] ?? 0; + $stmt = db()->prepare("DELETE FROM channel_rss_feeds WHERE id = ? AND channel_id = ?"); + $stmt->execute([$feed_id, $channel_id]); + echo json_encode(['success' => true]); + exit; + } + + if ($action === 'sync') { + // Fetch feeds for this channel + $stmt = db()->prepare("SELECT * FROM channel_rss_feeds WHERE channel_id = ?"); + $stmt->execute([$channel_id]); + $feeds = $stmt->fetchAll(); + + $new_items_count = 0; + foreach ($feeds as $feed) { + $rss_content = @file_get_contents($feed['url']); + if (!$rss_content) continue; + + $xml = @simplexml_load_string($rss_content); + if (!$xml) continue; + + $items = []; + if (isset($xml->channel->item)) { // RSS 2.0 + $items = $xml->channel->item; + } elseif (isset($xml->entry)) { // Atom + $items = $xml->entry; + } + + foreach ($items as $item) { + $guid = (string)($item->guid ?? ($item->id ?? $item->link)); + $title = (string)$item->title; + $link = (string)($item->link['href'] ?? $item->link); + $description = strip_tags((string)($item->description ?? $item->summary)); + + // Check if already exists + $stmt_check = db()->prepare("SELECT id FROM messages WHERE channel_id = ? AND rss_guid = ?"); + $stmt_check->execute([$channel_id, $guid]); + if ($stmt_check->fetch()) continue; + + // Insert as message from a special "RSS Bot" user or system + // Let's find or create an RSS Bot user + $stmt_bot = db()->prepare("SELECT id FROM users WHERE username = 'RSS Bot' AND is_bot = 1"); + $stmt_bot->execute(); + $bot = $stmt_bot->fetch(); + if (!$bot) { + $stmt_create_bot = db()->prepare("INSERT INTO users (username, is_bot, status) VALUES ('RSS Bot', 1, 'online')"); + $stmt_create_bot->execute(); + $bot_id = db()->lastInsertId(); + } else { + $bot_id = $bot['id']; + } + + $content = "**" . $title . "**\n" . $description . "\n" . $link; + + // For announcements, we might want to use metadata for a rich embed + $metadata = json_encode([ + 'title' => $title, + 'description' => mb_substr($description, 0, 200) . (mb_strlen($description) > 200 ? '...' : ''), + 'url' => $link, + 'site_name' => parse_url($feed['url'], PHP_URL_HOST) + ]); + + $stmt_msg = db()->prepare("INSERT INTO messages (channel_id, user_id, content, metadata, rss_guid) VALUES (?, ?, ?, ?, ?)"); + $stmt_msg->execute([$channel_id, $bot_id, $content, $metadata, $guid]); + $new_items_count++; + } + + $stmt_update_feed = db()->prepare("UPDATE channel_rss_feeds SET last_fetched_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt_update_feed->execute([$feed['id']]); + } + + echo json_encode(['success' => true, 'new_items' => $new_items_count]); + exit; + } +} else { + // GET: List feeds + $channel_id = $_GET['channel_id'] ?? 0; + $stmt = db()->prepare("SELECT * FROM channel_rss_feeds WHERE channel_id = ? ORDER BY created_at DESC"); + $stmt->execute([$channel_id]); + echo json_encode(['success' => true, 'feeds' => $stmt->fetchAll()]); + exit; +} diff --git a/api_v1_rules.php b/api_v1_rules.php new file mode 100644 index 0000000..9f07dd2 --- /dev/null +++ b/api_v1_rules.php @@ -0,0 +1,103 @@ +prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $chan = $stmt->fetch(); + + if (!$chan || !Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + try { + // Get max position + $stmt = db()->prepare("SELECT MAX(position) FROM channel_rules WHERE channel_id = ?"); + $stmt->execute([$channel_id]); + $pos = (int)$stmt->fetchColumn() + 1; + + $stmt = db()->prepare("INSERT INTO channel_rules (channel_id, content, position) VALUES (?, ?, ?)"); + $stmt->execute([$channel_id, $content, $pos]); + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + $id = $_GET['id'] ?? 0; + + $stmt = db()->prepare("SELECT c.server_id FROM channels c JOIN channel_rules r ON c.id = r.channel_id WHERE r.id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + + if ($res && Permissions::hasPermission($user_id, $res['server_id'], Permissions::MANAGE_CHANNELS)) { + $stmt = db()->prepare("DELETE FROM channel_rules WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'PATCH') { + $data = json_decode(file_get_contents('php://input'), true); + $id = $data['id'] ?? 0; + $dir = $data['dir'] ?? 'up'; + + $stmt = db()->prepare("SELECT channel_id, position FROM channel_rules WHERE id = ?"); + $stmt->execute([$id]); + $current = $stmt->fetch(); + + if ($current) { + $channel_id = $current['channel_id']; + $pos = $current['position']; + + if ($dir === 'up') { + $stmt = db()->prepare("SELECT id, position FROM channel_rules WHERE channel_id = ? AND position < ? ORDER BY position DESC LIMIT 1"); + } else { + $stmt = db()->prepare("SELECT id, position FROM channel_rules WHERE channel_id = ? AND position > ? ORDER BY position ASC LIMIT 1"); + } + $stmt->execute([$channel_id, $pos]); + $other = $stmt->fetch(); + + if ($other) { + db()->prepare("UPDATE channel_rules SET position = ? WHERE id = ?")->execute([$other['position'], $id]); + db()->prepare("UPDATE channel_rules SET position = ? WHERE id = ?")->execute([$pos, $other['id']]); + } + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Rule not found']); + } + exit; +} +if ($_SERVER['REQUEST_METHOD'] === 'PUT') { + $data = json_decode(file_get_contents('php://input'), true); + $id = $data['id'] ?? 0; + $content = $data['content'] ?? ''; + + $stmt = db()->prepare("SELECT c.server_id FROM channels c JOIN channel_rules r ON c.id = r.channel_id WHERE r.id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + + if ($res && Permissions::hasPermission($user_id, $res['server_id'], Permissions::MANAGE_CHANNELS)) { + $stmt = db()->prepare("UPDATE channel_rules SET content = ? WHERE id = ?"); + $stmt->execute([$content, $id]); + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + } + exit; +} diff --git a/api_v1_tags.php b/api_v1_tags.php new file mode 100644 index 0000000..19d0026 --- /dev/null +++ b/api_v1_tags.php @@ -0,0 +1,54 @@ + false, 'error' => 'Missing channel_id']); + exit; + } + $stmt = db()->prepare("SELECT * FROM forum_tags WHERE channel_id = ?"); + $stmt->execute([$channel_id]); + echo json_encode(['success' => true, 'tags' => $stmt->fetchAll()]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + if (!$data) $data = $_POST; + + $action = $data['action'] ?? 'create'; + $channel_id = $data['channel_id'] ?? 0; + + // Check permissions + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $chan = $stmt->fetch(); + if (!$chan || !Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + if ($action === 'create') { + $name = $data['name'] ?? ''; + $color = $data['color'] ?? '#7289da'; + if (!$name) { + echo json_encode(['success' => false, 'error' => 'Missing name']); + exit; + } + $stmt = db()->prepare("INSERT INTO forum_tags (channel_id, name, color) VALUES (?, ?, ?)"); + $stmt->execute([$channel_id, $name, $color]); + echo json_encode(['success' => true, 'tag_id' => db()->lastInsertId()]); + } elseif ($action === 'delete') { + $tag_id = $data['tag_id'] ?? 0; + $stmt = db()->prepare("DELETE FROM forum_tags WHERE id = ? AND channel_id = ?"); + $stmt->execute([$tag_id, $channel_id]); + echo json_encode(['success' => true]); + } + exit; +} diff --git a/api_v1_threads.php b/api_v1_threads.php new file mode 100644 index 0000000..8ddc361 --- /dev/null +++ b/api_v1_threads.php @@ -0,0 +1,80 @@ + false, 'error' => 'Missing data']); + exit; + } + + $tag_ids = $_POST['tag_ids'] ?? []; + if (is_string($tag_ids)) { + $tag_ids = array_filter(explode(',', $tag_ids)); + } + + try { + db()->beginTransaction(); + $stmt = db()->prepare("INSERT INTO forum_threads (channel_id, user_id, title) VALUES (?, ?, ?)"); + $stmt->execute([$channel_id, $user_id, $title]); + $thread_id = db()->lastInsertId(); + + if (!empty($tag_ids)) { + $stmtTag = db()->prepare("INSERT INTO thread_tags (thread_id, tag_id) VALUES (?, ?)"); + foreach ($tag_ids as $tag_id) { + if ($tag_id) $stmtTag->execute([$thread_id, $tag_id]); + } + } + db()->commit(); + echo json_encode(['success' => true, 'thread_id' => $thread_id]); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'PATCH' || (isset($_GET['action']) && $_GET['action'] === 'solve')) { + $data = json_decode(file_get_contents('php://input'), true) ?? $_POST; + $thread_id = $data['thread_id'] ?? 0; + $message_id = $data['message_id'] ?? null; // null to unsolve + $user_id = $_SESSION['user_id']; + + if (!$thread_id) { + echo json_encode(['success' => false, 'error' => 'Missing thread_id']); + exit; + } + + // Verify permission (thread owner or admin) + $stmt = db()->prepare("SELECT t.*, c.server_id FROM forum_threads t JOIN channels c ON t.channel_id = c.id WHERE t.id = ?"); + $stmt->execute([$thread_id]); + $thread = $stmt->fetch(); + + if (!$thread) { + echo json_encode(['success' => false, 'error' => 'Thread not found']); + exit; + } + + $stmtServer = db()->prepare("SELECT owner_id FROM servers WHERE id = ?"); + $stmtServer->execute([$thread['server_id']]); + $server = $stmtServer->fetch(); + + if ($thread['user_id'] != $user_id && $server['owner_id'] != $user_id) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + try { + $stmt = db()->prepare("UPDATE forum_threads SET solution_message_id = ? WHERE id = ?"); + $stmt->execute([$message_id, $thread_id]); + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; +} diff --git a/api_v1_user.php b/api_v1_user.php index 87938af..f7f851c 100644 --- a/api_v1_user.php +++ b/api_v1_user.php @@ -12,11 +12,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); + $sound_notifications = isset($_POST['sound_notifications']) ? (int)$_POST['sound_notifications'] : (int)($user['sound_notifications'] ?? 0); $theme = $_POST['theme'] ?? $user['theme'] ?? 'dark'; try { - $stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ?, dnd_mode = ?, theme = ? WHERE id = ?"); - $stmt->execute([$username, $avatar_url, $dnd_mode, $theme, $user['id']]); + $stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ?, dnd_mode = ?, sound_notifications = ?, theme = ? WHERE id = ?"); + $stmt->execute([$username, $avatar_url, $dnd_mode, $sound_notifications, $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 37eb8fa..f3498b6 100644 --- a/assets/css/discord.css +++ b/assets/css/discord.css @@ -785,3 +785,77 @@ body { .action-btn.pin.active { color: var(--blurple); } + +/* Announcement Style */ +.announcement-style { + background-color: rgba(88, 101, 242, 0.05); + border: 1px solid rgba(88, 101, 242, 0.1); + border-radius: 8px; + margin-bottom: 8px; + padding: 12px !important; +} + +.announcement-style .message-text { + font-size: 1.1em; + font-weight: 500; +} + +/* Rules Style */ +.rule-item { + transition: transform 0.2s; + cursor: default; +} + +.rule-item:hover { + transform: translateX(5px); +} + +/* Forum Style */ +.thread-item { + transition: background-color 0.2s; +} + +.thread-item:hover { + background-color: #35373c !important; +} + +/* YouTube Embed */ +.youtube-embed { + overflow: hidden; + position: relative; + max-width: 560px; +} + +.youtube-embed iframe { + width: 100%; + aspect-ratio: 16 / 9; +} + +/* Solution Style */ +.message-item.is-solution { + background-color: rgba(35, 165, 89, 0.05); + border-left: 2px solid #23a559; +} + +.action-btn.mark-solution.active { + color: #23a559; +} + +/* Forum Filters */ +.forum-filters .btn { + border-radius: 20px; + margin-right: 5px; + border: none; + background-color: var(--bg-servers); + color: var(--text-muted); + font-size: 0.8em; + padding: 4px 12px; +} +.forum-filters .btn:hover { + background-color: var(--hover); + color: var(--text-primary); +} +.forum-filters .btn.active { + background-color: var(--active); + color: var(--text-primary); +} diff --git a/assets/js/main.js b/assets/js/main.js index 3a9c459..0b16feb 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -6,12 +6,13 @@ document.addEventListener('DOMContentLoaded', () => { const typingIndicator = document.getElementById('typing-indicator'); // Emoji list for reactions - const EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥', '✅', '🚀']; + const EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥', '✅', '🚀', '❓', '💡', '📌', '💯']; // Scroll to bottom messagesList.scrollTop = messagesList.scrollHeight; const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1; + const currentThread = new URLSearchParams(window.location.search).get('thread_id'); let typingTimeout; // Notification Permission @@ -52,7 +53,18 @@ document.addEventListener('DOMContentLoaded', () => { new Notification(`Mention in #${window.currentChannelName}`, { body: `${data.username}: ${data.content}`, icon: data.avatar_url || '' - }); + if (e.target.classList.contains('move-rule-btn')) { + const id = e.target.dataset.id; + const dir = e.target.dataset.dir; + const resp = await fetch('api_v1_rules.php', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, dir }) + }); + if ((await resp.json()).success) location.reload(); + } + }); + } } } @@ -114,6 +126,9 @@ document.addEventListener('DOMContentLoaded', () => { const formData = new FormData(); formData.append('content', content); formData.append('channel_id', currentChannel); + if (currentThread) { + formData.append('thread_id', currentThread); + } const progressContainer = document.getElementById('upload-progress-container'); const progressBar = document.getElementById('upload-progress-bar'); @@ -383,11 +398,12 @@ document.addEventListener('DOMContentLoaded', () => { const div = document.createElement('div'); div.className = 'message-item p-2 border-bottom border-secondary'; div.style.backgroundColor = 'transparent'; + const authorStyle = msg.role_color ? `color: ${msg.role_color};` : ''; div.innerHTML = `
-
+
${escapeHTML(msg.username)} ${msg.time}
@@ -589,15 +605,145 @@ document.addEventListener('DOMContentLoaded', () => { channelSettingsBtns.forEach(btn => { btn.addEventListener('click', () => { const modal = document.getElementById('editChannelModal'); - modal.querySelector('#edit-channel-id').value = btn.dataset.id; + const channelId = btn.dataset.id; + const channelType = btn.dataset.type || 'chat'; + + modal.querySelector('#edit-channel-id').value = channelId; modal.querySelector('#edit-channel-name').value = btn.dataset.name; + modal.querySelector('#edit-channel-type').value = channelType; modal.querySelector('#edit-channel-files').checked = btn.dataset.files == '1'; modal.querySelector('#edit-channel-limit').value = btn.dataset.limit || ''; + modal.querySelector('#edit-channel-status').value = btn.dataset.status || ''; modal.querySelector('#edit-channel-theme').value = btn.dataset.theme || '#5865f2'; - modal.querySelector('#delete-channel-id').value = btn.dataset.id; + modal.querySelector('#delete-channel-id').value = channelId; + + // Show/Hide RSS tab + const rssTabNav = document.getElementById('rss-tab-nav'); + const statusContainer = document.getElementById('edit-channel-status-container'); + + if (channelType === 'announcement') { + rssTabNav.style.display = 'block'; + } else { + rssTabNav.style.display = 'none'; + // Switch to General tab if we were on RSS + if (document.getElementById('rss-tab-btn').classList.contains('active')) { + bootstrap.Tab.getInstance(modal.querySelector('.nav-link.active')).hide(); + bootstrap.Tab.getOrCreateInstance(modal.querySelector('[data-bs-target="#edit-channel-general"]')).show(); + } + } + + if (channelType === 'voice') { + statusContainer.style.display = 'block'; + } else { + statusContainer.style.display = 'none'; + } }); }); + // RSS Management + const editChannelType = document.getElementById('edit-channel-type'); + editChannelType?.addEventListener('change', () => { + const type = editChannelType.value; + const rssTabNav = document.getElementById('rss-tab-nav'); + const statusContainer = document.getElementById('edit-channel-status-container'); + + rssTabNav.style.display = (type === 'announcement') ? 'block' : 'none'; + statusContainer.style.display = (type === 'voice') ? 'block' : 'none'; + }); + + // RSS Management + const rssTabBtn = document.getElementById('rss-tab-btn'); + const rssFeedsList = document.getElementById('rss-feeds-list'); + const addRssBtn = document.getElementById('add-rss-btn'); + const syncRssBtn = document.getElementById('sync-rss-btn'); + + rssTabBtn?.addEventListener('click', loadRssFeeds); + + async function loadRssFeeds() { + const channelId = document.getElementById('edit-channel-id').value; + rssFeedsList.innerHTML = '
Loading feeds...
'; + try { + const resp = await fetch(`api_v1_rss.php?channel_id=${channelId}`); + const data = await resp.json(); + if (data.success) { + renderRssFeeds(data.feeds); + } + } catch (e) { console.error(e); } + } + + function renderRssFeeds(feeds) { + rssFeedsList.innerHTML = ''; + if (feeds.length === 0) { + rssFeedsList.innerHTML = '
No RSS feeds configured.
'; + return; + } + feeds.forEach(feed => { + const item = document.createElement('div'); + item.className = 'list-group-item bg-transparent text-white border-secondary p-2 mb-1'; + item.innerHTML = ` +
+ ${feed.url} + +
+
Last fetched: ${feed.last_fetched_at || 'Never'}
+ `; + rssFeedsList.appendChild(item); + }); + } + + addRssBtn?.addEventListener('click', async () => { + const channelId = document.getElementById('edit-channel-id').value; + const url = document.getElementById('new-rss-url').value.trim(); + if (!url) return; + + const formData = new FormData(); + formData.append('action', 'add'); + formData.append('channel_id', channelId); + formData.append('url', url); + + const resp = await fetch('api_v1_rss.php', { method: 'POST', body: formData }); + if ((await resp.json()).success) { + document.getElementById('new-rss-url').value = ''; + loadRssFeeds(); + } + }); + + syncRssBtn?.addEventListener('click', async () => { + const channelId = document.getElementById('edit-channel-id').value; + syncRssBtn.disabled = true; + syncRssBtn.textContent = 'Syncing...'; + + const formData = new FormData(); + formData.append('action', 'sync'); + formData.append('channel_id', channelId); + + try { + const resp = await fetch('api_v1_rss.php', { method: 'POST', body: formData }); + const result = await resp.json(); + if (result.success) { + alert(`Sync complete! Found ${result.new_items} new items.`); + loadRssFeeds(); + } + } catch (e) { console.error(e); } + + syncRssBtn.disabled = false; + syncRssBtn.textContent = 'Sync Now'; + }); + + rssFeedsList?.addEventListener('click', async (e) => { + if (e.target.classList.contains('delete-rss-btn')) { + const channelId = document.getElementById('edit-channel-id').value; + const feedId = e.target.dataset.id; + const formData = new FormData(); + formData.append('action', 'delete'); + formData.append('channel_id', channelId); + formData.append('feed_id', feedId); + + await fetch('api_v1_rss.php', { method: 'POST', body: formData }); + loadRssFeeds(); + } + }); + // Clear Channel History const clearHistoryBtn = document.getElementById('clear-channel-history-btn'); clearHistoryBtn?.addEventListener('click', async () => { @@ -624,33 +770,47 @@ document.addEventListener('DOMContentLoaded', () => { const rolesTabBtn = document.getElementById('roles-tab-btn'); const rolesList = document.getElementById('roles-list'); const addRoleBtn = document.getElementById('add-role-btn'); + const membersTabBtn = document.getElementById('members-tab-btn'); + const membersList = document.getElementById('server-members-list'); const activeServerId = new URLSearchParams(window.location.search).get('server_id') || 1; + let serverRoles = []; + let serverPermissions = []; + rolesTabBtn?.addEventListener('click', loadRoles); + membersTabBtn?.addEventListener('click', loadRoles); // Both tabs need roles data async function loadRoles() { - rolesList.innerHTML = '
Loading roles...
'; + if (rolesList) rolesList.innerHTML = '
Loading...
'; + if (membersList) membersList.innerHTML = '
Loading...
'; + try { const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`); const data = await resp.json(); if (data.success) { - renderRoles(data.roles); + serverRoles = data.roles; + serverPermissions = data.permissions_list; + if (rolesList) renderRoles(data.roles); + if (membersList) renderMembers(data.members); } } catch (e) { console.error(e); } } function renderRoles(roles) { rolesList.innerHTML = ''; + if (roles.length === 0) { + rolesList.innerHTML = '
No roles created yet.
'; + } 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.className = 'list-group-item bg-transparent text-white border-secondary d-flex justify-content-between align-items-center p-2 mb-1 rounded'; item.innerHTML = `
-
- ${role.name} +
+ ${role.name}
- +
`; @@ -658,16 +818,123 @@ document.addEventListener('DOMContentLoaded', () => { }); } - addRoleBtn?.addEventListener('click', async () => { - const name = prompt('Role name:'); - if (!name) return; - const color = prompt('Role color (hex):', '#99aab5'); + function renderMembers(members) { + membersList.innerHTML = ''; + members.forEach(member => { + const memberRoles = member.role_ids ? member.role_ids.split(',') : []; + 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 mb-2 rounded bg-dark'; + + let rolesHtml = ''; + serverRoles.forEach(role => { + const isAssigned = memberRoles.includes(role.id.toString()); + rolesHtml += ` +
+ + +
+ `; + }); + + item.innerHTML = ` +
+
+
+
${member.username}
+
+ ${rolesHtml || 'No roles available'} +
+
+
+ `; + membersList.appendChild(item); + }); + } + + // Role Editing Modal Logic + rolesList?.addEventListener('click', (e) => { + if (e.target.classList.contains('edit-role-btn-v2')) { + const role = e.target.dataset; + document.getElementById('edit-role-id').value = role.id; + document.getElementById('edit-role-name').value = role.name; + document.getElementById('edit-role-color').value = role.color; + + const permsContainer = document.getElementById('role-permissions-checkboxes'); + permsContainer.innerHTML = ''; + const currentPerms = parseInt(role.perms); + + serverPermissions.forEach(p => { + const isChecked = (currentPerms & p.value) === p.value; + permsContainer.innerHTML += ` +
+ + +
+ `; + }); + + const modal = new bootstrap.Modal(document.getElementById('roleEditorModal')); + modal.show(); + } + }); + + document.getElementById('save-role-btn')?.addEventListener('click', async () => { + const id = document.getElementById('edit-role-id').value; + const name = document.getElementById('edit-role-name').value; + const color = document.getElementById('edit-role-color').value; + + let permissions = 0; + document.querySelectorAll('.perm-check:checked').forEach(cb => { + permissions |= parseInt(cb.value); + }); 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 }) + body: JSON.stringify({ action: 'update', server_id: activeServerId, id, name, color, permissions }) + }); + const data = await resp.json(); + if (data.success) { + bootstrap.Modal.getInstance(document.getElementById('roleEditorModal')).hide(); + loadRoles(); + } + } catch (e) { console.error(e); } + }); + + membersList?.addEventListener('change', async (e) => { + if (e.target.classList.contains('role-assign-check')) { + const userId = e.target.dataset.userId; + const roleId = e.target.dataset.roleId; + const action = e.target.checked ? 'assign' : 'unassign'; + + try { + const resp = await fetch('api_v1_roles.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, server_id: activeServerId, user_id: userId, role_id: roleId }) + }); + const data = await resp.json(); + if (!data.success) { + alert(data.error || 'Failed to update role'); + e.target.checked = !e.target.checked; + } + } catch (e) { console.error(e); } + } + }); + + addRoleBtn?.addEventListener('click', async () => { + const name = prompt('Role name:'); + if (!name) return; + const color = '#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, permissions: 0 }) }); if ((await resp.json()).success) loadRoles(); } catch (e) { console.error(e); } @@ -678,23 +945,9 @@ document.addEventListener('DOMContentLoaded', () => { if (!confirm('Delete this role?')) return; const roleId = e.target.dataset.id; const resp = await fetch('api_v1_roles.php', { - method: 'DELETE', + method: 'POST', 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 }) + body: JSON.stringify({ action: 'delete', server_id: activeServerId, id: roleId }) }); if ((await resp.json()).success) loadRoles(); } @@ -854,6 +1107,216 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // Forum: New Thread + const newThreadBtn = document.getElementById('new-thread-btn'); + const newThreadModal = document.getElementById('newThreadModal') ? new bootstrap.Modal(document.getElementById('newThreadModal')) : null; + let selectedTagIds = []; + + newThreadBtn?.addEventListener('click', async () => { + if (!newThreadModal) return; + + // Load tags for this channel + const tagsList = document.getElementById('new-thread-tags-list'); + tagsList.innerHTML = '
Loading tags...
'; + selectedTagIds = []; + + try { + const resp = await fetch(`api_v1_tags.php?channel_id=${currentChannel}`); + const data = await resp.json(); + tagsList.innerHTML = ''; + if (data.success && data.tags.length > 0) { + data.tags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'badge rounded-pill p-2 border border-secondary'; + span.style.cursor = 'pointer'; + span.style.backgroundColor = 'transparent'; + span.dataset.id = tag.id; + span.dataset.color = tag.color; + span.textContent = tag.name; + span.onclick = () => { + if (selectedTagIds.includes(tag.id)) { + selectedTagIds = selectedTagIds.filter(id => id !== tag.id); + span.style.backgroundColor = 'transparent'; + } else { + selectedTagIds.push(tag.id); + span.style.backgroundColor = tag.color; + } + }; + tagsList.appendChild(span); + }); + } else { + tagsList.innerHTML = '
No tags available.
'; + } + } catch (e) { console.error(e); } + + newThreadModal.show(); + }); + + document.getElementById('submit-new-thread-btn')?.addEventListener('click', async () => { + const title = document.getElementById('new-thread-title').value.trim(); + if (!title) return; + + try { + const formData = new FormData(); + formData.append('channel_id', currentChannel); + formData.append('title', title); + formData.append('tag_ids', selectedTagIds.join(',')); + const resp = await fetch('api_v1_threads.php', { method: 'POST', body: formData }); + const result = await resp.json(); + if (result.success) { + window.location.href = `?server_id=${activeServerId}&channel_id=${currentChannel}&thread_id=${result.thread_id}`; + } else { + alert(result.error || 'Failed to create thread'); + } + } catch (e) { console.error(e); } + }); + + // Forum: Mark as Solution + document.addEventListener('click', async (e) => { + const solBtn = e.target.closest('.action-btn.mark-solution'); + if (solBtn) { + const threadId = solBtn.dataset.threadId; + const messageId = solBtn.classList.contains('active') ? null : solBtn.dataset.messageId; + + try { + const resp = await fetch('api_v1_threads.php?action=solve', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ thread_id: threadId, message_id: messageId }) + }); + const result = await resp.json(); + if (result.success) { + location.reload(); + } else { + alert(result.error || 'Failed to update solution'); + } + } catch (e) { console.error(e); } + } + }); + + // Forum: Manage Tags + const manageTagsBtn = document.getElementById('manage-tags-btn'); + const manageTagsModal = document.getElementById('manageTagsModal') ? new bootstrap.Modal(document.getElementById('manageTagsModal')) : null; + + manageTagsBtn?.addEventListener('click', async () => { + if (!manageTagsModal) return; + loadForumAdminTags(); + manageTagsModal.show(); + }); + + async function loadForumAdminTags() { + const list = document.getElementById('forum-tags-admin-list'); + list.innerHTML = '
Loading tags...
'; + try { + const resp = await fetch(`api_v1_tags.php?channel_id=${currentChannel}`); + const data = await resp.json(); + list.innerHTML = ''; + if (data.success && data.tags.length > 0) { + data.tags.forEach(tag => { + const div = document.createElement('div'); + div.className = 'd-flex justify-content-between align-items-center mb-2 p-2 bg-dark rounded'; + div.innerHTML = ` +
+
+ ${tag.name} +
+ + `; + list.appendChild(div); + }); + } else { + list.innerHTML = '
No tags created yet.
'; + } + } catch (e) { console.error(e); } + } + + document.getElementById('add-forum-tag-btn')?.addEventListener('click', async () => { + const name = document.getElementById('new-tag-name').value.trim(); + const color = document.getElementById('new-tag-color').value; + if (!name) return; + + try { + const resp = await fetch('api_v1_tags.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'create', channel_id: currentChannel, name, color }) + }); + if ((await resp.json()).success) { + document.getElementById('new-tag-name').value = ''; + loadForumAdminTags(); + } + } catch (e) { console.error(e); } + }); + + document.getElementById('forum-tags-admin-list')?.addEventListener('click', async (e) => { + if (e.target.classList.contains('delete-forum-tag-btn')) { + const tagId = e.target.dataset.id; + try { + const resp = await fetch('api_v1_tags.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete', channel_id: currentChannel, tag_id: tagId }) + }); + if ((await resp.json()).success) loadForumAdminTags(); + } catch (e) { console.error(e); } + } + }); + + // Rules: Add Rule + const addRuleBtn = document.getElementById('add-rule-btn'); + addRuleBtn?.addEventListener('click', async () => { + const content = prompt('Rule Content:'); + if (!content) return; + + try { + const formData = new FormData(); + formData.append('channel_id', currentChannel); + formData.append('content', content); + const resp = await fetch('api_v1_rules.php', { method: 'POST', body: formData }); + const result = await resp.json(); + if (result.success) { + location.reload(); + } else { + alert(result.error || 'Failed to add rule'); + } + } catch (e) { console.error(e); } + }); + + // Rules: Delete/Edit + document.addEventListener('click', async (e) => { + if (e.target.classList.contains('delete-rule-btn')) { + if (!confirm('Delete this rule?')) return; + const id = e.target.dataset.id; + const resp = await fetch(`api_v1_rules.php?id=${id}`, { method: 'DELETE' }); + if ((await resp.json()).success) location.reload(); + } + if (e.target.classList.contains('edit-rule-btn')) { + const id = e.target.dataset.id; + const oldContent = e.target.closest('.rule-item').querySelector('.rule-content').innerText; + const newContent = prompt('Edit Rule:', oldContent); + if (!newContent || newContent === oldContent) return; + + const resp = await fetch('api_v1_rules.php', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, content: newContent }) + }); + if ((await resp.json()).success) location.reload(); + } + }); + + // Channel Selection Type + const addChannelBtns = document.querySelectorAll('.add-channel-btn'); + addChannelBtns.forEach(btn => { + btn.addEventListener('click', () => { + const type = btn.dataset.type; + const select = document.getElementById('channel-type-select'); + if (select) { + select.value = type === 'voice' ? 'voice' : 'chat'; + } + }); + }); + // User Settings - Avatar Search const avatarSearchBtn = document.getElementById('search-avatar-btn'); const avatarSearchQuery = document.getElementById('avatar-search-query'); @@ -946,8 +1409,7 @@ document.addEventListener('DOMContentLoaded', () => { } const isMe = msg.user_id == window.currentUserId || msg.username == window.currentUsername; - // Check if user is server owner (could be passed in window object) - const isOwner = window.isServerOwner || false; + const hasManageRights = window.canManageChannels || window.isServerOwner || false; const pinHtml = ` @@ -955,7 +1417,7 @@ document.addEventListener('DOMContentLoaded', () => { `; - const actionsHtml = (isMe || isOwner) ? ` + const actionsHtml = (isMe || hasManageRights) ? `
${pinHtml} ${isMe ? ` @@ -982,10 +1444,19 @@ document.addEventListener('DOMContentLoaded', () => { } if (msg.is_pinned) div.classList.add('pinned'); + const ytRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/; + const ytMatch = msg.content.match(ytRegex); + let ytHtml = ''; + if (ytMatch && ytMatch[1]) { + ytHtml = `
`; + } + + const authorStyle = msg.role_color ? `color: ${msg.role_color};` : ''; + div.innerHTML = `
-
+
${escapeHTML(msg.username)} ${msg.time} ${pinnedBadge} @@ -994,7 +1465,8 @@ document.addEventListener('DOMContentLoaded', () => {
${escapeHTML(msg.content).replace(/\n/g, '
').replace(mentionRegex, `@${window.currentUsername}`)} ${attachmentHtml} - ${embedHtml} + ${ytHtml} + ${ytHtml ? '' : embedHtml}
+ diff --git a/db/migrations/20260215_channel_status.sql b/db/migrations/20260215_channel_status.sql new file mode 100644 index 0000000..954a31a --- /dev/null +++ b/db/migrations/20260215_channel_status.sql @@ -0,0 +1,2 @@ +-- Migration: Add status to channels for voice channels +ALTER TABLE channels ADD COLUMN status VARCHAR(255) DEFAULT NULL; diff --git a/db/migrations/20260215_forum_solution.sql b/db/migrations/20260215_forum_solution.sql new file mode 100644 index 0000000..ba4a5ec --- /dev/null +++ b/db/migrations/20260215_forum_solution.sql @@ -0,0 +1,3 @@ +-- Add solution support to forum threads +ALTER TABLE forum_threads ADD COLUMN solution_message_id INT NULL DEFAULT NULL; +ALTER TABLE forum_threads ADD FOREIGN KEY (solution_message_id) REFERENCES messages(id) ON DELETE SET NULL; diff --git a/db/migrations/20260215_forum_tags.sql b/db/migrations/20260215_forum_tags.sql new file mode 100644 index 0000000..c7881f4 --- /dev/null +++ b/db/migrations/20260215_forum_tags.sql @@ -0,0 +1,16 @@ +-- Add forum tags support +CREATE TABLE IF NOT EXISTS forum_tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + channel_id INT NOT NULL, + name VARCHAR(50) NOT NULL, + color VARCHAR(20) DEFAULT '#7289da', + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS thread_tags ( + thread_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (thread_id, tag_id), + FOREIGN KEY (thread_id) REFERENCES forum_threads(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES forum_tags(id) ON DELETE CASCADE +); diff --git a/db/migrations/20260215_rss_feeds.sql b/db/migrations/20260215_rss_feeds.sql new file mode 100644 index 0000000..73a4c5d --- /dev/null +++ b/db/migrations/20260215_rss_feeds.sql @@ -0,0 +1,12 @@ +-- Migration: Add RSS support for announcement channels +CREATE TABLE IF NOT EXISTS channel_rss_feeds ( + id INT AUTO_INCREMENT PRIMARY KEY, + channel_id INT NOT NULL, + url VARCHAR(255) NOT NULL, + last_fetched_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE +); + +ALTER TABLE messages ADD COLUMN IF NOT EXISTS rss_guid VARCHAR(255) NULL; +CREATE INDEX IF NOT EXISTS idx_rss_guid ON messages(rss_guid); diff --git a/index.php b/index.php index 1410983..ca8469c 100644 --- a/index.php +++ b/index.php @@ -67,7 +67,7 @@ if ($is_dm_view) { $stmt->execute([$active_server_id]); $channels = $stmt->fetchAll(); $active_channel_id = $_GET['channel_id'] ?? ($channels[0]['id'] ?? 1); - + // Fetch active channel details for theme $active_channel = null; foreach($channels as $c) { @@ -76,32 +76,99 @@ if ($is_dm_view) { break; } } + + require_once 'includes/permissions.php'; + $is_owner = false; + $can_manage_channels = false; + $can_manage_server = false; + foreach($servers as $s) { + if($s['id'] == $active_server_id) { + $is_owner = ($s['owner_id'] == $current_user_id); + $can_manage_channels = Permissions::hasPermission($current_user_id, $active_server_id, Permissions::MANAGE_CHANNELS) || $is_owner; + $can_manage_server = Permissions::hasPermission($current_user_id, $active_server_id, Permissions::MANAGE_SERVER) || Permissions::hasPermission($current_user_id, $active_server_id, Permissions::ADMINISTRATOR) || $is_owner; + break; + } + } + $channel_theme = $active_channel['theme_color'] ?? null; - // Fetch messages - $display_limit = !empty($active_channel['message_limit']) ? (int)$active_channel['message_limit'] : 50; - $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 " . $display_limit . " - "); - $stmt->execute([$active_channel_id]); - $messages = $stmt->fetchAll(); + $channel_type = $active_channel['type'] ?? 'chat'; + $active_thread_id = $_GET['thread_id'] ?? null; + $active_thread = null; + + if ($active_thread_id) { + $stmt = db()->prepare("SELECT t.*, u.username FROM forum_threads t JOIN users u ON t.user_id = u.id WHERE t.id = ?"); + $stmt->execute([$active_thread_id]); + $active_thread = $stmt->fetch(); + + if ($active_thread) { + $stmt = db()->prepare(" + SELECT m.*, u.username, u.avatar_url, + (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.thread_id = ? + ORDER BY m.created_at ASC + "); + $stmt->execute([$active_server_id, $active_thread_id]); + $messages = $stmt->fetchAll(); + } + } + + if (!$active_thread && $channel_type === 'rules') { + $stmt = db()->prepare("SELECT * FROM channel_rules WHERE channel_id = ? ORDER BY position ASC"); + $stmt->execute([$active_channel_id]); + $rules = $stmt->fetchAll(); + } elseif ($channel_type === 'forum') { + $filter_status = $_GET['status'] ?? 'all'; + $status_where = ""; + if ($filter_status === 'resolved') { + $status_where = " AND t.solution_message_id IS NOT NULL"; + } elseif ($filter_status === 'unresolved') { + $status_where = " AND t.solution_message_id IS NULL"; + } + + $stmt = db()->prepare(" + SELECT t.*, u.username, u.avatar_url, + (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) as message_count, + (SELECT MAX(created_at) FROM messages m WHERE m.thread_id = t.id) as last_activity, + (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color, + (SELECT GROUP_CONCAT(CONCAT(ft.name, ':', ft.color) SEPARATOR '|') FROM thread_tags tt JOIN forum_tags ft ON tt.tag_id = ft.id WHERE tt.thread_id = t.id) as tags + FROM forum_threads t + JOIN users u ON t.user_id = u.id + WHERE t.channel_id = ? " . $status_where . " + ORDER BY last_activity DESC, t.created_at DESC + "); + $stmt->execute([$active_server_id, $active_channel_id]); + $threads = $stmt->fetchAll(); + } else { + // Fetch messages + $display_limit = !empty($active_channel['message_limit']) ? (int)$active_channel['message_limit'] : 50; + $stmt = db()->prepare(" + SELECT m.*, u.username, u.avatar_url, + (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? + ORDER BY m.created_at ASC + LIMIT " . $display_limit . " + "); + $stmt->execute([$active_server_id, $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']; // Fetch members $stmt = db()->prepare(" - SELECT u.id, u.username, u.avatar_url, u.status + SELECT u.id, u.username, u.avatar_url, u.status, + (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color FROM users u JOIN server_members sm ON u.id = sm.user_id WHERE sm.server_id = ? "); - $stmt->execute([$active_server_id]); + $stmt->execute([$active_server_id, $active_server_id]); $members = $stmt->fetchAll(); } @@ -129,10 +196,14 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';