Commit 98d9abef authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: social features — groups, block/mute, profile view, spectate

Full end-to-end implementation with DB, API, frontend, and realtime sync:

- Block/Mute: player_blocks table, block checks in chat/matchmaking/invites,
  blocked-list UI in settings, block/mute buttons in opponent menu
- View Other Player's Profile: friendship_status, block_status, action buttons
  (add friend/challenge/message/block), spectate live indicator
- Groups: create/join/leave, group chat with realtime, game invites with
  accept flow, member management (add/remove), notifications
- Spectate: find-active-match API, chess spectate scene with live board
  updates, tournament spectate buttons, profile "watching live" indicator

DB migrations: groups + group_members tables, chat_messages columns added.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 6ba0c8c9
......@@ -137,6 +137,16 @@ if ($method === 'POST') {
if (!$content) jsonError('content required');
if (mb_strlen($content) > 500) jsonError('Message too long (max 500 chars)');
// Check blocks in both directions
$blocks = $sdb->get('player_blocks', [
'or' => "(and(player_id.eq.{$userId},blocked_id.eq.{$friendId}),and(player_id.eq.{$friendId},blocked_id.eq.{$userId}))",
'type' => 'eq.block',
'limit' => 1
]);
if (is_array($blocks) && !isset($blocks['error']) && !empty($blocks)) {
jsonError('Cannot send message to this player');
}
$friendship = findFriendship($sdb, $userId, $friendId);
if (!$friendship) jsonError('Not friends');
......
......@@ -93,6 +93,30 @@ if ($method === 'GET') {
jsonResponse(['profiles' => is_array($profiles) && !isset($profiles['error']) ? $profiles : []]);
}
if ($action === 'blocked-list') {
$blocks = $sdb->get('player_blocks', [
'player_id' => 'eq.' . $userId,
'select' => 'id,blocked_id,type,created_at',
'order' => 'created_at.desc'
]);
$items = is_array($blocks) && !isset($blocks['error']) ? $blocks : [];
if (!empty($items)) {
$blockedIds = implode(',', array_column($items, 'blocked_id'));
$profiles = $sdb->get('profiles', [
'id' => "in.({$blockedIds})",
'select' => 'id,username,display_name,avatar_url'
]);
$profileMap = [];
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $p) $profileMap[$p['id']] = $p;
}
foreach ($items as &$item) {
$item['profile'] = $profileMap[$item['blocked_id']] ?? null;
}
}
jsonResponse(['blocked' => $items]);
}
jsonError('Invalid action');
}
......@@ -105,6 +129,16 @@ if ($method === 'POST') {
if (!$targetId) jsonError('target_id required');
if ($targetId === $userId) jsonError('Cannot add yourself');
// Check if either user blocked the other
$blocked = $sdb->get('player_blocks', [
'or' => "(and(player_id.eq.{$userId},blocked_id.eq.{$targetId}),and(player_id.eq.{$targetId},blocked_id.eq.{$userId}))",
'type' => 'eq.block',
'limit' => 1
]);
if (is_array($blocked) && !isset($blocked['error']) && !empty($blocked)) {
jsonError('Cannot send request to this player');
}
// Check for existing friendship or pending request in EITHER direction
$existing = $sdb->get('friendships', [
'or' => "(and(requester_id.eq.{$userId},addressee_id.eq.{$targetId}),and(requester_id.eq.{$targetId},addressee_id.eq.{$userId}))",
......@@ -188,7 +222,77 @@ if ($method === 'POST') {
$targetId = $input['target_id'] ?? '';
$reason = $input['reason'] ?? 'other';
if (!$targetId) jsonError('target_id required');
// Just acknowledge — in future, store reports in a table
$sdb->insert('cheat_reports', [
'reported_player_id' => $targetId,
'reported_by' => $userId,
'reason' => $reason,
'description' => $input['description'] ?? '',
'status' => 'pending'
]);
jsonResponse(['success' => true]);
}
if ($action === 'block') {
$targetId = $input['target_id'] ?? '';
if (!$targetId) jsonError('target_id required');
if ($targetId === $userId) jsonError('Cannot block yourself');
$sdb->insert('player_blocks', [
'player_id' => $userId,
'blocked_id' => $targetId,
'type' => 'block'
]);
// Remove friendship if exists
$sdb->delete('friendships', [
'or' => "(and(requester_id.eq.{$userId},addressee_id.eq.{$targetId}),and(requester_id.eq.{$targetId},addressee_id.eq.{$userId}))"
]);
jsonResponse(['success' => true]);
}
if ($action === 'unblock') {
$targetId = $input['target_id'] ?? '';
if (!$targetId) jsonError('target_id required');
$sdb->delete('player_blocks', [
'player_id' => 'eq.' . $userId,
'blocked_id' => 'eq.' . $targetId
]);
jsonResponse(['success' => true]);
}
if ($action === 'mute') {
$targetId = $input['target_id'] ?? '';
if (!$targetId) jsonError('target_id required');
if ($targetId === $userId) jsonError('Cannot mute yourself');
// Upsert: change existing block to mute, or insert new mute
$existing = $sdb->get('player_blocks', [
'player_id' => 'eq.' . $userId,
'blocked_id' => 'eq.' . $targetId,
'limit' => 1
]);
if (!empty($existing) && !isset($existing['error']) && !empty($existing[0])) {
$sdb->update('player_blocks', ['type' => 'mute'], [
'player_id' => 'eq.' . $userId,
'blocked_id' => 'eq.' . $targetId
]);
} else {
$sdb->insert('player_blocks', [
'player_id' => $userId,
'blocked_id' => $targetId,
'type' => 'mute'
]);
}
jsonResponse(['success' => true]);
}
if ($action === 'unmute') {
$targetId = $input['target_id'] ?? '';
if (!$targetId) jsonError('target_id required');
$sdb->delete('player_blocks', [
'player_id' => 'eq.' . $userId,
'blocked_id' => 'eq.' . $targetId,
'type' => 'eq.mute'
]);
jsonResponse(['success' => true]);
}
......@@ -198,6 +302,16 @@ if ($method === 'POST') {
$timeControl = $input['time_control'] ?? 'rapid_10_0';
if (!$targetId) jsonError('target_id required');
// Check blocks
$blocked = $sdb->get('player_blocks', [
'or' => "(and(player_id.eq.{$userId},blocked_id.eq.{$targetId}),and(player_id.eq.{$targetId},blocked_id.eq.{$userId}))",
'type' => 'eq.block',
'limit' => 1
]);
if (is_array($blocked) && !isset($blocked['error']) && !empty($blocked)) {
jsonError('Cannot invite this player');
}
if ($gameKey === 'domino') {
$players = [$userId, $targetId];
$match = $sdb->insert('domino_matches', [
......@@ -288,6 +402,17 @@ if ($method === 'POST') {
$result = [];
$now = time();
// Get blocked user IDs to filter invites
$blocksData = $sdb->get('player_blocks', [
'player_id' => 'eq.' . $userId,
'type' => 'eq.block',
'select' => 'blocked_id'
]);
$blockedIds = [];
if (is_array($blocksData) && !isset($blocksData['error'])) {
$blockedIds = array_column($blocksData, 'blocked_id');
}
// Check chess matches table
$invites = $sdb->get('matches', [
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
......@@ -305,11 +430,13 @@ if ($method === 'POST') {
$inviteT = $gs['invite_t'] ?? 0;
if ($inviteTo !== $userId) continue;
if ($now - $inviteT > 120) continue;
$fromId = $gs['invite_from'] ?? null;
if ($fromId && in_array($fromId, $blockedIds)) continue;
$result[] = [
'match_id' => $inv['id'],
'game_key' => $inv['game_key'] ?? 'chess',
'time_control' => $inv['time_control'] ?? 'rapid_10_0',
'from_id' => $gs['invite_from'] ?? null,
'from_id' => $fromId,
'created_at' => $inv['created_at']
];
}
......@@ -332,11 +459,13 @@ if ($method === 'POST') {
$inviteT = $gs['invite_t'] ?? 0;
if ($inviteTo !== $userId) continue;
if ($now - $inviteT > 120) continue;
$fromId = $gs['invite_from'] ?? null;
if ($fromId && in_array($fromId, $blockedIds)) continue;
$result[] = [
'match_id' => $inv['id'],
'game_key' => 'domino',
'time_control' => 'standard',
'from_id' => $gs['invite_from'] ?? null,
'from_id' => $fromId,
'created_at' => $inv['created_at']
];
}
......@@ -359,11 +488,13 @@ if ($method === 'POST') {
$inviteT = $gs['invite_t'] ?? 0;
if ($inviteTo !== $userId) continue;
if ($now - $inviteT > 120) continue;
$fromId = $gs['invite_from'] ?? null;
if ($fromId && in_array($fromId, $blockedIds)) continue;
$result[] = [
'match_id' => $inv['id'],
'game_key' => 'ludo',
'time_control' => 'standard',
'from_id' => $gs['invite_from'] ?? null,
'from_id' => $fromId,
'created_at' => $inv['created_at']
];
}
......
......@@ -36,6 +36,9 @@ switch ($action) {
case 'get':
handleGet($db, $userId, $input);
break;
case 'find-active-match':
handleFindActiveMatch($userId, $input);
break;
default:
jsonError('Invalid action');
}
......@@ -382,3 +385,59 @@ function checkGameAchievements($sdb, string $userId, array $profileUpdates, int
}
}
}
function handleFindActiveMatch(string $userId, array $input): void {
$playerId = $input['player_id'] ?? ($_GET['player_id'] ?? '');
if (!$playerId) jsonError('player_id required');
$sdb = supabaseService();
// Check chess matches
$chess = $sdb->get('matches', [
'or' => "(white_player_id.eq.{$playerId},black_player_id.eq.{$playerId})",
'status' => 'eq.in_progress',
'select' => 'id,game_key,white_player_id,black_player_id',
'order' => 'started_at.desc',
'limit' => 1
]);
if (is_array($chess) && !isset($chess['error']) && !empty($chess)) {
jsonResponse([
'match_id' => $chess[0]['id'],
'game_key' => $chess[0]['game_key'] ?? 'chess',
'white_player_id' => $chess[0]['white_player_id'],
'black_player_id' => $chess[0]['black_player_id']
]);
}
// Check ludo matches
$ludo = $sdb->get('ludo_matches', [
'status' => 'eq.in_progress',
'select' => 'id,players',
'order' => 'started_at.desc',
'limit' => 10
]);
if (is_array($ludo) && !isset($ludo['error'])) {
foreach ($ludo as $m) {
$players = is_string($m['players']) ? json_decode($m['players'], true) : ($m['players'] ?? []);
foreach ($players as $p) {
if (($p['id'] ?? '') === $playerId) {
jsonResponse(['match_id' => $m['id'], 'game_key' => 'ludo']);
}
}
}
}
// Check domino matches
$domino = $sdb->get('domino_matches', [
'or' => "(player1_id.eq.{$playerId},player2_id.eq.{$playerId})",
'status' => 'eq.in_progress',
'select' => 'id',
'order' => 'created_at.desc',
'limit' => 1
]);
if (is_array($domino) && !isset($domino['error']) && !empty($domino)) {
jsonResponse(['match_id' => $domino[0]['id'], 'game_key' => 'domino']);
}
jsonResponse(['match_id' => null]);
}
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$method = $_SERVER['REQUEST_METHOD'];
$sdb = supabaseService();
if ($method === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'list') {
$memberships = $sdb->get('group_members', [
'user_id' => 'eq.' . $userId,
'select' => 'group_id,role,joined_at,groups(id,name,avatar_url,owner_id,member_count,created_at)'
]);
if (!is_array($memberships) || isset($memberships['error'])) {
jsonResponse(['groups' => []]);
}
$groups = array_map(function($m) {
$g = $m['groups'] ?? [];
$g['my_role'] = $m['role'];
return $g;
}, $memberships);
jsonResponse(['groups' => $groups]);
}
if ($action === 'detail') {
$groupId = $_GET['group_id'] ?? '';
if (!$groupId) jsonError('group_id required');
// Verify membership
$membership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'limit' => 1
]);
if (!is_array($membership) || isset($membership['error']) || empty($membership)) {
jsonError('Not a member of this group', 403);
}
$group = $sdb->get('groups', ['id' => 'eq.' . $groupId, 'select' => '*', 'limit' => 1]);
if (!is_array($group) || isset($group['error']) || empty($group)) {
jsonError('Group not found', 404);
}
$members = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'select' => 'user_id,role,joined_at,profiles(id,display_name,avatar_url,is_online)'
]);
jsonResponse([
'group' => $group[0],
'members' => is_array($members) && !isset($members['error']) ? $members : [],
'my_role' => $membership[0]['role']
]);
}
if ($action === 'chat-history') {
$groupId = $_GET['group_id'] ?? '';
if (!$groupId) jsonError('group_id required');
// Verify membership
$membership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'limit' => 1
]);
if (!is_array($membership) || isset($membership['error']) || empty($membership)) {
jsonError('Not a member', 403);
}
$limit = min(intval($_GET['limit'] ?? 50), 100);
$before = $_GET['before'] ?? null;
$params = [
'channel_type' => 'eq.group',
'channel_id' => 'eq.' . $groupId,
'select' => 'id,sender_id,content,message_type,metadata,created_at,profiles(display_name,avatar_url)',
'order' => 'created_at.desc',
'limit' => $limit
];
if ($before) $params['created_at'] = 'lt.' . $before;
$messages = $sdb->get('chat_messages', $params);
if (!is_array($messages) || isset($messages['error'])) {
jsonResponse(['messages' => []]);
}
// Filter muted users client-side info
$blocks = $sdb->get('player_blocks', [
'player_id' => 'eq.' . $userId,
'type' => 'eq.mute',
'select' => 'blocked_id'
]);
$mutedIds = [];
if (is_array($blocks) && !isset($blocks['error'])) {
$mutedIds = array_column($blocks, 'blocked_id');
}
jsonResponse([
'messages' => array_reverse($messages),
'muted_ids' => $mutedIds
]);
}
if ($action === 'check-group-invites') {
$groupId = $_GET['group_id'] ?? '';
if (!$groupId) jsonError('group_id required');
// Look for waiting matches with group_invite in game_state
$matches = $sdb->get('matches', [
'status' => 'eq.waiting',
'select' => 'id,game_key,white_player_id,game_state,created_at',
'order' => 'created_at.desc',
'limit' => 10
]);
$invites = [];
$now = time();
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
$gs = is_string($m['game_state']) ? json_decode($m['game_state'], true) : ($m['game_state'] ?? []);
if (($gs['group_id'] ?? '') !== $groupId) continue;
$createdAt = strtotime($m['created_at']);
if ($now - $createdAt > 120) continue;
$invites[] = [
'match_id' => $m['id'],
'game_key' => $m['game_key'],
'inviter_id' => $m['white_player_id'],
'required_players' => $gs['required_players'] ?? 2,
'accepted' => $gs['accepted'] ?? [],
'created_at' => $m['created_at']
];
}
}
// Also check ludo_matches
$ludoMatches = $sdb->get('ludo_matches', [
'status' => 'eq.waiting',
'select' => 'id,game_state,created_at',
'order' => 'created_at.desc',
'limit' => 10
]);
if (is_array($ludoMatches) && !isset($ludoMatches['error'])) {
foreach ($ludoMatches as $m) {
$gs = is_string($m['game_state']) ? json_decode($m['game_state'], true) : ($m['game_state'] ?? []);
if (($gs['group_id'] ?? '') !== $groupId) continue;
$createdAt = strtotime($m['created_at']);
if ($now - $createdAt > 120) continue;
$invites[] = [
'match_id' => $m['id'],
'game_key' => 'ludo',
'inviter_id' => $gs['accepted'][0] ?? null,
'required_players' => $gs['required_players'] ?? 2,
'accepted' => $gs['accepted'] ?? [],
'created_at' => $m['created_at']
];
}
}
jsonResponse(['invites' => $invites]);
}
jsonError('Invalid action');
}
if ($method === 'POST') {
$input = getInput();
$action = $input['action'] ?? '';
if ($action === 'create') {
$name = trim($input['name'] ?? '');
$memberIds = $input['member_ids'] ?? [];
if (!$name || mb_strlen($name) < 2 || mb_strlen($name) > 50) {
jsonError('Group name must be 2-50 characters');
}
if (count($memberIds) > 19) jsonError('Maximum 19 initial members (20 including you)');
// Filter out blocked users
$blocks = $sdb->get('player_blocks', [
'or' => "(player_id.eq.{$userId},blocked_id.eq.{$userId})",
'type' => 'eq.block',
'select' => 'player_id,blocked_id'
]);
$blockedIds = [];
if (is_array($blocks) && !isset($blocks['error'])) {
foreach ($blocks as $b) {
$blockedIds[] = ($b['player_id'] === $userId) ? $b['blocked_id'] : $b['player_id'];
}
}
$memberIds = array_values(array_filter($memberIds, fn($id) => !in_array($id, $blockedIds)));
// Create group
$group = $sdb->insert('groups', [
'name' => $name,
'owner_id' => $userId,
'member_count' => 1 + count($memberIds)
]);
if (isset($group['error'])) jsonError($group['error']);
$groupId = $group[0]['id'] ?? $group['id'] ?? null;
if (!$groupId) jsonError('Failed to create group');
// Add owner
$sdb->insert('group_members', [
'group_id' => $groupId,
'user_id' => $userId,
'role' => 'owner'
]);
// Add initial members
foreach ($memberIds as $memberId) {
$sdb->insert('group_members', [
'group_id' => $groupId,
'user_id' => $memberId,
'role' => 'member'
]);
// Notify
$sdb->insert('notifications', [
'user_id' => $memberId,
'type' => 'group_added',
'title' => 'تمت إضافتك لمجموعة ' . $name,
'title_ar' => 'تمت إضافتك لمجموعة ' . $name,
'body' => 'You were added to group ' . $name,
'data' => json_encode(['group_id' => $groupId])
]);
}
jsonResponse(['group' => $group[0] ?? $group, 'success' => true]);
}
if ($action === 'add-member') {
$groupId = $input['group_id'] ?? '';
$targetId = $input['user_id'] ?? '';
if (!$groupId || !$targetId) jsonError('group_id and user_id required');
// Check caller is owner/admin
$myMembership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'select' => 'role',
'limit' => 1
]);
if (!is_array($myMembership) || empty($myMembership) || !in_array($myMembership[0]['role'], ['owner', 'admin'])) {
jsonError('Only owner/admin can add members', 403);
}
// Check blocks
$blocked = $sdb->get('player_blocks', [
'or' => "(and(player_id.eq.{$targetId},blocked_id.eq.{$userId}),and(player_id.eq.{$userId},blocked_id.eq.{$targetId}))",
'type' => 'eq.block',
'limit' => 1
]);
if (is_array($blocked) && !isset($blocked['error']) && !empty($blocked)) {
jsonError('Cannot add this player');
}
// Check not already member
$existing = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $targetId,
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member');
}
// Check max members
$group = $sdb->get('groups', ['id' => 'eq.' . $groupId, 'select' => 'member_count,max_members,name', 'limit' => 1]);
if (is_array($group) && !empty($group) && $group[0]['member_count'] >= $group[0]['max_members']) {
jsonError('Group is full');
}
$sdb->insert('group_members', [
'group_id' => $groupId,
'user_id' => $targetId,
'role' => 'member'
]);
// Increment member_count
$newCount = ($group[0]['member_count'] ?? 0) + 1;
$sdb->update('groups', ['member_count' => $newCount], ['id' => 'eq.' . $groupId]);
// Notify
$groupName = $group[0]['name'] ?? 'مجموعة';
$sdb->insert('notifications', [
'user_id' => $targetId,
'type' => 'group_added',
'title' => 'تمت إضافتك لمجموعة ' . $groupName,
'title_ar' => 'تمت إضافتك لمجموعة ' . $groupName,
'body' => 'You were added to group ' . $groupName,
'data' => json_encode(['group_id' => $groupId])
]);
jsonResponse(['success' => true]);
}
if ($action === 'remove-member') {
$groupId = $input['group_id'] ?? '';
$targetId = $input['user_id'] ?? '';
if (!$groupId || !$targetId) jsonError('group_id and user_id required');
$myMembership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'select' => 'role',
'limit' => 1
]);
if (!is_array($myMembership) || empty($myMembership) || !in_array($myMembership[0]['role'], ['owner', 'admin'])) {
jsonError('Only owner/admin can remove members', 403);
}
// Can't remove owner
$targetMembership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $targetId,
'select' => 'role',
'limit' => 1
]);
if (is_array($targetMembership) && !empty($targetMembership) && $targetMembership[0]['role'] === 'owner') {
jsonError('Cannot remove group owner');
}
$sdb->delete('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $targetId
]);
// Decrement count
$group = $sdb->get('groups', ['id' => 'eq.' . $groupId, 'select' => 'member_count', 'limit' => 1]);
$newCount = max(1, ($group[0]['member_count'] ?? 1) - 1);
$sdb->update('groups', ['member_count' => $newCount], ['id' => 'eq.' . $groupId]);
jsonResponse(['success' => true]);
}
if ($action === 'leave') {
$groupId = $input['group_id'] ?? '';
if (!$groupId) jsonError('group_id required');
$myMembership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'select' => 'role',
'limit' => 1
]);
if (!is_array($myMembership) || empty($myMembership)) {
jsonError('Not a member');
}
if ($myMembership[0]['role'] === 'owner') {
// Transfer ownership to first admin, or delete group
$admins = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'role' => 'eq.admin',
'user_id' => 'neq.' . $userId,
'select' => 'user_id',
'limit' => 1
]);
if (is_array($admins) && !isset($admins['error']) && !empty($admins)) {
$sdb->update('groups', ['owner_id' => $admins[0]['user_id']], ['id' => 'eq.' . $groupId]);
$sdb->update('group_members', ['role' => 'owner'], [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $admins[0]['user_id']
]);
} else {
// Transfer to first member
$members = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'neq.' . $userId,
'select' => 'user_id',
'order' => 'joined_at.asc',
'limit' => 1
]);
if (is_array($members) && !isset($members['error']) && !empty($members)) {
$sdb->update('groups', ['owner_id' => $members[0]['user_id']], ['id' => 'eq.' . $groupId]);
$sdb->update('group_members', ['role' => 'owner'], [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $members[0]['user_id']
]);
} else {
// No other members — delete group
$sdb->delete('group_members', ['group_id' => 'eq.' . $groupId]);
$sdb->delete('groups', ['id' => 'eq.' . $groupId]);
jsonResponse(['success' => true, 'deleted' => true]);
}
}
}
$sdb->delete('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId
]);
$group = $sdb->get('groups', ['id' => 'eq.' . $groupId, 'select' => 'member_count', 'limit' => 1]);
if (is_array($group) && !empty($group)) {
$newCount = max(0, ($group[0]['member_count'] ?? 1) - 1);
$sdb->update('groups', ['member_count' => $newCount], ['id' => 'eq.' . $groupId]);
}
jsonResponse(['success' => true]);
}
if ($action === 'update') {
$groupId = $input['group_id'] ?? '';
if (!$groupId) jsonError('group_id required');
$myMembership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'select' => 'role',
'limit' => 1
]);
if (!is_array($myMembership) || empty($myMembership) || !in_array($myMembership[0]['role'], ['owner', 'admin'])) {
jsonError('Only owner/admin can update group', 403);
}
$data = [];
if (isset($input['name']) && mb_strlen(trim($input['name'])) >= 2) {
$data['name'] = trim($input['name']);
}
if (isset($input['avatar_url'])) {
$data['avatar_url'] = $input['avatar_url'];
}
if (empty($data)) jsonError('Nothing to update');
$sdb->update('groups', $data, ['id' => 'eq.' . $groupId]);
jsonResponse(['success' => true]);
}
if ($action === 'chat-send') {
$groupId = $input['group_id'] ?? '';
$content = trim($input['content'] ?? '');
$messageType = $input['message_type'] ?? 'text';
if (!$groupId) jsonError('group_id required');
if (!$content) jsonError('content required');
if (mb_strlen($content) > 500) jsonError('Message too long');
// Verify membership
$membership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'limit' => 1
]);
if (!is_array($membership) || isset($membership['error']) || empty($membership)) {
jsonError('Not a member', 403);
}
$msg = $sdb->insert('chat_messages', [
'channel_type' => 'group',
'channel_id' => $groupId,
'sender_id' => $userId,
'content' => $content,
'message_type' => $messageType,
'metadata' => new \stdClass()
]);
if (isset($msg['error'])) jsonError($msg['error']);
jsonResponse(['message' => $msg[0] ?? $msg, 'success' => true]);
}
if ($action === 'invite-game') {
$groupId = $input['group_id'] ?? '';
$gameKey = $input['game_key'] ?? 'chess';
$timeControl = $input['time_control'] ?? 'rapid_10_0';
$requiredPlayers = intval($input['required_players'] ?? 2);
if (!$groupId) jsonError('group_id required');
if ($requiredPlayers < 2 || $requiredPlayers > 4) jsonError('required_players must be 2-4');
// Verify membership
$membership = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'eq.' . $userId,
'limit' => 1
]);
if (!is_array($membership) || isset($membership['error']) || empty($membership)) {
jsonError('Not a member', 403);
}
$gameState = [
'group_id' => $groupId,
'group_invite' => true,
'required_players' => $requiredPlayers,
'accepted' => [$userId],
'invited_at' => gmdate('c')
];
if ($gameKey === 'ludo') {
$match = $sdb->insert('ludo_matches', [
'status' => 'waiting',
'player_count' => $requiredPlayers,
'game_state' => json_encode($gameState),
'players' => json_encode([['id' => $userId, 'color' => 'red']])
]);
} else {
$match = $sdb->insert('matches', [
'game_key' => $gameKey,
'match_type' => 'friendly',
'white_player_id' => $userId,
'status' => 'waiting',
'time_control' => $timeControl,
'game_state' => json_encode($gameState),
'is_rated' => false
]);
}
if (isset($match['error'])) jsonError($match['error']);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
// Notify all group members
$members = $sdb->get('group_members', [
'group_id' => 'eq.' . $groupId,
'user_id' => 'neq.' . $userId,
'select' => 'user_id'
]);
$group = $sdb->get('groups', ['id' => 'eq.' . $groupId, 'select' => 'name', 'limit' => 1]);
$groupName = $group[0]['name'] ?? 'مجموعة';
if (is_array($members) && !isset($members['error'])) {
foreach ($members as $m) {
$sdb->insert('notifications', [
'user_id' => $m['user_id'],
'type' => 'group_game',
'title' => 'دعوة لعب في ' . $groupName,
'title_ar' => 'دعوة لعب في ' . $groupName,
'body' => 'Game invite in ' . $groupName,
'data' => json_encode(['group_id' => $groupId, 'match_id' => $matchId, 'game_key' => $gameKey])
]);
}
}
// Post system message in group chat
$sdb->insert('chat_messages', [
'channel_type' => 'group',
'channel_id' => $groupId,
'sender_id' => $userId,
'content' => '__system:game_invite',
'message_type' => 'system',
'metadata' => json_encode(['game_key' => $gameKey, 'match_id' => $matchId, 'required_players' => $requiredPlayers])
]);
jsonResponse(['match_id' => $matchId, 'success' => true]);
}
if ($action === 'accept-group-invite') {
$matchId = $input['match_id'] ?? '';
$gameKey = $input['game_key'] ?? 'chess';
if (!$matchId) jsonError('match_id required');
$table = $gameKey === 'ludo' ? 'ludo_matches' : 'matches';
$match = $sdb->get($table, ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (!is_array($match) || isset($match['error']) || empty($match)) {
jsonError('Match not found');
}
$match = $match[0];
if ($match['status'] !== 'waiting') jsonError('Game already started or expired');
$gs = is_string($match['game_state']) ? json_decode($match['game_state'], true) : ($match['game_state'] ?? []);
$accepted = $gs['accepted'] ?? [];
$required = $gs['required_players'] ?? 2;
if (in_array($userId, $accepted)) jsonError('Already accepted');
$accepted[] = $userId;
$gs['accepted'] = $accepted;
if (count($accepted) >= $required) {
// Game starts
if ($gameKey === 'ludo') {
$colors = ['red', 'blue', 'green', 'yellow'];
$players = [];
foreach ($accepted as $i => $pid) {
$players[] = ['id' => $pid, 'color' => $colors[$i] ?? 'red'];
}
$sdb->update($table, [
'status' => 'in_progress',
'game_state' => json_encode($gs),
'players' => json_encode($players),
'current_player_index' => 0,
'started_at' => gmdate('c')
], ['id' => 'eq.' . $matchId]);
jsonResponse(['started' => true, 'match_id' => $matchId, 'player_index' => array_search($userId, $accepted)]);
} else {
// Chess/domino — 2 players
$whiteId = $accepted[0];
$blackId = $accepted[1];
$initialTime = 600000;
$tc = $match['time_control'] ?? 'rapid_10_0';
if (strpos($tc, 'bullet') !== false) $initialTime = 60000;
elseif (strpos($tc, 'blitz') !== false) $initialTime = 300000;
$sdb->update($table, [
'status' => 'in_progress',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'game_state' => json_encode($gs),
'initial_time_ms' => $initialTime,
'white_time_remaining_ms' => $initialTime,
'black_time_remaining_ms' => $initialTime,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'move_count' => 0,
'started_at' => gmdate('c')
], ['id' => 'eq.' . $matchId]);
$color = ($userId === $whiteId) ? 'w' : 'b';
jsonResponse(['started' => true, 'match_id' => $matchId, 'color' => $color]);
}
} else {
// Still waiting for more players
$sdb->update($table, [
'game_state' => json_encode($gs)
], ['id' => 'eq.' . $matchId]);
jsonResponse(['started' => false, 'accepted_count' => count($accepted), 'required' => $required]);
}
}
if ($action === 'decline-group-invite') {
$matchId = $input['match_id'] ?? '';
$gameKey = $input['game_key'] ?? 'chess';
if (!$matchId) jsonError('match_id required');
// No-op for now — just acknowledge
jsonResponse(['success' => true]);
}
jsonError('Invalid action');
}
jsonError('Method not allowed', 405);
......@@ -61,13 +61,26 @@ function handleQueue($db, string $userId, array $input): void {
]);
}
// Get block list for this player (both directions)
$blockedIds = [];
$blocks = $sdb->get('player_blocks', [
'or' => "(player_id.eq.{$userId},blocked_id.eq.{$userId})",
'type' => 'eq.block',
'select' => 'player_id,blocked_id'
]);
if (is_array($blocks) && !isset($blocks['error'])) {
foreach ($blocks as $b) {
$blockedIds[] = ($b['player_id'] === $userId) ? $b['blocked_id'] : $b['player_id'];
}
}
// Search for available opponent (service key bypasses RLS — can see ALL waiting players)
// Build URL manually to avoid http_build_query encoding issues
$excludeIds = array_merge([$userId], $blockedIds);
$searchUrl = SUPABASE_REST . '/matchmaking_queue'
. '?game_key=eq.' . urlencode($gameKey)
. '&time_control=eq.' . urlencode($timeControl)
. '&status=eq.waiting'
. '&player_id=neq.' . $userId
. '&player_id=not.in.(' . implode(',', $excludeIds) . ')'
. '&select=id,player_id,rating'
. '&order=queued_at.asc'
. '&limit=1';
......
......@@ -19,6 +19,21 @@ $method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$targetId = $_GET['id'] ?? $userId;
$db = supabase($token);
$sdb = supabaseService();
// If viewing another player's profile, check blocks
if ($targetId !== $userId) {
$blocked = $sdb->get('player_blocks', [
'player_id' => 'eq.' . $targetId,
'blocked_id' => 'eq.' . $userId,
'type' => 'eq.block',
'limit' => 1
]);
if (is_array($blocked) && !isset($blocked['error']) && !empty($blocked)) {
jsonError('Profile not available', 403);
}
}
$profiles = $db->get('profiles', ['id' => 'eq.' . $targetId, 'select' => '*', 'limit' => 1]);
if (!is_array($profiles) || isset($profiles['error']) || empty($profiles)) {
......@@ -30,6 +45,39 @@ if ($method === 'GET') {
$profile['ratings'] = is_array($ratings) && !isset($ratings['error']) ? $ratings : [];
// Add relationship info when viewing another player
if ($targetId !== $userId) {
// Friendship status
$friendship = $sdb->get('friendships', [
'or' => "(and(requester_id.eq.{$userId},addressee_id.eq.{$targetId}),and(requester_id.eq.{$targetId},addressee_id.eq.{$userId}))",
'select' => 'id,requester_id,addressee_id,status',
'limit' => 1
]);
if (is_array($friendship) && !isset($friendship['error']) && !empty($friendship)) {
$f = $friendship[0];
$profile['friendship_status'] = $f['status'];
$profile['friendship_is_mine'] = ($f['requester_id'] === $userId);
} else {
$profile['friendship_status'] = 'none';
$profile['friendship_is_mine'] = false;
}
// Check if current user has blocked/muted this player
$myBlock = $sdb->get('player_blocks', [
'player_id' => 'eq.' . $userId,
'blocked_id' => 'eq.' . $targetId,
'limit' => 1
]);
if (is_array($myBlock) && !isset($myBlock['error']) && !empty($myBlock)) {
$profile['block_status'] = $myBlock[0]['type'];
} else {
$profile['block_status'] = 'none';
}
// Strip private fields for other players
unset($profile['email']);
}
jsonResponse($profile);
}
......
......@@ -85,7 +85,17 @@ const strings = {
'settings.sound': 'الصوت',
'settings.language': 'اللغة',
'settings.on': 'مفعل',
'settings.off': 'معطل'
'settings.off': 'معطل',
'settings.blocked_list': 'المحظورين',
'block.block': 'حظر',
'block.unblock': 'إلغاء الحظر',
'block.mute': 'كتم',
'block.unmute': 'إلغاء الكتم',
'block.blocked': 'تم الحظر',
'block.muted': 'تم الكتم',
'block.confirm_block': 'حظر هذا اللاعب؟ لن يستطيع مراسلتك أو تحديك.',
'block.empty': 'لا يوجد لاعبين محظورين',
'block.cannot_message': 'لا يمكن مراسلة هذا اللاعب'
},
en: {
'app.name': 'EL3AB',
......@@ -171,7 +181,17 @@ const strings = {
'settings.sound': 'Sound',
'settings.language': 'Language',
'settings.on': 'On',
'settings.off': 'Off'
'settings.off': 'Off',
'settings.blocked_list': 'Blocked Players',
'block.block': 'Block',
'block.unblock': 'Unblock',
'block.mute': 'Mute',
'block.unmute': 'Unmute',
'block.blocked': 'Blocked',
'block.muted': 'Muted',
'block.confirm_block': 'Block this player? They won\'t be able to message or challenge you.',
'block.empty': 'No blocked players',
'block.cannot_message': 'Cannot message this player'
}
};
......
......@@ -7,6 +7,7 @@ import * as audio from './audio.js';
import * as juice from './juice.js';
import * as scene from './scene.js';
import { emoji } from './theme.js';
import { t } from './i18n.js';
let currentMatchId = null;
let currentMatchType = null; // 'chess' | 'ludo' | 'domino'
......@@ -59,6 +60,8 @@ function showOpponentActions(container, opponent) {
menu.innerHTML = `
<button class="mp-action" data-action="profile">${emoji('person', '👤', 12)} الملف الشخصي</button>
<button class="mp-action" data-action="friend">➕ إضافة صديق</button>
<button class="mp-action" data-action="mute">${emoji('mute', '🔇', 12)} ${t('block.mute')}</button>
<button class="mp-action" data-action="block" style="color:#EF4444;">${emoji('block', '🚫', 12)} ${t('block.block')}</button>
<button class="mp-action" data-action="report" style="color:#EF4444;">⚠️ إبلاغ</button>
`;
......@@ -82,6 +85,33 @@ function showOpponentActions(container, opponent) {
setTimeout(() => menu.remove(), 1500);
});
menu.querySelector('[data-action="mute"]').addEventListener('click', async () => {
const btn = menu.querySelector('[data-action="mute"]');
btn.style.opacity = '0.5';
btn.style.pointerEvents = 'none';
try {
await net.post('friends.php', { action: 'mute', target_id: opponent.id });
btn.textContent = '✓ ' + t('block.muted');
btn.style.color = '#64748b';
} catch (e) {}
juice.hapticLight();
setTimeout(() => menu.remove(), 1000);
});
menu.querySelector('[data-action="block"]').addEventListener('click', async () => {
if (!confirm(t('block.confirm_block'))) return;
const btn = menu.querySelector('[data-action="block"]');
btn.style.opacity = '0.5';
btn.style.pointerEvents = 'none';
try {
await net.post('friends.php', { action: 'block', target_id: opponent.id });
btn.textContent = '✓ ' + t('block.blocked');
btn.style.color = '#64748b';
} catch (e) {}
juice.hapticLight();
setTimeout(() => menu.remove(), 1000);
});
menu.querySelector('[data-action="report"]').addEventListener('click', () => {
reportOpponent(opponent.id);
menu.querySelector('[data-action="report"]').textContent = '✓ تم الإبلاغ';
......
......@@ -4,9 +4,11 @@ import { mountResult } from './scenes/result.js';
import { mountAnalysis } from './scenes/analysis.js';
import { mountHistory } from './scenes/history.js';
import { mountReview } from './scenes/review.js';
import { mountSpectate } from './scenes/spectate.js';
scene.register('chess-game', mountGame);
scene.register('chess-result', mountResult);
scene.register('chess-analysis', mountAnalysis);
scene.register('chess-history', mountHistory);
scene.register('chess-review', mountReview);
scene.register('chess-spectate', mountSpectate);
import * as scene from '../../../core/scene.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as realtime from '../../../core/realtime.js';
import { ChessBoard } from '../canvas/board.js';
import * as engine from '../logic/engine.js';
import { ChessClock, parseTimeControl } from '../logic/clock.js';
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js';
let board, clock, unsub;
export async function mountSpectate(el, params = {}) {
const { matchId } = params;
if (!matchId) { scene.pop(); return; }
scene.enterGameMode();
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#0f0f1e;justify-content:center;">
<div style="padding:8px 14px;display:flex;align-items:center;gap:8px;">
<button class="btn btn-secondary" id="back-btn" style="width:34px;height:34px;padding:0;">←</button>
<div style="flex:1;display:flex;align-items:center;gap:8px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#EF4444;animation:spectatePulse 1.5s infinite;"></span>
<span style="font-size:13px;font-weight:600;color:#EF4444;">مشاهدة مباشرة</span>
</div>
</div>
<div class="chess-bar" style="display:flex;align-items:center;justify-content:space-between;padding:8px 14px;">
<div style="display:flex;align-items:center;gap:8px;">
<div style="width:32px;height:32px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;">${emoji('person', '👤', 14)}</div>
<span id="black-name" style="font-size:13px;font-weight:600;color:#f8fafc;">أسود</span>
</div>
<div id="clock-black" style="font-size:16px;font-weight:700;font-family:'SF Mono',monospace;background:#1a1a2e;padding:4px 10px;border-radius:6px;color:#94a3b8;">--:--</div>
</div>
<div id="board-container" style="flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:4px 6px;"></div>
<div class="chess-bar" style="display:flex;align-items:center;justify-content:space-between;padding:8px 14px;">
<div style="display:flex;align-items:center;gap:8px;">
<div style="width:32px;height:32px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;">${emoji('person', '👤', 14)}</div>
<span id="white-name" style="font-size:13px;font-weight:600;color:#f8fafc;">أبيض</span>
</div>
<div id="clock-white" style="font-size:16px;font-weight:700;font-family:'SF Mono',monospace;background:#1a1a2e;padding:4px 10px;border-radius:6px;color:#94a3b8;">--:--</div>
</div>
<div id="move-list" style="max-height:40px;overflow-x:auto;white-space:nowrap;padding:4px 14px;font-family:'SF Mono',monospace;font-size:12px;color:#94a3b8;display:flex;gap:4px;align-items:center;"></div>
</div>
<style>
@keyframes spectatePulse { 0%,100%{opacity:1}50%{opacity:0.4} }
</style>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
cleanup();
audio.play('click');
scene.pop();
});
engine.create();
const boardContainer = el.querySelector('#board-container');
board = new ChessBoard(boardContainer, { interactive: false });
// Fetch initial match state
let matchData;
try {
matchData = await net.post('game.php', { action: 'get', match_id: matchId });
if (!matchData || matchData.error) {
el.querySelector('#board-container').innerHTML = `<div style="color:var(--error);padding:24px;">مباراة غير موجودة</div>`;
return;
}
} catch (e) {
el.querySelector('#board-container').innerHTML = `<div style="color:var(--error);padding:24px;">${t('common.error')}</div>`;
return;
}
// Load player names
loadPlayerNames(el, matchData.white_player_id, matchData.black_player_id);
// Set position
if (matchData.current_fen) {
engine.load(matchData.current_fen);
board.setPosition(matchData.current_fen);
}
// Set clocks
const tc = parseTimeControl(matchData.time_control || 'rapid_10_0');
clock = new ChessClock(tc.time, tc.increment);
if (matchData.white_time_remaining_ms) clock.white = matchData.white_time_remaining_ms;
if (matchData.black_time_remaining_ms) clock.black = matchData.black_time_remaining_ms;
updateClockDisplay(el);
// Load move list
if (matchData.moves) {
const moves = typeof matchData.moves === 'string' ? JSON.parse(matchData.moves) : matchData.moves;
renderMoveList(el, moves);
}
// If game already over
if (matchData.status === 'completed') {
showResult(el, matchData.result);
return;
}
// Subscribe to realtime updates
unsub = realtime.subscribeMatch(matchId, (payload) => {
if (payload.type !== 'UPDATE') return;
const data = payload.new;
if (!data) return;
if (data.current_fen) {
engine.load(data.current_fen);
board.setPosition(data.current_fen);
audio.play('move', 'game');
}
if (data.white_time_remaining_ms != null) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms != null) clock.black = data.black_time_remaining_ms;
updateClockDisplay(el);
if (data.moves) {
const moves = typeof data.moves === 'string' ? JSON.parse(data.moves) : data.moves;
renderMoveList(el, moves);
}
if (data.status === 'completed') {
showResult(el, data.result);
cleanup();
}
});
}
async function loadPlayerNames(el, whiteId, blackId) {
try {
const [wp, bp] = await Promise.all([
net.get('profile.php', { id: whiteId }),
net.get('profile.php', { id: blackId })
]);
if (wp && !wp.error) el.querySelector('#white-name').textContent = wp.display_name || 'أبيض';
if (bp && !bp.error) el.querySelector('#black-name').textContent = bp.display_name || 'أسود';
} catch (e) {}
}
function updateClockDisplay(el) {
const wEl = el.querySelector('#clock-white');
const bEl = el.querySelector('#clock-black');
if (wEl) wEl.textContent = clock.format(clock.white);
if (bEl) bEl.textContent = clock.format(clock.black);
}
function renderMoveList(el, moves) {
const container = el.querySelector('#move-list');
if (!container || !Array.isArray(moves)) return;
let html = '';
for (let i = 0; i < moves.length; i += 2) {
const num = Math.floor(i / 2) + 1;
html += `<span style="color:#475569;">${num}.</span> `;
html += `<span>${moves[i]?.san || moves[i] || ''}</span> `;
if (moves[i + 1]) html += `<span>${moves[i + 1]?.san || moves[i + 1] || ''}</span> `;
}
container.innerHTML = html;
container.scrollLeft = container.scrollWidth;
}
function showResult(el, result) {
const text = result === 'white_wins' ? 'فاز الأبيض' :
result === 'black_wins' ? 'فاز الأسود' :
result === 'draw' ? 'تعادل' : 'انتهت المباراة';
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.9);color:#f8fafc;padding:16px 32px;border-radius:12px;font-size:16px;font-weight:700;z-index:30;text-align:center;';
overlay.textContent = text;
el.querySelector('#board-container').style.position = 'relative';
el.querySelector('#board-container').appendChild(overlay);
}
function cleanup() {
if (unsub) { unsub(); unsub = null; }
}
......@@ -3,8 +3,10 @@ import { mountView } from './scenes/view.js';
import { mountSettings } from './scenes/settings.js';
import { mountEdit } from './scenes/edit.js';
import { mountOrgApply } from './scenes/org-apply.js';
import { mountBlockedList } from './scenes/blocked-list.js';
scene.register('profile-view', mountView);
scene.register('profile-settings', mountSettings);
scene.register('profile-edit', mountEdit);
scene.register('profile-org-apply', mountOrgApply);
scene.register('blocked-list', mountBlockedList);
import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export function mountBlockedList(el) {
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);">
<div style="display:flex;align-items:center;gap:var(--s-3);">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;">${t('settings.blocked_list')}</h2>
</div>
<div id="blocked-content" style="display:flex;flex-direction:column;gap:var(--s-3);">
<div style="text-align:center;padding:var(--s-6);color:var(--text-secondary);">${t('common.loading')}</div>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
loadBlockedList(el);
}
async function loadBlockedList(el) {
const container = el.querySelector('#blocked-content');
try {
const data = await net.get('friends.php', { action: 'blocked-list' });
const list = data.blocked || [];
if (!list.length) {
container.innerHTML = `<div style="text-align:center;padding:var(--s-6);color:var(--text-secondary);">${t('block.empty')}</div>`;
return;
}
container.innerHTML = list.map(item => `
<div class="card" style="display:flex;align-items:center;gap:var(--s-3);padding:var(--s-3);" data-id="${item.blocked_id}">
<div style="width:40px;height:40px;border-radius:50%;background:var(--bg-surface);display:flex;align-items:center;justify-content:center;overflow:hidden;">
${item.profile?.avatar_url ? `<img src="${item.profile.avatar_url}" style="width:40px;height:40px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 18)}
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:var(--text-primary);">${item.profile?.display_name || 'Player'}</div>
<div style="font-size:12px;color:var(--text-secondary);">${item.type === 'mute' ? t('block.muted') : t('block.blocked')}</div>
</div>
<button class="btn btn-secondary unblock-btn" data-id="${item.blocked_id}" data-type="${item.type}" style="min-height:32px;padding:4px 12px;font-size:12px;">
${item.type === 'mute' ? t('block.unmute') : t('block.unblock')}
</button>
</div>
`).join('');
container.querySelectorAll('.unblock-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const type = btn.dataset.type;
btn.disabled = true;
btn.style.opacity = '0.5';
try {
await net.post('friends.php', { action: type === 'mute' ? 'unmute' : 'unblock', target_id: id });
btn.closest('.card').remove();
audio.play('click');
if (!container.querySelector('.card')) {
container.innerHTML = `<div style="text-align:center;padding:var(--s-6);color:var(--text-secondary);">${t('block.empty')}</div>`;
}
} catch (e) {
btn.disabled = false;
btn.style.opacity = '1';
}
});
});
} catch (e) {
container.innerHTML = `<div style="text-align:center;padding:var(--s-6);color:var(--error);">${t('common.error')}</div>`;
}
}
......@@ -28,6 +28,12 @@ export function mountSettings(el) {
</div>
</div>
<div class="card" style="display:flex;flex-direction:column;gap:var(--s-3);">
<button class="settings-link" id="blocked-list-btn" style="background:none;border:none;color:var(--text-secondary);font-size:14px;text-align:inherit;cursor:pointer;display:flex;align-items:center;gap:var(--s-2);padding:0;font-family:inherit;">
${emoji('block', '🚫', 14)} ${t('settings.blocked_list')}
</button>
</div>
<div class="card" style="display:flex;flex-direction:column;gap:var(--s-3);">
<a href="/privacy-policy" target="_blank" style="color:var(--text-secondary);font-size:14px;text-decoration:none;display:flex;align-items:center;gap:var(--s-2);">
${emoji('lock', '🔒', 14)} ${t('settings.privacy_policy')}
......@@ -60,6 +66,11 @@ export function mountSettings(el) {
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
el.querySelector('#blocked-list-btn').addEventListener('click', () => {
audio.play('click');
scene.push('blocked-list');
});
el.querySelector('#toggle-audio').addEventListener('click', () => {
const current = store.get('audioEnabled');
store.set('audioEnabled', !current);
......
......@@ -6,7 +6,20 @@ import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export async function mountView(el) {
export async function mountView(el, params = {}) {
const myId = store.get('auth.userId');
const targetId = params.playerId || null;
const isOther = targetId && targetId !== myId;
if (isOther) {
await mountOtherProfile(el, targetId);
} else {
await mountOwnProfile(el);
}
}
// ========== OWN PROFILE ==========
async function mountOwnProfile(el) {
try {
const fresh = await net.get('profile.php');
if (fresh && !fresh.error) store.set('player', fresh);
......@@ -142,10 +155,227 @@ export async function mountView(el) {
bus.emit('auth:logout');
});
// Load org membership
loadOrgMembership(el);
}
// ========== OTHER PLAYER'S PROFILE ==========
async function mountOtherProfile(el, playerId) {
el.innerHTML = `<div style="padding:var(--s-4);text-align:center;color:var(--text-secondary);">${t('common.loading')}</div>`;
let player;
try {
player = await net.get('profile.php', { id: playerId });
if (!player || player.error) {
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);align-items:center;">
<button class="btn btn-secondary" id="back-btn" style="align-self:flex-start;width:36px;height:36px;padding:0;">←</button>
<div style="color:var(--text-secondary);padding:var(--s-6);">${player?.error || 'Profile not available'}</div>
</div>`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
return;
}
} catch (e) {
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);align-items:center;">
<button class="btn btn-secondary" id="back-btn" style="align-self:flex-start;width:36px;height:36px;padding:0;">←</button>
<div style="color:var(--error);padding:var(--s-6);">${t('common.error')}</div>
</div>`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
return;
}
const friendStatus = player.friendship_status || 'none';
const blockStatus = player.block_status || 'none';
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);">
<div style="display:flex;align-items:center;gap:var(--s-3);">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;flex:1;">الملف الشخصي</h2>
</div>
<!-- Player Card -->
<div class="card" style="text-align:center;padding:var(--s-6);">
<div style="width:80px;height:80px;border-radius:50%;margin:0 auto;background:var(--bg-surface);display:flex;align-items:center;justify-content:center;overflow:hidden;">
${player.avatar_url ? `<img src="${player.avatar_url}" style="width:80px;height:80px;border-radius:50%;object-fit:cover;">` : emoji('person', '👤', 40)}
</div>
<div style="font-size:18px;font-weight:700;margin-top:var(--s-3);">${player.display_name || player.username || 'Player'}</div>
<div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">Level ${player.level || 1}</div>
${player.bio ? `<div style="font-size:13px;color:var(--text-secondary);margin-top:var(--s-2);">${escapeHtml(player.bio)}</div>` : ''}
${player.country_code ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:2px;">${emoji('flag', '🏳️', 12)} ${player.country_code}</div>` : ''}
</div>
<!-- Stats -->
<div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${t('profile.stats')}</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-3);text-align:center;">
<div>
<div style="font-size:20px;font-weight:700;color:var(--gold);font-family:var(--font-lat);">${player.games_played || 0}</div>
<div style="font-size:11px;color:var(--text-secondary);">مباريات</div>
</div>
<div>
<div style="font-size:20px;font-weight:700;color:var(--win);font-family:var(--font-lat);">${player.games_won || 0}</div>
<div style="font-size:11px;color:var(--text-secondary);">فوز</div>
</div>
<div>
<div style="font-size:20px;font-weight:700;color:var(--cyan);font-family:var(--font-lat);">${player.current_streak || 0}</div>
<div style="font-size:11px;color:var(--text-secondary);">سلسلة</div>
</div>
</div>
</div>
<!-- Ratings -->
<div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">التصنيف</div>
<div style="display:flex;flex-direction:column;gap:var(--s-2);">
${renderRating('شطرنج (سريع)', player.elo_rapid)}
${renderRating('شطرنج (خاطف)', player.elo_blitz)}
${renderRating('شطرنج (رصاصة)', player.elo_bullet)}
</div>
</div>
<!-- Spectate -->
<div id="spectate-section" style="display:none;"></div>
<!-- Action Buttons -->
<div id="profile-actions" style="display:flex;flex-direction:column;gap:var(--s-3);">
${renderActionButtons(friendStatus, blockStatus)}
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
// Action button handlers
const addFriendBtn = el.querySelector('#btn-add-friend');
if (addFriendBtn) {
addFriendBtn.addEventListener('click', async () => {
addFriendBtn.disabled = true;
addFriendBtn.style.opacity = '0.5';
try {
await net.post('friends.php', { action: 'request', target_id: playerId });
addFriendBtn.textContent = '✓ تم إرسال الطلب';
addFriendBtn.style.color = 'var(--success)';
} catch (e) {
addFriendBtn.textContent = e.message || t('common.error');
addFriendBtn.disabled = false;
addFriendBtn.style.opacity = '1';
}
});
}
const challengeBtn = el.querySelector('#btn-challenge');
if (challengeBtn) {
challengeBtn.addEventListener('click', () => {
audio.play('click');
scene.push('challenge', { friendId: playerId, friendName: player.display_name });
});
}
const messageBtn = el.querySelector('#btn-message');
if (messageBtn) {
messageBtn.addEventListener('click', () => {
audio.play('click');
scene.push('chat', { friendId: playerId, friendName: player.display_name });
});
}
const blockBtn = el.querySelector('#btn-block');
if (blockBtn) {
blockBtn.addEventListener('click', async () => {
if (!confirm(t('block.confirm_block'))) return;
blockBtn.disabled = true;
blockBtn.style.opacity = '0.5';
try {
await net.post('friends.php', { action: 'block', target_id: playerId });
blockBtn.textContent = '✓ ' + t('block.blocked');
blockBtn.style.color = 'var(--text-secondary)';
const actionsDiv = el.querySelector('#profile-actions');
if (addFriendBtn) addFriendBtn.remove();
if (challengeBtn) challengeBtn.remove();
if (messageBtn) messageBtn.remove();
} catch (e) {
blockBtn.disabled = false;
blockBtn.style.opacity = '1';
}
});
}
const unblockBtn = el.querySelector('#btn-unblock');
if (unblockBtn) {
unblockBtn.addEventListener('click', async () => {
unblockBtn.disabled = true;
unblockBtn.style.opacity = '0.5';
try {
await net.post('friends.php', { action: 'unblock', target_id: playerId });
mountOtherProfile(el, playerId);
} catch (e) {
unblockBtn.disabled = false;
unblockBtn.style.opacity = '1';
}
});
}
// Check if player has an active match (for spectate)
if (blockStatus !== 'block') {
checkActiveMatch(el, playerId);
}
}
async function checkActiveMatch(el, playerId) {
const section = el.querySelector('#spectate-section');
if (!section) return;
try {
const data = await net.post('game.php', { action: 'find-active-match', player_id: playerId });
if (data.match_id) {
section.style.display = 'block';
const gameLabel = data.game_key === 'ludo' ? 'لودو' : data.game_key === 'domino' ? 'دومينو' : 'شطرنج';
section.innerHTML = `
<div class="card" style="background:linear-gradient(135deg,#1a2a1a,#0f1f0f);border:1px solid rgba(52,211,153,0.3);padding:var(--s-3);display:flex;align-items:center;gap:var(--s-3);">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;animation:specPulse 1.5s infinite;"></span>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:#34D399;">يلعب الآن — ${gameLabel}</div>
</div>
<button class="btn btn-primary" id="btn-spectate" style="min-height:32px;padding:4px 14px;font-size:12px;">${emoji('eye', '👁', 13)} شاهد</button>
</div>
<style>@keyframes specPulse{0%,100%{opacity:1}50%{opacity:0.3}}</style>
`;
section.querySelector('#btn-spectate').addEventListener('click', () => {
audio.play('click');
if (data.game_key === 'chess') {
scene.push('chess-spectate', { matchId: data.match_id });
} else if (data.game_key === 'ludo') {
scene.push('ludo-game', { matchId: data.match_id, mode: 'spectate' });
} else {
scene.push('chess-spectate', { matchId: data.match_id });
}
});
}
} catch (e) {}
}
function renderActionButtons(friendStatus, blockStatus) {
if (blockStatus === 'block') {
return `<button class="btn btn-secondary w-full" id="btn-unblock" style="min-height:44px;">${t('block.unblock')}</button>`;
}
let buttons = '';
if (friendStatus === 'accepted') {
buttons += `<button class="btn btn-primary w-full" id="btn-challenge" style="min-height:44px;">${emoji('swords', '⚔️', 16)} تحدي</button>`;
buttons += `<button class="btn btn-secondary w-full" id="btn-message" style="min-height:44px;">${emoji('chat', '💬', 16)} مراسلة</button>`;
} else if (friendStatus === 'pending') {
buttons += `<button class="btn btn-secondary w-full" disabled style="min-height:44px;opacity:0.6;">طلب صداقة معلق</button>`;
} else {
buttons += `<button class="btn btn-primary w-full" id="btn-add-friend" style="min-height:44px;">➕ إضافة صديق</button>`;
}
buttons += `<button class="btn btn-secondary w-full" id="btn-block" style="min-height:44px;color:var(--error);">${emoji('block', '🚫', 16)} ${t('block.block')}</button>`;
return buttons;
}
// ========== SHARED HELPERS ==========
async function loadOrgMembership(el) {
const content = el.querySelector('#org-membership-content');
if (!content) return;
......@@ -214,3 +444,9 @@ function renderRating(label, rating) {
</div>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
......@@ -100,19 +100,31 @@ async function loadLiveData(el, tournamentId) {
content.innerHTML = html;
// Spectate buttons — find match for spectating
// Spectate buttons — find active match for the player pair
content.querySelectorAll('.spectate-btn').forEach(btn => {
btn.addEventListener('click', async () => {
audio.play('click');
const whiteId = btn.dataset.white;
const blackId = btn.dataset.black;
try {
const matches = await net.get('swiss.php', { action: 'my-games', tournament_id: tournamentId });
// For spectating, we'd need to find the match between these two players
// For now, push a message
btn.textContent = '...';
btn.disabled = true;
} catch (e) {}
try {
const data = await net.post('game.php', { action: 'find-active-match', player_id: whiteId });
if (data.match_id) {
scene.push('chess-spectate', { matchId: data.match_id });
} else {
const data2 = await net.post('game.php', { action: 'find-active-match', player_id: blackId });
if (data2.match_id) {
scene.push('chess-spectate', { matchId: data2.match_id });
} else {
btn.textContent = 'لا توجد مباراة';
setTimeout(() => { btn.textContent = 'شاهد'; btn.disabled = false; }, 2000);
}
}
} catch (e) {
btn.textContent = 'خطأ';
setTimeout(() => { btn.textContent = 'شاهد'; btn.disabled = false; }, 2000);
}
});
});
......
......@@ -3,8 +3,16 @@ import { mountFriends } from './scenes/friends.js';
import { mountNotifications } from './scenes/notifications.js';
import { mountActivity } from './scenes/activity.js';
import { mountChat } from './scenes/chat.js';
import { mountGroups } from './scenes/groups.js';
import { mountGroupCreate } from './scenes/group-create.js';
import { mountGroupChat } from './scenes/group-chat.js';
import { mountGroupMembers } from './scenes/group-members.js';
scene.register('friends', mountFriends);
scene.register('notifications', mountNotifications);
scene.register('activity-feed', mountActivity);
scene.register('friend-chat', mountChat);
scene.register('groups', mountGroups);
scene.register('group-create', mountGroupCreate);
scene.register('group-chat', mountGroupChat);
scene.register('group-members', mountGroupMembers);
......@@ -27,6 +27,7 @@ export function mountFriends(el) {
<button class="social-tab active" data-tab="friends">الأصدقاء</button>
<button class="social-tab" data-tab="pending">الطلبات <span id="pending-count" style="display:none;font-size:10px;background:#EF4444;color:#fff;border-radius:50%;padding:1px 5px;margin-right:2px;"></span></button>
<button class="social-tab" data-tab="online">متصلين</button>
<button class="social-tab" data-tab="groups">${emoji('group', '👥', 12)} المجموعات</button>
<button class="social-tab" data-tab="activity">${emoji('news', '📰', 12)} أخبار</button>
</div>
</div>
......@@ -177,6 +178,7 @@ async function loadTab(el, tab) {
case 'friends': await loadFriends(content); break;
case 'pending': await loadPending(content, el); break;
case 'online': await loadOnline(content); break;
case 'groups': scene.push('groups'); return;
case 'activity': await loadActivity(content); break;
}
}
......
import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js';
import * as bus from '../../../core/bus.js';
import * as realtime from '../../../core/realtime.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
let realtimeSub = null;
let inviteTimer = null;
export async function mountGroupChat(el, params = {}) {
const groupId = params.groupId;
if (!groupId) { scene.pop(); return; }
const myId = store.get('auth.userId');
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;">
<div style="padding:10px 16px;background:#0f0f1e;display:flex;align-items:center;gap:10px;border-bottom:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary" id="back-btn" style="width:34px;height:34px;padding:0;font-size:14px;">←</button>
<div id="group-header" style="flex:1;cursor:pointer;">
<div style="font-size:15px;font-weight:600;color:#f8fafc;">جاري التحميل...</div>
</div>
<button class="btn btn-secondary" id="invite-game-btn" style="min-height:32px;padding:5px 10px;font-size:12px;">${emoji('swords', '⚔️', 13)} لعب</button>
</div>
<div id="invite-banner" style="display:none;"></div>
<div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:6px;"></div>
<div style="padding:10px 16px;background:#0f0f1e;border-top:1px solid rgba(255,255,255,0.06);display:flex;gap:8px;align-items:center;">
<input class="input" id="msg-input" type="text" placeholder="اكتب رسالة..." style="flex:1;min-height:38px;" maxlength="500">
<button class="btn btn-primary" id="send-btn" style="min-height:38px;padding:0 14px;">إرسال</button>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
cleanup();
audio.play('click');
scene.pop();
});
// Load group detail
let groupData = null;
let members = [];
let mutedIds = [];
const memberMap = {};
try {
const detail = await net.get('groups.php', { action: 'detail', group_id: groupId });
groupData = detail.group;
members = detail.members || [];
members.forEach(m => { memberMap[m.user_id] = m.profiles || {}; });
const header = el.querySelector('#group-header');
header.innerHTML = `
<div style="font-size:15px;font-weight:600;color:#f8fafc;">${escapeHtml(groupData.name)}</div>
<div style="font-size:11px;color:#64748b;">${members.length} أعضاء</div>
`;
header.addEventListener('click', () => {
audio.play('click');
scene.push('group-members', { groupId, myRole: detail.my_role });
});
} catch (e) {
el.querySelector('#group-header').innerHTML = `<div style="color:var(--error);">خطأ في تحميل المجموعة</div>`;
return;
}
// Load chat history
try {
const chatData = await net.get('groups.php', { action: 'chat-history', group_id: groupId });
mutedIds = chatData.muted_ids || [];
renderMessages(el.querySelector('#chat-messages'), chatData.messages || [], myId, memberMap, mutedIds);
scrollToBottom(el);
} catch (e) {}
// Subscribe to realtime for new messages
realtimeSub = realtime.subscribe('chat_messages', `channel_id=eq.${groupId}`, (payload) => {
if (payload.type === 'INSERT') {
const msg = payload.new;
if (!msg || mutedIds.includes(msg.sender_id)) return;
if (msg.sender_id === myId) return;
appendMessage(el.querySelector('#chat-messages'), msg, myId, memberMap);
scrollToBottom(el);
}
});
// Send message
const input = el.querySelector('#msg-input');
const sendBtn = el.querySelector('#send-btn');
async function sendMessage() {
const content = input.value.trim();
if (!content) return;
input.value = '';
sendBtn.disabled = true;
try {
await net.post('groups.php', { action: 'chat-send', group_id: groupId, content });
} catch (e) {
bus.emit('toast', { text: t('common.error'), type: 'error' });
input.value = content;
}
sendBtn.disabled = false;
input.focus();
}
sendBtn.addEventListener('click', sendMessage);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMessage(); });
// Invite game button
el.querySelector('#invite-game-btn').addEventListener('click', () => {
audio.play('click');
showGamePicker(el, groupId);
});
// Check for active game invites
checkGroupInvites(el, groupId, myId);
inviteTimer = setInterval(() => checkGroupInvites(el, groupId, myId), 5000);
}
function renderMessages(container, messages, myId, memberMap, mutedIds) {
container.innerHTML = messages
.filter(m => !mutedIds.includes(m.sender_id))
.map(m => messageHtml(m, myId, memberMap))
.join('');
}
function appendMessage(container, msg, myId, memberMap) {
const div = document.createElement('div');
div.innerHTML = messageHtml(msg, myId, memberMap);
container.appendChild(div.firstElementChild);
}
function messageHtml(msg, myId, memberMap) {
const isMine = msg.sender_id === myId;
if (msg.message_type === 'system') {
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : (msg.metadata || {});
let systemText = msg.content;
if (msg.content === '__system:game_invite') {
const gameLabel = meta.game_key === 'ludo' ? 'لودو' : meta.game_key === 'domino' ? 'دومينو' : 'شطرنج';
systemText = `${emoji('swords', '⚔️', 12)} دعوة لعب ${gameLabel} (${meta.required_players} لاعبين)`;
}
return `<div style="text-align:center;padding:6px;font-size:11px;color:#64748b;">${systemText}</div>`;
}
const profile = memberMap[msg.sender_id] || msg.profiles || {};
const name = profile.display_name || 'Player';
const time = new Date(msg.created_at).toLocaleTimeString('ar', { hour: '2-digit', minute: '2-digit' });
if (isMine) {
return `
<div style="display:flex;justify-content:flex-end;">
<div style="max-width:75%;background:#2563EB;border-radius:12px 12px 4px 12px;padding:8px 12px;">
<div style="font-size:13px;color:#fff;word-wrap:break-word;">${escapeHtml(msg.content)}</div>
<div style="font-size:9px;color:rgba(255,255,255,0.5);margin-top:2px;">${time}</div>
</div>
</div>`;
}
return `
<div style="display:flex;gap:8px;align-items:flex-end;">
<div style="width:28px;height:28px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;">
${profile.avatar_url ? `<img src="${profile.avatar_url}" style="width:28px;height:28px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 12)}
</div>
<div style="max-width:70%;background:#1a1a2e;border-radius:12px 12px 12px 4px;padding:8px 12px;">
<div style="font-size:10px;font-weight:600;color:#64748b;margin-bottom:2px;">${escapeHtml(name)}</div>
<div style="font-size:13px;color:#e2e8f0;word-wrap:break-word;">${escapeHtml(msg.content)}</div>
<div style="font-size:9px;color:#4a5568;margin-top:2px;">${time}</div>
</div>
</div>`;
}
function scrollToBottom(el) {
const chat = el.querySelector('#chat-messages');
if (chat) chat.scrollTop = chat.scrollHeight;
}
async function checkGroupInvites(el, groupId, myId) {
const banner = el.querySelector('#invite-banner');
if (!banner) return;
try {
const data = await net.get('groups.php', { action: 'check-group-invites', group_id: groupId });
const invites = data.invites || [];
if (!invites.length) { banner.style.display = 'none'; return; }
banner.style.display = 'block';
banner.innerHTML = invites.map(inv => {
const gameLabel = inv.game_key === 'ludo' ? 'لودو' : inv.game_key === 'domino' ? 'دومينو' : 'شطرنج';
const accepted = inv.accepted || [];
const isAccepted = accepted.includes(myId);
const isInviter = accepted[0] === myId;
return `
<div style="display:flex;align-items:center;gap:8px;padding:8px 16px;background:linear-gradient(135deg,#1a2a1a,#0f1f0f);border-bottom:1px solid rgba(52,211,153,0.2);">
<span style="font-size:16px;">⚔️</span>
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:#34D399;">${gameLabel}${accepted.length}/${inv.required_players}</div>
</div>
${isAccepted || isInviter ? `<span style="font-size:11px;color:#64748b;">✓ قبلت</span>` : `<button class="btn btn-primary accept-invite-btn" data-match="${inv.match_id}" data-game="${inv.game_key}" style="min-height:28px;padding:4px 12px;font-size:11px;">انضم</button>`}
</div>`;
}).join('');
banner.querySelectorAll('.accept-invite-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '...';
try {
const res = await net.post('groups.php', {
action: 'accept-group-invite',
match_id: btn.dataset.match,
game_key: btn.dataset.game
});
if (res.started) {
cleanup();
if (btn.dataset.game === 'ludo') {
scene.push('ludo-game', { matchId: res.match_id, playerIndex: res.player_index });
} else {
scene.push('chess-game', { matchId: res.match_id, color: res.color });
}
} else {
btn.textContent = '✓ قبلت';
btn.style.background = 'transparent';
btn.style.color = '#64748b';
}
} catch (e) {
btn.textContent = 'خطأ';
btn.disabled = false;
}
});
});
} catch (e) {}
}
function showGamePicker(el, groupId) {
const existing = document.getElementById('game-picker-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'game-picker-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:flex-end;justify-content:center;';
overlay.innerHTML = `
<div style="background:#1a1a2e;border-radius:20px 20px 0 0;padding:24px;width:100%;max-width:400px;display:flex;flex-direction:column;gap:12px;">
<div style="font-size:16px;font-weight:700;color:#f8fafc;text-align:center;">دعوة لعب في المجموعة</div>
<button class="game-pick" data-game="chess" data-players="2" style="display:flex;align-items:center;gap:12px;padding:14px;background:#2a2a4a;border-radius:12px;border:none;cursor:pointer;width:100%;">
<span style="font-size:24px;">♟️</span>
<div style="text-align:right;flex:1;">
<div style="font-size:14px;font-weight:600;color:#f8fafc;">شطرنج</div>
<div style="font-size:11px;color:#64748b;">لاعبين</div>
</div>
</button>
<button class="game-pick" data-game="ludo" data-players="4" style="display:flex;align-items:center;gap:12px;padding:14px;background:#2a2a4a;border-radius:12px;border:none;cursor:pointer;width:100%;">
<span style="font-size:24px;">🎲</span>
<div style="text-align:right;flex:1;">
<div style="font-size:14px;font-weight:600;color:#f8fafc;">لودو</div>
<div style="font-size:11px;color:#64748b;">2-4 لاعبين</div>
</div>
</button>
<button class="game-pick" data-game="domino" data-players="2" style="display:flex;align-items:center;gap:12px;padding:14px;background:#2a2a4a;border-radius:12px;border:none;cursor:pointer;width:100%;">
<span style="font-size:24px;">🁣</span>
<div style="text-align:right;flex:1;">
<div style="font-size:14px;font-weight:600;color:#f8fafc;">دومينو</div>
<div style="font-size:11px;color:#64748b;">لاعبين</div>
</div>
</button>
<button id="cancel-pick" class="btn btn-secondary w-full" style="min-height:40px;">${t('common.cancel')}</button>
</div>
`;
overlay.querySelector('#cancel-pick').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelectorAll('.game-pick').forEach(btn => {
btn.addEventListener('click', async () => {
const gameKey = btn.dataset.game;
const requiredPlayers = parseInt(btn.dataset.players);
overlay.remove();
try {
await net.post('groups.php', {
action: 'invite-game',
group_id: groupId,
game_key: gameKey,
required_players: requiredPlayers
});
bus.emit('toast', { text: 'تم إرسال دعوة اللعب!', type: 'success' });
} catch (e) {
bus.emit('toast', { text: e.message || t('common.error'), type: 'error' });
}
});
});
document.body.appendChild(overlay);
}
function cleanup() {
if (realtimeSub) { realtimeSub(); realtimeSub = null; }
if (inviteTimer) { clearInterval(inviteTimer); inviteTimer = null; }
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export async function mountGroupCreate(el) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;">
<div style="padding:12px 16px;background:#0f0f1e;display:flex;align-items:center;gap:12px;">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;color:#f8fafc;">إنشاء مجموعة</h2>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;">
<div>
<label style="font-size:13px;color:var(--text-secondary);display:block;margin-bottom:6px;">اسم المجموعة</label>
<input class="input" id="group-name" type="text" placeholder="اسم المجموعة..." maxlength="50" style="width:100%;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);display:block;margin-bottom:6px;">اختر أصدقاء للإضافة</label>
<div id="friends-picker" style="display:flex;flex-direction:column;gap:6px;">
<div style="text-align:center;padding:12px;color:var(--text-secondary);font-size:13px;">${t('common.loading')}</div>
</div>
</div>
<button class="btn btn-primary w-full" id="create-btn" style="min-height:48px;font-size:15px;font-weight:700;">إنشاء المجموعة</button>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
const selectedIds = new Set();
// Load friends
try {
const data = await net.get('friends.php', { action: 'list' });
const friends = data.friends || [];
const picker = el.querySelector('#friends-picker');
if (!friends.length) {
picker.innerHTML = `<div style="text-align:center;padding:12px;color:var(--text-secondary);font-size:13px;">لا يوجد أصدقاء</div>`;
} else {
picker.innerHTML = friends.map(f => `
<label data-id="${f.id}" style="display:flex;align-items:center;gap:10px;padding:10px;background:#1a1a2e;border-radius:10px;cursor:pointer;transition:background 0.1s;">
<input type="checkbox" value="${f.id}" style="width:18px;height:18px;accent-color:#2563EB;">
<div style="width:36px;height:36px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;">
${f.avatar_url ? `<img src="${f.avatar_url}" style="width:36px;height:36px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 16)}
</div>
<span style="font-size:13px;font-weight:500;color:#f8fafc;">${escapeHtml(f.display_name || f.username || 'Player')}</span>
</label>
`).join('');
picker.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
if (cb.checked) selectedIds.add(cb.value);
else selectedIds.delete(cb.value);
});
});
}
} catch (e) {
el.querySelector('#friends-picker').innerHTML = `<div style="color:var(--error);font-size:13px;">${t('common.error')}</div>`;
}
el.querySelector('#create-btn').addEventListener('click', async () => {
const name = el.querySelector('#group-name').value.trim();
if (!name || name.length < 2) {
bus.emit('toast', { text: 'أدخل اسم المجموعة (حرفين على الأقل)', type: 'error' });
return;
}
const btn = el.querySelector('#create-btn');
btn.disabled = true;
btn.textContent = 'جاري الإنشاء...';
try {
const res = await net.post('groups.php', {
action: 'create',
name,
member_ids: [...selectedIds]
});
if (res.error) throw new Error(res.error);
audio.play('click');
bus.emit('toast', { text: 'تم إنشاء المجموعة!', type: 'success' });
scene.pop();
scene.push('group-chat', { groupId: res.group?.id });
} catch (e) {
bus.emit('toast', { text: e.message || t('common.error'), type: 'error' });
btn.disabled = false;
btn.textContent = 'إنشاء المجموعة';
}
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export async function mountGroupMembers(el, params = {}) {
const { groupId, myRole } = params;
if (!groupId) { scene.pop(); return; }
const myId = store.get('auth.userId');
const canManage = myRole === 'owner' || myRole === 'admin';
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;">
<div style="padding:12px 16px;background:#0f0f1e;display:flex;align-items:center;gap:12px;">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;color:#f8fafc;flex:1;">الأعضاء</h2>
${canManage ? `<button class="btn btn-primary" id="add-member-btn" style="min-height:34px;padding:6px 12px;font-size:12px;">+ إضافة</button>` : ''}
</div>
<div id="members-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div style="text-align:center;padding:24px;color:var(--text-secondary);">${t('common.loading')}</div>
</div>
<div style="padding:12px 16px;border-top:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary w-full" id="leave-btn" style="min-height:40px;color:var(--error);">مغادرة المجموعة</button>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
// Load members
try {
const detail = await net.get('groups.php', { action: 'detail', group_id: groupId });
const members = detail.members || [];
const container = el.querySelector('#members-list');
container.innerHTML = members.map(m => {
const p = m.profiles || {};
const roleLabel = m.role === 'owner' ? '👑 مالك' : m.role === 'admin' ? '⭐ مشرف' : '';
const isMe = m.user_id === myId;
const showRemove = canManage && !isMe && m.role !== 'owner';
return `
<div class="member-row" style="display:flex;align-items:center;gap:12px;padding:12px;background:#1a1a2e;border-radius:10px;margin-bottom:6px;">
<div style="width:40px;height:40px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;cursor:pointer;" data-profile="${m.user_id}">
${p.avatar_url ? `<img src="${p.avatar_url}" style="width:40px;height:40px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 18)}
</div>
<div style="flex:1;cursor:pointer;" data-profile="${m.user_id}">
<div style="font-size:14px;font-weight:500;color:#f8fafc;">${escapeHtml(p.display_name || 'Player')}${isMe ? ' (أنت)' : ''}</div>
${roleLabel ? `<div style="font-size:11px;color:#64748b;">${roleLabel}</div>` : ''}
</div>
${p.is_online ? '<div style="width:8px;height:8px;border-radius:50%;background:#34D399;"></div>' : ''}
${showRemove ? `<button class="btn btn-secondary remove-btn" data-id="${m.user_id}" style="min-height:28px;padding:4px 10px;font-size:11px;color:var(--error);">إزالة</button>` : ''}
</div>`;
}).join('');
// Profile clicks
container.querySelectorAll('[data-profile]').forEach(el2 => {
el2.addEventListener('click', () => {
const pid = el2.dataset.profile;
if (pid !== myId) {
audio.play('click');
scene.push('profile-view', { playerId: pid });
}
});
});
// Remove buttons
container.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('إزالة هذا العضو من المجموعة؟')) return;
btn.disabled = true;
try {
await net.post('groups.php', { action: 'remove-member', group_id: groupId, user_id: btn.dataset.id });
btn.closest('.member-row').remove();
audio.play('click');
} catch (e) {
btn.disabled = false;
bus.emit('toast', { text: e.message || t('common.error'), type: 'error' });
}
});
});
} catch (e) {
el.querySelector('#members-list').innerHTML = `<div style="color:var(--error);text-align:center;padding:24px;">${t('common.error')}</div>`;
}
// Add member button
const addBtn = el.querySelector('#add-member-btn');
if (addBtn) {
addBtn.addEventListener('click', () => {
audio.play('click');
showAddMemberPicker(el, groupId);
});
}
// Leave group
el.querySelector('#leave-btn').addEventListener('click', async () => {
if (!confirm('هل تريد مغادرة المجموعة؟')) return;
try {
await net.post('groups.php', { action: 'leave', group_id: groupId });
audio.play('click');
scene.pop();
scene.pop();
} catch (e) {
bus.emit('toast', { text: e.message || t('common.error'), type: 'error' });
}
});
}
async function showAddMemberPicker(el, groupId) {
const existing = document.getElementById('add-member-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'add-member-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:flex-end;justify-content:center;';
overlay.innerHTML = `
<div style="background:#1a1a2e;border-radius:20px 20px 0 0;padding:20px;width:100%;max-width:400px;max-height:60vh;display:flex;flex-direction:column;gap:12px;">
<div style="font-size:15px;font-weight:700;color:#f8fafc;text-align:center;">إضافة صديق</div>
<div id="add-friends-list" style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:6px;">
<div style="text-align:center;color:var(--text-secondary);font-size:13px;">جاري التحميل...</div>
</div>
<button class="btn btn-secondary w-full" id="close-add">${t('common.close')}</button>
</div>
`;
overlay.querySelector('#close-add').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
try {
const data = await net.get('friends.php', { action: 'list' });
const friends = data.friends || [];
const list = overlay.querySelector('#add-friends-list');
if (!friends.length) {
list.innerHTML = `<div style="text-align:center;color:var(--text-secondary);font-size:13px;">لا يوجد أصدقاء</div>`;
return;
}
list.innerHTML = friends.map(f => `
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#2a2a4a;border-radius:10px;">
<div style="width:32px;height:32px;border-radius:50%;background:#1a1a2e;display:flex;align-items:center;justify-content:center;overflow:hidden;">
${f.avatar_url ? `<img src="${f.avatar_url}" style="width:32px;height:32px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 14)}
</div>
<span style="flex:1;font-size:13px;color:#f8fafc;">${escapeHtml(f.display_name || 'Player')}</span>
<button class="btn btn-primary add-one-btn" data-id="${f.id}" style="min-height:28px;padding:4px 12px;font-size:11px;">إضافة</button>
</div>
`).join('');
list.querySelectorAll('.add-one-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '...';
try {
await net.post('groups.php', { action: 'add-member', group_id: groupId, user_id: btn.dataset.id });
btn.textContent = '✓';
btn.style.background = 'transparent';
btn.style.color = '#34D399';
} catch (e) {
btn.textContent = e.message || 'خطأ';
btn.style.color = 'var(--error)';
}
});
});
} catch (e) {
overlay.querySelector('#add-friends-list').innerHTML = `<div style="color:var(--error);font-size:13px;">${t('common.error')}</div>`;
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export async function mountGroups(el) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;">
<div style="padding:12px 16px;background:#0f0f1e;display:flex;align-items:center;gap:12px;">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;color:#f8fafc;flex:1;">المجموعات</h2>
<button class="btn btn-primary" id="create-btn" style="min-height:34px;padding:6px 14px;font-size:12px;">+ إنشاء</button>
</div>
<div id="groups-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div style="text-align:center;padding:24px;color:var(--text-secondary);">${t('common.loading')}</div>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
el.querySelector('#create-btn').addEventListener('click', () => { audio.play('click'); scene.push('group-create'); });
try {
const data = await net.get('groups.php', { action: 'list' });
const groups = data.groups || [];
const container = el.querySelector('#groups-list');
if (!groups.length) {
container.innerHTML = `
<div style="text-align:center;padding:48px 24px;color:var(--text-secondary);">
<div style="font-size:40px;margin-bottom:12px;">${emoji('group', '👥', 40)}</div>
<div style="font-size:14px;">لا توجد مجموعات بعد</div>
<div style="font-size:12px;margin-top:4px;">أنشئ مجموعة وادعُ أصدقائك!</div>
</div>`;
return;
}
container.innerHTML = groups.map(g => `
<div class="group-card" data-id="${g.id}" style="display:flex;align-items:center;gap:12px;padding:14px;background:#1a1a2e;border-radius:12px;margin-bottom:8px;cursor:pointer;transition:transform 0.1s;">
<div style="width:48px;height:48px;border-radius:12px;background:#2a2a4a;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;">
${g.avatar_url ? `<img src="${g.avatar_url}" style="width:48px;height:48px;object-fit:cover;border-radius:12px;">` : `<span style="font-size:22px;">${emoji('group', '👥', 22)}</span>`}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:14px;font-weight:600;color:#f8fafc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(g.name)}</div>
<div style="font-size:12px;color:#64748b;">${g.member_count || 1} أعضاء • ${g.my_role === 'owner' ? 'مالك' : g.my_role === 'admin' ? 'مشرف' : 'عضو'}</div>
</div>
<span style="color:#64748b;font-size:16px;">←</span>
</div>
`).join('');
container.querySelectorAll('.group-card').forEach(card => {
card.addEventListener('click', () => {
audio.play('click');
scene.push('group-chat', { groupId: card.dataset.id });
});
});
} catch (e) {
el.querySelector('#groups-list').innerHTML = `<div style="text-align:center;padding:24px;color:var(--error);">${t('common.error')}</div>`;
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment