Commit bf608999 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(social): full friends system — search, add, pending requests, online filter, remove

Friends scene rebuilt:
- 3 tabs: الأصدقاء (all), الطلبات (pending), متصلين (online)
- Search players by username (🔍 button → input + results)
- Send friend request with + button (success feedback)
- Accept/reject pending requests (✓/✕ buttons)
- Remove friend (with confirm dialog)
- Challenge online friend (️ → navigates to game select)
- View profile button
- Online dot indicator on avatars
- Empty states with helpful CTAs

API updated:
- New action: search — finds players by username (ilike)
- Returns id, username, display_name, avatar_url, level, is_online

UI polish:
- Tab system with active state
- Card-based friend list with actions
- Animations and haptic on interactions
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e1001508
...@@ -56,6 +56,20 @@ if ($method === 'GET') { ...@@ -56,6 +56,20 @@ if ($method === 'GET') {
]); ]);
jsonResponse(['pending' => $pending ?: []]); jsonResponse(['pending' => $pending ?: []]);
} }
if ($action === 'search') {
$query = $_GET['query'] ?? '';
if (strlen($query) < 2) jsonError('Query too short');
$results = $db->get('profiles', [
'username' => 'ilike.*' . $query . '*',
'id' => 'neq.' . $userId,
'select' => 'id,username,display_name,avatar_url,level,is_online',
'limit' => 10
]);
jsonResponse(['players' => is_array($results) && !isset($results['error']) ? $results : []]);
}
} }
if ($method === 'POST') { if ($method === 'POST') {
......
...@@ -2,58 +2,260 @@ import * as net from '../../../core/net.js'; ...@@ -2,58 +2,260 @@ import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
let activeTab = 'friends';
export async function mountFriends(el) { export async function mountFriends(el) {
el.innerHTML = ` el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);"> <div style="display:flex;flex-direction:column;height:100%;">
<h2 style="font-size:20px;font-weight:700;">${t('social.friends')}</h2> <!-- Header with search -->
<div id="friends-list"> <div style="padding:12px 16px;background:#0f0f1e;">
<div class="skeleton" style="height:60px;margin-bottom:var(--s-2);"></div> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<div class="skeleton" style="height:60px;margin-bottom:var(--s-2);"></div> <h2 style="font-size:18px;font-weight:700;color:#f8fafc;">${t('social.friends')}</h2>
<div class="skeleton" style="height:60px;"></div> <button class="btn btn-secondary" id="btn-search" style="min-height:34px;padding:6px 14px;font-size:12px;">🔍 بحث</button>
</div>
<!-- Tabs -->
<div style="display:flex;gap:6px;">
<button class="social-tab active" data-tab="friends">الأصدقاء</button>
<button class="social-tab" data-tab="pending">الطلبات</button>
<button class="social-tab" data-tab="online">متصلين</button>
</div>
</div> </div>
<div id="social-content" style="flex:1;overflow-y:auto;padding:12px 16px;"></div>
</div> </div>
<style>
.social-tab{background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);color:#94a3b8;font-size:12px;font-weight:600;padding:7px 14px;border-radius:8px;cursor:pointer;font-family:inherit;transition:all 0.15s;}
.social-tab.active{background:#2563EB;border-color:#2563EB;color:#fff;}
.social-tab:active{transform:scale(0.95);}
.friend-card{display:flex;align-items:center;gap:12px;padding:12px;background:#1a1a2e;border-radius:12px;margin-bottom:8px;transition:transform 0.1s;}
.friend-card:active{transform:scale(0.98);}
.friend-avatar{width:44px;height:44px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;font-size:18px;overflow:hidden;position:relative;}
.friend-avatar img{width:100%;height:100%;object-fit:cover;border-radius:50%;}
.online-dot{position:absolute;bottom:0;right:0;width:12px;height:12px;border-radius:50%;background:#34D399;border:2px solid #1a1a2e;}
.friend-actions{display:flex;gap:6px;}
.friend-action{width:34px;height:34px;border-radius:8px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer;transition:transform 0.1s,background 0.15s;}
.friend-action:active{transform:scale(0.85);background:rgba(255,255,255,0.1);}
</style>
`; `;
// Search button
el.querySelector('#btn-search').addEventListener('click', () => {
audio.play('click');
showSearch(el);
});
// Tab switching
el.querySelectorAll('.social-tab').forEach(tab => {
tab.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll('.social-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
activeTab = tab.dataset.tab;
loadTab(el, activeTab);
});
});
loadTab(el, 'friends');
}
async function loadTab(el, tab) {
const content = el.querySelector('#social-content');
content.innerHTML = '<div style="text-align:center;padding:20px;color:#64748b;">جاري التحميل...</div>';
switch (tab) {
case 'friends': await loadFriends(content); break;
case 'pending': await loadPending(content); break;
case 'online': await loadOnline(content); break;
}
}
async function loadFriends(content) {
try { try {
const data = await net.get('friends.php', { action: 'list' }); const data = await net.get('friends.php', { action: 'list' });
renderFriends(el, data.friends || data || []); const friends = data.friends || [];
if (friends.length === 0) {
content.innerHTML = `
<div style="text-align:center;padding:40px 20px;">
<div style="font-size:48px;margin-bottom:12px;opacity:0.5;">👥</div>
<div style="font-size:15px;font-weight:700;color:#f8fafc;margin-bottom:6px;">لا يوجد أصدقاء بعد</div>
<div style="font-size:12px;color:#64748b;margin-bottom:16px;">ابحث عن لاعبين وأرسل لهم طلب صداقة</div>
<button class="btn btn-primary" id="empty-search" style="font-size:13px;padding:10px 24px;">🔍 ابحث عن لاعبين</button>
</div>`;
content.querySelector('#empty-search')?.addEventListener('click', () => showSearch(content.closest('[style*="height:100%"]') || content.parentElement));
return;
}
content.innerHTML = friends.map(f => renderFriendCard(f, 'friend')).join('');
bindFriendActions(content);
} catch (e) {
content.innerHTML = `<div style="text-align:center;color:#ef4444;">فشل التحميل</div>`;
}
}
async function loadPending(content) {
try {
const data = await net.get('friends.php', { action: 'pending' });
const pending = data.pending || [];
if (pending.length === 0) {
content.innerHTML = `<div style="text-align:center;padding:40px;color:#64748b;">لا توجد طلبات معلقة</div>`;
return;
}
content.innerHTML = pending.map(p => `
<div class="friend-card">
<div class="friend-avatar">👤</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:#f8fafc;">${p.requester_id?.substring(0, 8) || 'Player'}</div>
<div style="font-size:11px;color:#64748b;">أرسل لك طلب صداقة</div>
</div>
<div class="friend-actions">
<div class="friend-action" data-accept="${p.id}" title="قبول" style="color:#34D399;"></div>
<div class="friend-action" data-reject="${p.id}" title="رفض" style="color:#EF4444;"></div>
</div>
</div>
`).join('');
content.querySelectorAll('[data-accept]').forEach(btn => {
btn.addEventListener('click', async () => {
audio.play('coin', 'reward');
juice.hapticLight();
await net.post('friends.php', { action: 'accept', friendship_id: btn.dataset.accept });
btn.closest('.friend-card').style.opacity = '0.5';
btn.closest('.friend-card').querySelector('.friend-actions').innerHTML = '<span style="font-size:11px;color:#34D399;">✓ تم القبول</span>';
});
});
content.querySelectorAll('[data-reject]').forEach(btn => {
btn.addEventListener('click', async () => {
audio.play('click');
await net.del('friends.php', { target_id: btn.dataset.reject });
btn.closest('.friend-card').remove();
});
});
} catch (e) { } catch (e) {
el.querySelector('#friends-list').innerHTML = `<p style="color:var(--text-secondary);text-align:center;">${t('common.empty')}</p>`; content.innerHTML = `<div style="text-align:center;color:#ef4444;">فشل التحميل</div>`;
} }
} }
function renderFriends(el, friends) { async function loadOnline(content) {
const list = el.querySelector('#friends-list'); try {
if (friends.length === 0) { const data = await net.get('friends.php', { action: 'list' });
list.innerHTML = ` const friends = (data.friends || []).filter(f => f.is_online);
<div style="text-align:center;padding:48px 24px;">
<div style="font-size:56px;margin-bottom:16px;opacity:0.6;">👥</div> if (friends.length === 0) {
<div style="font-size:16px;font-weight:700;color:#f8fafc;margin-bottom:6px;">لا يوجد أصدقاء بعد</div> content.innerHTML = `<div style="text-align:center;padding:40px;color:#64748b;">لا يوجد أصدقاء متصلين الآن</div>`;
<div style="font-size:13px;color:#64748b;margin-bottom:20px;">أضف أصدقاء للعب معهم ومتابعة نتائجهم</div> return;
<button class="btn btn-primary" style="font-size:14px;" onclick="navigator.clipboard.writeText(window.location.origin);this.textContent='✓ تم نسخ الرابط';">📤 شارك رابط الدعوة</button> }
</div>`;
return; content.innerHTML = friends.map(f => renderFriendCard(f, 'online')).join('');
bindFriendActions(content);
} catch (e) {
content.innerHTML = `<div style="text-align:center;color:#ef4444;">فشل التحميل</div>`;
} }
}
list.innerHTML = friends.map(f => ` function renderFriendCard(f, context) {
<div class="card" style="display:flex;align-items:center;gap:var(--s-3);padding:var(--s-3);margin-bottom:var(--s-2);"> return `
<div style="width:40px;height:40px;border-radius:50%;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;font-size:16px;"> <div class="friend-card" data-uid="${f.id}">
${f.avatar_url ? `<img src="${f.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">` : '👤'} <div class="friend-avatar">
${f.avatar_url ? `<img src="${f.avatar_url}">` : '👤'}
${f.is_online ? '<div class="online-dot"></div>' : ''}
</div> </div>
<div style="flex:1;"> <div style="flex:1;">
<div style="font-size:14px;font-weight:600;">${f.display_name || f.username}</div> <div style="font-size:13px;font-weight:600;color:#f8fafc;">${f.display_name || f.username}</div>
<div style="font-size:11px;color:${f.is_online ? 'var(--success)' : 'var(--text-muted)'};">${f.is_online ? t('social.online') : 'Offline'}</div> <div style="font-size:11px;color:${f.is_online ? '#34D399' : '#64748b'};">${f.is_online ? 'متصل الآن' : 'غير متصل'}</div>
</div>
<div class="friend-actions">
${f.is_online ? `<div class="friend-action" data-invite="${f.id}" title="دعوة للعب">⚔️</div>` : ''}
<div class="friend-action" data-profile="${f.id}" title="الملف الشخصي">👤</div>
<div class="friend-action" data-remove="${f.id}" title="إزالة" style="font-size:12px;">✕</div>
</div> </div>
${f.is_online ? `<button class="btn btn-secondary" style="font-size:11px;padding:var(--s-1) var(--s-3);min-height:32px;" data-challenge="${f.id}">⚔️</button>` : ''}
</div> </div>
`).join(''); `;
}
list.querySelectorAll('[data-challenge]').forEach(btn => { function bindFriendActions(content) {
content.querySelectorAll('[data-invite]').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
bus.emit('navigate', { world: 'play', scene: 'play-time-select', params: { mode: 'friend', friendId: btn.dataset.challenge } }); juice.hapticLight();
bus.emit('navigate', { world: 'play' });
setTimeout(() => {
const tile = document.querySelector('[data-game="chess"]');
if (tile) tile.click();
}, 500);
}); });
}); });
content.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('إزالة هذا الصديق؟')) return;
audio.play('click');
await net.del('friends.php', { target_id: btn.dataset.remove });
btn.closest('.friend-card').remove();
});
});
}
function showSearch(el) {
const content = el.querySelector('#social-content');
content.innerHTML = `
<div style="margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<input class="input" id="search-input" type="text" placeholder="اسم المستخدم..." style="flex:1;min-height:40px;font-size:14px;">
<button class="btn btn-primary" id="search-go" style="min-height:40px;padding:8px 16px;font-size:13px;">بحث</button>
</div>
</div>
<div id="search-results"></div>
`;
const input = content.querySelector('#search-input');
const btn = content.querySelector('#search-go');
input.focus();
const doSearch = async () => {
const query = input.value.trim();
if (query.length < 2) return;
const results = content.querySelector('#search-results');
results.innerHTML = '<div style="text-align:center;color:#64748b;padding:12px;">جاري البحث...</div>';
try {
const data = await net.get('friends.php', { action: 'search', query });
const players = data.players || [];
if (players.length === 0) {
results.innerHTML = '<div style="text-align:center;color:#64748b;padding:12px;">لم يتم العثور على نتائج</div>';
return;
}
results.innerHTML = players.map(p => `
<div class="friend-card">
<div class="friend-avatar">${p.avatar_url ? `<img src="${p.avatar_url}">` : '👤'}</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:#f8fafc;">${p.display_name || p.username}</div>
<div style="font-size:11px;color:#64748b;">Level ${p.level || 1}</div>
</div>
<button class="btn btn-primary" data-add="${p.id}" style="min-height:32px;padding:6px 12px;font-size:11px;">+ أضف</button>
</div>
`).join('');
results.querySelectorAll('[data-add]').forEach(addBtn => {
addBtn.addEventListener('click', async () => {
audio.play('coin', 'reward');
juice.hapticLight();
await net.post('friends.php', { action: 'request', target_id: addBtn.dataset.add });
addBtn.textContent = '✓ تم الإرسال';
addBtn.disabled = true;
addBtn.style.background = '#34D399';
});
});
} catch (e) {
results.innerHTML = '<div style="text-align:center;color:#ef4444;">فشل البحث</div>';
}
};
btn.addEventListener('click', doSearch);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
} }
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