Commit e620362f authored by Mahmoud Aglan's avatar Mahmoud Aglan

nuke v2 SPA, rebuild as single-file app with working games

The ES modules approach was overengineered and broken:
- Import maps failed silently
- Game engines couldn't find window.App
- Nothing was responsive

New approach: everything in index.php + one CSS file.
- Loads public/js/app.js (game engines need App.fetch, App.toast etc)
- Simple defineScreen() router — no modules, no imports, no build step
- Chess game works: loads chess.min.js + board.js + game.js, enters immersive mode
- Ludo/Domino/Backgammon lobbies create games via API
- All screens responsive (max-width adapts, clamp() on hero heights)
- Visual identity from DESIGN.md: game-specific gradients, 20px radius cards, gold brand
- Header shows level + coins + gems (the game currencies, not chess ELO)
- DESIGN.md added as the visual specification document
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f39a8dcb
# EL3AB Design Specification v3
## Identity
EL3AB is a **social gaming hub for Arabic speakers** wrapping 4 classic board games (Chess, Ludo, Domino, Backgammon) in a platform that feels alive, dense, and responsive. Reference: Yalla Ludo + competitive depth.
## Design Pillars
1. **One Thumb, Full Experience** — Every primary action reachable with one thumb. < 3 taps to play.
2. **Touch = Feedback** — Every interaction responds with visual + audio + haptic simultaneously.
3. **Cards, Not Pages** — One UI primitive (the card). Cards stack, scroll, expand into sheets.
4. **Color = Game** — Each game owns a color identity. You always know which game context you're in.
5. **Always Alive** — No screen is static. Minimum: floating glow, pulsing dots, breathing buttons.
## Anti-Pillars
- NOT a web app (no material design, no breadcrumbs, no "dashboard")
- NOT minimal (dense = purposeful, every pixel works)
- NOT chess-first (all 4 games equal)
- NOT dark-for-trendy (dark bg is functional — makes colors pop)
## Color System
| Context | Primary | Secondary | Gradient |
|---------|---------|-----------|----------|
| Chess | #2563EB (blue) | #F5B731 (gold) | blue → gold |
| Ludo | #8B5CF6 (purple) | #EC4899 (pink) | purple → pink |
| Domino | #10B981 (emerald) | #06B6D4 (cyan) | emerald → cyan |
| Backgammon | #F59E0B (amber) | #EF4444 (red) | amber → red |
| Platform/Brand | #F5B731 (gold) | #00D4FF (cyan) | — |
| Background | #030A12 | #081420 | — |
| Text Primary | #F8FAFC | — | — |
| Text Secondary | #94A3B8 | — | — |
| Win | #34D399 | — | — |
| Loss | #F87171 | — | — |
## Card Anatomy
```
┌─────────────────────────────────┐
│ GRADIENT HEADER (game color) │ 60-80px
│ ◉ Icon Game Name ● ● │
├─────────────────────────────────┤
│ CONTENT BODY (bg-1) │ flexible
├─────────────────────────────────┤
│ ACTION FOOTER (optional) │ CTA/chips
└─────────────────────────────────┘
```
- Radius: 20px
- Border: 1px solid rgba(255,255,255,0.08)
- Shadow: 0 8px 32px rgba(0,0,0,0.4)
- Touch: scale(0.97) + shadow compress + haptic
## Screen Map
### Home (vertical scroll)
1. Header (fixed): Brand + Level + Coins + Gems + Notifications
2. Streak card (daily reward, pulses if unclaimed)
3. Games 2x2 grid (equal, always visible above fold)
4. My ratings (horizontal scroll pills)
5. Recent matches (last 5)
6. Friends online (avatar row)
7. Nav bar (fixed): Home | Games | Rank | Social | Me
### Game Lobby (bottom sheet, 70% screen)
- Mode selection (chips)
- Time control / bot picker (per game)
- "Play" CTA (large, game-color gradient)
- Swipe down to dismiss
### In-Game (immersive)
- Header + Nav hide
- Full-screen game board
- Opponent bar (top) + Player bar (bottom)
- Action drawer (swipe up): resign/draw/settings
### Post-Game (results card, slides up)
- W/L/D icon + confetti/shake
- Rating change (animated counter)
- XP + coins earned
- Rematch + Home buttons
## Interaction Rules
- Button press: scale(0.95) + haptic(10ms) + audio(click)
- Card press: scale(0.97) + tilt-toward-finger + shadow compress
- Navigation: slide transitions (250ms, ease-out)
- Game enter: zoom + darken
- Game exit: zoom out + brighten
- Win: confetti burst + haptic([20,50,20]) + fanfare
- Lose: subtle shake + haptic([50,30,50]) + descending tone
- Coin earn: particles fly to header counter + tick animation
- Level up: full-screen overlay + confetti + badge reveal
## Typography
- Arabic: IBM Plex Sans Arabic (400, 500, 600, 700, 800)
- Numbers: system monospace, tabular-nums
- Brand: "EL3AB" — always gold, weight 800
## Spacing
- Card padding: 16px
- Card gap: 12px
- Section gap: 20px
- Touch target minimum: 44px
- Border radius: 20px (cards), 12px (buttons), 9999px (pills/badges)
## Responsive
- Mobile-first: 320px - 480px (primary)
- Tablet: 768px+ (2-column games grid becomes 4-column, sidebar appears)
- Desktop: 1024px+ (sidebar nav, wider content area)
- Game boards: always square, max-width: min(100vw - 32px, 400px)
export function tickNumber(el, from, to, duration = 600) {
const start = performance.now();
const diff = to - from;
function frame(now) {
const t = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(from + diff * eased).toLocaleString();
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
export function flyTo(el, targetEl, duration = 500) {
const from = el.getBoundingClientRect();
const to = targetEl.getBoundingClientRect();
const dx = to.left - from.left + to.width / 2 - from.width / 2;
const dy = to.top - from.top + to.height / 2 - from.height / 2;
return el.animate([
{ transform: 'translate(0, 0) scale(1)', opacity: 1 },
{ transform: `translate(${dx}px, ${dy}px) scale(0.2)`, opacity: 0 }
], { duration, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'forwards' }).finished;
}
export function pop(el) {
el.animate([
{ transform: 'scale(0.8)', opacity: 0 },
{ transform: 'scale(1.05)', opacity: 1 },
{ transform: 'scale(1)' }
], { duration: 300, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' });
}
export function shake(el) {
el.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(-4px)' },
{ transform: 'translateX(4px)' },
{ transform: 'translateX(-2px)' },
{ transform: 'translateX(2px)' },
{ transform: 'translateX(0)' }
], { duration: 300, easing: 'ease-out' });
}
export function fadeIn(el, duration = 250) {
el.animate([
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'translateY(0)' }
], { duration, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' });
}
export function scaleIn(el, duration = 300) {
el.animate([
{ transform: 'scale(0.85)', opacity: 0 },
{ transform: 'scale(1)', opacity: 1 }
], { duration, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', fill: 'forwards' });
}
import { state } from './state.js';
const cache = new Map();
export async function api(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : endpoint;
const headers = { 'Content-Type': 'application/json' };
const token = state.getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const config = { headers, ...options };
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
}
try {
const res = await fetch(url, config);
if (res.status === 401) {
state.logout();
return null;
}
const text = await res.text();
try {
return JSON.parse(text);
} catch {
console.error('API parse error:', endpoint, text.slice(0, 200));
return null;
}
} catch (err) {
console.error('API fetch error:', endpoint, err);
return null;
}
}
export async function cachedApi(endpoint, ttlMs = 60000) {
const key = endpoint;
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttlMs) return cached.data;
const data = await api(endpoint);
if (data) cache.set(key, { data, time: Date.now() });
return data;
}
export function clearCache(pattern) {
if (!pattern) { cache.clear(); return; }
for (const key of cache.keys()) {
if (key.includes(pattern)) cache.delete(key);
}
}
let ctx = null;
let muted = localStorage.getItem('el3ab_muted') === '1';
function getCtx() {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
function synth(freq, duration, type = 'sine', gain = 0.15) {
if (muted) return;
const c = getCtx();
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.value = gain;
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + duration);
osc.connect(g).connect(c.destination);
osc.start();
osc.stop(c.currentTime + duration);
}
export const audio = {
tap() { synth(800, 0.05, 'sine', 0.08); },
move() { synth(300, 0.08, 'triangle', 0.1); },
capture() { synth(200, 0.15, 'sawtooth', 0.12); },
coin() { synth(1200, 0.1, 'sine', 0.1); synth(1600, 0.1, 'sine', 0.08); },
win() {
synth(523, 0.15, 'triangle', 0.12);
setTimeout(() => synth(659, 0.15, 'triangle', 0.12), 100);
setTimeout(() => synth(784, 0.3, 'triangle', 0.15), 200);
},
lose() {
synth(400, 0.2, 'triangle', 0.1);
setTimeout(() => synth(350, 0.3, 'triangle', 0.08), 150);
},
levelUp() {
[523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => synth(f, 0.2, 'sine', 0.12), i * 80);
});
},
error() { synth(200, 0.2, 'square', 0.08); },
dice() { for (let i = 0; i < 5; i++) setTimeout(() => synth(100 + Math.random() * 200, 0.04, 'noise' in window ? 'noise' : 'triangle', 0.06), i * 30); },
notify() { synth(880, 0.08, 'sine', 0.1); setTimeout(() => synth(1100, 0.12, 'sine', 0.08), 80); },
mute() { muted = true; localStorage.setItem('el3ab_muted', '1'); },
unmute() { muted = false; localStorage.removeItem('el3ab_muted'); },
isMuted() { return muted; },
toggle() { muted ? this.unmute() : this.mute(); return !muted; }
};
const can = 'vibrate' in navigator;
export const haptics = {
tap() { can && navigator.vibrate(10); },
heavy() { can && navigator.vibrate(30); },
success() { can && navigator.vibrate([20, 50, 20]); },
error() { can && navigator.vibrate([50, 30, 50, 30, 50]); },
selection() { can && navigator.vibrate(5); },
};
let canvas, ctx, particles = [], raf = null;
export function initCanvas() {
canvas = document.getElementById('particles');
if (!canvas) return;
ctx = canvas.getContext('2d');
resize();
window.addEventListener('resize', resize);
}
function resize() {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
function loop() {
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles = particles.filter(p => {
p.update();
p.draw(ctx);
return p.alive;
});
if (particles.length > 0) {
raf = requestAnimationFrame(loop);
} else {
raf = null;
}
}
function startLoop() {
if (!raf) raf = requestAnimationFrame(loop);
}
export function confetti(x, y, count = 40) {
const colors = ['#F5B731', '#00D4FF', '#8B5CF6', '#34D399', '#F87171', '#FBBF24'];
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5);
const speed = 4 + Math.random() * 6;
const size = 4 + Math.random() * 4;
const color = colors[Math.floor(Math.random() * colors.length)];
const spin = (Math.random() - 0.5) * 0.3;
let life = 1;
particles.push({
x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 3,
size, color, rotation: Math.random() * Math.PI * 2, spin, alive: true,
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.15;
this.vx *= 0.98;
this.rotation += this.spin;
life -= 0.012;
if (life <= 0 || this.y > canvas.height) this.alive = false;
},
draw(c) {
c.save();
c.translate(this.x, this.y);
c.rotate(this.rotation);
c.globalAlpha = life;
c.fillStyle = this.color;
c.fillRect(-this.size / 2, -this.size / 2, this.size, this.size * 0.6);
c.restore();
}
});
}
startLoop();
}
export function coinBurst(x, y, count = 12) {
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.2;
const speed = 3 + Math.random() * 4;
let life = 1;
particles.push({
x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
size: 6 + Math.random() * 3, alive: true,
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.12;
life -= 0.018;
if (life <= 0) this.alive = false;
},
draw(c) {
c.globalAlpha = life;
c.fillStyle = '#F5B731';
c.beginPath();
c.arc(this.x, this.y, this.size, 0, Math.PI * 2);
c.fill();
c.globalAlpha = life * 0.6;
c.fillStyle = '#FFF';
c.beginPath();
c.arc(this.x - this.size * 0.2, this.y - this.size * 0.2, this.size * 0.3, 0, Math.PI * 2);
c.fill();
}
});
}
startLoop();
}
const routes = new Map();
let currentScreen = null;
let mainEl = null;
export function register(path, screenFn) {
routes.set(path, screenFn);
}
export function navigate(path, params = {}, replace = false) {
const url = new URL(path, window.location.origin);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
if (replace) {
history.replaceState({ path, params }, '', url);
} else {
history.pushState({ path, params }, '', url);
}
render(path, params);
}
export function init(container) {
mainEl = container;
window.addEventListener('popstate', (e) => {
const s = e.state || {};
render(s.path || location.pathname, s.params || Object.fromEntries(new URLSearchParams(location.search)));
});
document.addEventListener('click', (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('#') || link.target === '_blank') return;
if (href.startsWith('/api/')) return;
e.preventDefault();
navigate(href);
});
const path = location.pathname;
const params = Object.fromEntries(new URLSearchParams(location.search));
history.replaceState({ path, params }, '', location.href);
render(path, params);
}
async function render(path, params = {}) {
const screenFn = matchRoute(path);
if (!screenFn) {
mainEl.innerHTML = '<div class="empty">الصفحة غير موجودة</div>';
return;
}
if (currentScreen && currentScreen.destroy) {
mainEl.firstElementChild?.classList.add('screen-exit');
await sleep(150);
currentScreen.destroy();
}
mainEl.innerHTML = '';
const screen = await screenFn(params);
currentScreen = screen;
const wrapper = document.createElement('div');
wrapper.className = 'screen main-inner';
mainEl.appendChild(wrapper);
if (screen.render) {
const html = screen.render(params);
wrapper.innerHTML = html;
}
if (screen.mount) {
await screen.mount(wrapper, params);
}
}
function matchRoute(path) {
const clean = path.replace(/\/$/, '') || '/';
if (routes.has(clean)) return routes.get(clean);
for (const [pattern, fn] of routes) {
if (pattern.includes(':')) {
const regex = new RegExp('^' + pattern.replace(/:([^/]+)/g, '([^/]+)') + '$');
if (regex.test(clean)) return fn;
}
}
return null;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
export function getParams() {
return Object.fromEntries(new URLSearchParams(location.search));
}
export const state = {
user: null,
screen: null,
notifications: [],
_subs: new Map(),
get(key) { return this[key]; },
set(key, value) {
const old = this[key];
this[key] = value;
if (this._subs.has(key)) {
this._subs.get(key).forEach(fn => fn(value, old));
}
},
on(key, fn) {
if (!this._subs.has(key)) this._subs.set(key, new Set());
this._subs.get(key).add(fn);
return () => this._subs.get(key).delete(fn);
},
isLoggedIn() {
return !!localStorage.getItem('el3ab_token');
},
getToken() {
return localStorage.getItem('el3ab_token') || '';
},
getUser() {
try { return JSON.parse(localStorage.getItem('el3ab_user')); } catch { return null; }
},
setAuth(token, user) {
localStorage.setItem('el3ab_token', token);
localStorage.setItem('el3ab_user', JSON.stringify(user));
this.set('user', user);
},
logout() {
localStorage.removeItem('el3ab_token');
localStorage.removeItem('el3ab_user');
this.set('user', null);
window.location.href = '/';
}
};
import { state } from './core/state.js';
import { navigate, register, init as initRouter, getParams } from './core/router.js';
import { initCanvas } from './core/particles.js';
import { api } from './core/api.js';
async function boot() {
initCanvas();
// Register routes
register('/', () => import('./screens/home.js').then(m => m.default));
register('/login', () => import('./screens/login.js').then(m => m.default));
register('/play', () => import('./screens/chess-lobby.js').then(m => m.default));
register('/ludo', () => import('./screens/ludo-lobby.js').then(m => m.default));
register('/domino', () => import('./screens/domino-lobby.js').then(m => m.default));
register('/backgammon', () => import('./screens/backgammon-lobby.js').then(m => m.default));
register('/game', () => import('./screens/game-chess.js').then(m => m.default));
register('/profile', () => import('./screens/profile.js').then(m => m.default));
register('/leaderboard', () => import('./screens/leaderboard.js').then(m => m.default));
register('/friends', () => import('./screens/friends.js').then(m => m.default));
register('/shop', () => import('./screens/shop.js').then(m => m.default));
register('/achievements', () => import('./screens/achievements.js').then(m => m.default));
register('/settings', () => import('./screens/settings.js').then(m => m.default));
register('/games', () => import('./screens/home.js').then(m => m.default));
// Auth check
if (!state.isLoggedIn() && location.pathname !== '/login' && location.pathname !== '/register') {
navigate('/login', {}, true);
initRouter(document.getElementById('main'));
return;
}
// Load profile if logged in
if (state.isLoggedIn()) {
const data = await api('/api/profile');
if (data && data.profile) {
state.set('user', data.profile);
updateShell(data.profile);
}
}
initRouter(document.getElementById('main'));
setupNav();
}
function updateShell(profile) {
const lvl = document.getElementById('hdr-level');
if (lvl) lvl.textContent = `Lv ${profile.level || 1}`;
const coins = document.getElementById('hdr-coins');
if (coins) coins.textContent = (profile.coins || 0).toLocaleString();
const gems = document.getElementById('hdr-gems');
if (gems) gems.textContent = profile.gems || 0;
}
function setupNav() {
const navItems = document.querySelectorAll('.nav-item');
const updateActive = () => {
const path = location.pathname;
navItems.forEach(item => {
const href = item.getAttribute('href');
const match = (href === '/' && path === '/') || (href !== '/' && path.startsWith(href));
item.classList.toggle('active', match);
});
};
updateActive();
window.addEventListener('popstate', updateActive);
// Also listen for navigate events
const orig = history.pushState;
history.pushState = function() {
orig.apply(this, arguments);
updateActive();
};
}
// Boot when DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
// Export for global access by game engines
window.El3ab = { state, navigate, api };
import { api } from '../core/api.js';
export default {
render() {
return `
<h2 style="font-size:22px;font-weight:700;text-align:center;margin-bottom:var(--sp-4);">🏅 الانجازات</h2>
<div id="ach-list"><div class="skeleton" style="height:80px;margin-bottom:var(--sp-2);"></div><div class="skeleton" style="height:80px;margin-bottom:var(--sp-2);"></div><div class="skeleton" style="height:80px;"></div></div>
`;
},
async mount(el) {
const data = await api('/api/achievements');
const container = el.querySelector('#ach-list');
if (!data?.achievements?.length) { container.innerHTML = '<div class="empty">لا يوجد انجازات</div>'; return; }
const total = data.achievements.length;
const unlocked = data.achievements.filter(a => a.unlocked).length;
container.innerHTML = `
<div style="text-align:center;margin-bottom:var(--sp-4);">
<span style="font-size:24px;font-weight:700;">${unlocked}</span>
<span style="color:var(--text-3);font-size:14px;"> / ${total}</span>
</div>
` + data.achievements.map(a => {
const done = a.unlocked;
return `<div class="card card-pad" style="margin-bottom:var(--sp-2);display:flex;align-items:center;gap:var(--sp-3);${done ? '' : 'opacity:0.5;'}">
<div style="width:40px;height:40px;border-radius:var(--r-sm);background:${done ? 'var(--gold-dim)' : 'var(--bg-3)'};display:flex;align-items:center;justify-content:center;font-size:20px;">${done ? '✅' : '🔒'}</div>
<div style="flex:1;">
<p style="font-size:14px;font-weight:600;">${a.name_ar || a.name}</p>
<p style="font-size:12px;color:var(--text-3);">${a.description_ar || a.description || ''}</p>
</div>
${a.xp_reward ? `<span class="badge badge-cyan">+${a.xp_reward} XP</span>` : ''}
</div>`;
}).join('');
},
destroy() {}
};
import { api } from '../core/api.js';
import { navigate } from '../core/router.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
export default {
render() {
return `
<h2 style="font-size:20px;font-weight:700;text-align:center;margin-bottom:var(--sp-2);">🎯 طاولة</h2>
<p style="text-align:center;color:var(--text-3);font-size:13px;margin-bottom:var(--sp-5);">طاولة زهر كلاسيكية</p>
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">مباراة جديدة</p>
<div style="margin-bottom:var(--sp-3);">
<label class="input-label">طول المباراة</label>
<div class="chip-row" id="match-length">
<button class="chip active" data-len="1">1 نقطة</button>
<button class="chip" data-len="3">3 نقاط</button>
<button class="chip" data-len="5">5 نقاط</button>
</div>
</div>
<button class="btn btn-primary btn-block btn-lg" id="btn-create">انشئ غرفة</button>
</div>
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">انضم لغرفة</p>
<div style="display:flex;gap:var(--sp-2);">
<input class="input" id="room-code" placeholder="كود الغرفة" dir="ltr" style="flex:1;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--r-md);padding:0 var(--sp-4);">
<button class="btn btn-secondary" id="btn-join">انضم</button>
</div>
</div>
<button class="btn btn-ghost btn-block" id="btn-match">⚔️ ابحث عن خصم</button>
`;
},
mount(el) {
const lenRow = el.querySelector('#match-length');
lenRow.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
haptics.selection();
lenRow.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
});
});
el.querySelector('#btn-create').addEventListener('click', async () => {
haptics.tap(); audio.tap();
const len = parseInt(el.querySelector('#match-length .chip.active').dataset.len);
const res = await api('/api/backgammon', { method: 'POST', body: { action: 'create', match_length: len } });
if (res?.ok && res.match) navigate(`/backgammon-game?match_id=${res.match.id}`);
});
el.querySelector('#btn-join').addEventListener('click', async () => {
haptics.tap();
const code = el.querySelector('#room-code').value.trim();
if (!code) return;
const res = await api('/api/backgammon', { method: 'POST', body: { action: 'join', room_code: code } });
if (res?.ok) navigate(`/backgammon-game?match_id=${res.match.id}`);
});
el.querySelector('#btn-match').addEventListener('click', async () => {
haptics.tap(); audio.tap();
await api('/api/backgammon', { method: 'POST', body: { action: 'matchmake', sub_action: 'join' } });
});
},
destroy() {}
};
import { api, cachedApi } from '../core/api.js';
import { navigate } from '../core/router.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
const TC = {
bullet: [{ t: 60, i: 0, l: '1+0' }, { t: 60, i: 1, l: '1+1' }, { t: 120, i: 1, l: '2+1' }],
blitz: [{ t: 180, i: 0, l: '3+0' }, { t: 180, i: 2, l: '3+2' }, { t: 300, i: 0, l: '5+0' }, { t: 300, i: 3, l: '5+3' }],
rapid: [{ t: 600, i: 0, l: '10+0' }, { t: 600, i: 5, l: '10+5' }, { t: 900, i: 10, l: '15+10' }],
classical: [{ t: 1800, i: 0, l: '30+0' }, { t: 3600, i: 0, l: '60+0' }]
};
let bots = [];
export default {
render() {
return `
<h2 style="font-size:20px;font-weight:700;text-align:center;margin-bottom:var(--sp-2);">♟️ شطرنج</h2>
<p style="text-align:center;color:var(--text-3);font-size:13px;margin-bottom:var(--sp-5);">اختر نوع المباراة</p>
<!-- VS Human -->
<div class="card card-pad" style="border-color:rgba(245,183,49,0.15);margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">⚔️ ضد لاعب حقيقي</p>
<div class="chip-row" id="mp-cat" style="margin-bottom:var(--sp-2);">
<button class="chip active" data-cat="bullet">Bullet</button>
<button class="chip" data-cat="blitz">Blitz</button>
<button class="chip" data-cat="rapid">Rapid</button>
<button class="chip" data-cat="classical">Classical</button>
</div>
<div class="chip-row" id="mp-tc" style="margin-bottom:var(--sp-4);"></div>
<button class="btn btn-primary btn-block btn-lg" id="btn-mp">ابحث عن خصم</button>
</div>
<!-- VS Bot -->
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">🤖 ضد البوت</p>
<div class="chip-row" id="bot-cat" style="margin-bottom:var(--sp-2);">
<button class="chip active" data-cat="blitz">Blitz</button>
<button class="chip" data-cat="rapid">Rapid</button>
</div>
<div class="chip-row" id="bot-tc" style="margin-bottom:var(--sp-3);"></div>
<div style="margin-bottom:var(--sp-3);">
<label class="input-label">الخصم</label>
<select class="input" id="bot-select" dir="ltr" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--r-md);height:var(--touch);padding:0 var(--sp-4);">
<option>جاري التحميل...</option>
</select>
</div>
<div class="chip-row" id="color-select" style="margin-bottom:var(--sp-4);">
<button class="chip active" data-color="w">⬜ ابيض</button>
<button class="chip" data-color="b">⬛ اسود</button>
<button class="chip" data-color="random">🎲 عشوائي</button>
</div>
<button class="btn btn-secondary btn-block btn-lg" id="btn-bot">ابدأ المباراة</button>
</div>
<!-- Quick Match -->
<button class="btn btn-ghost btn-block" id="btn-quick" style="margin-bottom:var(--sp-4);">
⚡ مباراة سريعة ضد بوت عشوائي
</button>
`;
},
async mount(el) {
const data = await cachedApi('/api/bots.php', 60000);
if (data?.bots) {
bots = data.bots;
const select = el.querySelector('#bot-select');
select.innerHTML = bots.map(b => {
const elo = Math.round((b.elo_min + b.elo_max) / 2);
return `<option value="${b.id}">${b.name} (${elo})</option>`;
}).join('');
}
function renderTC(containerId, cat) {
const opts = TC[cat] || TC.blitz;
const container = el.querySelector(`#${containerId}`);
container.innerHTML = opts.map((o, i) =>
`<button class="chip${i === 0 ? ' active' : ''}" data-time="${o.t}" data-inc="${o.i}">${o.l}</button>`
).join('');
setupChips(container);
}
function setupChips(container) {
container.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
haptics.selection();
container.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
});
});
}
['mp-cat', 'bot-cat'].forEach(catId => {
const catEl = el.querySelector(`#${catId}`);
const tcId = catId === 'mp-cat' ? 'mp-tc' : 'bot-tc';
setupChips(catEl);
catEl.addEventListener('click', (e) => {
const chip = e.target.closest('.chip');
if (!chip) return;
renderTC(tcId, chip.dataset.cat);
});
renderTC(tcId, catId === 'mp-cat' ? 'bullet' : 'blitz');
});
setupChips(el.querySelector('#color-select'));
el.querySelector('#btn-mp').addEventListener('click', () => {
haptics.tap(); audio.tap();
const tc = el.querySelector('#mp-tc .chip.active');
navigate(`/matchmaking?tc=chess&time=${tc.dataset.time * 1000}&inc=${tc.dataset.inc * 1000}`);
});
el.querySelector('#btn-bot').addEventListener('click', () => {
haptics.tap(); audio.tap();
const tc = el.querySelector('#bot-tc .chip.active');
let color = el.querySelector('#color-select .chip.active').dataset.color;
if (color === 'random') color = Math.random() < 0.5 ? 'w' : 'b';
const bot = el.querySelector('#bot-select').value;
navigate(`/game?bot=${bot}&color=${color}&time=${tc.dataset.time}&inc=${tc.dataset.inc}&rated=true`);
});
el.querySelector('#btn-quick').addEventListener('click', () => {
haptics.tap(); audio.tap();
const available = bots.length ? bots.filter(b => b.id !== 'grandmaster').map(b => b.id) : ['nour'];
const bot = available[Math.floor(Math.random() * available.length)];
const color = Math.random() < 0.5 ? 'w' : 'b';
navigate(`/game?bot=${bot}&color=${color}&time=300&inc=0&rated=false`);
});
},
destroy() {}
};
import { api } from '../core/api.js';
import { navigate } from '../core/router.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
export default {
render() {
return `
<h2 style="font-size:20px;font-weight:700;text-align:center;margin-bottom:var(--sp-2);">🁣 دومينو</h2>
<p style="text-align:center;color:var(--text-3);font-size:13px;margin-bottom:var(--sp-5);">فردي او فرق</p>
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">مباراة سريعة</p>
<div style="margin-bottom:var(--sp-3);">
<label class="input-label">النوع</label>
<div class="chip-row" id="mode-select">
<button class="chip active" data-mode="2p">فردي (2 لاعبين)</button>
<button class="chip" data-mode="4p_teams">فرق (4 لاعبين)</button>
</div>
</div>
<div style="margin-bottom:var(--sp-3);">
<label class="input-label">الصعوبة</label>
<div class="chip-row" id="diff-select">
<button class="chip active" data-diff="easy">سهل</button>
<button class="chip" data-diff="medium">متوسط</button>
<button class="chip" data-diff="hard">صعب</button>
</div>
</div>
<button class="btn btn-primary btn-block btn-lg" id="btn-start">ابدأ</button>
</div>
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">انضم لغرفة</p>
<div style="display:flex;gap:var(--sp-2);">
<input class="input" id="room-code" placeholder="كود الغرفة" dir="ltr" style="flex:1;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--r-md);padding:0 var(--sp-4);">
<button class="btn btn-secondary" id="btn-join">انضم</button>
</div>
</div>
`;
},
mount(el) {
['mode-select', 'diff-select'].forEach(id => {
const container = el.querySelector(`#${id}`);
container.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
haptics.selection();
container.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
});
});
});
el.querySelector('#btn-start').addEventListener('click', async () => {
haptics.tap(); audio.tap();
const mode = el.querySelector('#mode-select .chip.active').dataset.mode;
const diff = el.querySelector('#diff-select .chip.active').dataset.diff;
const res = await api('/api/domino', { method: 'POST', body: { action: 'create', mode, difficulty: diff } });
if (res?.ok && res.match) {
const startRes = await api('/api/domino', { method: 'POST', body: { action: 'start', match_id: res.match.id } });
if (startRes?.ok) navigate(`/domino-game?match_id=${startRes.match.id}`);
}
});
el.querySelector('#btn-join').addEventListener('click', async () => {
haptics.tap();
const code = el.querySelector('#room-code').value.trim();
if (!code) return;
const res = await api('/api/domino', { method: 'POST', body: { action: 'join', room_code: code } });
if (res?.ok) navigate(`/domino-game?match_id=${res.match.id}`);
});
},
destroy() {}
};
import { api } from '../core/api.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
export default {
render() {
return `
<h2 style="font-size:22px;font-weight:700;text-align:center;margin-bottom:var(--sp-4);">👥 الاصدقاء</h2>
<!-- Search -->
<div style="display:flex;gap:var(--sp-2);margin-bottom:var(--sp-4);">
<input class="input" id="friend-search" placeholder="ابحث عن لاعب..." style="flex:1;">
<button class="btn btn-secondary btn-sm" id="btn-search">بحث</button>
</div>
<!-- Requests -->
<section id="requests-sec" style="display:none;margin-bottom:var(--sp-4);">
<div class="sec-hdr"><h2 class="sec-title">طلبات صداقة</h2></div>
<div class="card" id="requests-list"></div>
</section>
<!-- Friends List -->
<section>
<div class="sec-hdr"><h2 class="sec-title">اصدقائي</h2></div>
<div class="card" id="friends-list"><div class="empty">جاري التحميل...</div></div>
</section>
<!-- Search Results -->
<section id="search-sec" style="display:none;margin-top:var(--sp-4);">
<div class="sec-hdr"><h2 class="sec-title">نتائج البحث</h2></div>
<div class="card" id="search-results"></div>
</section>
`;
},
async mount(el) {
// Load friends
const friendsData = await api('/api/friends?action=list');
const list = el.querySelector('#friends-list');
if (friendsData?.friends?.length) {
list.innerHTML = '<div class="activity-list">' + friendsData.friends.map(f => `
<div class="activity-item">
<div style="width:8px;height:8px;border-radius:50%;background:${f.is_online ? 'var(--online)' : 'var(--text-3)'}"></div>
<div class="activity-detail"><p class="activity-name">${f.display_name || f.username}</p></div>
<span style="font-size:11px;color:var(--text-3);">${f.is_online ? 'متصل' : 'غير متصل'}</span>
</div>
`).join('') + '</div>';
} else {
list.innerHTML = '<div class="empty">لا يوجد اصدقاء بعد</div>';
}
// Load requests
const reqData = await api('/api/friends?action=requests');
if (reqData?.requests?.length) {
el.querySelector('#requests-sec').style.display = '';
el.querySelector('#requests-list').innerHTML = '<div class="activity-list">' + reqData.requests.map(r => `
<div class="activity-item">
<div class="activity-detail"><p class="activity-name">${r.display_name || r.username}</p></div>
<button class="btn btn-sm btn-secondary" data-accept="${r.id}">قبول</button>
<button class="btn btn-sm btn-ghost" data-reject="${r.id}">رفض</button>
</div>
`).join('') + '</div>';
el.querySelector('#requests-list').addEventListener('click', async (e) => {
const acceptBtn = e.target.closest('[data-accept]');
const rejectBtn = e.target.closest('[data-reject]');
if (acceptBtn) {
haptics.success(); audio.coin();
await api('/api/friends', { method: 'POST', body: { action: 'accept', request_id: acceptBtn.dataset.accept } });
acceptBtn.closest('.activity-item').remove();
} else if (rejectBtn) {
haptics.tap();
await api('/api/friends', { method: 'POST', body: { action: 'reject', request_id: rejectBtn.dataset.reject } });
rejectBtn.closest('.activity-item').remove();
}
});
}
// Search
el.querySelector('#btn-search').addEventListener('click', async () => {
const q = el.querySelector('#friend-search').value.trim();
if (!q) return;
haptics.tap();
const data = await api(`/api/friends?action=search&q=${encodeURIComponent(q)}`);
const sec = el.querySelector('#search-sec');
const results = el.querySelector('#search-results');
if (data?.results?.length) {
sec.style.display = '';
results.innerHTML = '<div class="activity-list">' + data.results.map(u => `
<div class="activity-item">
<div class="activity-detail"><p class="activity-name">${u.display_name || u.username}</p></div>
<button class="btn btn-sm btn-secondary" data-add="${u.id}">اضف</button>
</div>
`).join('') + '</div>';
results.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-add]');
if (!btn) return;
haptics.tap(); audio.tap();
await api('/api/friends', { method: 'POST', body: { action: 'add', user_id: btn.dataset.add } });
btn.textContent = 'تم ✓';
btn.disabled = true;
});
} else {
sec.style.display = '';
results.innerHTML = '<div class="empty">لا نتائج</div>';
}
});
},
destroy() {}
};
import { getParams } from '../core/router.js';
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.body.appendChild(s);
});
}
function loadCSS(href) {
if (document.querySelector(`link[href="${href}"]`)) return;
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = href;
document.head.appendChild(l);
}
export default {
render() {
return `
<div id="chess-game-container" style="display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;">
<div id="opponent-info" style="display:flex;align-items:center;gap:var(--sp-3);width:100%;max-width:400px;padding:0 var(--sp-2);">
<span style="font-size:13px;font-weight:600;" id="opponent-name">Bot</span>
<span style="flex:1"></span>
<span style="font-size:14px;font-weight:700;font-family:var(--font-mono);" id="clock-opponent">--:--</span>
</div>
<div id="board-wrapper" style="width:100%;max-width:400px;aspect-ratio:1;">
<div id="board"></div>
</div>
<div id="player-info" style="display:flex;align-items:center;gap:var(--sp-3);width:100%;max-width:400px;padding:0 var(--sp-2);">
<span style="font-size:13px;font-weight:600;" id="player-name">انت</span>
<span style="flex:1"></span>
<span style="font-size:14px;font-weight:700;font-family:var(--font-mono);" id="clock-player">--:--</span>
</div>
<div style="display:flex;gap:var(--sp-2);margin-top:var(--sp-2);">
<button class="btn btn-ghost btn-sm" id="btn-resign">استسلام</button>
<button class="btn btn-ghost btn-sm" id="btn-draw">تعادل</button>
</div>
</div>
`;
},
async mount(el) {
const params = getParams();
const bot = params.bot || 'nour';
const color = params.color || 'w';
const time = parseInt(params.time) || 300;
const inc = parseInt(params.inc) || 0;
const rated = params.rated !== 'false';
// Enter immersive mode
document.getElementById('app').classList.add('immersive');
// Load game engine
loadCSS('/public/css/chessboard.css');
await loadScript('/public/js/chess.min.js');
await loadScript('/public/js/board.js');
await loadScript('/public/js/game.js');
// Initialize
if (window.Board && window.Game) {
window.Board.init('board', {
flipped: color === 'b',
playerColor: color
});
window.Game.start({
color: color,
botId: bot,
time: time,
increment: inc,
rated: rated
});
}
},
destroy() {
document.getElementById('app').classList.remove('immersive');
if (window.Game && window.Game.cleanup) window.Game.cleanup();
}
};
import { state } from '../core/state.js';
import { api, cachedApi } from '../core/api.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
import { coinBurst } from '../core/particles.js';
import { tickNumber, pop } from '../core/animate.js';
export default {
render() {
return `
<!-- Daily Streak -->
<div class="streak" id="streak">
<span class="streak-fire">🔥</span>
<div class="streak-text">
<p class="streak-day" id="streak-day">اليوم 0</p>
<p class="streak-reward" id="streak-reward">+50 عملة</p>
</div>
<button class="streak-btn" id="streak-btn">اجمع</button>
</div>
<!-- Games Grid -->
<section>
<div class="sec-hdr">
<h2 class="sec-title">العب</h2>
</div>
<div class="game-grid" id="games-grid">
<div class="skeleton" style="height:120px;border-radius:var(--r-xl)"></div>
<div class="skeleton" style="height:120px;border-radius:var(--r-xl)"></div>
<div class="skeleton" style="height:120px;border-radius:var(--r-xl)"></div>
<div class="skeleton" style="height:120px;border-radius:var(--r-xl)"></div>
</div>
</section>
<!-- Ratings -->
<section id="ratings-sec" style="display:none">
<div class="sec-hdr">
<h2 class="sec-title">تصنيفاتي</h2>
<a href="/profile" class="sec-link">الكل</a>
</div>
<div class="stats-row" id="ratings-grid"></div>
</section>
<!-- Recent Games -->
<section>
<div class="sec-hdr">
<h2 class="sec-title">اخر المباريات</h2>
</div>
<div class="card" id="recent-games">
<div class="empty">لم تلعب اي مباراة بعد</div>
</div>
</section>
`;
},
async mount(el) {
const profile = state.get('user');
// Streak
if (profile) {
const streak = profile.daily_streak || 0;
el.querySelector('#streak-day').textContent = `اليوم ${streak}`;
const cfgData = await cachedApi('/api/config.php?category=economy', 120000);
const base = cfgData?.config?.daily_reward_base || 50;
const bonus = cfgData?.config?.daily_reward_streak_bonus || 10;
el.querySelector('#streak-reward').textContent = `+${base + streak * bonus} عملة`;
const today = new Date().toISOString().slice(0, 10);
const claimed = profile.last_daily_reward === today;
if (claimed) {
const btn = el.querySelector('#streak-btn');
btn.textContent = 'تم ✓';
btn.disabled = true;
}
}
// Streak claim
el.querySelector('#streak-btn').addEventListener('click', async (e) => {
haptics.tap();
const btn = e.target;
btn.disabled = true;
const res = await api('/api/daily-reward', { method: 'POST' });
if (res?.ok) {
audio.coin();
haptics.success();
const rect = btn.getBoundingClientRect();
coinBurst(rect.left + rect.width / 2, rect.top);
btn.textContent = 'تم ✓';
el.querySelector('#streak-day').textContent = `اليوم ${res.streak}`;
const coinsEl = document.getElementById('hdr-coins');
if (coinsEl) tickNumber(coinsEl, parseInt(coinsEl.textContent.replace(/,/g, '')) || 0, res.coins, 800);
} else if (res?.error === 'already_claimed') {
btn.textContent = 'تم ✓';
} else {
btn.disabled = false;
audio.error();
haptics.error();
}
});
// Games grid
const gamesData = await cachedApi('/api/games.php', 60000);
if (gamesData?.games) {
const routes = { chess: '/play', ludo: '/ludo', domino: '/domino', backgammon: '/backgammon' };
const heroes = { chess: 'game-card-hero--chess', ludo: 'game-card-hero--ludo', domino: 'game-card-hero--domino', backgammon: 'game-card-hero--backgammon' };
const icons = { chess: 'icon-play', ludo: 'icon-ludo', domino: 'icon-domino', backgammon: 'icon-backgammon' };
const grid = el.querySelector('#games-grid');
grid.innerHTML = gamesData.games.filter(g => routes[g.game_key]).map(g => {
const route = routes[g.game_key];
const enabled = g.is_enabled;
return `<a href="${enabled ? route : '#'}" class="game-card" ${!enabled ? 'style="opacity:0.5;pointer-events:none"' : ''}>
<div class="game-card-hero ${heroes[g.game_key] || ''}">
<svg class="icon-xl"><use href="/public/icons/sprite.svg#${icons[g.game_key] || 'icon-play'}"></use></svg>
${!enabled ? '<span class="game-card-soon">قريبا</span>' : ''}
</div>
<div class="game-card-body">
<span class="game-card-name">${g.name_ar}</span>
${enabled ? '<span class="game-card-live"><span class="game-card-live-dot"></span></span>' : ''}
</div>
</a>`;
}).join('');
grid.querySelectorAll('.game-card').forEach((card, i) => {
card.style.animationDelay = `${i * 80}ms`;
card.classList.add('anim-pop');
});
}
// Ratings
try {
const ratingsData = await api('/api/ratings.php?action=player');
if (ratingsData?.ratings?.length) {
el.querySelector('#ratings-sec').style.display = '';
const names = { chess: 'شطرنج', ludo: 'لودو', backgammon: 'طاولة', domino: 'دومينو' };
const modes = { bullet: 'بوليت', blitz: 'بليتز', rapid: 'رابيد', classical: 'كلاسيك', default: '' };
el.querySelector('#ratings-grid').innerHTML = ratingsData.ratings.map(r => {
const label = (modes[r.mode] || '') ? `${names[r.game_key] || r.game_key} ${modes[r.mode]}` : (names[r.game_key] || r.game_key);
return `<div class="stat"><div class="stat-val">${r.rating}</div><div class="stat-lbl">${label}</div></div>`;
}).join('');
}
} catch {}
// Recent games
const [gamesRes, botsRes] = await Promise.all([
api('/api/game?action=recent'),
cachedApi('/api/bots.php', 60000)
]);
const botMap = {};
if (botsRes?.bots) botsRes.bots.forEach(b => { botMap[b.id] = b.name_ar?.split(' ')[0] || b.name; });
if (gamesRes?.games?.length) {
el.querySelector('#recent-games').innerHTML = `<div class="activity-list">${gamesRes.games.slice(0, 5).map(g => {
const cls = g.result === 'win' ? 'activity-icon--win' : g.result === 'loss' ? 'activity-icon--loss' : 'activity-icon--draw';
const text = g.result === 'win' ? 'فوز' : g.result === 'loss' ? 'خسارة' : 'تعادل';
const color = g.result === 'win' ? 'color:var(--win)' : g.result === 'loss' ? 'color:var(--loss)' : '';
const icon = g.result === 'win' ? 'check' : g.result === 'loss' ? 'x' : 'clock';
const name = botMap[g.bot_id] || g.bot_id || '?';
return `<div class="activity-item">
<div class="activity-icon ${cls}"><svg class="icon-sm"><use href="/public/icons/sprite.svg#icon-${icon}"></use></svg></div>
<div class="activity-detail"><p class="activity-name">ضد ${name}</p></div>
<span class="activity-result" style="${color}">${text}</span>
</div>`;
}).join('')}</div>`;
}
},
destroy() {}
};
import { api } from '../core/api.js';
import { haptics } from '../core/haptics.js';
const GAMES = [
{ key: 'chess', name: 'شطرنج', modes: ['bullet', 'blitz', 'rapid', 'classical'] },
{ key: 'ludo', name: 'لودو', modes: ['default'] },
{ key: 'backgammon', name: 'طاولة', modes: ['default'] },
{ key: 'domino', name: 'دومينو', modes: ['default'] },
];
const MODE_NAMES = { bullet: 'بوليت', blitz: 'بليتز', rapid: 'رابيد', classical: 'كلاسيك', default: 'عام' };
let currentGame = 'chess';
let currentMode = 'blitz';
export default {
render() {
return `
<h2 style="font-size:22px;font-weight:700;text-align:center;margin-bottom:var(--sp-4);">🏆 المتصدرين</h2>
<div class="chip-row" id="lb-games" style="justify-content:center;margin-bottom:var(--sp-3);">
${GAMES.map(g => `<button class="chip${g.key === 'chess' ? ' active' : ''}" data-game="${g.key}">${g.name}</button>`).join('')}
</div>
<div class="chip-row" id="lb-modes" style="justify-content:center;margin-bottom:var(--sp-4);"></div>
<div class="card" id="lb-list"><div class="empty">جاري التحميل...</div></div>
`;
},
async mount(el) {
const gamesRow = el.querySelector('#lb-games');
const modesRow = el.querySelector('#lb-modes');
const list = el.querySelector('#lb-list');
function setupChips(container, onChange) {
container.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
haptics.selection();
container.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
onChange(c);
});
});
}
function renderModes() {
const game = GAMES.find(g => g.key === currentGame);
if (!game || game.modes.length <= 1) {
modesRow.innerHTML = '';
currentMode = game?.modes[0] || 'default';
} else {
modesRow.innerHTML = game.modes.map((m, i) =>
`<button class="chip${(m === 'blitz' || i === 0) ? ' active' : ''}" data-mode="${m}">${MODE_NAMES[m]}</button>`
).join('');
currentMode = game.modes.includes('blitz') ? 'blitz' : game.modes[0];
setupChips(modesRow, (c) => { currentMode = c.dataset.mode; load(); });
}
}
async function load() {
list.innerHTML = '<div class="empty">جاري التحميل...</div>';
let players = [];
try {
const data = await api(`/api/ratings.php?action=leaderboard&game=${currentGame}&mode=${currentMode}`);
if (data?.leaderboard?.length) players = data.leaderboard;
} catch {}
if (!players.length) {
try {
const legacy = await api(`/api/leaderboard?mode=${currentMode}`);
if (legacy?.players) players = legacy.players.map((p, i) => ({
rank: i + 1, display_name: p.display_name, username: p.username, rating: p[`elo_${currentMode}`] || 1200
}));
} catch {}
}
if (!players.length) {
list.innerHTML = '<div class="empty">لا يوجد لاعبين بعد</div>';
return;
}
list.innerHTML = '<div class="activity-list">' + players.map((p, i) => {
const rank = p.rank || i + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank;
return `<div class="activity-item">
<span style="min-width:28px;text-align:center;font-weight:700;font-size:${rank <= 3 ? '18px' : '13px'};color:var(--text-3);">${medal}</span>
<div class="activity-detail"><p class="activity-name">${p.display_name || p.username || '---'}</p></div>
<span style="font-weight:700;font-family:var(--font-mono);font-size:14px;">${p.rating}</span>
</div>`;
}).join('') + '</div>';
}
setupChips(gamesRow, (c) => { currentGame = c.dataset.game; renderModes(); load(); });
renderModes();
load();
},
destroy() {}
};
import { state } from '../core/state.js';
import { navigate } from '../core/router.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
import { shake } from '../core/animate.js';
export default {
render() {
return `
<div class="login-page">
<div class="login-card">
<h1 class="login-brand">EL3AB</h1>
<p class="login-subtitle">سجل دخولك وابدأ اللعب</p>
<form id="login-form">
<div class="input-group">
<label class="input-label">البريد الالكتروني</label>
<input type="email" class="input" id="login-email" placeholder="email@example.com" required dir="ltr" autocomplete="email">
</div>
<div class="input-group">
<label class="input-label">كلمة المرور</label>
<input type="password" class="input" id="login-pass" placeholder="••••••••" required dir="ltr" autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" id="login-btn">تسجيل الدخول</button>
</form>
<p class="login-footer">ما عندك حساب؟ <a href="/register">انشئ حساب</a></p>
<div id="login-error" class="login-error" style="display:none"></div>
</div>
</div>
`;
},
mount(el) {
const form = el.querySelector('#login-form');
const btn = el.querySelector('#login-btn');
const errEl = el.querySelector('#login-error');
form.addEventListener('submit', async (e) => {
e.preventDefault();
haptics.tap();
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'جاري الدخول...';
const email = el.querySelector('#login-email').value;
const password = el.querySelector('#login-pass').value;
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'login', email, password })
});
const data = await res.json();
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = 'block';
shake(errEl);
haptics.error();
audio.error();
} else {
state.setAuth(data.access_token, data.user);
audio.coin();
haptics.success();
navigate('/', {}, true);
window.location.reload();
}
} catch {
errEl.textContent = 'حدث خطأ في الاتصال';
errEl.style.display = 'block';
shake(errEl);
haptics.error();
}
btn.disabled = false;
btn.textContent = 'تسجيل الدخول';
});
},
destroy() {}
};
import { api } from '../core/api.js';
import { navigate } from '../core/router.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
export default {
render() {
return `
<h2 style="font-size:20px;font-weight:700;text-align:center;margin-bottom:var(--sp-2);">🎲 لودو</h2>
<p style="text-align:center;color:var(--text-3);font-size:13px;margin-bottom:var(--sp-5);">العب مع اصدقائك او ضد البوت</p>
<!-- Quick Play vs Bots -->
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">مباراة سريعة</p>
<div style="margin-bottom:var(--sp-3);">
<label class="input-label">عدد اللاعبين</label>
<div class="chip-row" id="player-count">
<button class="chip" data-count="2">2</button>
<button class="chip active" data-count="3">3</button>
<button class="chip" data-count="4">4</button>
</div>
</div>
<div style="margin-bottom:var(--sp-3);">
<label class="input-label">صعوبة البوت</label>
<div class="chip-row" id="difficulty">
<button class="chip active" data-diff="easy">سهل</button>
<button class="chip" data-diff="hard">صعب</button>
</div>
</div>
<button class="btn btn-primary btn-block btn-lg" id="btn-start">ابدأ اللعب</button>
</div>
<!-- Join Room -->
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<p style="font-size:16px;font-weight:700;margin-bottom:var(--sp-3);">انضم لغرفة</p>
<div style="display:flex;gap:var(--sp-2);">
<input class="input" id="room-code" placeholder="كود الغرفة" dir="ltr" style="flex:1;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--r-md);padding:0 var(--sp-4);">
<button class="btn btn-secondary" id="btn-join">انضم</button>
</div>
</div>
<!-- Matchmaking -->
<button class="btn btn-ghost btn-block" id="btn-match">⚔️ ابحث عن خصم حقيقي</button>
`;
},
mount(el) {
function setupChips(id) {
const container = el.querySelector(`#${id}`);
container.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
haptics.selection();
container.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
});
});
}
setupChips('player-count');
setupChips('difficulty');
el.querySelector('#btn-start').addEventListener('click', async () => {
haptics.tap(); audio.tap();
const count = parseInt(el.querySelector('#player-count .chip.active').dataset.count);
const diff = el.querySelector('#difficulty .chip.active').dataset.diff;
const bots = [];
for (let i = 1; i < count; i++) bots.push({ name: `بوت ${i}`, difficulty: diff });
const res = await api('/api/ludo', { method: 'POST', body: { action: 'create', player_count: count, bots } });
if (res?.ok && res.match) {
const startRes = await api('/api/ludo', { method: 'POST', body: { action: 'start', match_id: res.match.id } });
if (startRes?.ok) {
navigate(`/ludo-game?match_id=${startRes.match.id}`);
}
}
});
el.querySelector('#btn-join').addEventListener('click', async () => {
haptics.tap();
const code = el.querySelector('#room-code').value.trim();
if (!code) return;
const res = await api('/api/ludo', { method: 'POST', body: { action: 'join', room_code: code } });
if (res?.ok) navigate(`/ludo-game?match_id=${res.match.id}`);
});
el.querySelector('#btn-match').addEventListener('click', async () => {
haptics.tap(); audio.tap();
await api('/api/ludo', { method: 'POST', body: { action: 'matchmake', sub_action: 'join' } });
// TODO: matchmaking polling screen
});
},
destroy() {}
};
import { state } from '../core/state.js';
import { api } from '../core/api.js';
import { navigate } from '../core/router.js';
const TITLES = { 1:'مبتدئ', 2:'مستجد', 3:'مستجد ٢', 4:'لاعب', 5:'لاعب ٢', 6:'ماهر', 7:'ماهر ٢', 8:'خبير', 9:'خبير ٢', 10:'استاذ', 11:'استاذ ٢', 12:'بطل', 13:'بطل ٢', 14:'اسطورة', 15:'غراند ماستر' };
const XP_REQ = [0, 0, 100, 300, 600, 1000, 1500, 2100, 2800, 3600, 4500, 5500, 6600, 7800, 9100, 10500];
export default {
render() {
return `
<div style="display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);padding:var(--sp-6) 0 var(--sp-4);background:linear-gradient(180deg,rgba(0,212,255,0.03),transparent);border-radius:var(--r-xl);border:1px solid var(--border);margin-bottom:var(--sp-4);">
<div style="width:72px;height:72px;border-radius:50%;background:var(--bg-3);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px var(--gold);">
<svg class="icon-xl" style="color:var(--text-3)"><use href="/public/icons/sprite.svg#icon-profile"></use></svg>
</div>
<h2 style="font-size:20px;font-weight:700;" id="p-name">---</h2>
<p style="font-size:13px;color:var(--text-3);" id="p-username">@---</p>
<div style="display:flex;gap:var(--sp-2);">
<span class="badge badge-gold" id="p-level">Lv 1</span>
<span class="badge badge-cyan" id="p-title">مبتدئ</span>
</div>
</div>
<!-- XP Bar -->
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<div style="display:flex;justify-content:space-between;margin-bottom:var(--sp-2);">
<span style="font-size:12px;color:var(--text-3);">مستوى الحساب</span>
<span style="font-size:12px;color:var(--text-3);" id="p-xp">0 / 100 XP</span>
</div>
<div style="width:100%;height:6px;background:var(--bg-3);border-radius:var(--r-full);overflow:hidden;">
<div id="p-xp-bar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--cyan),var(--gold));border-radius:var(--r-full);transition:width 0.8s var(--ease);"></div>
</div>
</div>
<!-- Ratings -->
<section style="margin-bottom:var(--sp-4);">
<div class="sec-hdr"><h2 class="sec-title">تصنيفات الالعاب</h2></div>
<div id="p-ratings"><div class="skeleton" style="height:80px"></div></div>
</section>
<!-- Stats -->
<section style="margin-bottom:var(--sp-4);">
<div class="sec-hdr"><h2 class="sec-title">احصائيات</h2></div>
<div class="stats-row" id="p-stats">
<div class="stat"><div class="stat-val" id="s-games">0</div><div class="stat-lbl">مباريات</div></div>
<div class="stat"><div class="stat-val" style="color:var(--win)" id="s-wins">0</div><div class="stat-lbl">فوز</div></div>
<div class="stat"><div class="stat-val" id="s-draws">0</div><div class="stat-lbl">تعادل</div></div>
<div class="stat"><div class="stat-val" style="color:var(--loss)" id="s-losses">0</div><div class="stat-lbl">خسارة</div></div>
</div>
</section>
<!-- Economy -->
<div class="card card-pad" style="margin-bottom:var(--sp-4);">
<div style="display:flex;justify-content:space-around;">
<div style="text-align:center;"><p style="font-size:18px;font-weight:700;" id="p-coins">0</p><p style="font-size:11px;color:var(--text-3);">عملات</p></div>
<div style="text-align:center;"><p style="font-size:18px;font-weight:700;" id="p-gems">0</p><p style="font-size:11px;color:var(--text-3);">جواهر</p></div>
<div style="text-align:center;"><p style="font-size:18px;font-weight:700;" id="p-streak">0</p><p style="font-size:11px;color:var(--text-3);">🔥 ايام</p></div>
</div>
</div>
<!-- Actions -->
<a href="/settings" class="btn btn-ghost btn-block" style="margin-bottom:var(--sp-2);">⚙️ الاعدادات</a>
<button class="btn btn-danger btn-block" id="btn-logout">تسجيل خروج</button>
`;
},
async mount(el) {
const data = await api('/api/profile');
if (!data?.profile) return;
const p = data.profile;
el.querySelector('#p-name').textContent = p.display_name || p.username || '---';
el.querySelector('#p-username').textContent = '@' + (p.username || '---');
el.querySelector('#p-level').textContent = 'Lv ' + (p.level || 1);
el.querySelector('#p-title').textContent = TITLES[p.level] || 'مبتدئ';
el.querySelector('#s-games').textContent = p.total_games_played || 0;
el.querySelector('#s-wins').textContent = p.total_wins || 0;
el.querySelector('#s-draws').textContent = p.total_draws || 0;
el.querySelector('#s-losses').textContent = p.total_losses || 0;
el.querySelector('#p-coins').textContent = (p.coins || 0).toLocaleString();
el.querySelector('#p-gems').textContent = p.gems || 0;
el.querySelector('#p-streak').textContent = p.daily_streak || 0;
const xp = p.xp || 0;
const lvl = p.level || 1;
const next = XP_REQ[lvl + 1] || (lvl * 1200);
const prev = XP_REQ[lvl] || 0;
const pct = Math.min(100, Math.round(((xp - prev) / (next - prev)) * 100));
el.querySelector('#p-xp').textContent = `${xp} / ${next} XP`;
setTimeout(() => { el.querySelector('#p-xp-bar').style.width = pct + '%'; }, 100);
// Ratings
try {
const rd = await api('/api/ratings.php?action=player');
if (rd?.ratings?.length) {
const names = { chess:'شطرنج', ludo:'لودو', backgammon:'طاولة', domino:'دومينو' };
const modes = { bullet:'بوليت', blitz:'بليتز', rapid:'رابيد', classical:'كلاسيك', default:'' };
el.querySelector('#p-ratings').innerHTML = '<div class="stats-row">' + rd.ratings.map(r => {
const lbl = (modes[r.mode] || '') ? `${names[r.game_key]||r.game_key} ${modes[r.mode]}` : (names[r.game_key]||r.game_key);
return `<div class="stat"><div class="stat-val">${r.rating}</div><div class="stat-lbl">${lbl}</div></div>`;
}).join('') + '</div>';
} else {
el.querySelector('#p-ratings').innerHTML = '<p style="color:var(--text-3);font-size:13px;">العب مباريات لتظهر تصنيفاتك</p>';
}
} catch { el.querySelector('#p-ratings').innerHTML = ''; }
el.querySelector('#btn-logout').addEventListener('click', () => state.logout());
},
destroy() {}
};
import { audio } from '../core/audio.js';
import { haptics } from '../core/haptics.js';
import { state } from '../core/state.js';
export default {
render() {
const muted = audio.isMuted();
return `
<h2 style="font-size:22px;font-weight:700;text-align:center;margin-bottom:var(--sp-5);">⚙️ الاعدادات</h2>
<div class="card card-pad" style="margin-bottom:var(--sp-3);">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:14px;font-weight:500;">🔊 الصوت</span>
<button class="btn btn-sm ${muted ? 'btn-ghost' : 'btn-secondary'}" id="btn-sound">${muted ? 'مكتوم' : 'مفعل'}</button>
</div>
</div>
<div class="card card-pad" style="margin-bottom:var(--sp-3);">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:14px;font-weight:500;">📳 الاهتزاز</span>
<span style="font-size:12px;color:var(--text-3);">${'vibrate' in navigator ? 'مدعوم' : 'غير مدعوم'}</span>
</div>
</div>
<div class="card card-pad" style="margin-bottom:var(--sp-5);">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:14px;font-weight:500;">🌐 اللغة</span>
<span style="font-size:12px;color:var(--text-3);">العربية</span>
</div>
</div>
<button class="btn btn-danger btn-block" id="btn-logout">تسجيل خروج</button>
`;
},
mount(el) {
el.querySelector('#btn-sound').addEventListener('click', (e) => {
haptics.tap();
const on = audio.toggle();
e.target.textContent = on ? 'مفعل' : 'مكتوم';
e.target.className = `btn btn-sm ${on ? 'btn-secondary' : 'btn-ghost'}`;
if (on) audio.tap();
});
el.querySelector('#btn-logout').addEventListener('click', () => {
haptics.tap();
state.logout();
});
},
destroy() {}
};
import { api } from '../core/api.js';
import { haptics } from '../core/haptics.js';
import { audio } from '../core/audio.js';
import { coinBurst } from '../core/particles.js';
const CATEGORIES = [
{ key: 'board_theme', name: 'رقعة اللعب' },
{ key: 'piece_set', name: 'طقم القطع' },
{ key: 'avatar_frame', name: 'إطار الصورة' },
{ key: 'trail_effect', name: 'تأثيرات' },
];
export default {
render() {
return `
<h2 style="font-size:22px;font-weight:700;text-align:center;margin-bottom:var(--sp-2);">🛍️ المتجر</h2>
<div style="display:flex;justify-content:center;gap:var(--sp-4);margin-bottom:var(--sp-4);">
<div style="text-align:center;"><span style="font-size:18px;font-weight:700;color:var(--gold);" id="shop-coins">0</span><p style="font-size:10px;color:var(--text-3);">عملات</p></div>
<div style="text-align:center;"><span style="font-size:18px;font-weight:700;color:var(--purple);" id="shop-gems">0</span><p style="font-size:10px;color:var(--text-3);">جواهر</p></div>
</div>
<div class="chip-row" id="shop-cats" style="justify-content:center;margin-bottom:var(--sp-4);">
${CATEGORIES.map((c, i) => `<button class="chip${i === 0 ? ' active' : ''}" data-cat="${c.key}">${c.name}</button>`).join('')}
</div>
<div id="shop-items"><div class="empty">جاري التحميل...</div></div>
`;
},
async mount(el) {
let currentCat = 'board_theme';
const profileData = await api('/api/profile');
if (profileData?.profile) {
el.querySelector('#shop-coins').textContent = (profileData.profile.coins || 0).toLocaleString();
el.querySelector('#shop-gems').textContent = profileData.profile.gems || 0;
}
const catsRow = el.querySelector('#shop-cats');
catsRow.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
haptics.selection();
catsRow.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
currentCat = c.dataset.cat;
loadItems();
});
});
async function loadItems() {
const container = el.querySelector('#shop-items');
container.innerHTML = '<div class="skeleton" style="height:80px;margin-bottom:var(--sp-2);"></div>'.repeat(3);
const data = await api(`/api/shop?type=${currentCat}`);
if (!data?.items?.length) { container.innerHTML = '<div class="empty">لا يوجد عناصر</div>'; return; }
container.innerHTML = data.items.map(item => {
const rarityColor = { common:'var(--text-3)', uncommon:'var(--win)', rare:'var(--cyan)', epic:'var(--purple)', legendary:'var(--gold)' }[item.rarity] || 'var(--text-3)';
return `<div class="card card-pad" style="margin-bottom:var(--sp-2);display:flex;align-items:center;gap:var(--sp-3);${item.owned ? 'opacity:0.6' : ''}">
<div style="width:44px;height:44px;border-radius:var(--r-sm);background:var(--bg-3);border:2px solid ${rarityColor};display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg class="icon-lg" style="color:${rarityColor}"><use href="/public/icons/sprite.svg#icon-star"></use></svg>
</div>
<div style="flex:1;min-width:0;">
<p style="font-size:14px;font-weight:600;">${item.name_ar || item.name}</p>
<p style="font-size:11px;color:${rarityColor};">${item.rarity || ''}</p>
</div>
${item.owned
? (item.equipped ? '<span class="badge badge-win">مُجهّز</span>' : `<button class="btn btn-sm btn-ghost" data-equip="${item.id}">تجهيز</button>`)
: `<button class="btn btn-sm btn-primary" data-buy="${item.id}">🪙 ${item.price_coins || 0}</button>`
}
</div>`;
}).join('');
container.addEventListener('click', async (e) => {
const buyBtn = e.target.closest('[data-buy]');
const equipBtn = e.target.closest('[data-equip]');
if (buyBtn) {
haptics.tap();
const res = await api('/api/shop', { method: 'POST', body: { action: 'buy', item_id: buyBtn.dataset.buy } });
if (res?.ok) {
audio.coin(); haptics.success();
const rect = buyBtn.getBoundingClientRect();
coinBurst(rect.left, rect.top);
loadItems();
const pd = await api('/api/profile');
if (pd?.profile) el.querySelector('#shop-coins').textContent = (pd.profile.coins || 0).toLocaleString();
} else {
audio.error(); haptics.error();
}
} else if (equipBtn) {
haptics.tap();
await api('/api/shop', { method: 'POST', body: { action: 'equip', item_id: equipBtn.dataset.equip, equip: true } });
audio.tap();
loadItems();
}
});
}
loadItems();
},
destroy() {}
};
/* === KEYFRAME LIBRARY === */
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-3px); }
40% { transform: translateX(3px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
@keyframes pop {
0% { transform: scale(0.8); opacity: 0; }
60% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes slide-down {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scale-in {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--gold-glow); }
50% { box-shadow: 0 0 20px 4px var(--gold-glow); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes count-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes radar {
0% { transform: scale(0.3); opacity: 1; }
100% { transform: scale(2); opacity: 0; }
}
@keyframes bounce-in {
0% { transform: scale(0); }
50% { transform: scale(1.15); }
70% { transform: scale(0.95); }
100% { transform: scale(1); }
}
@keyframes confetti-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
@keyframes coin-fly {
0% { transform: translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(var(--fly-x), var(--fly-y)) scale(0.3); opacity: 0; }
}
@keyframes fire-flicker {
0%, 100% { transform: scale(1) rotate(-2deg); }
50% { transform: scale(1.1) rotate(2deg); }
}
@keyframes skeleton-wave {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Utility animation classes */
.anim-pop { animation: pop var(--duration-normal) var(--ease-bounce) both; }
.anim-shake { animation: shake 0.4s ease both; }
.anim-float { animation: float 3s ease-in-out infinite; }
.anim-pulse { animation: glow-pulse 2s ease-in-out infinite; }
.anim-bounce-in { animation: bounce-in 0.5s var(--ease-bounce) both; }
.anim-fade-in { animation: fade-in var(--duration-normal) var(--ease) both; }
/* Skeleton loading */
.skeleton {
background: linear-gradient(90deg, var(--bg-2) 25%, var(--bg-3) 50%, var(--bg-2) 75%);
background-size: 200% 100%;
animation: skeleton-wave 1.5s infinite linear;
border-radius: var(--r-md);
}
/* === BUTTONS === */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
min-height: var(--touch);
padding: 10px 20px;
font-weight: 600;
font-size: 14px;
border-radius: var(--r-md);
transition: transform var(--duration-fast), box-shadow var(--duration-normal);
position: relative;
overflow: hidden;
white-space: nowrap;
}
.btn:active { transform: scale(0.95); }
.btn:disabled { opacity: 0.4; pointer-events: none; }
.btn-primary {
background: var(--gold);
color: var(--text-inverse);
box-shadow: var(--shadow-gold);
}
.btn-secondary {
background: var(--cyan);
color: var(--text-inverse);
box-shadow: var(--shadow-cyan);
}
.btn-ghost {
background: transparent;
color: var(--text-2);
border: 1px solid var(--border-light);
}
.btn-ghost:active { background: rgba(255,255,255,0.04); }
.btn-danger { background: var(--loss-dim); color: var(--loss); border: 1px solid rgba(248,113,113,0.2); }
.btn-block { display: flex; width: 100%; }
.btn-lg { min-height: 52px; font-size: 16px; font-weight: 700; border-radius: var(--r-lg); }
.btn-sm { min-height: 32px; padding: 6px 14px; font-size: 12px; border-radius: var(--r-sm); }
/* === CARDS === */
.card {
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
transition: transform var(--duration-fast), box-shadow var(--duration-fast);
}
.card:active { transform: scale(0.98); }
.card-pad { padding: var(--sp-4); }
/* === GAME CARDS (home grid) === */
.game-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-3);
}
.game-card {
position: relative;
border-radius: var(--r-xl);
overflow: hidden;
background: var(--bg-1);
border: 1px solid var(--border);
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-bounce), box-shadow var(--duration-fast);
}
.game-card:active {
transform: scale(0.96);
}
.game-card-hero {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.game-card-hero--chess { background: linear-gradient(135deg, #1a2a4a, #2a5a8a); }
.game-card-hero--ludo { background: linear-gradient(135deg, #2a1a4a, #6a2a9a); }
.game-card-hero--domino { background: linear-gradient(135deg, #1a3a2a, #2a7a5a); }
.game-card-hero--backgammon { background: linear-gradient(135deg, #3a2a1a, #7a5a2a); }
.game-card-hero .icon-xl { color: rgba(255,255,255,0.9); }
.game-card-body {
padding: var(--sp-3);
display: flex;
align-items: center;
justify-content: space-between;
}
.game-card-name {
font-size: 14px;
font-weight: 700;
}
.game-card-live {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-3);
}
.game-card-live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--online);
animation: pulse-dot 2s infinite;
}
.game-card-soon {
position: absolute;
top: var(--sp-2);
left: var(--sp-2);
padding: 2px 8px;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
border-radius: var(--r-full);
font-size: 10px;
font-weight: 600;
color: var(--text-2);
}
/* === INPUTS === */
.input-group { margin-bottom: var(--sp-4); }
.input-label { display: block; font-size: 13px; font-weight: 500; color: var(--text-2); margin-bottom: var(--sp-2); }
.input {
width: 100%;
height: var(--touch);
padding: 0 var(--sp-4);
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
color: var(--text-1);
font-size: 14px;
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.08);
}
.input::placeholder { color: var(--text-3); }
/* === CHIPS === */
.chip-row {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.chip {
min-height: 36px;
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-2);
cursor: pointer;
transition: all var(--duration-fast);
}
.chip:active { transform: scale(0.95); }
.chip.active { background: var(--cyan); border-color: var(--cyan); color: var(--text-inverse); }
/* === BADGES === */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--r-full);
}
.badge-gold { background: var(--gold-dim); color: var(--gold); }
.badge-cyan { background: var(--cyan-dim); color: var(--cyan); }
.badge-win { background: var(--win-dim); color: var(--win); }
.badge-loss { background: var(--loss-dim); color: var(--loss); }
/* === STREAK BANNER === */
.streak {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--r-lg);
}
.streak-fire {
font-size: 24px;
animation: fire-flicker 1.5s ease-in-out infinite;
}
.streak-text { flex: 1; }
.streak-day { font-size: 14px; font-weight: 600; }
.streak-reward { font-size: 12px; color: var(--text-3); }
.streak-btn {
min-height: 32px;
padding: 6px 14px;
background: var(--gold);
color: var(--text-inverse);
font-size: 12px;
font-weight: 700;
border-radius: var(--r-sm);
transition: transform var(--duration-fast);
box-shadow: var(--shadow-gold);
}
.streak-btn:active { transform: scale(0.92); }
.streak-btn:disabled { opacity: 0.4; background: var(--bg-3); color: var(--text-3); box-shadow: none; }
/* === STATS === */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: var(--sp-2);
}
.stat {
background: var(--bg-2);
border-radius: var(--r-md);
padding: var(--sp-3);
text-align: center;
}
.stat-val {
font-size: 18px;
font-weight: 700;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.stat-lbl {
font-size: 10px;
color: var(--text-3);
margin-top: 2px;
}
/* === SECTION HEADERS === */
.sec-hdr {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-3);
}
.sec-title {
font-size: 16px;
font-weight: 700;
}
.sec-link {
font-size: 12px;
color: var(--cyan);
font-weight: 500;
}
/* === ACTIVITY LIST === */
.activity-list { display: flex; flex-direction: column; }
.activity-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
.activity-item:last-child { border-bottom: none; }
.activity-icon {
width: 32px;
height: 32px;
border-radius: var(--r-sm);
display: flex;
align-items: center;
justify-content: center;
}
.activity-icon--win { background: var(--win-dim); color: var(--win); }
.activity-icon--loss { background: var(--loss-dim); color: var(--loss); }
.activity-icon--draw { background: rgba(148,163,184,0.12); color: var(--draw); }
.activity-detail { flex: 1; }
.activity-name { font-size: 13px; font-weight: 600; }
.activity-meta { font-size: 11px; color: var(--text-3); }
.activity-result { font-size: 12px; font-weight: 700; }
/* === TOAST === */
.toast {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--r-md);
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slide-down var(--duration-normal) var(--ease-bounce);
pointer-events: auto;
}
.toast--success { border-color: var(--win); }
.toast--error { border-color: var(--loss); }
.toast--info { border-color: var(--cyan); }
/* === EMPTY STATE === */
.empty {
padding: var(--sp-10) var(--sp-5);
text-align: center;
color: var(--text-3);
font-size: 14px;
}
/* === LOGIN === */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: var(--sp-5);
}
.login-card { width: 100%; max-width: 380px; }
.login-brand {
font-weight: 800;
font-size: 40px;
color: var(--gold);
text-align: center;
letter-spacing: -1px;
margin-bottom: var(--sp-2);
text-shadow: 0 0 40px var(--gold-glow);
}
.login-subtitle {
text-align: center;
font-size: 14px;
color: var(--text-3);
margin-bottom: var(--sp-6);
}
.login-error {
padding: var(--sp-3);
background: var(--loss-dim);
border: 1px solid rgba(248,113,113,0.2);
border-radius: var(--r-md);
color: var(--loss);
font-size: 13px;
text-align: center;
margin-top: var(--sp-4);
animation: shake 0.4s ease;
}
.login-footer {
text-align: center;
font-size: 13px;
color: var(--text-3);
margin-top: var(--sp-5);
}
.login-footer a { color: var(--cyan); font-weight: 500; }
This diff is collapsed.
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
direction: rtl;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text-1);
background: var(--bg-0);
-webkit-font-smoothing: antialiased;
height: 100%;
}
body {
min-height: 100dvh;
overflow-x: hidden;
overscroll-behavior: none;
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; border: none; background: none; font: inherit; color: inherit; }
input, select, textarea { font: inherit; color: inherit; background: none; border: none; outline: none; }
img { display: block; max-width: 100%; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: var(--r-full); }
:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* === APP SHELL === */
#app {
display: flex;
flex-direction: column;
min-height: 100dvh;
position: relative;
}
/* Header */
.hdr {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: var(--header-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-4);
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(1.4);
border-bottom: 1px solid var(--border);
padding-top: env(safe-area-inset-top);
transition: transform var(--duration-normal) var(--ease);
}
.hdr--hidden { transform: translateY(-100%); }
.hdr-brand {
font-weight: 800;
font-size: 20px;
color: var(--gold);
letter-spacing: -0.5px;
}
.hdr-center {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.hdr-level {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 4px 10px;
background: var(--bg-2);
border-radius: var(--r-full);
font-size: 12px;
font-weight: 700;
color: var(--gold);
}
.hdr-currency {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: 13px;
font-weight: 600;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.hdr-currency-icon {
width: 16px;
height: 16px;
}
.hdr-actions {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.hdr-btn {
position: relative;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--r-sm);
color: var(--text-3);
transition: background var(--duration-fast), color var(--duration-fast);
}
.hdr-btn:active { background: var(--bg-2); color: var(--text-1); }
.hdr-btn-dot {
position: absolute;
top: 8px;
left: 8px;
width: 8px;
height: 8px;
background: var(--loss);
border-radius: 50%;
border: 2px solid var(--bg-0);
animation: pulse-dot 2s infinite;
}
/* Main content area */
.main {
flex: 1;
padding-top: var(--header-h);
padding-bottom: calc(var(--nav-h) + env(safe-area-inset-bottom) + var(--sp-4));
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
}
.main-inner {
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
/* Bottom Navigation */
.nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: calc(var(--nav-h) + env(safe-area-inset-bottom));
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: center;
padding-bottom: env(safe-area-inset-bottom);
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(1.4);
border-top: 1px solid var(--border);
transition: transform var(--duration-normal) var(--ease);
}
.nav--hidden { transform: translateY(100%); }
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
height: var(--nav-h);
color: var(--text-3);
font-size: 10px;
font-weight: 500;
transition: color var(--duration-fast);
position: relative;
}
.nav-item.active { color: var(--cyan); }
.nav-item.active::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 3px;
background: var(--cyan);
border-radius: 0 0 var(--r-full) var(--r-full);
}
.nav-item .icon {
width: 22px;
height: 22px;
transition: transform var(--duration-fast) var(--ease-bounce);
}
.nav-item:active .icon { transform: scale(0.85); }
.nav-badge {
position: absolute;
top: 6px;
left: 50%;
margin-left: 6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--loss);
border-radius: var(--r-full);
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
/* Desktop sidebar */
@media (min-width: 768px) {
.nav { display: none; }
.sidebar {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: var(--sidebar-w);
background: var(--bg-surface);
border-left: 1px solid var(--border);
padding: var(--sp-5) var(--sp-3);
overflow-y: auto;
z-index: 90;
}
.main {
margin-right: var(--sidebar-w);
max-width: 600px;
}
.hdr {
right: var(--sidebar-w);
}
}
/* Sidebar (hidden on mobile) */
.sidebar {
display: none;
}
.sidebar-brand {
font-weight: 800;
font-size: 22px;
color: var(--gold);
padding: 0 var(--sp-3);
margin-bottom: var(--sp-6);
}
.sidebar-section {
margin-bottom: var(--sp-4);
}
.sidebar-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-3);
padding: 0 var(--sp-3);
margin-bottom: var(--sp-2);
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 500;
color: var(--text-2);
transition: background var(--duration-fast), color var(--duration-fast);
}
.sidebar-item:active { background: rgba(255,255,255,0.04); color: var(--text-1); }
.sidebar-item.active { background: var(--cyan-dim); color: var(--cyan); }
/* Screen transitions */
.screen {
animation: screen-enter var(--duration-normal) var(--ease) both;
}
.screen-exit {
animation: screen-exit var(--duration-fast) var(--ease) both;
}
@keyframes screen-enter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes screen-exit {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
/* Toast container */
.toast-layer {
position: fixed;
top: calc(var(--header-h) + var(--sp-3));
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
flex-direction: column;
gap: var(--sp-2);
pointer-events: none;
width: calc(100% - var(--sp-8));
max-width: 360px;
}
/* Overlay / modals */
.overlay {
position: fixed;
inset: 0;
z-index: 150;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
opacity: 0;
transition: opacity var(--duration-normal);
pointer-events: none;
}
.overlay.active {
opacity: 1;
pointer-events: auto;
}
/* Particle canvas */
#particles {
position: fixed;
inset: 0;
z-index: 300;
pointer-events: none;
}
/* Icon base */
.icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
.icon-sm { width: 16px; height: 16px; }
.icon-lg { width: 24px; height: 24px; }
.icon-xl { width: 32px; height: 32px; }
.icon-fill { fill: currentColor; stroke: none; }
/* Game immersive mode */
.immersive .hdr,
.immersive .nav { transform: translateY(-100%); }
.immersive .nav { transform: translateY(100%); }
.immersive .main { padding-top: 0; padding-bottom: 0; max-width: 100%; }
:root {
/* Backgrounds */
--bg-0: #030A12;
--bg-1: #081420;
--bg-2: #0F1F30;
--bg-3: #182B42;
--bg-surface: #0C1926;
--bg-elevated: #142438;
--bg-glass: rgba(8, 20, 32, 0.85);
/* Brand */
--gold: #F5B731;
--gold-dim: rgba(245, 183, 49, 0.12);
--gold-glow: rgba(245, 183, 49, 0.25);
--cyan: #00D4FF;
--cyan-dim: rgba(0, 212, 255, 0.10);
--cyan-glow: rgba(0, 212, 255, 0.2);
--purple: #8B5CF6;
--purple-dim: rgba(139, 92, 246, 0.12);
/* Status */
--win: #34D399;
--win-dim: rgba(52, 211, 153, 0.12);
--loss: #F87171;
--loss-dim: rgba(248, 113, 113, 0.12);
--draw: #94A3B8;
--online: #22C55E;
--warning: #FBBF24;
/* Text */
--text-1: #F8FAFC;
--text-2: #CBD5E1;
--text-3: #8B9DB7;
--text-inverse: #0F172A;
/* Border */
--border: rgba(255, 255, 255, 0.07);
--border-light: rgba(255, 255, 255, 0.12);
--border-focus: rgba(0, 212, 255, 0.5);
/* Radius */
--r-xs: 6px;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-xl: 20px;
--r-2xl: 28px;
--r-full: 9999px;
/* Spacing */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sp-12: 48px;
/* Shadows */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 48px rgba(0, 0, 0, 0.5);
--shadow-gold: 0 4px 20px rgba(245, 183, 49, 0.2);
--shadow-cyan: 0 4px 20px rgba(0, 212, 255, 0.15);
/* Layout */
--header-h: 56px;
--nav-h: 64px;
--sidebar-w: 220px;
--content-max: 480px;
--touch: 44px;
/* Type */
--font: 'IBM Plex Sans Arabic', system-ui, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
/* Motion */
--ease: cubic-bezier(0.2, 0, 0, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
}
This diff is collapsed.
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