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') { ...@@ -137,6 +137,16 @@ if ($method === 'POST') {
if (!$content) jsonError('content required'); if (!$content) jsonError('content required');
if (mb_strlen($content) > 500) jsonError('Message too long (max 500 chars)'); 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); $friendship = findFriendship($sdb, $userId, $friendId);
if (!$friendship) jsonError('Not friends'); if (!$friendship) jsonError('Not friends');
......
...@@ -93,6 +93,30 @@ if ($method === 'GET') { ...@@ -93,6 +93,30 @@ if ($method === 'GET') {
jsonResponse(['profiles' => is_array($profiles) && !isset($profiles['error']) ? $profiles : []]); 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'); jsonError('Invalid action');
} }
...@@ -105,6 +129,16 @@ if ($method === 'POST') { ...@@ -105,6 +129,16 @@ if ($method === 'POST') {
if (!$targetId) jsonError('target_id required'); if (!$targetId) jsonError('target_id required');
if ($targetId === $userId) jsonError('Cannot add yourself'); 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 // Check for existing friendship or pending request in EITHER direction
$existing = $sdb->get('friendships', [ $existing = $sdb->get('friendships', [
'or' => "(and(requester_id.eq.{$userId},addressee_id.eq.{$targetId}),and(requester_id.eq.{$targetId},addressee_id.eq.{$userId}))", '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') { ...@@ -188,7 +222,77 @@ if ($method === 'POST') {
$targetId = $input['target_id'] ?? ''; $targetId = $input['target_id'] ?? '';
$reason = $input['reason'] ?? 'other'; $reason = $input['reason'] ?? 'other';
if (!$targetId) jsonError('target_id required'); 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]); jsonResponse(['success' => true]);
} }
...@@ -198,6 +302,16 @@ if ($method === 'POST') { ...@@ -198,6 +302,16 @@ if ($method === 'POST') {
$timeControl = $input['time_control'] ?? 'rapid_10_0'; $timeControl = $input['time_control'] ?? 'rapid_10_0';
if (!$targetId) jsonError('target_id required'); 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') { if ($gameKey === 'domino') {
$players = [$userId, $targetId]; $players = [$userId, $targetId];
$match = $sdb->insert('domino_matches', [ $match = $sdb->insert('domino_matches', [
...@@ -288,6 +402,17 @@ if ($method === 'POST') { ...@@ -288,6 +402,17 @@ if ($method === 'POST') {
$result = []; $result = [];
$now = time(); $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 // Check chess matches table
$invites = $sdb->get('matches', [ $invites = $sdb->get('matches', [
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})", 'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
...@@ -305,11 +430,13 @@ if ($method === 'POST') { ...@@ -305,11 +430,13 @@ if ($method === 'POST') {
$inviteT = $gs['invite_t'] ?? 0; $inviteT = $gs['invite_t'] ?? 0;
if ($inviteTo !== $userId) continue; if ($inviteTo !== $userId) continue;
if ($now - $inviteT > 120) continue; if ($now - $inviteT > 120) continue;
$fromId = $gs['invite_from'] ?? null;
if ($fromId && in_array($fromId, $blockedIds)) continue;
$result[] = [ $result[] = [
'match_id' => $inv['id'], 'match_id' => $inv['id'],
'game_key' => $inv['game_key'] ?? 'chess', 'game_key' => $inv['game_key'] ?? 'chess',
'time_control' => $inv['time_control'] ?? 'rapid_10_0', 'time_control' => $inv['time_control'] ?? 'rapid_10_0',
'from_id' => $gs['invite_from'] ?? null, 'from_id' => $fromId,
'created_at' => $inv['created_at'] 'created_at' => $inv['created_at']
]; ];
} }
...@@ -332,11 +459,13 @@ if ($method === 'POST') { ...@@ -332,11 +459,13 @@ if ($method === 'POST') {
$inviteT = $gs['invite_t'] ?? 0; $inviteT = $gs['invite_t'] ?? 0;
if ($inviteTo !== $userId) continue; if ($inviteTo !== $userId) continue;
if ($now - $inviteT > 120) continue; if ($now - $inviteT > 120) continue;
$fromId = $gs['invite_from'] ?? null;
if ($fromId && in_array($fromId, $blockedIds)) continue;
$result[] = [ $result[] = [
'match_id' => $inv['id'], 'match_id' => $inv['id'],
'game_key' => 'domino', 'game_key' => 'domino',
'time_control' => 'standard', 'time_control' => 'standard',
'from_id' => $gs['invite_from'] ?? null, 'from_id' => $fromId,
'created_at' => $inv['created_at'] 'created_at' => $inv['created_at']
]; ];
} }
...@@ -359,11 +488,13 @@ if ($method === 'POST') { ...@@ -359,11 +488,13 @@ if ($method === 'POST') {
$inviteT = $gs['invite_t'] ?? 0; $inviteT = $gs['invite_t'] ?? 0;
if ($inviteTo !== $userId) continue; if ($inviteTo !== $userId) continue;
if ($now - $inviteT > 120) continue; if ($now - $inviteT > 120) continue;
$fromId = $gs['invite_from'] ?? null;
if ($fromId && in_array($fromId, $blockedIds)) continue;
$result[] = [ $result[] = [
'match_id' => $inv['id'], 'match_id' => $inv['id'],
'game_key' => 'ludo', 'game_key' => 'ludo',
'time_control' => 'standard', 'time_control' => 'standard',
'from_id' => $gs['invite_from'] ?? null, 'from_id' => $fromId,
'created_at' => $inv['created_at'] 'created_at' => $inv['created_at']
]; ];
} }
......
...@@ -36,6 +36,9 @@ switch ($action) { ...@@ -36,6 +36,9 @@ switch ($action) {
case 'get': case 'get':
handleGet($db, $userId, $input); handleGet($db, $userId, $input);
break; break;
case 'find-active-match':
handleFindActiveMatch($userId, $input);
break;
default: default:
jsonError('Invalid action'); jsonError('Invalid action');
} }
...@@ -382,3 +385,59 @@ function checkGameAchievements($sdb, string $userId, array $profileUpdates, int ...@@ -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]);
}
This diff is collapsed.
...@@ -61,13 +61,26 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -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) // 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' $searchUrl = SUPABASE_REST . '/matchmaking_queue'
. '?game_key=eq.' . urlencode($gameKey) . '?game_key=eq.' . urlencode($gameKey)
. '&time_control=eq.' . urlencode($timeControl) . '&time_control=eq.' . urlencode($timeControl)
. '&status=eq.waiting' . '&status=eq.waiting'
. '&player_id=neq.' . $userId . '&player_id=not.in.(' . implode(',', $excludeIds) . ')'
. '&select=id,player_id,rating' . '&select=id,player_id,rating'
. '&order=queued_at.asc' . '&order=queued_at.asc'
. '&limit=1'; . '&limit=1';
......
...@@ -19,6 +19,21 @@ $method = $_SERVER['REQUEST_METHOD']; ...@@ -19,6 +19,21 @@ $method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') { if ($method === 'GET') {
$targetId = $_GET['id'] ?? $userId; $targetId = $_GET['id'] ?? $userId;
$db = supabase($token); $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]); $profiles = $db->get('profiles', ['id' => 'eq.' . $targetId, 'select' => '*', 'limit' => 1]);
if (!is_array($profiles) || isset($profiles['error']) || empty($profiles)) { if (!is_array($profiles) || isset($profiles['error']) || empty($profiles)) {
...@@ -30,6 +45,39 @@ if ($method === 'GET') { ...@@ -30,6 +45,39 @@ if ($method === 'GET') {
$profile['ratings'] = is_array($ratings) && !isset($ratings['error']) ? $ratings : []; $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); jsonResponse($profile);
} }
......
...@@ -85,7 +85,17 @@ const strings = { ...@@ -85,7 +85,17 @@ const strings = {
'settings.sound': 'الصوت', 'settings.sound': 'الصوت',
'settings.language': 'اللغة', 'settings.language': 'اللغة',
'settings.on': 'مفعل', '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: { en: {
'app.name': 'EL3AB', 'app.name': 'EL3AB',
...@@ -171,7 +181,17 @@ const strings = { ...@@ -171,7 +181,17 @@ const strings = {
'settings.sound': 'Sound', 'settings.sound': 'Sound',
'settings.language': 'Language', 'settings.language': 'Language',
'settings.on': 'On', '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'; ...@@ -7,6 +7,7 @@ import * as audio from './audio.js';
import * as juice from './juice.js'; import * as juice from './juice.js';
import * as scene from './scene.js'; import * as scene from './scene.js';
import { emoji } from './theme.js'; import { emoji } from './theme.js';
import { t } from './i18n.js';
let currentMatchId = null; let currentMatchId = null;
let currentMatchType = null; // 'chess' | 'ludo' | 'domino' let currentMatchType = null; // 'chess' | 'ludo' | 'domino'
...@@ -59,6 +60,8 @@ function showOpponentActions(container, opponent) { ...@@ -59,6 +60,8 @@ function showOpponentActions(container, opponent) {
menu.innerHTML = ` menu.innerHTML = `
<button class="mp-action" data-action="profile">${emoji('person', '👤', 12)} الملف الشخصي</button> <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="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> <button class="mp-action" data-action="report" style="color:#EF4444;">⚠️ إبلاغ</button>
`; `;
...@@ -82,6 +85,33 @@ function showOpponentActions(container, opponent) { ...@@ -82,6 +85,33 @@ function showOpponentActions(container, opponent) {
setTimeout(() => menu.remove(), 1500); 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', () => { menu.querySelector('[data-action="report"]').addEventListener('click', () => {
reportOpponent(opponent.id); reportOpponent(opponent.id);
menu.querySelector('[data-action="report"]').textContent = '✓ تم الإبلاغ'; menu.querySelector('[data-action="report"]').textContent = '✓ تم الإبلاغ';
......
...@@ -4,9 +4,11 @@ import { mountResult } from './scenes/result.js'; ...@@ -4,9 +4,11 @@ import { mountResult } from './scenes/result.js';
import { mountAnalysis } from './scenes/analysis.js'; import { mountAnalysis } from './scenes/analysis.js';
import { mountHistory } from './scenes/history.js'; import { mountHistory } from './scenes/history.js';
import { mountReview } from './scenes/review.js'; import { mountReview } from './scenes/review.js';
import { mountSpectate } from './scenes/spectate.js';
scene.register('chess-game', mountGame); scene.register('chess-game', mountGame);
scene.register('chess-result', mountResult); scene.register('chess-result', mountResult);
scene.register('chess-analysis', mountAnalysis); scene.register('chess-analysis', mountAnalysis);
scene.register('chess-history', mountHistory); scene.register('chess-history', mountHistory);
scene.register('chess-review', mountReview); 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'; ...@@ -3,8 +3,10 @@ import { mountView } from './scenes/view.js';
import { mountSettings } from './scenes/settings.js'; import { mountSettings } from './scenes/settings.js';
import { mountEdit } from './scenes/edit.js'; import { mountEdit } from './scenes/edit.js';
import { mountOrgApply } from './scenes/org-apply.js'; import { mountOrgApply } from './scenes/org-apply.js';
import { mountBlockedList } from './scenes/blocked-list.js';
scene.register('profile-view', mountView); scene.register('profile-view', mountView);
scene.register('profile-settings', mountSettings); scene.register('profile-settings', mountSettings);
scene.register('profile-edit', mountEdit); scene.register('profile-edit', mountEdit);
scene.register('profile-org-apply', mountOrgApply); 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) { ...@@ -28,6 +28,12 @@ export function mountSettings(el) {
</div> </div>
</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);"> <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);"> <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')} ${emoji('lock', '🔒', 14)} ${t('settings.privacy_policy')}
...@@ -60,6 +66,11 @@ export function mountSettings(el) { ...@@ -60,6 +66,11 @@ export function mountSettings(el) {
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); }); 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', () => { el.querySelector('#toggle-audio').addEventListener('click', () => {
const current = store.get('audioEnabled'); const current = store.get('audioEnabled');
store.set('audioEnabled', !current); store.set('audioEnabled', !current);
......
This diff is collapsed.
...@@ -100,19 +100,31 @@ async function loadLiveData(el, tournamentId) { ...@@ -100,19 +100,31 @@ async function loadLiveData(el, tournamentId) {
content.innerHTML = html; content.innerHTML = html;
// Spectate buttons — find match for spectating // Spectate buttons — find active match for the player pair
content.querySelectorAll('.spectate-btn').forEach(btn => { content.querySelectorAll('.spectate-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
audio.play('click'); audio.play('click');
const whiteId = btn.dataset.white; const whiteId = btn.dataset.white;
const blackId = btn.dataset.black; 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.textContent = '...';
btn.disabled = true; 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'; ...@@ -3,8 +3,16 @@ import { mountFriends } from './scenes/friends.js';
import { mountNotifications } from './scenes/notifications.js'; import { mountNotifications } from './scenes/notifications.js';
import { mountActivity } from './scenes/activity.js'; import { mountActivity } from './scenes/activity.js';
import { mountChat } from './scenes/chat.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('friends', mountFriends);
scene.register('notifications', mountNotifications); scene.register('notifications', mountNotifications);
scene.register('activity-feed', mountActivity); scene.register('activity-feed', mountActivity);
scene.register('friend-chat', mountChat); 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) { ...@@ -27,6 +27,7 @@ export function mountFriends(el) {
<button class="social-tab active" data-tab="friends">الأصدقاء</button> <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="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="online">متصلين</button>
<button class="social-tab" data-tab="groups">${emoji('group', '👥', 12)} المجموعات</button>
<button class="social-tab" data-tab="activity">${emoji('news', '📰', 12)} أخبار</button> <button class="social-tab" data-tab="activity">${emoji('news', '📰', 12)} أخبار</button>
</div> </div>
</div> </div>
...@@ -177,6 +178,7 @@ async function loadTab(el, tab) { ...@@ -177,6 +178,7 @@ async function loadTab(el, tab) {
case 'friends': await loadFriends(content); break; case 'friends': await loadFriends(content); break;
case 'pending': await loadPending(content, el); break; case 'pending': await loadPending(content, el); break;
case 'online': await loadOnline(content); break; case 'online': await loadOnline(content); break;
case 'groups': scene.push('groups'); return;
case 'activity': await loadActivity(content); break; case 'activity': await loadActivity(content); break;
} }
} }
......
This diff is collapsed.
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