Commit b0303129 authored by Mahmoud Aglan's avatar Mahmoud Aglan

init

parents
.DS_Store
storage/uploads/*
!storage/uploads/.gitkeep
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
# Security headers
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'"
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAwNRFitb/O8fS9TaUoCZ/VJZUEehvdyb1EgCjPrQbTeT6TlZh
rvkzHYcGIKsgarI6wuP9aK4+rLW8SL9VP6Ey3G4CgY/Hx9ZxhoSN5N2ffZkJE1Ji
hvgkDXzSN+l4P3e422ICxuVQqozba8/o8pZo46EzgRry760i9RcR8Q8JsXysjeQ1
Q68F8JhUYt1GNQlc5/A1EHEHyv1XIMDkYQ0eart1iUf9uvU/tp6pFTNUq/UtL/BT
RaJdnShbstS7bsfZwkyRtzXUlu15z/xdCsoXbbz+GC4oV7thzZQ+eRS8sZBGTsHF
6AaNqvd3QQnbFEpUSDzK3xupVEvLw3BbwYFvxwIDAQABAoIBAB4Gr9F/yvynD/1p
A1mwxPEJ+4tSU1ENeunTuZfA+eN2PVfHcayKV2BIrzaVDxYuLKI+WC5du5qvLeNy
D7c5xa63XqKIHgbLKKBWsbWqoPQwyU397SOxLgP/pMhaDYRsgxd+Oop4GMiF6IDw
PgjQTQLtDhUTejLCFghuEDgmLE87oi4oV3m8y36Yl1gHSHLzHivk8tiJoFdd9Jw3
FdM9wPS2FcafGaT7CDhbmo8XtHgynxbjCAX6D8tOpsbhVuClseRLXMfhkai0UuO4
JhgJ4tvDoxW3G/3qZkSvL/jUr5gybUCjVAcBfE7HIvfcYKQxU+iX9R8Q7cWzFO/d
RjooSWECgYEA9DDSYxBXuOkVHq9KDWnRBWUslLk2i9nvPEL+JVPqzR6Q+uRnZzl/
j54XBd25lAcCkTDmjZTHKFroX5uvgBzHGfGZFGtoZuObfVkzK9eicupPqTe7rcN6
fTJWeJnNJsYbkGzv0jrqvBJOG+/9zGVsn7UQZvWHiS9lgKYrDz2Vx5cCgYEAyieS
3xFK+lytLgJ6pqNx6RuvEAKgouZi3sgYIxwyMSA9Yap/5po6Osj8w9X18Bvm6YYF
gok+Zx63pEB7296RrmGDxkOw5Hl/gH07Yx2hvM4et3RyvK3udOXCdcXgWN0ue/Uf
H75UZ4CLAmNALEUa9lOcB2uydVHOhXCmgPveH1ECgYAtShzLKM3MStaS8VnfsP+G
a6RgFRXrzEjVuWsfizfiQUgMcG5JM93Xyi9k9CGmNcKhIRuxqKVjc7DjgqGDNlMr
GacVpXIgmxhMoE2gVQcZHyIVNXQGn1nJfJuTFJt7FIUqPTohmLHOneqEvfcpgKor
2M4o+mLf6718pdUYp4hvEwKBgBSJhLBIz3cz5xwfgFphjHcEKvrTaYJjKXQ8m8cl
XCwFfHbpnWjODlBejt9OY1frXcAnr3Odgct0IW/8ZRjnOaGfooWH5vavKTbigiAF
qKLHxfMZT3a/rNQPa3wPiEU+4zQQqQLOkUCanIS3lJNqydxwjg9q74xfrT19Pk0o
SV6hAoGBAKfidUGqWGH3FugbgG2cm7rK54nh978brZLKglekR1RlRWKG7QpRP33v
D13y3BD1rRM3vguD2aABhwqbYVt1hjHA+mv+yDzJps08FtZIasiTRpm2mFanOD84
yKf+0/HMD2G45HzoMYdG6BdZ5HP1y4WFNfRoxjwCTnwyrDMNJhOl
-----END RSA PRIVATE KEY-----
================================================================================
AL-ARCADE SELF-HOSTED SUPABASE — CONNECTION & REFERENCE
================================================================================
SERVER ACCESS
=============
IP: 3.68.63.185
User: ubuntu
SSH Key: NewServer.pem
SSH Command: ssh -i NewServer.pem ubuntu@3.68.63.185
All docker commands require sudo.
SUPABASE API URL
================
https://safe-supabase-kong.caprover.al-arcade.com
SUPABASE STUDIO (Dashboard)
============================
URL: https://safe-supabase-studio.caprover.al-arcade.com
Auth: HTTP Basic Auth
Username: admin
Password: Alarcade123#
API KEYS
========
Anon Key (public, client-side safe):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84
Service Role Key (secret, server-side only, bypasses RLS):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4
JWT Secret:
902343981eb82f43ff7a3757f3fcf25f14a2b9c729454eae5029ee3d1f189eb7
DATABASE DIRECT CONNECTION
==========================
Host: safe-supabase-db (internal) or localhost from server
Port: 5432
Database: postgres
Admin User: supabase_admin
Password: 28ac17bf9d4f7a3d1bad045408102cf5
Connection String (from server):
postgresql://supabase_admin:28ac17bf9d4f7a3d1bad045408102cf5@localhost:5432/postgres
Connection Pooler (Supavisor):
Port 6543 (transaction mode)
API ENDPOINTS
=============
All endpoints are relative to the API URL above.
All require header: apikey: <anon_key or service_role_key>
REST API: /rest/v1/
Auth: /auth/v1/
Storage: /storage/v1/
Realtime: /realtime/v1/
Edge Functions: /functions/v1/<function_name>
GraphQL: /graphql/v1
Postgres Meta: /pg/
CLIENT SDK SETUP
================
JavaScript/TypeScript:
----------------------
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://safe-supabase-kong.caprover.al-arcade.com',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84'
)
Unity C#:
---------
var url = "https://safe-supabase-kong.caprover.al-arcade.com";
var key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84";
var client = new Supabase.Client(url, key);
Flutter/Dart:
-------------
final supabase = Supabase.initialize(
url: 'https://safe-supabase-kong.caprover.al-arcade.com',
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84',
);
Python:
-------
from supabase import create_client
supabase = create_client(
"https://safe-supabase-kong.caprover.al-arcade.com",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84"
)
AVAILABLE FEATURES
==================
1. DATABASE (PostgreSQL 15)
- Create tables, schemas, views, functions, triggers
- Row Level Security (RLS) policies
- Extensions: pgvector, pg_graphql, pgjwt, uuid-ossp, pgcrypto
- Full SQL access via psql or REST API
2. AUTHENTICATION (GoTrue v2.186.0)
- Email/password sign-up and login
- Anonymous users
- JWT-based sessions
- Admin user management API
- Auto-confirm enabled (no SMTP configured yet)
3. STORAGE (v1.22.12)
- Create buckets (public or private)
- Upload/download files up to 50MB
- Image transformations via ImgProxy
- RLS policies on buckets/objects
4. REALTIME (v2.34.47)
- Postgres Changes (subscribe to INSERT/UPDATE/DELETE)
- Broadcast (send messages between clients)
- Presence (track online users)
- Enable per table: ALTER PUBLICATION supabase_realtime ADD TABLE <table_name>;
5. EDGE FUNCTIONS (Deno runtime v1.71.2)
- Deploy at /captain/data/safe-supabase/functions/
- Each function is a folder with index.ts
- Accessible at /functions/v1/<function_name>
6. REST API (PostgREST v12.2.8)
- Auto-generated REST endpoints for all tables
- Filtering, pagination, ordering, embedding (joins)
- Respects RLS policies based on JWT role
7. GRAPHQL (pg_graphql)
- Auto-generated GraphQL schema from tables
- Endpoint: /graphql/v1
8. CONNECTION POOLING (Supavisor 2.7.4)
- Transaction mode on port 6543
- Max 100 client connections, pool size 20
9. IMAGE TRANSFORMATION (ImgProxy v3.30.1)
- Resize, crop, format conversion
- WebP auto-detection
10. ANALYTICS (Logflare 1.36.1)
- PostgreSQL backend (not BigQuery)
- Log collection via Vector
MANAGING VIA SSH (for AI agents)
================================
Run SQL:
sudo docker exec safe-supabase-db psql -U supabase_admin -d postgres -c "YOUR SQL HERE"
Create a table:
sudo docker exec safe-supabase-db psql -U supabase_admin -d postgres -c "
CREATE TABLE public.my_table (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
created_at timestamptz DEFAULT now(),
name text NOT NULL
);
ALTER TABLE public.my_table ENABLE ROW LEVEL SECURITY;
"
Enable Realtime on a table:
sudo docker exec safe-supabase-db psql -U supabase_admin -d postgres -c "
ALTER PUBLICATION supabase_realtime ADD TABLE public.my_table;
"
Create a storage bucket:
curl -X POST http://localhost:8787/storage/v1/bucket \
-H 'apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4' \
-H 'Content-Type: application/json' \
-d '{"id":"my-bucket","name":"my-bucket","public":true}'
List auth users:
curl http://localhost:8787/auth/v1/admin/users \
-H 'apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4'
Deploy an edge function:
# Create function directory on server
sudo mkdir -p /captain/data/safe-supabase/functions/my-function
# Write index.ts to the function directory
# Function becomes available at /functions/v1/my-function
Restart a service:
sudo docker restart safe-supabase-<service>
# Services: db, kong, auth, rest, realtime, storage, functions, meta, analytics, supavisor, imgproxy, vector, studio
View service logs:
sudo docker logs safe-supabase-<service> --tail 50
DOCKER COMPOSE LOCATION
========================
/captain/data/safe-supabase/compose/docker-compose.yml
PERSISTENT DATA
===============
/captain/data/safe-supabase/db/data — PostgreSQL data
/captain/data/safe-supabase/storage — File uploads
/captain/data/safe-supabase/functions — Edge functions code
/captain/data/safe-supabase/kong — Kong config
IMPORTANT NOTES
===============
- This is a SINGLE PROJECT deployment (not multi-tenant like supabase.com)
- All apps/games share the same database — use schemas or table prefixes to organize
- The anon key is safe to embed in client apps (RLS protects data)
- The service role key must NEVER be in client code (it bypasses all security)
- Always enable RLS on tables and write policies before exposing to clients
- Database is NOT exposed to the internet — only accessible via Kong API or SSH
- Expires: JWT keys expire in ~5 years (2030-01-01)
================================================================================
# Swiss System Tournament API — Complete Documentation
**Base URL:** `https://swissapi.caprover.al-arcade.com`
**API Prefix:** `/api/v1`
**Version:** 1.0.0
---
## Table of Contents
1. [Overview](#overview)
2. [Authentication](#authentication)
3. [Error Handling](#error-handling)
4. [Rate Limiting](#rate-limiting)
5. [Endpoints](#endpoints)
- [Health & Root](#health--root)
- [Auth](#auth)
- [Organizations](#organizations)
- [Events](#events)
- [Tournaments](#tournaments)
- [Players](#players)
- [Rounds](#rounds)
- [Pairings](#pairings)
- [Standings](#standings)
- [Categories](#categories)
- [Export](#export)
6. [Enums & Types](#enums--types)
7. [FIDE Dutch Pairing Engine](#fide-dutch-pairing-engine)
8. [Tiebreak Systems](#tiebreak-systems)
9. [Realtime WebSocket](#realtime-websocket)
10. [Tournament Lifecycle](#tournament-lifecycle)
11. [Multi-Tenancy & RBAC](#multi-tenancy--rbac)
---
## Overview
A full SaaS multi-tenant REST API for managing FIDE-compliant Swiss System chess tournaments. Features:
- Full FIDE Dutch System (C.04) pairing engine
- 11 tiebreak calculators
- Elo rating calculations with K-factor
- Multi-organization support with role-based access
- Realtime updates via Supabase WebSocket
- FIDE TRF export format
- Crosstable generation
- Bulk player import
- Automatic standings recalculation
---
## Authentication
All authenticated endpoints require a JWT Bearer token in the `Authorization` header:
```
Authorization: Bearer <access_token>
```
Tokens are obtained via `/api/v1/auth/login` or `/api/v1/auth/signup`. Refresh expired tokens via `/api/v1/auth/refresh`.
The JWT contains:
- `sub` — User UUID
- `role` — Supabase role
- `exp` — Expiration timestamp
---
## Error Handling
All errors follow this format:
```json
{
"statusCode": 400,
"message": "Human-readable error description",
"code": "MACHINE_READABLE_CODE"
}
```
### Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| `VALIDATION_ERROR` | 400 | Request body/params failed validation |
| `UNAUTHORIZED` | 401 | Missing or invalid JWT token |
| `FORBIDDEN` | 403 | Insufficient role/permissions |
| `NOT_FOUND` | 404 | Resource does not exist |
| `CONFLICT` | 409 | Duplicate resource or state conflict |
| `BUSINESS_RULE_VIOLATION` | 422 | Operation violates tournament rules |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
---
## Rate Limiting
- **Default:** 100 requests per 60 seconds per IP
- Headers returned: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
- Exceeding returns `429 Too Many Requests`
---
## Endpoints
---
### Health & Root
#### `GET /`
Returns API metadata. No auth required.
**Response `200`:**
```json
{
"name": "Swiss System Tournament API",
"version": "1.0.0",
"docs": "/api/v1"
}
```
#### `GET /health`
Health check endpoint. No auth required.
**Response `200`:**
```json
{
"status": "ok",
"timestamp": "2026-05-24T18:04:39.000Z"
}
```
---
### Auth
Base path: `/api/v1/auth`
---
#### `POST /api/v1/auth/signup`
Create a new user account. Optionally creates an organization.
**Auth required:** No
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `email` | string | Yes | Valid email address |
| `password` | string | Yes | 8-128 characters |
| `fullName` | string | Yes | 1-200 characters |
| `organizationName` | string | No | 1-200 characters. If provided, creates an org and assigns user as `org_admin` |
**Example Request:**
```json
{
"email": "arbiter@chess-club.org",
"password": "securePassword123",
"fullName": "Magnus Carlsen",
"organizationName": "Oslo Chess Club"
}
```
**Response `201`:**
```json
{
"user": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "arbiter@chess-club.org",
"fullName": "Magnus Carlsen"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "v1.MjAyNi0wNS0yNFQx..."
}
```
---
#### `POST /api/v1/auth/login`
Authenticate an existing user.
**Auth required:** No
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `email` | string | Yes | Valid email |
| `password` | string | Yes | Min 1 character |
**Example Request:**
```json
{
"email": "arbiter@chess-club.org",
"password": "securePassword123"
}
```
**Response `200`:**
```json
{
"user": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "arbiter@chess-club.org",
"fullName": "Magnus Carlsen"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "v1.MjAyNi0wNS0yNFQx...",
"expiresAt": 1716580800
}
```
---
#### `POST /api/v1/auth/refresh`
Exchange a refresh token for new access/refresh tokens.
**Auth required:** No
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `refreshToken` | string | Yes | Valid refresh token |
**Response `200`:**
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "v1.MjAyNi0wNS0yNFQx...",
"expiresAt": 1716580800
}
```
---
#### `GET /api/v1/auth/me`
Get the authenticated user's profile.
**Auth required:** Yes
**Response `200`:**
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"fullName": "Magnus Carlsen",
"displayName": "Magnus",
"avatarUrl": null,
"fideId": "1503014",
"fideRatingStandard": 2830,
"fideRatingRapid": 2830,
"fideRatingBlitz": 2886,
"nationalId": null,
"nationalRating": null,
"birthDate": "1990-11-30",
"countryCode": "NOR",
"title": "GM",
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
```
---
#### `PATCH /api/v1/auth/me`
Update the authenticated user's profile.
**Auth required:** Yes
**Request Body (all fields optional):**
| Field | Type | Constraints |
|-------|------|-------------|
| `fullName` | string | 1-200 characters |
| `displayName` | string | Max 100 characters |
| `fideId` | string | FIDE ID number |
| `nationalId` | string | National federation ID |
| `birthDate` | string | Date format |
| `countryCode` | string | Exactly 3 characters (ISO 3166-1 alpha-3) |
| `title` | string | e.g. "GM", "IM", "FM", "CM", "WGM", "WIM" |
**Example Request:**
```json
{
"fideId": "1503014",
"fideRatingStandard": 2830,
"title": "GM",
"countryCode": "NOR"
}
```
**Response `200`:** Updated profile (same shape as `GET /me`).
---
### Organizations
Base path: `/api/v1/organizations`
All organization endpoints require authentication.
---
#### `POST /api/v1/organizations`
Create a new organization. The creating user becomes `org_admin`.
**Auth required:** Yes
**Role required:** None (any authenticated user)
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1-200 characters |
| `contactEmail` | string | Yes | Valid email |
| `website` | string | No | Valid URL |
| `countryCode` | string | No | Exactly 3 characters |
| `fideFederationId` | string | No | FIDE federation code |
**Example Request:**
```json
{
"name": "Egyptian Chess Federation",
"contactEmail": "info@ecf-chess.org",
"website": "https://ecf-chess.org",
"countryCode": "EGY",
"fideFederationId": "EGY"
}
```
**Response `201`:**
```json
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Egyptian Chess Federation",
"slug": "egyptian-chess-federation",
"logoUrl": null,
"website": "https://ecf-chess.org",
"countryCode": "EGY",
"fideFederationId": "EGY",
"contactEmail": "info@ecf-chess.org",
"settings": {},
"subscriptionTier": "free",
"maxTournaments": 5,
"maxPlayersPerTournament": 200,
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
```
---
#### `GET /api/v1/organizations`
List all organizations the authenticated user is a member of.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Egyptian Chess Federation",
"slug": "egyptian-chess-federation",
"contactEmail": "info@ecf-chess.org",
"role": "org_admin",
"...": "..."
}
]
}
```
---
#### `GET /api/v1/organizations/:orgId`
Get a single organization's details.
**Auth required:** Yes
**Role required:** Any org member
**Path Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `orgId` | UUID | Organization ID |
**Response `200`:** Full organization object.
---
#### `PATCH /api/v1/organizations/:orgId`
Update an organization.
**Auth required:** Yes
**Role required:** `org_admin` or higher
**Request Body:** Same fields as create, all optional.
**Response `200`:** Updated organization object.
---
#### `DELETE /api/v1/organizations/:orgId`
Delete an organization and all its data (events, tournaments, etc).
**Auth required:** Yes
**Role required:** `org_admin`
**Response:** `204 No Content`
---
#### `GET /api/v1/organizations/:orgId/members`
List all members of an organization with their profiles.
**Auth required:** Yes
**Role required:** Any org member
**Response `200`:**
```json
{
"data": [
{
"id": "membership-uuid",
"userId": "user-uuid",
"organizationId": "org-uuid",
"role": "org_admin",
"status": "active",
"invitedBy": null,
"joinedAt": "2026-05-24T18:00:00.000Z",
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z",
"profile": {
"fullName": "Magnus Carlsen",
"displayName": "Magnus",
"fideId": "1503014"
}
}
]
}
```
---
#### `POST /api/v1/organizations/:orgId/members`
Invite a user to the organization by email.
**Auth required:** Yes
**Role required:** `org_admin`
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `email` | string | Yes | Valid email of existing user |
| `role` | string | Yes | `org_admin`, `arbiter`, `player`, or `spectator` |
**Example Request:**
```json
{
"email": "arbiter@chess-club.org",
"role": "arbiter"
}
```
**Response `201`:** Membership record.
---
#### `PATCH /api/v1/organizations/:orgId/members/:memberId`
Change a member's role.
**Auth required:** Yes
**Role required:** `org_admin`
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `role` | string | Yes | `org_admin`, `arbiter`, `player`, or `spectator` |
**Response `200`:** Updated membership record.
---
#### `DELETE /api/v1/organizations/:orgId/members/:memberId`
Remove a member from the organization.
**Auth required:** Yes
**Role required:** `org_admin`
**Response:** `204 No Content`
---
### Events
Events are multi-tournament containers (e.g., "Cairo Open 2026" with sections A, B, C).
---
#### `POST /api/v1/organizations/:orgId/events`
Create a new event within an organization.
**Auth required:** Yes
**Role required:** `org_admin` or `arbiter`
**Org membership:** Required
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1-200 characters |
| `description` | string | No | Free text |
| `venue` | string | No | Venue name |
| `city` | string | No | City name |
| `countryCode` | string | No | 3 characters (ISO) |
| `dateStart` | string | Yes | Date (YYYY-MM-DD) |
| `dateEnd` | string | Yes | Date (YYYY-MM-DD) |
| `timeControlDescription` | string | No | e.g., "90min + 30sec/move" |
| `timeControlType` | string | No | `standard`, `rapid`, `blitz`, `bullet`. Default: `standard` |
| `chiefArbiterId` | UUID | No | User ID of chief arbiter |
| `isFideRated` | boolean | No | Default: `false` |
**Example Request:**
```json
{
"name": "Cairo International Open 2026",
"description": "9-round Swiss system tournament",
"venue": "Cairo Convention Center",
"city": "Cairo",
"countryCode": "EGY",
"dateStart": "2026-07-01",
"dateEnd": "2026-07-09",
"timeControlDescription": "90 minutes for 40 moves + 30 minutes + 30 seconds increment from move 1",
"timeControlType": "standard",
"isFideRated": true
}
```
**Response `201`:**
```json
{
"id": "event-uuid",
"organizationId": "org-uuid",
"name": "Cairo International Open 2026",
"description": "9-round Swiss system tournament",
"venue": "Cairo Convention Center",
"city": "Cairo",
"countryCode": "EGY",
"dateStart": "2026-07-01",
"dateEnd": "2026-07-09",
"timeControlDescription": "90 minutes for 40 moves + 30 minutes + 30 seconds increment from move 1",
"timeControlType": "standard",
"chiefArbiterId": null,
"deputyArbiterIds": [],
"status": "draft",
"isFideRated": true,
"fideEventId": null,
"metadata": "{}",
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
```
---
#### `GET /api/v1/organizations/:orgId/events`
List all events in an organization, ordered by `dateStart` descending.
**Auth required:** Yes
**Role required:** Any org member
**Response `200`:**
```json
{
"data": [ ...array of event objects... ]
}
```
---
#### `GET /api/v1/events/:eventId`
Get a single event by ID.
**Auth required:** Yes
**Response `200`:** Full event object.
---
#### `PATCH /api/v1/events/:eventId`
Update an event.
**Auth required:** Yes
**Request Body:** Same fields as create, all optional.
**Response `200`:** Updated event object.
---
#### `DELETE /api/v1/events/:eventId`
Delete an event and all its tournaments.
**Auth required:** Yes
**Response:** `204 No Content`
---
### Tournaments
Tournaments are individual sections within an event (e.g., "Open A", "Under 1800", "Women's Section").
---
#### `POST /api/v1/events/:eventId/tournaments`
Create a tournament within an event.
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1-200 characters |
| `tournamentType` | string | No | `swiss` (default), `round_robin`, `double_round_robin` |
| `roundsNumber` | integer | Yes | 1-30 |
| `tableStartNumber` | integer | No | Default: 1 |
| `pairingSystem` | string | No | Default: `dutch` |
| `accelerationMethod` | string/null | No | e.g., "baku" |
| `accelerationRounds` | integer | No | Default: 0 |
| `initialOrdering` | string[] | No | Default: `["fide_rating","name"]` |
| `tiebreakRules` | string[] | No | Default: `["buchholz_cut_1","buchholz","sonneborn_berger"]`. See [Tiebreak Systems](#tiebreak-systems) for valid values |
| `maxPlayers` | integer | No | Default: 200 |
| `byeValue` | string | No | Default: `"1.00"`. Points awarded for a bye |
| `maxByesPerPlayer` | integer | No | Default: 1 |
| `registrationOpensAt` | datetime | No | ISO 8601 |
| `registrationClosesAt` | datetime | No | ISO 8601 |
**Example Request:**
```json
{
"name": "Open Section A",
"tournamentType": "swiss",
"roundsNumber": 9,
"tiebreakRules": ["buchholz_cut_1", "buchholz", "sonneborn_berger", "progressive_score"],
"maxPlayers": 150,
"byeValue": "1.00",
"maxByesPerPlayer": 1,
"accelerationMethod": "baku",
"accelerationRounds": 3
}
```
**Response `201`:**
```json
{
"id": "tournament-uuid",
"eventId": "event-uuid",
"organizationId": "org-uuid",
"name": "Open Section A",
"tournamentType": "swiss",
"status": "draft",
"roundsNumber": 9,
"currentRound": 0,
"tableStartNumber": 1,
"pairingSystem": "dutch",
"accelerationMethod": "baku",
"accelerationRounds": 3,
"colorAllocationRule": "equalise_alternate",
"initialOrdering": ["fide_rating", "name"],
"tiebreakRules": ["buchholz_cut_1", "buchholz", "sonneborn_berger", "progressive_score"],
"ratedMinimum": null,
"ratedMaximum": null,
"kFactorOverride": null,
"maxPlayers": 150,
"byeValue": "1.00",
"maxByesPerPlayer": 1,
"registrationOpensAt": null,
"registrationClosesAt": null,
"startedAt": null,
"completedAt": null,
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
```
---
#### `GET /api/v1/events/:eventId/tournaments`
List all tournaments in an event.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [ ...array of tournament objects... ]
}
```
---
#### `GET /api/v1/tournaments/:tournamentId`
Get a single tournament.
**Auth required:** Yes
**Response `200`:** Full tournament object.
---
#### `PATCH /api/v1/tournaments/:tournamentId`
Update a tournament's settings.
**Auth required:** Yes
**Request Body:** Same fields as create, all optional.
**Response `200`:** Updated tournament object.
---
#### `POST /api/v1/tournaments/:tournamentId/start`
Transition tournament from `draft`/`registration` to `in_progress`.
**Auth required:** Yes
**Business Rules:**
- Status must be `draft` or `registration`
- At least 2 registered players required
**Response `200`:** Tournament with `status: "in_progress"` and `startedAt` set.
---
#### `POST /api/v1/tournaments/:tournamentId/complete`
Mark a tournament as completed.
**Auth required:** Yes
**Business Rules:**
- Status must be `in_progress`
**Response `200`:** Tournament with `status: "completed"` and `completedAt` set.
---
#### `DELETE /api/v1/tournaments/:tournamentId`
Delete a tournament and all associated data.
**Auth required:** Yes
**Response:** `204 No Content`
---
### Players
Tournament player registration and management.
---
#### `POST /api/v1/tournaments/:tournamentId/players`
Register a single player in a tournament.
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1-200 characters |
| `startNumber` | integer | No | Auto-assigned if omitted |
| `birthDate` | string | No | Date (YYYY-MM-DD) |
| `countryCode` | string | No | 3 characters |
| `city` | string | No | Player's city |
| `club` | string | No | Club name |
| `title` | string | No | FIDE title (GM, IM, FM, etc.) |
| `fideId` | string | No | FIDE player ID |
| `fideRatingStandard` | integer | No | FIDE standard rating |
| `fideRatingRapid` | integer | No | FIDE rapid rating |
| `fideRatingBlitz` | integer | No | FIDE blitz rating |
| `nationalId` | string | No | National federation ID |
| `nationalRating` | integer | No | National rating |
| `categoryId` | UUID | No | Category/section UUID |
**Example Request:**
```json
{
"name": "Anand, Viswanathan",
"fideId": "5000017",
"fideRatingStandard": 2751,
"countryCode": "IND",
"title": "GM",
"club": "Petroleum Sports Promotion Board"
}
```
**Response `201`:**
```json
{
"id": "player-uuid",
"tournamentId": "tournament-uuid",
"organizationId": "org-uuid",
"categoryId": null,
"userId": null,
"startNumber": 1,
"name": "Anand, Viswanathan",
"birthDate": null,
"countryCode": "IND",
"city": null,
"club": "Petroleum Sports Promotion Board",
"title": "GM",
"fideId": "5000017",
"fideRatingStandard": 2751,
"fideRatingRapid": null,
"fideRatingBlitz": null,
"nationalId": null,
"nationalRating": null,
"isActive": true,
"withdrawnAfterRound": null,
"roundsExcluded": [],
"receivedByeInRounds": [],
"totalPoints": "0",
"floatHistory": [],
"colorHistory": [],
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
```
---
#### `GET /api/v1/tournaments/:tournamentId/players`
List all players in a tournament, ordered by `startNumber` ascending.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [ ...array of player objects... ]
}
```
---
#### `GET /api/v1/tournaments/:tournamentId/players/:playerId`
Get a single player's details.
**Auth required:** Yes
**Response `200`:** Full player object.
---
#### `PATCH /api/v1/tournaments/:tournamentId/players/:playerId`
Update a player's information.
**Auth required:** Yes
**Request Body:** Same fields as create, all optional.
**Response `200`:** Updated player object.
---
#### `DELETE /api/v1/tournaments/:tournamentId/players/:playerId`
Remove or withdraw a player.
**Auth required:** Yes
**Behavior:**
- If tournament is `in_progress`: Player is **withdrawn** (`isActive=false`, `withdrawnAfterRound` set). Returns `200` with updated player.
- If tournament is `draft`/`registration`: Player is **hard-deleted**. Returns `204 No Content`.
---
#### `POST /api/v1/tournaments/:tournamentId/players/import`
Bulk import multiple players at once.
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `players` | array | Yes | Array of player objects (same fields as single create) |
**Example Request:**
```json
{
"players": [
{ "name": "Carlsen, Magnus", "fideId": "1503014", "fideRatingStandard": 2830, "countryCode": "NOR", "title": "GM" },
{ "name": "Nakamura, Hikaru", "fideId": "2016192", "fideRatingStandard": 2794, "countryCode": "USA", "title": "GM" },
{ "name": "Ding, Liren", "fideId": "8603677", "fideRatingStandard": 2780, "countryCode": "CHN", "title": "GM" }
]
}
```
**Response `201`:**
```json
{
"data": [ ...array of created player objects... ],
"count": 3
}
```
---
### Rounds
Round generation and management. The pairing engine runs here.
---
#### `GET /api/v1/tournaments/:tournamentId/rounds`
List all rounds in a tournament, ordered by `roundNumber` ascending.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [
{
"id": "round-uuid",
"tournamentId": "tournament-uuid",
"organizationId": "org-uuid",
"roundNumber": 1,
"status": "completed",
"scheduledAt": null,
"startedAt": "2026-07-01T09:00:00.000Z",
"completedAt": "2026-07-01T14:00:00.000Z",
"pairedAt": "2026-07-01T08:45:00.000Z",
"pairedBy": "user-uuid",
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
]
}
```
---
#### `GET /api/v1/tournaments/:tournamentId/rounds/:roundNum`
Get a specific round by number.
**Auth required:** Yes
**Path Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `tournamentId` | UUID | Tournament ID |
| `roundNum` | integer | Round number (1-indexed) |
**Response `200`:** Single round object.
---
#### `POST /api/v1/tournaments/:tournamentId/rounds/generate`
**Run the FIDE Dutch Pairing Engine** to generate the next round's pairings.
**Auth required:** Yes
**Business Rules:**
- Tournament must have status `in_progress`
- If a previous round exists, it must have status `completed`
- `currentRound + 1` must not exceed `roundsNumber`
- At least 2 active players required
**Response `201`:**
```json
{
"round": {
"id": "round-uuid",
"tournamentId": "tournament-uuid",
"roundNumber": 1,
"status": "paired",
"pairedAt": "2026-07-01T08:45:00.000Z",
"pairedBy": "user-uuid"
},
"pairingsCount": 75,
"bye": 142,
"logs": [
"Round 1: 150 active players",
"Bye allocated to player #142 (lowest in lowest score group)",
"Score group 0.0: 149 players",
"Bracket pairing: S1=75, S2=74",
"All 75 pairings generated successfully"
]
}
```
The `bye` field contains the `startNumber` of the player who received the bye, or `null` if even number of players.
The `logs` array provides a human-readable audit trail of every pairing decision made by the engine.
---
#### `DELETE /api/v1/tournaments/:tournamentId/rounds/:roundNum`
Unpair the last round (delete all its pairings and the round itself).
**Auth required:** Yes
**Business Rules:**
- Can only unpair the **last** round (where `currentRound === roundNum`)
- Decrements `tournament.currentRound`
**Response:** `204 No Content`
---
### Pairings
Individual game pairings within rounds.
---
#### `GET /api/v1/rounds/:roundId/pairings`
List all pairings in a round, ordered by `boardNumber` ascending.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [
{
"id": "pairing-uuid",
"roundId": "round-uuid",
"tournamentId": "tournament-uuid",
"organizationId": "org-uuid",
"boardNumber": 1,
"whitePlayerId": "player-uuid-1",
"blackPlayerId": "player-uuid-2",
"result": "not_played",
"whitePoints": "0.00",
"blackPoints": "0.00",
"isForfeit": false,
"isBye": false,
"resultEnteredBy": null,
"resultEnteredAt": null,
"resultConfirmed": false,
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
]
}
```
---
#### `GET /api/v1/pairings/:pairingId`
Get a single pairing.
**Auth required:** Yes
**Response `200`:** Full pairing object.
---
#### `PATCH /api/v1/pairings/:pairingId/result`
Enter or update a game result.
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `result` | string | Yes | See [Game Results](#game-results) enum |
**Example Request:**
```json
{
"result": "white_wins"
}
```
**Response `200`:** Updated pairing with computed points:
```json
{
"id": "pairing-uuid",
"boardNumber": 1,
"whitePlayerId": "player-uuid-1",
"blackPlayerId": "player-uuid-2",
"result": "white_wins",
"whitePoints": "1.00",
"blackPoints": "0.00",
"isForfeit": false,
"resultEnteredBy": "user-uuid",
"resultEnteredAt": "2026-07-01T14:30:00.000Z",
"...": "..."
}
```
**Point Calculation:**
| Result | White Points | Black Points |
|--------|:-----------:|:------------:|
| `white_wins` | 1.00 | 0.00 |
| `black_wins` | 0.00 | 1.00 |
| `draw` | 0.50 | 0.50 |
| `white_forfeit` | 0.00 | 1.00 |
| `black_forfeit` | 1.00 | 0.00 |
| `double_forfeit` | 0.00 | 0.00 |
| `bye_full` | 1.00 | 0.00 |
| `bye_half` | 0.50 | 0.00 |
| `bye_zero` | 0.00 | 0.00 |
**Side Effects:** Recalculates `totalPoints` for all affected players.
---
#### `POST /api/v1/rounds/:roundId/pairings/results`
Enter multiple results at once (batch).
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `results` | array | Yes | Array of `{ pairingId: UUID, result: GameResult }` |
**Example Request:**
```json
{
"results": [
{ "pairingId": "uuid-1", "result": "white_wins" },
{ "pairingId": "uuid-2", "result": "draw" },
{ "pairingId": "uuid-3", "result": "black_wins" }
]
}
```
**Response `200`:**
```json
{
"data": [ ...updated pairing objects... ],
"allComplete": true
}
```
**Side Effects:**
- If `allComplete` is `true`: Round status is automatically updated to `completed`.
- Recalculates all player points.
---
#### `POST /api/v1/rounds/:roundId/pairings/manual`
Manually create a pairing (arbiter override).
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `boardNumber` | integer | Yes | Min 1 |
| `whitePlayerId` | UUID | Yes | Must be a player in the tournament |
| `blackPlayerId` | UUID/null | No | `null` for bye |
| `isBye` | boolean | No | Default: `false`. If `true`, auto-sets result to `bye_full` |
**Example Request:**
```json
{
"boardNumber": 76,
"whitePlayerId": "player-uuid",
"blackPlayerId": null,
"isBye": true
}
```
**Response `201`:** Created pairing object.
---
#### `DELETE /api/v1/pairings/:pairingId`
Delete a single pairing.
**Auth required:** Yes
**Response:** `204 No Content`
---
### Standings
Tournament standings with tiebreak calculations.
---
#### `GET /api/v1/tournaments/:tournamentId/standings`
Get current standings for a tournament.
**Auth required:** Yes
**Query Parameters:**
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `round` | integer | No | Round number. Defaults to current round |
| `category` | UUID | No | Filter by category ID |
**Example:** `GET /api/v1/tournaments/abc123/standings?round=5&category=cat-uuid`
**Response `200`:**
```json
{
"data": [
{
"rank": 1,
"rankCategory": 1,
"playerId": "player-uuid",
"name": "Carlsen, Magnus",
"startNumber": 1,
"points": 4.5,
"tiebreakValues": [18.5, 22.0, 15.75, 10.0],
"fideRating": 2830,
"club": "Offerspill Chess Club",
"gamesPlayed": 5,
"wins": 4,
"draws": 1,
"losses": 0
},
{
"rank": 2,
"rankCategory": 2,
"playerId": "player-uuid-2",
"name": "Nakamura, Hikaru",
"startNumber": 2,
"points": 4.0,
"tiebreakValues": [19.0, 23.5, 14.00, 10.0],
"fideRating": 2794,
"club": null,
"gamesPlayed": 5,
"wins": 3,
"draws": 2,
"losses": 0
}
],
"roundNumber": 5
}
```
The `tiebreakValues` array corresponds to the tournament's `tiebreakRules` array in order. For example, if `tiebreakRules: ["buchholz_cut_1", "buchholz", "sonneborn_berger", "progressive_score"]`, then `tiebreakValues[0]` is Buchholz Cut 1, `[1]` is full Buchholz, etc.
---
#### `POST /api/v1/tournaments/:tournamentId/standings/recalculate`
Force recalculation of all standings and tiebreaks.
**Auth required:** Yes
**Business Rules:** At least one round must have been played.
**Response `200`:**
```json
{
"data": [ ...recalculated standings... ],
"roundNumber": 5
}
```
---
### Categories
Sub-sections within a tournament (e.g., "Under 2000", "Women", "Seniors 65+").
---
#### `POST /api/v1/tournaments/:tournamentId/categories`
Create a category.
**Auth required:** Yes
**Request Body:**
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1-100 characters |
| `abbreviation` | string | No | Max 10 characters |
| `description` | string | No | Free text |
| `minRating` | integer | No | Minimum rating for eligibility |
| `maxRating` | integer | No | Maximum rating for eligibility |
| `minAge` | integer | No | Minimum age |
| `maxAge` | integer | No | Maximum age |
| `gender` | string/null | No | `"M"`, `"F"`, or `null` |
| `sortOrder` | integer | No | Display order. Default: 0 |
**Example Request:**
```json
{
"name": "Under 2000",
"abbreviation": "U2000",
"maxRating": 1999,
"sortOrder": 1
}
```
**Response `201`:**
```json
{
"id": "category-uuid",
"tournamentId": "tournament-uuid",
"organizationId": "org-uuid",
"name": "Under 2000",
"abbreviation": "U2000",
"description": null,
"minRating": null,
"maxRating": 1999,
"minAge": null,
"maxAge": null,
"gender": null,
"sortOrder": 1,
"createdAt": "2026-05-24T18:00:00.000Z",
"updatedAt": "2026-05-24T18:00:00.000Z"
}
```
---
#### `GET /api/v1/tournaments/:tournamentId/categories`
List all categories for a tournament, ordered by `sortOrder`.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [ ...array of category objects... ]
}
```
---
#### `PATCH /api/v1/categories/:categoryId`
Update a category.
**Auth required:** Yes
**Request Body:** Same fields as create, all optional.
**Response `200`:** Updated category object.
---
#### `DELETE /api/v1/categories/:categoryId`
Delete a category. Players in this category will have their `categoryId` set to `null`.
**Auth required:** Yes
**Response:** `204 No Content`
---
### Export
Export tournament data in various formats.
---
#### `GET /api/v1/tournaments/:tournamentId/export/trf`
Export in **FIDE Tournament Report File** format. This is the official format required for FIDE-rated tournament submissions.
**Auth required:** Yes
**Response:** Plain text file download.
- `Content-Type: text/plain`
- `Content-Disposition: attachment; filename="Tournament_Name.trf"`
**TRF Format Example:**
```
012 Cairo International Open 2026
022 Cairo
032 EGY
042 2026-07-01
052 Chief Arbiter Name
062 150
072 9
082 1234567890
092 Standard
102 IA Chief Arbiter
132 90/40+30+30
001 1 Carlsen g NOR 1503014 2830 9.0 1 w 1 2 b 1 3 w = ...
001 2 Nakamura g USA 2016192 2794 8.5 1 b 0 2 w 1 3 b 1 ...
```
---
#### `GET /api/v1/tournaments/:tournamentId/export/json`
Export complete tournament data as JSON.
**Auth required:** Yes
**Response `200`:**
```json
{
"tournament": { ...full tournament object... },
"event": { ...full event object... },
"players": [ ...all player objects... ],
"rounds": [ ...all round objects... ],
"pairings": [ ...all pairing objects... ]
}
```
---
#### `GET /api/v1/tournaments/:tournamentId/export/crosstable`
Generate a crosstable showing all head-to-head results.
**Auth required:** Yes
**Response `200`:**
```json
{
"data": [
{
"startNumber": 1,
"name": "Carlsen, Magnus",
"rating": 2830,
"points": 7.5,
"results": {
"2": "1w",
"5": "0.5b",
"8": "1w",
"3": "0.5w",
"12": "1b",
"4": "1w",
"6": "0.5b",
"9": "1w",
"7": "1b"
}
}
]
}
```
The `results` object maps opponent `startNumber` to a result string:
- `"1w"` = Win with white
- `"1b"` = Win with black
- `"0.5w"` = Draw with white
- `"0.5b"` = Draw with black
- `"0w"` = Loss with white
- `"0b"` = Loss with black
---
## Enums & Types
### Tournament Type
| Value | Description |
|-------|-------------|
| `swiss` | Swiss system (paired by engine) |
| `round_robin` | Everyone plays everyone |
| `double_round_robin` | Everyone plays everyone twice |
### Tournament Status
| Value | Description |
|-------|-------------|
| `draft` | Initial state, setup phase |
| `registration` | Open for player registration |
| `in_progress` | Tournament has started, rounds being played |
| `completed` | All rounds finished |
| `cancelled` | Tournament was cancelled |
### Round Status
| Value | Description |
|-------|-------------|
| `pending` | Round exists but not yet paired |
| `paired` | Pairings generated, games not started |
| `in_progress` | Games being played |
| `completed` | All results entered |
### Game Results
| Value | White Points | Black Points | Notes |
|-------|:---:|:---:|-------|
| `white_wins` | 1 | 0 | Normal white victory |
| `black_wins` | 0 | 1 | Normal black victory |
| `draw` | 0.5 | 0.5 | Drawn game |
| `white_forfeit` | 0 | 1 | White forfeited |
| `black_forfeit` | 1 | 0 | Black forfeited |
| `double_forfeit` | 0 | 0 | Both forfeited |
| `bye_full` | 1 | - | Full-point bye |
| `bye_half` | 0.5 | - | Half-point bye |
| `bye_zero` | 0 | - | Zero-point bye |
| `not_played` | 0 | 0 | Default, no result yet |
### User Roles (hierarchy)
| Role | Level | Capabilities |
|------|:-----:|-------------|
| `super_admin` | 5 | Everything across all orgs |
| `org_admin` | 4 | Full control of own org |
| `arbiter` | 3 | Manage events/tournaments they're assigned to |
| `player` | 2 | View data, participate |
| `spectator` | 1 | Read-only access |
### Time Control Type
| Value | Description |
|-------|-------------|
| `standard` | Classical (≥60 min) |
| `rapid` | Rapid (15-60 min) |
| `blitz` | Blitz (3-15 min) |
| `bullet` | Bullet (<3 min) |
### FIDE Titles
| Value | Description |
|-------|-------------|
| `GM` | Grandmaster |
| `IM` | International Master |
| `FM` | FIDE Master |
| `CM` | Candidate Master |
| `WGM` | Woman Grandmaster |
| `WIM` | Woman International Master |
| `WFM` | Woman FIDE Master |
| `WCM` | Woman Candidate Master |
---
## FIDE Dutch Pairing Engine
The API implements the **FIDE Dutch System (C.04)** pairing algorithm. This is the official system used in FIDE-rated Swiss tournaments worldwide.
### How It Works
When you call `POST /tournaments/:id/rounds/generate`, the engine:
1. **Filters active players** — excludes withdrawn and excluded players
2. **Allocates bye** — lowest-ranked player in lowest score group who hasn't had a bye
3. **Applies acceleration** (if configured) — artificially inflates scores in early rounds to create more decisive games at the top
4. **Forms score groups** — players with the same score are grouped together
5. **For each bracket** (highest score group down):
- Splits into S1 (top half by rating) and S2 (bottom half)
- Checks compatibility constraints:
- No player pair that already played each other
- Color allocation constraints (absolute/strong/mild)
- Attempts pairing with systematic transposition and exchange
- Applies float rules for players moving between groups
6. **Assigns board numbers** — top-rated pair on board 1
7. **Allocates colors** according to FIDE C.04 rules
### Color Allocation Rules (FIDE C.04.A7-A9)
| Priority | Rule | Description |
|----------|------|-------------|
| **Absolute** | Must | Had same color last 2 games → MUST get other color |
| **Strong** | Should | More total games with one color → SHOULD get other color |
| **Mild** | Prefer | Alternate from last game → preference only |
### Acceleration
When `accelerationMethod` is set (e.g., "baku"), players' virtual scores are boosted in the first N rounds (`accelerationRounds`) to create stronger pairings at the top tables early in the tournament.
### Float Management
Players who cannot be paired within their score group "float" up or down:
- **Float up**: Paired against a higher-scoring opponent
- **Float down**: Paired against a lower-scoring opponent
- The engine tracks float history to avoid repeated floating
---
## Tiebreak Systems
The following tiebreak calculators are available. Set them in `tournament.tiebreakRules`:
| Value | Name | Description |
|-------|------|-------------|
| `buchholz` | Buchholz | Sum of all opponents' scores |
| `buchholz_cut_1` | Buchholz Cut 1 | Buchholz minus lowest opponent score |
| `buchholz_median` | Buchholz Median | Buchholz minus highest and lowest |
| `sonneborn_berger` | Sonneborn-Berger | Sum of defeated opponents' scores + half of drawn opponents' scores |
| `direct_encounter` | Direct Encounter | Result between tied players |
| `number_of_wins` | Number of Wins | Total games won (excludes forfeits) |
| `number_of_blacks` | Number of Blacks | Games played with black pieces |
| `koya` | Koya System | Points scored against opponents with ≥50% |
| `progressive_score` | Progressive Score | Cumulative sum of round-by-round scores |
| `average_rating_opponents` | Average Rating of Opponents | Mean rating of all opponents faced |
| `performance_rating` | Performance Rating | FIDE performance rating |
### FIDE Recommended Tiebreak Order
For Swiss tournaments, FIDE recommends:
```json
["buchholz_cut_1", "buchholz", "sonneborn_berger"]
```
For Round Robin:
```json
["direct_encounter", "number_of_wins", "sonneborn_berger"]
```
---
## Realtime WebSocket
The API broadcasts live updates via Supabase Realtime. Connect to receive instant notifications when:
- **Pairings are generated** — new round appears
- **Results are entered** — game results update live
- **Standings change** — rankings recalculated
### Connecting
```javascript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://safe-supabase-kong.caprover.al-arcade.com',
'YOUR_ANON_KEY'
)
// Subscribe to pairings changes for a tournament
supabase
.channel('tournament-updates')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'pairings',
filter: `tournament_id=eq.YOUR_TOURNAMENT_ID`
}, (payload) => {
console.log('Pairing update:', payload)
})
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'standings',
filter: `tournament_id=eq.YOUR_TOURNAMENT_ID`
}, (payload) => {
console.log('Standing update:', payload)
})
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'rounds',
filter: `tournament_id=eq.YOUR_TOURNAMENT_ID`
}, (payload) => {
console.log('Round update:', payload)
})
.subscribe()
```
### Published Tables
| Table | Events | Use Case |
|-------|--------|----------|
| `pairings` | INSERT, UPDATE | New pairings, result entry |
| `standings` | INSERT, UPDATE | Ranking changes |
| `rounds` | INSERT, UPDATE | New rounds, status changes |
| `tournament_players` | UPDATE | Player withdrawal, score updates |
---
## Tournament Lifecycle
```
┌──────────┐ start() ┌─────────────┐ complete() ┌───────────┐
│ draft │ ───────────────> │ in_progress │ ──────────────> │ completed │
└──────────┘ └─────────────┘ └───────────┘
│ │
│ (optional) │ generate() / result / generate() ...
v │
┌──────────────┐ │
│ registration │ ───────────────────┘
└──────────────┘ start()
┌───────────┐
│ cancelled │ (can set from any state)
└───────────┘
```
### Typical Tournament Flow
1. **Create event**`POST /organizations/:orgId/events`
2. **Create tournament**`POST /events/:eventId/tournaments`
3. **Register players**`POST /tournaments/:id/players` (or `/import` for bulk)
4. **Start tournament**`POST /tournaments/:id/start`
5. **Generate round 1**`POST /tournaments/:id/rounds/generate`
6. **Enter results**`PATCH /pairings/:id/result` (or batch via `POST /rounds/:id/pairings/results`)
7. **Repeat steps 5-6** for each round
8. **View standings**`GET /tournaments/:id/standings`
9. **Complete tournament**`POST /tournaments/:id/complete`
10. **Export**`GET /tournaments/:id/export/trf`
---
## Multi-Tenancy & RBAC
### Organization Isolation
Every resource belongs to an organization via `organization_id`. PostgreSQL Row Level Security (RLS) policies enforce that:
- Users can only see data from organizations they belong to
- Admin operations require `org_admin` role in that specific org
- The API layer enforces this at the application level too (defense-in-depth)
### Role-Based Access Control Matrix
| Action | Super Admin | Org Admin | Arbiter | Player | Spectator |
|--------|:-:|:-:|:-:|:-:|:-:|
| Create organizations | Yes | Yes | Yes | Yes | Yes |
| Manage own org settings | Yes | Yes | - | - | - |
| Delete org | Yes | Yes | - | - | - |
| Invite/remove members | Yes | Yes | - | - | - |
| Create events | Yes | Yes | Yes | - | - |
| Manage tournaments | Yes | Yes | Yes | - | - |
| Generate pairings | Yes | Yes | Yes | - | - |
| Enter results | Yes | Yes | Yes | - | - |
| View standings/pairings | Yes | Yes | Yes | Yes | Yes |
| Export TRF | Yes | Yes | Yes | - | - |
| View audit logs | Yes | Yes | - | - | - |
### Organization Header
For routes that require org context, include:
```
X-Organization-Id: <org-uuid>
```
Or use the `:orgId` path parameter where available.
---
## Full cURL Examples
### Complete Tournament Setup
```bash
# 1. Sign up
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/auth/signup \
-H "Content-Type: application/json" \
-d '{
"email": "admin@chess-club.org",
"password": "mySecurePass123",
"fullName": "Tournament Director",
"organizationName": "Local Chess Club"
}'
# Save the token
TOKEN="eyJhbGciOiJIUzI1NiIs..."
# 2. Create event
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/organizations/$ORG_ID/events \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Spring Open 2026",
"dateStart": "2026-06-01",
"dateEnd": "2026-06-03",
"venue": "Community Center",
"timeControlType": "rapid",
"timeControlDescription": "15+10"
}'
# 3. Create tournament
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/events/$EVENT_ID/tournaments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Open Section",
"roundsNumber": 7,
"tiebreakRules": ["buchholz_cut_1", "buchholz", "sonneborn_berger"]
}'
# 4. Bulk import players
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/tournaments/$TOURNAMENT_ID/players/import \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"players": [
{"name": "Smith, John", "fideRatingStandard": 2100, "countryCode": "USA"},
{"name": "Mueller, Hans", "fideRatingStandard": 1950, "countryCode": "GER"},
{"name": "Petrov, Ivan", "fideRatingStandard": 2200, "countryCode": "RUS"}
]
}'
# 5. Start tournament
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/tournaments/$TOURNAMENT_ID/start \
-H "Authorization: Bearer $TOKEN"
# 6. Generate round 1
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/tournaments/$TOURNAMENT_ID/rounds/generate \
-H "Authorization: Bearer $TOKEN"
# 7. Enter results (batch)
curl -X POST https://swissapi.caprover.al-arcade.com/api/v1/rounds/$ROUND_ID/pairings/results \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"results": [
{"pairingId": "uuid-1", "result": "white_wins"},
{"pairingId": "uuid-2", "result": "draw"},
{"pairingId": "uuid-3", "result": "black_wins"}
]
}'
# 8. Get standings
curl https://swissapi.caprover.al-arcade.com/api/v1/tournaments/$TOURNAMENT_ID/standings \
-H "Authorization: Bearer $TOKEN"
# 9. Export TRF
curl https://swissapi.caprover.al-arcade.com/api/v1/tournaments/$TOURNAMENT_ID/export/trf \
-H "Authorization: Bearer $TOKEN" \
-o tournament.trf
```
---
## SDK Integration Example (TypeScript)
```typescript
const API_BASE = 'https://swissapi.caprover.al-arcade.com/api/v1';
class SwissSystemClient {
private token: string;
constructor(token: string) {
this.token = token;
}
private async request(method: string, path: string, body?: unknown) {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
if (res.status === 204) return null;
return res.json();
}
// Auth
static async login(email: string, password: string) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
return new SwissSystemClient(data.accessToken);
}
// Tournaments
createTournament(eventId: string, data: unknown) {
return this.request('POST', `/events/${eventId}/tournaments`, data);
}
startTournament(id: string) {
return this.request('POST', `/tournaments/${id}/start`);
}
// Players
importPlayers(tournamentId: string, players: unknown[]) {
return this.request('POST', `/tournaments/${tournamentId}/players/import`, { players });
}
// Rounds
generateRound(tournamentId: string) {
return this.request('POST', `/tournaments/${tournamentId}/rounds/generate`);
}
// Results
enterResults(roundId: string, results: Array<{pairingId: string, result: string}>) {
return this.request('POST', `/rounds/${roundId}/pairings/results`, { results });
}
// Standings
getStandings(tournamentId: string, round?: number) {
const query = round ? `?round=${round}` : '';
return this.request('GET', `/tournaments/${tournamentId}/standings${query}`);
}
// Export
async exportTRF(tournamentId: string): Promise<string> {
const res = await fetch(`${API_BASE}/tournaments/${tournamentId}/export/trf`, {
headers: { 'Authorization': `Bearer ${this.token}` },
});
return res.text();
}
}
```
---
## Database Schema (ERD Summary)
```
organizations
└── org_memberships ── user_profiles (auth.users)
└── events
└── tournaments
├── categories
├── tournament_players
├── rounds
│ └── pairings
└── standings
└── audit_logs
```
All tables have `organization_id` for multi-tenancy isolation via RLS.
---
## Deployment Info
| Property | Value |
|----------|-------|
| Public URL | `https://swissapi.caprover.al-arcade.com` |
| Internal URL | `http://srv-captain--swissapi` |
| Health Check | `GET /health` |
| Runtime | Node.js 20 (Alpine) |
| Database | PostgreSQL 15 (Supabase) |
| Auth Provider | Supabase GoTrue |
| Realtime | Supabase Realtime (WebSocket) |
================================================================================
STOCKFISH CHESS BOT API - COMPLETE REFERENCE
================================================================================
BASE URL: https://stockfishapi.caprover.al-arcade.com
================================================================================
ENDPOINTS
================================================================================
--------------------------------------------------------------------------------
1. GET MOVE FROM BOT
--------------------------------------------------------------------------------
POST /api/chess/move
Description:
Get a chess move from a specific bot personality. The bot will play according
to its configured skill level, depth, contempt, and blunder probability.
Response includes simulated human-like think time.
Headers:
Content-Type: application/json
Request Body:
{
"fen": "string (required) - FEN notation of the current board position",
"bot_id": "string (required) - ID of the bot to play against",
"time_limit_ms": 0 (optional, int) - time limit in ms. If 0 or omitted, uses depth-based search
}
Response (200 OK):
{
"best_move": "e2e4", // UCI move notation (from-square + to-square, e.g. e2e4, g1f3, e7e8q for promotion)
"evaluation": 0.35, // centipawn evaluation / 100. Positive = white advantage. 999.0 = white mates, -999.0 = black mates
"depth": 10, // search depth reached
"nodes": 125000, // nodes searched
"think_time_ms": 1500, // total time including artificial delay (simulates human thinking)
"pv": "e2e4 e7e5 g1f3" // principal variation (best line), space-separated UCI moves
}
Errors:
400: {"error": "invalid request body"}
400: {"error": "fen is required"}
400: {"error": "bot_id is required"}
404: {"error": "bot not found"}
500: {"error": "engine error: ..."}
Example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/move \
-H "Content-Type: application/json" \
-d '{"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "bot_id": "nour"}'
--------------------------------------------------------------------------------
2. ANALYZE POSITION
--------------------------------------------------------------------------------
POST /api/chess/analyze
Description:
Deep multi-line analysis of a position at full Stockfish strength (skill 20).
Returns multiple candidate moves ranked by evaluation.
Headers:
Content-Type: application/json
Request Body:
{
"fen": "string (required) - FEN notation of the position to analyze",
"depth": 18, (optional, int 1-30, default 18) - search depth
"lines": 3 (optional, int 1-5, default 3) - number of candidate moves to return
}
Response (200 OK):
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"depth": 18,
"lines": [
{
"rank": 1,
"move": "e7e5", // best move in UCI notation
"evaluation": -0.25, // eval from engine perspective (positive = side to move is better)
"depth": 18,
"pv": "e7e5 g1f3 b8c6 ..." // full principal variation
},
{
"rank": 2,
"move": "c7c5",
"evaluation": -0.15,
"depth": 18,
"pv": "c7c5 g1f3 d7d6 ..."
}
]
}
Errors:
400: {"error": "invalid request body"}
400: {"error": "fen is required"}
500: {"error": "engine error: ..."}
Timeout: 30 seconds max per analysis request.
Example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/analyze \
-H "Content-Type: application/json" \
-d '{"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", "depth": 20, "lines": 3}'
--------------------------------------------------------------------------------
3. LIST BOTS
--------------------------------------------------------------------------------
GET /api/chess/bots
Description:
Returns all available bot personalities sorted by difficulty (easiest first).
Response (200 OK):
{
"bots": [
{
"id": "amina",
"name": "Amina",
"name_ar": "أمينة المبتدئة",
"style": "beginner",
"style_ar": "مبتدئة",
"bio": "Just learning chess! Makes mistakes but tries her best.",
"bio_ar": "لسه بتتعلم شطرنج! بتغلط كتير بس بتحاول.",
"elo_min": 400,
"elo_max": 600,
"skill_level": 1,
"depth": 3,
"contempt": 0,
"blunder_chance": 0.30,
"think_time_min_ms": 500,
"think_time_max_ms": 2000,
"opening_book": [],
"avatar_id": "bot-amina",
"portrait_url": "/portraits/amina.png"
},
...
]
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/api/chess/bots
--------------------------------------------------------------------------------
4. POOL STATS
--------------------------------------------------------------------------------
GET /api/chess/stats
Description:
Returns the current Stockfish process pool status.
Response (200 OK):
{
"pool_alive": 6, // number of Stockfish processes currently running
"pool_idle": 4 // number of those processes currently idle (available)
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/api/chess/stats
--------------------------------------------------------------------------------
5. HEALTH CHECK
--------------------------------------------------------------------------------
GET /health
Description:
Verifies the engine is operational by running a depth-1 search on the
starting position. Used by CapRover/Docker health checks.
Response (200 OK):
{
"status": "healthy",
"engine": "stockfish-18",
"pool_alive": 6,
"pool_idle": 4
}
Response (503 Service Unavailable):
{
"status": "unhealthy",
"error": "acquire process: context deadline exceeded"
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/health
================================================================================
AVAILABLE BOTS (sorted by difficulty)
================================================================================
ID | Name | Arabic Name | Style | Style (AR) | ELO | Skill | Depth | Blunder% | Think Time (ms) | Portrait
--------------|-----------------|------------------|--------------|-------------|-----------|-------|-------|----------|-----------------|------------------
amina | Amina | أمينة المبتدئة | beginner | مبتدئة | 400-600 | 1 | 3 | 30% | 500-2000 | /portraits/amina.png
tarek | Tarek | طارق المتحفظ | defensive | دفاعي | 800-1000 | 5 | 6 | 15% | 1000-3000 | /portraits/tarek.png
nour | Nour | نور المهاجمة | aggressive | هجومية | 1000-1200 | 8 | 10 | 8% | 800-3000 | /portraits/nour.png
omar | Omar | عمر الاستراتيجي | positional | استراتيجي | 1200-1400 | 11 | 12 | 4% | 1500-4000 | /portraits/omar.png
layla | Layla | ليلى المبدعة | creative | إبداعية | 1400-1600 | 14 | 14 | 2% | 1000-5000 | /portraits/layla.png
ziad | Ziad | زياد الصلب | solid | صلب | 1600-1800 | 17 | 16 | 1% | 2000-6000 | /portraits/ziad.png
grandmaster | Grandmaster Bot | الجراند ماستر | near_perfect | شبه مثالي | 2000-2200 | 20 | 20 | 0% | 3000-8000 | /portraits/grandmaster.png
================================================================================
BOT BEHAVIOR DETAILS
================================================================================
BLUNDER MECHANISM:
Each move request, the bot rolls against its blunder_chance probability.
If it "blunders", the engine searches at depth=1 with skill_level=0,
producing a weak/random move. Otherwise it plays at its configured strength.
THINK TIME SIMULATION:
After the engine returns a move, the API adds artificial delay to simulate
human-like thinking. The delay is random between think_time_min and think_time_max.
Total response time = engine_time + artificial_delay.
If the client disconnects (context cancelled), the delay is aborted.
CONTEMPT:
Positive contempt = bot plays more aggressively, avoids draws.
Negative contempt = bot is happy to draw, plays defensively.
Range: -100 to 100.
SKILL LEVEL:
Stockfish's internal skill parameter (0-20).
0 = weakest, introduces random errors.
20 = full strength, no artificial weakening.
OPENING BOOKS (metadata only, not enforced by engine):
Listed per bot for frontend display. The engine does not use opening books;
it calculates from the given FEN position directly.
AVATAR IDs:
Each bot has an avatar_id field for frontend use (e.g. "bot-amina", "bot-nour").
Map these to your avatar image assets.
PORTRAITS:
Each bot has a portrait_url field pointing to a 512x512 pixel image.
Portraits are served publicly at: https://stockfishapi.caprover.al-arcade.com/portraits/{bot_id}.png
Supported formats: PNG, JPG, WebP.
Upload via admin panel: /admin/bots/edit/{id} -> Portrait upload form.
Recommended dimensions: 512x512 pixels (square).
These are character portraits/avatars for display in the chess UI.
STYLE LABELS (Arabic):
Each bot has a "style_ar" field with the Arabic translation of its play style.
Use this for bilingual (EN/AR) UI display.
Examples: "مبتدئة" (beginner), "دفاعي" (defensive), "هجومية" (aggressive),
"استراتيجي" (positional), "إبداعية" (creative), "صلب" (solid),
"شبه مثالي" (near_perfect).
================================================================================
MOVE NOTATION
================================================================================
All moves use UCI (Universal Chess Interface) long algebraic notation:
- Normal move: source_square + destination_square (e.g. "e2e4", "g1f3")
- Pawn promotion: source + destination + piece_letter (e.g. "e7e8q" for queen promotion)
- Castling: king's start + king's end (e.g. "e1g1" for white kingside, "e1c1" for queenside)
- En passant: normal pawn capture notation (e.g. "e5d6")
Piece letters for promotion: q=queen, r=rook, b=bishop, n=knight
================================================================================
FEN (Forsyth-Edwards Notation) FORMAT
================================================================================
A FEN string describes a complete board position in a single line:
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
| | | | | |
| | | | | +-- fullmove number
| | | | +----- halfmove clock (50-move rule)
| | | +---------- en passant target square ("-" if none)
| | +----------------- castling availability (KQkq or "-")
| +--------------------------- active color: "w" or "b"
+------------------------------------- piece placement (rank 8 to rank 1, "/" separated)
Piece letters: K=king, Q=queen, R=rook, B=bishop, N=knight, P=pawn
Uppercase = white, lowercase = black
Numbers = consecutive empty squares
Starting position FEN:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
================================================================================
EVALUATION VALUES
================================================================================
The "evaluation" field in responses:
- Measured in pawns (centipawns / 100)
- Positive = white advantage
- Negative = black advantage
- +0.50 means white is half a pawn ahead
- +999.0 = white has forced checkmate
- -999.0 = black has forced checkmate
- Values near 0.0 = roughly equal position
================================================================================
RATE LIMITING
================================================================================
- 60 requests per minute per IP address
- Applies to all endpoints
- X-Forwarded-For header is respected (for reverse proxy setups)
- When exceeded: 429 Too Many Requests {"error": "rate limit exceeded"}
================================================================================
CORS
================================================================================
- Access-Control-Allow-Origin: * (all origins allowed)
- Allowed Methods: GET, POST, OPTIONS
- Allowed Headers: Content-Type, Authorization, apikey
- Preflight cache: 86400 seconds (24 hours)
================================================================================
MANAGEMENT API (Full Control)
================================================================================
Base: https://stockfishapi.caprover.al-arcade.com/api/manage
Authentication:
All /api/manage/* endpoints require an API key via header:
X-API-Key: sk-alarc-stockfish-mgmt-2024
OR:
Authorization: Bearer sk-alarc-stockfish-mgmt-2024
Rate Limiting: None on management endpoints (auth-gated).
--------------------------------------------------------------------------------
SYSTEM INFO
--------------------------------------------------------------------------------
GET /api/manage/info
Returns full system overview + all available endpoint list.
Response:
{
"engine": "stockfish-18",
"version": "1.0.0",
"bot_count": 7,
"pool": { "alive": 6, "idle": 4, "max_size": 12, "idle_timeout": 300 },
"settings": { "port": "80", "stockfish_path": "/usr/local/bin/stockfish" },
"endpoints": { ... all endpoints listed ... }
}
--------------------------------------------------------------------------------
BOT CRUD
--------------------------------------------------------------------------------
GET /api/manage/bots
List all bots with count.
GET /api/manage/bots/{id}
Get a single bot by ID.
POST /api/manage/bots
Create a new bot. Send full bot JSON in body.
Required: id, name, style
Returns 201 on success, 409 if ID already exists.
Body:
{
"id": "yasmin",
"name": "Yasmin",
"name_ar": "ياسمين",
"style": "tricky",
"style_ar": "ماكرة",
"bio": "Sets traps and waits for you to fall in.",
"bio_ar": "بتحط فخاخ وبتستنى تقع فيها.",
"elo_min": 1100,
"elo_max": 1300,
"skill_level": 9,
"depth": 11,
"contempt": 20,
"blunder_chance": 0.06,
"think_time_min_ms": 1000,
"think_time_max_ms": 3500,
"opening_book": ["sicilian", "french"],
"avatar_id": "bot-yasmin",
"portrait_url": "/portraits/yasmin.png"
}
PATCH /api/manage/bots/{id}
Partial update. Only send fields you want to change.
Body: { "skill_level": 12, "blunder_chance": 0.05, "style_ar": "ذكية" }
PUT /api/manage/bots/{id}
Full replace. Overwrites entire bot with new data.
Body: same as POST but ID comes from URL.
DELETE /api/manage/bots/{id}
Delete a bot and its portrait files.
--------------------------------------------------------------------------------
BULK OPERATIONS
--------------------------------------------------------------------------------
POST /api/manage/bots/bulk
Create multiple bots at once.
Body: [ {bot1}, {bot2}, ... ]
Response: { "created": ["id1","id2"], "skipped": ["id3 (already exists)"] }
DELETE /api/manage/bots/bulk
Delete multiple bots at once.
Body: { "ids": ["amina", "tarek"] }
Response: { "deleted": ["amina","tarek"], "not_found": [] }
GET /api/manage/bots/export
Download all bots as JSON file (Content-Disposition: attachment).
POST /api/manage/bots/import?overwrite=true
Import bots from JSON array. With overwrite=true, existing bots are replaced.
Body: [ {bot1}, {bot2}, ... ]
Response: { "imported": ["id1"], "skipped": ["id2 (exists)"], "total": 8 }
--------------------------------------------------------------------------------
PORTRAIT MANAGEMENT
--------------------------------------------------------------------------------
POST /api/manage/bots/{id}/portrait
Upload a portrait image (512x512 px).
Content-Type: multipart/form-data
Field name: "portrait"
Accepted: .png, .jpg, .jpeg, .webp (max 10MB)
curl example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina/portrait \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-F "portrait=@amina_512x512.png"
Response: { "status": "uploaded", "bot_id": "amina", "portrait_url": "/portraits/amina.png", "size_bytes": 245000 }
DELETE /api/manage/bots/{id}/portrait
Delete all portrait files for a bot.
Response: { "status": "deleted", "bot_id": "amina" }
Portraits are served publicly (no auth):
GET https://stockfishapi.caprover.al-arcade.com/portraits/{bot_id}.png
--------------------------------------------------------------------------------
ENGINE / POOL
--------------------------------------------------------------------------------
GET /api/manage/pool
Full pool stats with utilization percentage.
Response:
{
"pool_alive": 6,
"pool_idle": 4,
"pool_max_size": 12,
"pool_utilization": "16.7%",
"idle_timeout_sec": 300
}
POST /api/manage/test-move
Test the engine directly. Supports raw mode (custom params) or bot mode.
Bot mode:
{ "fen": "...", "bot_id": "nour" }
Raw mode (bypass bot config, use custom engine params):
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"raw_mode": true,
"depth": 25,
"skill_level": 20,
"contempt": 0,
"time_limit_ms": 5000,
"multi_pv": 3
}
Response:
{
"best_move": "e7e5",
"evaluation": -0.12,
"depth": 25,
"nodes": 5000000,
"pv": "e7e5 g1f3 b8c6",
"engine_time_ms": 3200,
"request": { "fen": "...", "depth": 25, "skill_level": 20, "contempt": 0, "multi_pv": 3 }
}
POST /api/manage/analyze
Deep analysis (up to depth 40, up to 10 lines).
{ "fen": "...", "depth": 30, "lines": 5 }
Response: { "fen": "...", "depth": 30, "lines": [...], "engine_time_ms": 12000 }
--------------------------------------------------------------------------------
SETTINGS
--------------------------------------------------------------------------------
GET /api/manage/settings
{ "port": "80", "pool_size": 12, "idle_timeout_sec": 300, "stockfish_path": "/usr/local/bin/stockfish" }
PATCH /api/manage/settings
Update settings (partial). Some require restart.
Body: { "pool_size": 16, "idle_timeout_sec": 600 }
Response: { "status": "updated", "settings": {...}, "note": "some changes require restart" }
--------------------------------------------------------------------------------
LOGS
--------------------------------------------------------------------------------
GET /api/manage/logs?limit=50
Get recent request logs (max 100).
Response:
{
"logs": [
{ "Timestamp": "2024-01-15 14:30:22", "Method": "POST", "Path": "/api/chess/move", "Status": 200, "Duration": "45ms", "IP": "1.2.3.4" },
...
],
"count": 50
}
================================================================================
MANAGEMENT API - QUICK REFERENCE (curl examples)
================================================================================
# Auth header (use in all requests)
AUTH="-H 'X-API-Key: sk-alarc-stockfish-mgmt-2024'"
# System info
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/info \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# List bots
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/bots \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Get single bot
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/bots/nour \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Create bot
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"id":"yasmin","name":"Yasmin","name_ar":"ياسمين","style":"tricky","style_ar":"ماكرة","skill_level":9,"depth":11,"contempt":20,"blunder_chance":0.06,"think_time_min_ms":1000,"think_time_max_ms":3500,"elo_min":1100,"elo_max":1300}' | jq .
# Update bot (partial)
curl -s -X PATCH https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"skill_level":2,"blunder_chance":0.25}' | jq .
# Replace bot (full)
curl -s -X PUT https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"name":"Amina","name_ar":"أمينة","style":"beginner","style_ar":"مبتدئة","skill_level":1,"depth":3}' | jq .
# Delete bot
curl -s -X DELETE https://stockfishapi.caprover.al-arcade.com/api/manage/bots/yasmin \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Bulk create
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots/bulk \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '[{"id":"bot1","name":"Bot One","style":"test","skill_level":5,"depth":5},{"id":"bot2","name":"Bot Two","style":"test","skill_level":10,"depth":10}]' | jq .
# Bulk delete
curl -s -X DELETE https://stockfishapi.caprover.al-arcade.com/api/manage/bots/bulk \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"ids":["bot1","bot2"]}' | jq .
# Export all bots (download)
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/bots/export \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" -o bots_backup.json
# Import bots (with overwrite)
curl -s -X POST "https://stockfishapi.caprover.al-arcade.com/api/manage/bots/import?overwrite=true" \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d @bots_backup.json | jq .
# Upload portrait
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina/portrait \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-F "portrait=@amina.png" | jq .
# Delete portrait
curl -s -X DELETE https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina/portrait \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Pool stats
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/pool \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Test move (bot mode)
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/test-move \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","bot_id":"grandmaster"}' | jq .
# Test move (raw mode - custom engine params)
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/test-move \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","raw_mode":true,"depth":30,"skill_level":20,"contempt":0}' | jq .
# Deep analysis
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/analyze \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","depth":30,"lines":5}' | jq .
# Get settings
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/settings \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Update settings
curl -s -X PATCH https://stockfishapi.caprover.al-arcade.com/api/manage/settings \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"pool_size":16,"idle_timeout_sec":600}' | jq .
# View logs
curl -s "https://stockfishapi.caprover.al-arcade.com/api/manage/logs?limit=20" \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
================================================================================
ADMIN PANEL
================================================================================
URL: https://stockfishapi.caprover.al-arcade.com/admin
Login: username "admin", password "Alarcade123#"
Features:
- Dashboard: live pool stats, bot count, request metrics
- Bot management: create, edit, delete bots at runtime
- Pool monitoring: alive/idle process counts
- Test Move: test any bot with a custom FEN position
- Request Logs: last 100 requests with method, path, status, duration, IP
- Settings: view/modify port, pool size, idle timeout
Note: Runtime changes (bot edits, new bots) persist only until container restart.
Core bot definitions are compiled into the binary.
================================================================================
INFRASTRUCTURE
================================================================================
- Engine: Stockfish 18 (compiled from source, NNUE enabled)
- Runtime: Go 1.22, single static binary
- Process Pool: 12 Stockfish processes (configurable via POOL_SIZE env)
- Pre-warmed: half the pool at startup for instant first requests
- Idle Reaper: kills processes unused for 300s (configurable via IDLE_TIMEOUT_SEC)
- Platform: Docker on CapRover (Ubuntu 22.04 base)
- Architecture: x86-64-sse41-popcnt (broad server CPU compatibility)
- Health Check: every 30s, 10s timeout, 3 retries, 15s start period
- Port: 80 inside container (CapRover handles HTTPS/reverse proxy)
================================================================================
ENVIRONMENT VARIABLES
================================================================================
PORT=80 # HTTP listen port
STOCKFISH_PATH=/usr/local/bin/stockfish # path to Stockfish binary
POOL_SIZE=12 # max concurrent Stockfish processes
IDLE_TIMEOUT_SEC=300 # kill idle processes after this many seconds
================================================================================
INTEGRATION EXAMPLES
================================================================================
--- JavaScript/Fetch ---
// Get a move from "nour" bot
const response = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
bot_id: 'nour'
})
});
const data = await response.json();
console.log(data.best_move); // e.g. "e7e5"
// Analyze a position
const analysis = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
depth: 20,
lines: 3
})
});
const result = await analysis.json();
result.lines.forEach(line => {
console.log(`#${line.rank}: ${line.move} (eval: ${line.evaluation})`);
});
// List all bots
const botsResp = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/bots');
const botsData = await botsResp.json();
botsData.bots.forEach(bot => {
console.log(`${bot.name} (${bot.elo_min}-${bot.elo_max} ELO) - ${bot.style}`);
});
--- Python ---
import requests
# Get move
resp = requests.post('https://stockfishapi.caprover.al-arcade.com/api/chess/move', json={
'fen': 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
'bot_id': 'omar'
})
move = resp.json()
print(f"Best move: {move['best_move']}, Eval: {move['evaluation']}")
# Analyze
resp = requests.post('https://stockfishapi.caprover.al-arcade.com/api/chess/analyze', json={
'fen': 'r1bqkbnr/pppppppp/2n5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2',
'depth': 22,
'lines': 5
})
for line in resp.json()['lines']:
print(f"#{line['rank']}: {line['move']} (eval {line['evaluation']:.2f})")
--- cURL ---
# Quick move
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/move \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","bot_id":"amina"}' | jq .
# Health check
curl -s https://stockfishapi.caprover.al-arcade.com/health | jq .
================================================================================
TYPICAL GAME FLOW (for client integration)
================================================================================
1. GET /api/chess/bots -> show bot selection to user
2. User picks a bot (e.g. "nour") and starts a game
3. Client tracks FEN locally (or uses a chess library)
4. When it's the bot's turn:
POST /api/chess/move with current FEN and bot_id
5. Apply the returned best_move to the board
6. Repeat from step 4 until checkmate/stalemate/draw
7. Optionally: POST /api/chess/analyze for post-game analysis
Important notes for client implementation:
- Always send the FULL FEN including castling rights, en passant, move counters
- The API does NOT validate if the FEN is a legal position
- The API does NOT track game state - it's stateless (one move per request)
- Handle the artificial think_time_ms for UX (show "thinking..." animation)
- Handle 429 rate limit gracefully (retry after 1 second)
- The response time can be up to 8000ms+ for grandmaster bot (due to simulated think time)
================================================================================
ERROR HANDLING
================================================================================
All errors return JSON with an "error" field:
400 Bad Request - malformed JSON, missing required fields
404 Not Found - invalid bot_id
429 Too Many Requests - rate limit exceeded (60/min/IP)
500 Internal Server Error - Stockfish process crash, pool exhaustion, timeout
503 Service Unavailable - health check failed (engine not responding)
Recommended retry strategy:
- 429: wait 1-2 seconds, retry
- 500: wait 2-5 seconds, retry up to 3 times
- 503: service is down, alert user
================================================================================
RESPONSE TIME EXPECTATIONS
================================================================================
Bot | Typical Response Time (includes simulated think time)
----------------|------------------------------------------------------
amina | 500ms - 2000ms
tarek | 1000ms - 3000ms
nour | 800ms - 3000ms
omar | 1500ms - 4000ms
layla | 1000ms - 5000ms
ziad | 2000ms - 6000ms
grandmaster | 3000ms - 8000ms
Analysis endpoint: 1-30 seconds depending on depth (no artificial delay).
Health check: < 5 seconds.
Set client timeouts accordingly (recommend 15s for moves, 35s for analysis).
FROM php:8.3-apache
RUN a2enmod rewrite headers
RUN docker-php-ext-install opcache
RUN sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html/storage
ENV APP_ENV=production
ENV APP_SECRET=dev-secret-change-in-production-64chars-minimum-required-here!!
ENV SUPABASE_URL=https://safe-supabase-kong.caprover.al-arcade.com
ENV SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4
ENV SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84
ENV STOCKFISH_API_URL=https://stockfishapi.caprover.al-arcade.com
ENV STOCKFISH_API_KEY=sk-alarc-stockfish-mgmt-2024
ENV SWISS_API_URL=https://swissapi.caprover.al-arcade.com/api/v1
ENV ADMIN_USERNAME=admin
EXPOSE 80
# EL3AB MANAGEMENT SYSTEM
> Pure PHP + HTML + CSS + JS management panel for the El3ab competitive gaming platform.
> No frameworks. No npm. No build tools. Raw muscle, modular, sexy UI.
> Deploy: GitLab repo → CapRover (PHP container, auto-deploy on push to main).
---
# PHILOSOPHY
- **PHP** — Server logic, API proxy, session management, DB operations
- **HTML** — Semantic, accessible, RTL-native
- **CSS** — Custom properties, animations, responsive grid, zero libraries
- **JS** — Vanilla ES6+, fetch API, DOM manipulation, no jQuery/React/Vue
- **Arabic-first** — All UI text in Arabic, numbers are standard (0123456789), RTL layout
- **Modular** — Each feature is a self-contained folder with its own PHP/JS/CSS
- **Full CRUD** — Every entity: Create, Read (list + detail), Update, Delete with confirmation
- **Edge cases** — Empty states, loading, errors, validation, pagination, search, bulk actions, soft delete
---
# TABLE OF CONTENTS
1. [Infrastructure & Connections](#1-infrastructure--connections)
2. [Project Structure](#2-project-structure)
3. [Authentication & Authorization](#3-authentication--authorization)
4. [Database Schema](#4-database-schema)
5. [UI/UX Design System](#5-uiux-design-system)
6. [Module: Dashboard](#6-module-dashboard)
7. [Module: Players](#7-module-players)
8. [Module: Games](#8-module-games)
9. [Module: Chess Bots (Stockfish)](#9-module-chess-bots-stockfish)
10. [Module: Tournaments (Swiss API)](#10-module-tournaments-swiss-api)
11. [Module: Organizations](#11-module-organizations)
12. [Module: Economy & Virtual Currency](#12-module-economy--virtual-currency)
13. [Module: Advertisements](#13-module-advertisements)
14. [Module: Moderation & Reports](#14-module-moderation--reports)
15. [Module: Feature Flags](#15-module-feature-flags)
16. [Module: System Settings](#16-module-system-settings)
17. [Module: Branding & Theming](#17-module-branding--theming)
18. [Module: Analytics](#18-module-analytics)
19. [Module: Notifications](#19-module-notifications)
20. [Module: Audit Log](#20-module-audit-log)
21. [CRUD Patterns & Edge Cases](#21-crud-patterns--edge-cases)
22. [API Proxy Layer](#22-api-proxy-layer)
23. [Deployment & CapRover](#23-deployment--caprover)
24. [Execution Plan](#24-execution-plan)
---
# 1. INFRASTRUCTURE & CONNECTIONS
## Server
| Key | Value |
|-----|-------|
| IP | 3.68.63.185 |
| User | ubuntu |
| SSH | `ssh -i NewServer.pem ubuntu@3.68.63.185` |
## Supabase (Self-Hosted)
| Key | Value |
|-----|-------|
| API URL | `https://safe-supabase-kong.caprover.al-arcade.com` |
| Studio | `https://safe-supabase-studio.caprover.al-arcade.com` |
| Anon Key | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84` |
| Service Role Key | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4` |
| DB Direct | `postgresql://supabase_admin:28ac17bf9d4f7a3d1bad045408102cf5@localhost:5432/postgres` |
| REST | `/rest/v1/` |
| Auth | `/auth/v1/` |
| Storage | `/storage/v1/` |
## Stockfish Bot API
| Key | Value |
|-----|-------|
| Base URL | `https://stockfishapi.caprover.al-arcade.com` |
| Management Key | `sk-alarc-stockfish-mgmt-2024` |
| Admin Panel | `/admin` (admin/Alarcade123#) |
| Get Move | `POST /api/chess/move` |
| Analyze | `POST /api/chess/analyze` |
| List Bots | `GET /api/chess/bots` |
| CRUD Bots | `/api/manage/bots` (GET/POST/PATCH/PUT/DELETE) |
| Portraits | `POST /api/manage/bots/{id}/portrait` |
| Pool Stats | `GET /api/manage/pool` |
## Swiss System Tournament API
| Key | Value |
|-----|-------|
| Base URL | `https://swissapi.caprover.al-arcade.com/api/v1` |
| Health | `GET /health` |
| Auth | `POST /api/v1/auth/login` + `POST /api/v1/auth/signup` |
| Organizations | Full CRUD at `/api/v1/organizations` |
| Events | Full CRUD at `/api/v1/organizations/:orgId/events` |
| Tournaments | Full CRUD + start/complete at `/api/v1/events/:eventId/tournaments` |
| Players | Full CRUD + bulk import at `/api/v1/tournaments/:id/players` |
| Rounds | Generate + delete at `/api/v1/tournaments/:id/rounds` |
| Pairings | Results + manual at `/api/v1/rounds/:id/pairings` |
| Standings | `GET /api/v1/tournaments/:id/standings` |
| Export | TRF, JSON, Crosstable |
---
# 2. PROJECT STRUCTURE
```
el3ab-management/
├── captain-definition # CapRover deploy config
├── Dockerfile # PHP 8.3 + Apache
├── .htaccess # URL rewriting
├── index.php # Router / entry point
├── config/
│ ├── app.php # Constants, API keys, DB config
│ ├── routes.php # Route definitions
│ └── permissions.php # Role → permission matrix
├── core/
│ ├── Database.php # Supabase REST client (service_role)
│ ├── Auth.php # Session management, login/logout
│ ├── Router.php # URL → controller dispatcher
│ ├── View.php # Template renderer (include-based)
│ ├── Validator.php # Input validation helpers
│ ├── ApiProxy.php # HTTP client for external APIs
│ ├── AuditLog.php # Log every write action
│ ├── Pagination.php # Offset/limit pagination
│ └── Response.php # JSON/HTML response helpers
├── modules/
│ ├── dashboard/
│ │ ├── controller.php
│ │ ├── views/
│ │ │ └── index.php
│ │ └── assets/
│ │ ├── dashboard.css
│ │ └── dashboard.js
│ ├── players/
│ │ ├── controller.php # list, show, create, update, delete, ban, unban
│ │ ├── views/
│ │ │ ├── list.php
│ │ │ ├── show.php
│ │ │ ├── form.php # create + edit (same form)
│ │ │ └── _table_row.php # partial for AJAX reload
│ │ └── assets/
│ │ ├── players.css
│ │ └── players.js
│ ├── games/
│ ├── chess-bots/
│ ├── tournaments/
│ ├── organizations/
│ ├── economy/
│ ├── ads/
│ ├── moderation/
│ ├── feature-flags/
│ ├── settings/
│ ├── branding/
│ ├── analytics/
│ ├── notifications/
│ └── audit-log/
├── layouts/
│ ├── app.php # Main layout (sidebar + topbar + content)
│ ├── auth.php # Login layout (centered card)
│ ├── partials/
│ │ ├── sidebar.php
│ │ ├── topbar.php
│ │ ├── toast.php # Toast notification system
│ │ ├── modal.php # Reusable modal shell
│ │ ├── confirm-dialog.php # Delete confirmation
│ │ ├── empty-state.php # No data illustration
│ │ ├── loading-skeleton.php # Skeleton loaders
│ │ └── pagination.php # Pagination component
│ └── components/
│ ├── data-table.php # Sortable, searchable table
│ ├── stat-card.php # Dashboard stat card
│ ├── form-field.php # Input wrapper with validation
│ ├── toggle-switch.php # Boolean toggle
│ ├── color-picker.php # Theme color picker
│ ├── file-upload.php # Drag & drop upload
│ ├── badge.php # Status badges
│ └── dropdown.php # Action dropdown menu
├── public/
│ ├── css/
│ │ ├── variables.css # CSS custom properties (colors, spacing, motion)
│ │ ├── reset.css # Modern CSS reset
│ │ ├── layout.css # Grid, sidebar, responsive
│ │ ├── components.css # Buttons, cards, forms, tables
│ │ ├── animations.css # Keyframes, transitions
│ │ └── utilities.css # Helpers (flex, text, spacing)
│ ├── js/
│ │ ├── app.js # Global: toast, modal, fetch wrapper
│ │ ├── sidebar.js # Sidebar toggle, active state
│ │ ├── data-table.js # Sort, search, pagination, bulk select
│ │ └── form-validation.js # Client-side validation
│ ├── fonts/
│ │ └── ibm-plex-arabic/ # IBM Plex Arabic (variable weight)
│ └── img/
│ ├── logo.svg
│ ├── empty-states/ # SVG illustrations for empty states
│ └── icons/ # Custom SVG icons (if needed beyond Lucide CDN)
└── storage/
└── uploads/ # Temp upload dir (move to Supabase Storage)
```
---
# 3. AUTHENTICATION & AUTHORIZATION
## Login
- **URL:** `/login`
- **Credentials:** `admin` / `Alarcade123#` (superadmin — hardcoded check in Phase 1)
- **Session:** PHP `$_SESSION` with CSRF token
- **Timeout:** 24 hours inactivity → force re-login
- **Remember me:** Optional persistent cookie (30 days)
## Roles
| Role | Level | Access |
|------|:-----:|--------|
| `superadmin` | 100 | Everything. Full CRUD on all entities. System settings. |
| `admin` | 80 | All modules except system settings and branding |
| `moderator` | 50 | Players (view/ban), Reports, Moderation only |
| `viewer` | 10 | Read-only access to all modules |
## Phase 1 (NOW)
Single superadmin user. Login form validates against hardcoded credentials:
- Email/username: `admin`
- Password: `Alarcade123#`
Password stored as `password_hash()` in a PHP constant. Session created on success.
## Phase 2 (Later)
Multi-user from `platform_admins` table in Supabase with proper password hashing and role assignment.
## Permission Check Pattern
```php
// In every controller action:
Auth::requireRole('admin'); // Redirects to /login if not authenticated or insufficient role
```
## CSRF Protection
Every form includes `<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">`.
Every POST/PUT/DELETE validates the token server-side before processing.
---
# 4. DATABASE SCHEMA
All tables live in Supabase PostgreSQL. The PHP app talks to them via the REST API using the **service_role key** (bypasses RLS — this is an admin panel, RLS is for player-facing apps).
## Core Tables
```sql
-- Platform admin users (Phase 2)
CREATE TABLE platform_admins (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('superadmin','admin','moderator','viewer')),
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Player profiles (main player table)
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
username TEXT UNIQUE NOT NULL,
display_name TEXT,
display_name_ar TEXT,
email TEXT,
avatar_url TEXT,
country_code TEXT,
city TEXT,
bio TEXT,
-- Ratings
elo_blitz INT DEFAULT 1200,
elo_rapid INT DEFAULT 1200,
elo_classical INT DEFAULT 1200,
elo_backgammon INT DEFAULT 1200,
elo_dominoes INT DEFAULT 1200,
elo_ludo INT DEFAULT 1200,
elo_trivia INT DEFAULT 1200,
-- Status
level INT DEFAULT 1,
xp INT DEFAULT 0,
is_online BOOLEAN DEFAULT false,
is_banned BOOLEAN DEFAULT false,
ban_reason TEXT,
banned_at TIMESTAMPTZ,
banned_by UUID,
-- Economy
coins INT DEFAULT 0,
gems INT DEFAULT 0,
-- Stats
total_matches INT DEFAULT 0,
total_wins INT DEFAULT 0,
total_losses INT DEFAULT 0,
total_draws INT DEFAULT 0,
win_streak INT DEFAULT 0,
best_win_streak INT DEFAULT 0,
-- Timestamps
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Game plugins registry
CREATE TABLE game_plugins (
game_key TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_ar TEXT NOT NULL,
description TEXT,
description_ar TEXT,
icon TEXT,
is_enabled BOOLEAN DEFAULT true,
min_players INT DEFAULT 2,
max_players INT DEFAULT 2,
supports_ranked BOOLEAN DEFAULT true,
supports_tournament BOOLEAN DEFAULT true,
matchmaking_config JSONB DEFAULT '{}',
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Matches (all games)
CREATE TABLE matches (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
game_key TEXT NOT NULL REFERENCES game_plugins(game_key),
status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting','in_progress','completed','cancelled','abandoned')),
mode TEXT DEFAULT 'ranked' CHECK (mode IN ('ranked','casual','tournament','bot')),
-- Players (JSONB array of player objects)
players JSONB NOT NULL DEFAULT '[]',
-- Results
winner_id UUID,
result TEXT CHECK (result IN ('win','draw','forfeit','timeout','disconnect')),
-- Timing
time_control TEXT,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_seconds INT,
-- Game-specific state
game_state JSONB DEFAULT '{}',
move_history JSONB DEFAULT '[]',
-- Tournament link
tournament_id UUID,
round_number INT,
-- Meta
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Organizations
CREATE TABLE organizations (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
name_ar TEXT,
slug TEXT UNIQUE,
logo_url TEXT,
description TEXT,
description_ar TEXT,
contact_email TEXT,
website TEXT,
country_code TEXT,
city TEXT,
is_verified BOOLEAN DEFAULT false,
verified_at TIMESTAMPTZ,
verification_docs JSONB DEFAULT '[]',
-- Swiss API link
swiss_api_org_id UUID,
swiss_api_token TEXT,
-- Settings
settings JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Organization members
CREATE TABLE org_members (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id),
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','arbiter','member')),
status TEXT DEFAULT 'active' CHECK (status IN ('active','invited','suspended')),
joined_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(org_id, user_id)
);
-- Tournaments (managed through Swiss API but tracked locally)
CREATE TABLE el3ab_tournaments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
game_key TEXT NOT NULL REFERENCES game_plugins(game_key),
name TEXT NOT NULL,
name_ar TEXT,
description TEXT,
-- Swiss API references
swiss_api_event_id UUID,
swiss_api_tournament_id UUID,
-- Config
format TEXT DEFAULT 'swiss' CHECK (format IN ('swiss','round_robin','bracket','arena')),
status TEXT DEFAULT 'draft' CHECK (status IN ('draft','registration','in_progress','completed','cancelled')),
max_players INT DEFAULT 64,
rounds_number INT,
time_control TEXT,
-- Economy
entry_fee_coins INT DEFAULT 0,
prize_pool JSONB DEFAULT '[]',
-- Dates
registration_opens_at TIMESTAMPTZ,
registration_closes_at TIMESTAMPTZ,
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
-- Meta
banner_url TEXT,
rules TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Feature flags
CREATE TABLE feature_flags (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
label_ar TEXT NOT NULL,
description TEXT,
is_enabled BOOLEAN DEFAULT false,
target TEXT DEFAULT 'all' CHECK (target IN ('all','percentage','user_list','org_list')),
target_value JSONB DEFAULT '{}',
category TEXT DEFAULT 'general',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- System configuration (key-value)
CREATE TABLE system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
value_type TEXT DEFAULT 'string' CHECK (value_type IN ('string','number','boolean','json')),
label TEXT,
label_ar TEXT,
description TEXT,
category TEXT DEFAULT 'general',
is_editable BOOLEAN DEFAULT true,
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Platform theme tokens
CREATE TABLE platform_theme (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
label TEXT NOT NULL,
label_ar TEXT,
value TEXT NOT NULL,
value_type TEXT DEFAULT 'color' CHECK (value_type IN ('color','size','font','gradient','shadow')),
sort_order INT DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Platform assets (branding)
CREATE TABLE platform_assets (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
label TEXT NOT NULL,
label_ar TEXT,
asset_url TEXT,
fallback_type TEXT DEFAULT 'color' CHECK (fallback_type IN ('color','gradient','icon','text','none')),
fallback_value TEXT,
dimensions JSONB,
sort_order INT DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Advertisements
CREATE TABLE ad_campaigns (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
advertiser TEXT NOT NULL,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft','active','paused','completed','expired')),
-- Content
image_url TEXT,
click_url TEXT,
title TEXT,
title_ar TEXT,
body TEXT,
body_ar TEXT,
-- Targeting
placement TEXT NOT NULL CHECK (placement IN ('banner_top','banner_bottom','interstitial','sidebar','in_game','reward_video')),
target_countries JSONB DEFAULT '[]',
target_games JSONB DEFAULT '[]',
-- Schedule
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
-- Budget
budget_total INT DEFAULT 0,
budget_spent INT DEFAULT 0,
cpm NUMERIC(10,2) DEFAULT 0,
-- Stats
impressions INT DEFAULT 0,
clicks INT DEFAULT 0,
-- Meta
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Moderation reports
CREATE TABLE cheat_reports (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
reporter_id UUID REFERENCES profiles(id),
reported_id UUID NOT NULL REFERENCES profiles(id),
match_id UUID REFERENCES matches(id),
reason TEXT NOT NULL CHECK (reason IN ('cheating','harassment','inappropriate_name','spam','other')),
description TEXT,
evidence_urls JSONB DEFAULT '[]',
status TEXT DEFAULT 'pending' CHECK (status IN ('pending','reviewing','resolved','dismissed')),
resolution TEXT,
resolved_by UUID,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Economy transactions
CREATE TABLE transactions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES profiles(id),
type TEXT NOT NULL CHECK (type IN ('earn','spend','refund','admin_grant','admin_revoke','tournament_prize','tournament_entry')),
currency TEXT NOT NULL CHECK (currency IN ('coins','gems')),
amount INT NOT NULL,
balance_after INT NOT NULL,
description TEXT,
reference_type TEXT,
reference_id UUID,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Notifications
CREATE TABLE notifications (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES profiles(id),
type TEXT NOT NULL,
title TEXT NOT NULL,
title_ar TEXT,
body TEXT,
body_ar TEXT,
data JSONB DEFAULT '{}',
is_read BOOLEAN DEFAULT false,
is_broadcast BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Audit log
CREATE TABLE audit_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
old_value JSONB,
new_value JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
```
---
# 5. UI/UX DESIGN SYSTEM
## Brand Colors
| Token | Hex | Usage |
|-------|-----|-------|
| `--brand-blue` | `#2082F0` | Primary actions, links, active states |
| `--brand-orange` | `#E84D1E` | Destructive, alerts, live indicators |
| `--brand-gold` | `#E4AC38` | Premium, trophies, success |
| `--brand-sand` | `#FFCC66` | Soft highlights, secondary |
| `--brand-navy` | `#152132` | Background, depth |
| `--brand-cyan` | `#00FFFF` | Hover glows, accents |
| `--brand-purple` | `#6834BE` | Rare, elite, legendary |
## Surface Colors (Dark Theme)
| Token | Value |
|-------|-------|
| `--bg-primary` | `#0a0e1a` |
| `--bg-secondary` | `#111827` |
| `--bg-elevated` | `#1a2235` |
| `--bg-hover` | `#1f2937` |
| `--border` | `rgba(255,255,255,0.06)` |
| `--border-hover` | `rgba(255,255,255,0.12)` |
| `--text-primary` | `#ffffff` |
| `--text-secondary` | `rgba(255,255,255,0.6)` |
| `--text-muted` | `rgba(255,255,255,0.3)` |
## Typography
- **Font:** IBM Plex Arabic (self-hosted, variable weight)
- **Direction:** RTL (dir="rtl", lang="ar")
- **Numbers:** Always `0123456789` (font-feature-settings: "tnum" for tabular)
- **Sizes:** 12px (caption), 14px (body), 16px (subtitle), 20px (title), 28px (heading), 36px (hero)
## Spacing Scale
`4px, 8px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px`
## Border Radius
| Usage | Value |
|-------|-------|
| Buttons/Inputs | 8px |
| Cards | 12px |
| Modals | 16px |
| Avatars | 50% |
| Badges | 999px (pill) |
## Animations & Motion
```css
:root {
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
}
```
**Animation Rules:**
- Every page element enters with `fadeInUp` (staggered 50ms)
- Cards lift on hover (`translateY(-2px)` + shadow increase)
- Buttons scale on press (`scale(0.97)`) and glow on hover
- Modals slide up from bottom with backdrop fade
- Toasts slide in from top-right with spring physics
- Data tables: rows fade in staggered on load
- Stat numbers: count-up animation on dashboard
- Toggle switches: smooth slide with color transition
- Skeleton loaders: gradient shimmer using brand colors
- Delete: row shrinks height + fades out before removal
- Page transitions: content fades out/in (no full reload for AJAX actions)
**Microinteractions:**
- Sidebar nav items: icon rotates 5deg on hover, text slides right
- Active sidebar item: glowing left border (brand-blue)
- Form inputs: label floats up on focus, border transitions to brand-blue
- Checkboxes: satisfying checkmark draw animation
- Dropdowns: scale from origin point with opacity
- Status badges: subtle pulse animation for "live" states
## Responsive Breakpoints
| Name | Width | Behavior |
|------|-------|----------|
| Desktop | > 1200px | Full sidebar + content |
| Tablet | 768-1200px | Collapsible sidebar (icon-only) |
| Mobile | < 768px | Hidden sidebar (hamburger toggle) |
## RTL Considerations
- `dir="rtl"` on `<html>`
- Sidebar on the **right** side
- All `margin-left``margin-inline-start`
- All `text-align: left``text-align: start`
- Icons that indicate direction (arrows, chevrons) flip horizontally
- Number inputs remain LTR for readability (`dir="ltr"` on inputs)
---
# 6. MODULE: DASHBOARD
**Route:** `/` or `/dashboard`
## Layout
4 stat cards (top) + service health (middle) + recent activity (bottom)
## Stat Cards
| Card | Source | Icon |
|------|--------|------|
| اللاعبون المتصلون / اجمالي اللاعبين | `profiles` count where is_online=true / total | Users |
| المباريات النشطة / اجمالي المباريات | `matches` where status=in_progress / total | Gamepad |
| البطولات الجارية | `el3ab_tournaments` where status=in_progress | Trophy |
| البلاغات المعلقة | `cheat_reports` where status=pending | AlertTriangle |
## Service Health Panel
PHP cURL to each service, return status + latency:
- Supabase REST: `GET /rest/v1/` with apikey header
- Stockfish API: `GET /health`
- Swiss API: `GET /health`
Show green/red dot + latency in ms.
## Recent Activity
Last 10 entries from `audit_log` table, showing who did what when.
## Quick Actions
4 shortcut buttons: انشاء بطولة, مراجعة بلاغات, ادارة الالعاب, التحليلات
---
# 7. MODULE: PLAYERS
**Route:** `/players`
## Features
| Action | Method | Endpoint |
|--------|--------|----------|
| List all | GET | `profiles?select=*&order=created_at.desc` |
| Search | GET | `profiles?or=(username.ilike.*{q}*,display_name.ilike.*{q}*)` |
| View details | GET | `profiles?id=eq.{id}` |
| Edit profile | PATCH | `profiles?id=eq.{id}` |
| Ban player | PATCH | Set `is_banned=true, ban_reason, banned_at, banned_by` |
| Unban player | PATCH | Set `is_banned=false, ban_reason=null` |
| Grant currency | POST | Insert into `transactions` + update `profiles.coins/gems` |
| Revoke currency | POST | Insert into `transactions` + update `profiles.coins/gems` |
| Reset rating | PATCH | Set elo back to 1200 for selected game |
| View match history | GET | `matches?players->contains({id})&order=created_at.desc` |
| View transactions | GET | `transactions?user_id=eq.{id}&order=created_at.desc` |
| Delete player | DELETE | Soft-delete: set `is_active=false` (never hard delete) |
## List View
Data table with columns:
- الصورة (avatar)
- اسم المستخدم (username)
- الاسم المعروض (display_name)
- المستوى (level)
- العملات (coins)
- الحالة (online/offline/banned badge)
- آخر نشاط (last_active_at)
- الاجراءات (dropdown: عرض, تعديل, حظر, حذف)
Features: Search, sort by any column, pagination (25/50/100 per page), bulk select + bulk ban.
## Detail View
Full player card with:
- All ratings per game (editable inline)
- Match history tab
- Transaction history tab
- Reports tab (reports against this player)
- Activity timeline
## Form (Create/Edit)
All fields with proper validation. Country dropdown. Avatar upload (to Supabase Storage).
## Edge Cases
- Ban: Confirm dialog "هل تريد حظر هذا اللاعب؟" with reason textarea (required)
- Unban: Confirm dialog
- Grant/Revoke: Amount field with positive validation, auto-calculate balance_after
- If player has active matches when banned: those matches auto-forfeit
- Empty search: Show "لا توجد نتائج" with illustration
- Pagination: Show "عرض 1-25 من 1,423 لاعب"
---
# 8. MODULE: GAMES
**Route:** `/games`
## CRUD on `game_plugins`
| Action | Description |
|--------|-------------|
| List | All games with enable/disable toggle |
| Create | Add new game (game_key, name, name_ar, settings) |
| Edit | Modify game settings (matchmaking config, player counts) |
| Toggle | Enable/disable with immediate effect |
| Delete | Only if no matches exist for this game (else soft-disable) |
## Game Card Layout (not a table — visual grid)
Each game as a large card showing:
- Icon/image
- Arabic name + English name
- Player count range
- Supports ranked? Supports tournament?
- Total matches played
- Enable/disable toggle
- Edit button
## Matchmaking Config Editor
JSON editor for per-game matchmaking settings:
```json
{
"elo_range_initial": 100,
"elo_range_expansion_rate": 50,
"elo_range_max": 500,
"queue_timeout_seconds": 60,
"min_level_ranked": 5
}
```
---
# 9. MODULE: CHESS BOTS (STOCKFISH)
**Route:** `/chess-bots`
## API Proxy
All requests go through PHP proxy at `/api/proxy/stockfish.php` which adds the `X-API-Key: sk-alarc-stockfish-mgmt-2024` header.
## Features
| Action | Stockfish API Endpoint |
|--------|------------------------|
| List bots | `GET /api/manage/bots` |
| View bot | `GET /api/manage/bots/{id}` |
| Create bot | `POST /api/manage/bots` |
| Edit bot | `PATCH /api/manage/bots/{id}` |
| Delete bot | `DELETE /api/manage/bots/{id}` |
| Upload portrait | `POST /api/manage/bots/{id}/portrait` |
| Test bot move | `POST /api/manage/test-move` |
| Pool stats | `GET /api/manage/pool` |
| Export all | `GET /api/manage/bots/export` |
| Import bots | `POST /api/manage/bots/import` |
## Bot Card Layout
Each bot displayed as a character card:
- Portrait image (512x512)
- Name (Arabic + English)
- Style badge (Arabic)
- ELO range bar (visual)
- Difficulty stars (skill_level / 20 * 5)
- Blunder % indicator
- Think time range
- Edit / Delete / Test buttons
## Bot Form
| Field | Type | Validation |
|-------|------|------------|
| id | text | Required, lowercase, no spaces, unique |
| name | text | Required |
| name_ar | text | Required |
| style | select | beginner/defensive/aggressive/positional/creative/solid/near_perfect |
| style_ar | text | Required |
| bio | textarea | Optional |
| bio_ar | textarea | Optional |
| elo_min | number | Required, 0-3000 |
| elo_max | number | Required, > elo_min |
| skill_level | range slider | 0-20 |
| depth | number | 1-30 |
| contempt | range slider | -100 to 100 |
| blunder_chance | range slider | 0.00-1.00 (show as %) |
| think_time_min_ms | number | > 0 |
| think_time_max_ms | number | > think_time_min_ms |
| portrait | file upload | PNG/JPG/WebP, max 10MB |
## Test Panel
- FEN input (with "وضع البداية" button to reset to starting position)
- Bot selector dropdown
- "اختبر النقلة" button
- Result display: best_move, evaluation bar, depth, PV line
- Mini chessboard visualization (optional — CSS grid with unicode pieces)
## Pool Monitor
Live display of:
- Processes alive / max
- Processes idle
- Utilization % (progress bar)
- Auto-refresh every 10 seconds
---
# 10. MODULE: TOURNAMENTS (SWISS API)
**Route:** `/tournaments`
## Architecture
The management panel creates/manages tournaments via the Swiss System API. Local `el3ab_tournaments` table stores the El3ab-specific metadata (entry fees, prizes, banner). The Swiss API handles the actual pairing engine.
## Workflow
1. Admin creates tournament in El3ab panel → saves to `el3ab_tournaments`
2. PHP backend creates corresponding org/event/tournament in Swiss API
3. Players register via player app → PHP adds them to Swiss API tournament
4. Admin generates rounds → PHP calls Swiss API pairing engine
5. Results come in → PHP updates Swiss API
6. Standings are read from Swiss API and displayed
## Features
| Action | Description |
|--------|-------------|
| List | All tournaments with status filter (draft/registration/in_progress/completed) |
| Create | Wizard: game → format → settings → economy → schedule |
| Edit | Modify settings (only if draft/registration) |
| Start | Transition to in_progress (calls Swiss API start) |
| Generate Round | Call Swiss API pairing engine |
| Enter Results | Per-pairing result entry |
| View Standings | Live standings from Swiss API |
| Complete | Mark as completed, distribute prizes |
| Cancel | Cancel with optional refund |
| Export | TRF file, JSON, Crosstable |
## Tournament Create Wizard (Multi-Step Form)
**Step 1 — اساسيات:**
- Name / Name (Arabic)
- Game (dropdown from game_plugins)
- Organization (optional, dropdown)
- Description
**Step 2 — النظام:**
- Format: Swiss / Round Robin / Bracket / Arena
- Rounds number
- Time control
- Tiebreak rules (multi-select)
- Max players
**Step 3 — الاقتصاد:**
- Entry fee (coins)
- Prize distribution (1st, 2nd, 3rd + custom JSON)
- Charity percentage (optional)
**Step 4 — الجدول:**
- Registration opens/closes (datetime pickers)
- Tournament starts/ends
- Banner image upload
**Step 5 — مراجعة:**
- Summary of all settings
- Confirm & Create button
## Round Management View
- List of rounds with status
- "توليد الجولة التالية" button (generates via Swiss API)
- Per-round: list pairings with board number, white/black players, result
- Bulk result entry (dropdown per pairing: white wins / draw / black wins / forfeit)
- "ادخال جميع النتائج" button
## Standings View
Table pulled from Swiss API `GET /tournaments/:id/standings`:
- Rank
- Player name
- Points
- Tiebreak values
- Games played / W / D / L
---
# 11. MODULE: ORGANIZATIONS
**Route:** `/organizations`
## CRUD
| Action | Description |
|--------|-------------|
| List | All orgs with search, filter (verified/unverified), sort |
| Create | Name, slug, contact, country, logo upload |
| Edit | All fields, link to Swiss API org |
| Verify | Mark as verified (set is_verified=true, verified_at) |
| Manage Members | List members, add/remove, change roles |
| Delete | Soft-delete (set is_active=false) |
## Organization Detail View
Tabs:
- **معلومات عامة** — All fields, edit in-place
- **الاعضاء** — Members table with role badges, add/remove/change role
- **البطولات** — Tournaments owned by this org
- **التحقق** — Verification documents, approve/reject
## Member Management
- Add member by email/username search
- Role dropdown: owner / admin / arbiter / member
- Remove with confirmation
- Show join date
---
# 12. MODULE: ECONOMY & VIRTUAL CURRENCY
**Route:** `/economy`
## Currencies
| Currency | Arabic | Icon | Use |
|----------|--------|------|-----|
| Coins | عملات | 🪙 | Earned through play, spent on entry fees |
| Gems | جواهر | 💎 | Premium currency, purchased or rewards |
## Admin Actions
| Action | Description |
|--------|-------------|
| Grant coins/gems | Select player(s), amount, reason → insert transaction + update profile |
| Revoke coins/gems | Same but negative |
| View all transactions | Filterable table: user, type, currency, date range |
| Bulk grant | CSV upload or manual multi-select |
| Economy stats | Total coins in circulation, total gems, daily earn/spend rates |
| Price config | Edit costs of items (tournament entry, cosmetics) in system_config |
## Transaction Table
Columns: اللاعب, النوع, العملة, المبلغ, الرصيد بعد, الوصف, التاريخ
Filters: Currency, Type (earn/spend/refund/admin_grant/tournament), Date range, Player search
## Edge Cases
- Granting more than 1,000,000 at once: require confirmation "مبلغ كبير - هل انت متأكد؟"
- Revoke more than player's balance: reject with error "الرصيد غير كاف"
- Transaction always logs the admin who performed it (created_by)
---
# 13. MODULE: ADVERTISEMENTS
**Route:** `/ads`
## CRUD
| Action | Description |
|--------|-------------|
| List | All campaigns with status badges, impressions/clicks stats |
| Create | Name, advertiser, placement, content, targeting, schedule, budget |
| Edit | All fields |
| Activate/Pause | Toggle status |
| View stats | Impressions, clicks, CTR, budget spent |
| Delete | With confirmation |
## Campaign Form
| Field | Type |
|-------|------|
| name | text |
| advertiser | text |
| placement | select (banner_top, banner_bottom, interstitial, sidebar, in_game, reward_video) |
| image_url | file upload (to Supabase Storage) |
| click_url | URL |
| title / title_ar | text |
| body / body_ar | textarea |
| target_countries | multi-select |
| target_games | multi-select (from game_plugins) |
| starts_at / ends_at | datetime |
| budget_total | number |
| cpm | number |
## Stats Dashboard
Per campaign:
- Impressions (number + sparkline chart)
- Clicks (number + sparkline)
- CTR % (calculated)
- Budget: spent / total (progress bar)
- Status timeline
---
# 14. MODULE: MODERATION & REPORTS
**Route:** `/moderation`
## Report Queue
Table of all `cheat_reports` ordered by created_at desc, filterable by status.
Columns: المبلغ, المبلغ عنه, السبب, المباراة, الحالة, التاريخ, الاجراءات
## Actions per Report
| Action | Description |
|--------|-------------|
| View | Full details, evidence links, match replay link |
| Resolve | Mark resolved with action text (what was done) |
| Dismiss | Mark as dismissed with reason |
| Ban reported player | Quick-ban from report view |
| View reporter | Link to reporter's profile |
| View reported | Link to reported player's profile |
| View match | Link to match details |
## Status Flow
```
pending → reviewing → resolved
→ dismissed
```
## Bulk Actions
- Select multiple pending reports
- Bulk dismiss (with shared reason)
- Bulk resolve
---
# 15. MODULE: FEATURE FLAGS
**Route:** `/feature-flags`
## CRUD
| Action | Description |
|--------|-------------|
| List | All flags with toggle switches |
| Create | id, label, label_ar, description, target, category |
| Edit | All fields |
| Toggle | Enable/disable with immediate effect |
| Delete | With confirmation |
## Flag Card Layout
Each flag as a card:
- Toggle switch (prominent)
- Label (Arabic)
- Category badge
- Target indicator (all / percentage / specific users)
- Description (expandable)
## Target Types
| Target | Config |
|--------|--------|
| `all` | Flag applies to everyone |
| `percentage` | Slider: 0-100% of users |
| `user_list` | Search and add specific user IDs |
| `org_list` | Search and add specific org IDs |
---
# 16. MODULE: SYSTEM SETTINGS
**Route:** `/settings`
## Key-Value Editor
Grouped by category. Each setting shows:
- Label (Arabic)
- Current value
- Edit (inline or modal depending on type)
- Value type indicator
## Categories
| Category | Example Settings |
|----------|-----------------|
| `matchmaking` | elo_range_initial, queue_timeout, min_level_ranked |
| `economy` | daily_login_coins, win_reward_coins, tournament_rake_percent |
| `moderation` | auto_ban_threshold, report_cooldown_minutes |
| `platform` | maintenance_mode, platform_name, platform_version |
| `limits` | max_username_length, max_avatar_size_kb, max_orgs_per_user |
## Inline Editing
- String: text input
- Number: number input with +/- buttons
- Boolean: toggle switch
- JSON: code editor (textarea with monospace font)
## Protection
- `is_editable=false` settings are shown but greyed out
- Every change logs to audit_log
- Confirmation for critical settings (maintenance_mode, etc.)
---
# 17. MODULE: BRANDING & THEMING
**Route:** `/branding`
## Tabs
### Tab 1: الالوان (Colors)
Grid of color tokens from `platform_theme` where category='color':
- Color swatch preview
- Label (Arabic)
- Hex value
- Color picker input
- Save button per token
### Tab 2: الاصول المرئية (Assets)
Grid of all `platform_assets`:
- Current image preview (or fallback visualization)
- Label (Arabic)
- Upload button (drag & drop)
- Reset button (clear asset_url → show fallback)
- Fallback type indicator
### Tab 3: معاينة حية (Live Preview)
Miniature preview of how the current theme looks applied to sample UI elements (buttons, cards, text, backgrounds).
## Edge Cases
- Invalid color: validate hex format before saving
- Asset upload: validate file type (PNG/JPG/SVG/WebP), max 5MB
- Show color contrast ratio for text colors (accessibility)
- "اعادة الى الافتراضي" button to reset all tokens to defaults
---
# 18. MODULE: ANALYTICS
**Route:** `/analytics`
## Data Sources
All data pulled from Supabase tables with SQL aggregation:
- Player growth (registrations per day/week/month)
- Match volume per game
- Revenue (virtual currency flow)
- Active users (DAU/WAU/MAU)
- Tournament participation rates
- Top games by match count
## Charts (Pure CSS/JS — no chart libraries)
- **Bar charts:** CSS grid + div heights based on %
- **Sparklines:** SVG polyline
- **Progress bars:** CSS gradient fills
- **Numbers:** Count-up animation
## Date Range Filter
Dropdown: اليوم / هذا الاسبوع / هذا الشهر / آخر 3 اشهر / مخصص
## Stat Cards (Top Row)
| Stat | Source |
|------|--------|
| اللاعبون الجدد اليوم | profiles WHERE created_at > today |
| المباريات اليوم | matches WHERE created_at > today |
| الايرادات (عملات) | SUM(transactions.amount) WHERE type='spend' AND today |
| معدل الاحتفاظ | (active_7d / total) * 100 |
## Game Distribution Chart
Horizontal bars showing match count per game, sorted descending.
---
# 19. MODULE: NOTIFICATIONS
**Route:** `/notifications`
## Features
| Action | Description |
|--------|-------------|
| Send to user | Select user, type, title, body |
| Broadcast | Send to all users (is_broadcast=true) |
| View history | All sent notifications with read/unread stats |
| Templates | Pre-defined notification templates (maintenance, update, event) |
| Delete old | Bulk delete notifications older than X days |
## Notification Types
| Type | Usage |
|------|-------|
| `system` | Platform updates, maintenance |
| `tournament` | Registration open, round generated, results |
| `economy` | Currency granted, prize awarded |
| `moderation` | Account warning, ban notification |
| `social` | Friend request, org invite |
## Broadcast Form
- Title (Arabic)
- Body (Arabic)
- Type selector
- Optional: target specific game players only
- Preview before send
- Confirm: "سيتم ارسال هذا الاشعار الى {count} لاعب. متأكد؟"
---
# 20. MODULE: AUDIT LOG
**Route:** `/audit-log`
## Features
- Read-only table of all admin actions
- Filters: actor, action, entity_type, date range
- Search by entity_id
- Export as CSV
## Table Columns
| Column | Description |
|--------|-------------|
| التاريخ | Timestamp |
| المستخدم | Admin who performed action |
| الاجراء | create / update / delete / ban / toggle / grant |
| النوع | Entity type (player, tournament, setting, etc.) |
| المعرف | Entity ID (clickable link to entity) |
| التفاصيل | Expandable JSON diff (old_value → new_value) |
| IP | IP address of admin |
## Auto-Logging
Every write operation (create/update/delete) in any module automatically inserts an audit_log entry via `AuditLog::log()` helper.
---
# 21. CRUD PATTERNS & EDGE CASES
## Standard CRUD Flow (every module follows this)
### List View
- Skeleton loader while fetching
- Empty state with illustration if no data
- Search with debounce (300ms)
- Column sort (ASC/DESC toggle)
- Pagination with page size selector
- Bulk select checkbox + bulk actions dropdown
- Status filter pills/tabs
- "اضافة جديد" button (prominent, top-right for RTL)
### Create/Edit Form
- Client-side validation (required fields, formats, ranges)
- Server-side validation (duplicate checks, foreign key existence)
- CSRF token on every form
- Loading state on submit button (spinner, disable)
- Success → toast notification + redirect to list
- Error → inline error messages per field + toast
- Unsaved changes warning on navigation away
### Delete
- Always requires confirmation dialog
- Dialog shows entity name/identifier
- "حذف" button is red, requires typing entity name for critical entities
- Soft delete preferred (set is_active=false or status=deleted)
- Hard delete only for truly ephemeral data (notifications, old logs)
### Detail/Show View
- Breadcrumb navigation
- Edit button (top-right)
- Delete button (bottom, with warning color)
- Related entities in tabs
- Activity timeline (from audit_log)
## Edge Cases Handled Everywhere
| Edge Case | Handling |
|-----------|----------|
| Network timeout | Retry button + "فشل الاتصال" toast |
| 401 Unauthorized | Redirect to login immediately |
| 403 Forbidden | "ليس لديك صلاحية" page |
| 404 Not Found | "العنصر غير موجود" with back button |
| 409 Conflict | "يوجد عنصر بنفس البيانات" with suggestion |
| 422 Validation | Highlight invalid fields, scroll to first error |
| 429 Rate Limit | "كثرة الطلبات, انتظر قليلا" with countdown |
| 500 Server Error | "خطأ في الخادم" with support contact |
| Empty state | Custom illustration + helpful action button |
| Long loading | Skeleton loader (never blank screen) |
| Large dataset | Pagination + "يتم التحميل..." indicator |
| Concurrent edit | Last-write-wins (show "تم تعديل هذا العنصر بواسطة آخر" if updated_at changed) |
| Special characters | All user input sanitized (htmlspecialchars) |
| XSS | Output encoding everywhere, CSP headers |
| SQL Injection | N/A (using Supabase REST, not raw SQL) but parameterize everything |
| File upload | Validate type, size, dimensions on both client and server |
| Session expiry | AJAX calls detect 401 → show "انتهت الجلسة" modal → login |
---
# 22. API PROXY LAYER
PHP proxies all external API calls (browser never talks directly to Stockfish/Swiss/Supabase).
## Why Proxy
1. Hides API keys from browser
2. Avoids CORS entirely
3. Adds audit logging
4. Rate limiting/throttling
5. Response transformation
## Proxy Files
```
api/
├── supabase.php # All Supabase REST calls
├── stockfish.php # All Stockfish management calls
├── swiss.php # All Swiss API calls
└── health.php # Health check aggregator
```
## Supabase Proxy Pattern
```php
// api/supabase.php
class SupabaseProxy {
private string $baseUrl = 'https://safe-supabase-kong.caprover.al-arcade.com/rest/v1';
private string $serviceKey = '...service_role_key...';
public function select(string $table, array $params = []): array {
$query = http_build_query($params);
$response = $this->request('GET', "/{$table}?{$query}");
return json_decode($response, true);
}
public function insert(string $table, array $data): array { ... }
public function update(string $table, array $match, array $data): array { ... }
public function delete(string $table, array $match): bool { ... }
private function request(string $method, string $path, ?array $body = null): string {
// cURL with Authorization: Bearer $serviceKey
// + apikey header
// + Prefer: return=representation for INSERT/UPDATE
}
}
```
---
# 23. DEPLOYMENT & CAPROVER
## Dockerfile
```dockerfile
FROM php:8.3-apache
# Enable Apache modules
RUN a2enmod rewrite headers
# Install extensions
RUN docker-php-ext-install opcache
# Configure Apache for .htaccess
RUN sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
# Copy source
COPY . /var/www/html/
# Set permissions
RUN chown -R www-data:www-data /var/www/html/storage
EXPOSE 80
```
## captain-definition
```json
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
```
## .htaccess (URL Rewriting)
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
# Security headers
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
```
## CapRover Config
| Setting | Value |
|---------|-------|
| App Name | `el3ab-management` |
| Container HTTP Port | 80 |
| Force HTTPS | Yes |
| Websocket Support | No |
| Persistent Data | No (stateless PHP, all data in Supabase) |
## Environment Variables (in CapRover)
```
SUPABASE_URL=https://safe-supabase-kong.caprover.al-arcade.com
SUPABASE_SERVICE_KEY=eyJhbGci...service_role...
SUPABASE_ANON_KEY=eyJhbGci...anon...
STOCKFISH_API_URL=https://stockfishapi.caprover.al-arcade.com
STOCKFISH_API_KEY=sk-alarc-stockfish-mgmt-2024
SWISS_API_URL=https://swissapi.caprover.al-arcade.com/api/v1
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=$2y$12$... (bcrypt hash of Alarcade123#)
APP_SECRET=random-64-char-string-for-csrf-and-sessions
```
---
# 24. EXECUTION PLAN
## Phase 1: Foundation (Week 1)
| Task | Priority |
|------|----------|
| Project scaffold (folders, Dockerfile, .htaccess, index.php) | P0 |
| Core classes: Router, Auth, View, Database (Supabase REST client) | P0 |
| Login page (hardcoded admin/Alarcade123#) | P0 |
| Main layout: sidebar + topbar + content area | P0 |
| CSS design system: variables, reset, layout, components | P0 |
| Dashboard with real data (player count, match count, service health) | P0 |
## Phase 2: Core Modules (Week 2)
| Task | Priority |
|------|----------|
| Players module: full CRUD + ban/unban + currency grant | P0 |
| Games module: CRUD on game_plugins | P0 |
| Database migration script (create all tables via SSH) | P0 |
| Toast notifications, confirm dialogs, loading states | P0 |
| Pagination component, data table with sort/search | P0 |
## Phase 3: External Integrations (Week 3)
| Task | Priority |
|------|----------|
| Chess Bots module: full CRUD via Stockfish API proxy | P1 |
| Tournaments module: create wizard + Swiss API integration | P1 |
| Organizations module: CRUD + member management | P1 |
| API proxy layer (supabase.php, stockfish.php, swiss.php) | P0 |
## Phase 4: Management Features (Week 4)
| Task | Priority |
|------|----------|
| Economy module: transactions, grant/revoke, stats | P1 |
| Moderation module: report queue, resolve/dismiss | P1 |
| Advertisements module: campaign CRUD | P2 |
| Feature flags module: toggle switches, targeting | P1 |
| Notifications module: send/broadcast | P2 |
## Phase 5: Polish & Extras (Week 5)
| Task | Priority |
|------|----------|
| System settings module: key-value editor | P1 |
| Branding module: theme colors + asset upload | P2 |
| Analytics module: stats, charts, date ranges | P2 |
| Audit log module: read-only history | P1 |
| Animation polish: all microinteractions, page transitions | P1 |
| Mobile responsiveness pass | P1 |
| Security audit: CSRF, XSS, session, headers | P0 |
| Deploy to CapRover, test end-to-end | P0 |
---
# SUMMARY
| Metric | Value |
|--------|-------|
| Technology | PHP 8.3 + HTML + CSS + JS (zero frameworks) |
| Modules | 15 |
| Database Tables | 14 |
| External APIs | 3 (Supabase, Stockfish, Swiss) |
| Language | Arabic (RTL) with standard numbers (0-9) |
| Auth | Single superadmin (admin / Alarcade123#) |
| Deploy | CapRover (Dockerfile → auto-deploy on push) |
| CRUD Coverage | Every entity has Create, Read, Update, Delete |
| Edge Cases | Network errors, empty states, validation, concurrent edits, XSS, CSRF |
<?php
$supabase = ApiProxy::healthCheck(
SUPABASE_URL . '/rest/v1/',
['apikey: ' . SUPABASE_SERVICE_KEY]
);
$stockfish = ApiProxy::healthCheck(STOCKFISH_API_URL . '/health');
$swiss = ApiProxy::healthCheck(SWISS_API_URL . '/health');
Response::json([
'supabase' => $supabase,
'stockfish' => $stockfish,
'swiss' => $swiss,
]);
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
<?php
define('APP_NAME', 'El3ab Management');
define('APP_VERSION', '1.0.0');
define('APP_ENV', getenv('APP_ENV') ?: 'production');
define('APP_SECRET', getenv('APP_SECRET') ?: 'dev-secret-change-in-production-64chars-minimum-required-here!!');
define('SUPABASE_URL', getenv('SUPABASE_URL') ?: 'https://safe-supabase-kong.caprover.al-arcade.com');
define('SUPABASE_SERVICE_KEY', getenv('SUPABASE_SERVICE_KEY') ?: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4');
define('SUPABASE_ANON_KEY', getenv('SUPABASE_ANON_KEY') ?: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84');
define('STOCKFISH_API_URL', getenv('STOCKFISH_API_URL') ?: 'https://stockfishapi.caprover.al-arcade.com');
define('STOCKFISH_API_KEY', getenv('STOCKFISH_API_KEY') ?: 'sk-alarc-stockfish-mgmt-2024');
define('SWISS_API_URL', getenv('SWISS_API_URL') ?: 'https://swissapi.caprover.al-arcade.com/api/v1');
define('ADMIN_USERNAME', getenv('ADMIN_USERNAME') ?: 'admin');
define('ADMIN_PASSWORD_HASH', '$2y$12$6HZ3kC4ogVWhgm1ZaU7.A.oM3xJC6aYHL9Iw.5eZ84tVrEjDQE9zO');
define('SESSION_TIMEOUT', 86400); // 24 hours
define('REMEMBER_ME_DAYS', 30);
define('PER_PAGE_DEFAULT', 25);
define('PER_PAGE_OPTIONS', [25, 50, 100]);
define('BASE_PATH', dirname(__DIR__));
define('MODULES_PATH', BASE_PATH . '/modules');
define('LAYOUTS_PATH', BASE_PATH . '/layouts');
define('PUBLIC_PATH', BASE_PATH . '/public');
define('STORAGE_PATH', BASE_PATH . '/storage');
<?php
return [
'superadmin' => ['*'],
'admin' => [
'dashboard', 'players', 'games', 'chess-bots', 'tournaments',
'organizations', 'economy', 'ads', 'moderation', 'feature-flags',
'notifications', 'analytics', 'audit-log'
],
'moderator' => [
'dashboard', 'players.list', 'players.show', 'players.ban', 'players.unban',
'moderation'
],
'viewer' => [
'dashboard', 'players.list', 'players.show', 'games.list',
'tournaments.list', 'tournaments.show', 'organizations.list',
'analytics', 'audit-log'
],
];
<?php
return [
'' => ['module' => 'dashboard', 'action' => 'index'],
'dashboard' => ['module' => 'dashboard', 'action' => 'index'],
'login' => ['module' => 'auth', 'action' => 'login'],
'logout' => ['module' => 'auth', 'action' => 'logout'],
'players' => ['module' => 'players', 'action' => 'list'],
'players/create' => ['module' => 'players', 'action' => 'create'],
'players/store' => ['module' => 'players', 'action' => 'store'],
'players/{id}' => ['module' => 'players', 'action' => 'show'],
'players/{id}/edit' => ['module' => 'players', 'action' => 'edit'],
'players/{id}/update' => ['module' => 'players', 'action' => 'update'],
'players/{id}/delete' => ['module' => 'players', 'action' => 'delete'],
'players/{id}/ban' => ['module' => 'players', 'action' => 'ban'],
'players/{id}/unban' => ['module' => 'players', 'action' => 'unban'],
'players/{id}/grant' => ['module' => 'players', 'action' => 'grant'],
'players/{id}/revoke' => ['module' => 'players', 'action' => 'revoke'],
'games' => ['module' => 'games', 'action' => 'list'],
'games/create' => ['module' => 'games', 'action' => 'create'],
'games/store' => ['module' => 'games', 'action' => 'store'],
'games/{id}/edit' => ['module' => 'games', 'action' => 'edit'],
'games/{id}/update' => ['module' => 'games', 'action' => 'update'],
'games/{id}/toggle' => ['module' => 'games', 'action' => 'toggle'],
'games/{id}/delete' => ['module' => 'games', 'action' => 'delete'],
'chess-bots' => ['module' => 'chess-bots', 'action' => 'list'],
'chess-bots/create' => ['module' => 'chess-bots', 'action' => 'create'],
'chess-bots/store' => ['module' => 'chess-bots', 'action' => 'store'],
'chess-bots/{id}' => ['module' => 'chess-bots', 'action' => 'show'],
'chess-bots/{id}/edit' => ['module' => 'chess-bots', 'action' => 'edit'],
'chess-bots/{id}/update' => ['module' => 'chess-bots', 'action' => 'update'],
'chess-bots/{id}/delete' => ['module' => 'chess-bots', 'action' => 'delete'],
'chess-bots/{id}/portrait' => ['module' => 'chess-bots', 'action' => 'portrait'],
'chess-bots/test-move' => ['module' => 'chess-bots', 'action' => 'testMove'],
'chess-bots/pool' => ['module' => 'chess-bots', 'action' => 'pool'],
'tournaments' => ['module' => 'tournaments', 'action' => 'list'],
'tournaments/create' => ['module' => 'tournaments', 'action' => 'create'],
'tournaments/store' => ['module' => 'tournaments', 'action' => 'store'],
'tournaments/{id}' => ['module' => 'tournaments', 'action' => 'show'],
'tournaments/{id}/edit' => ['module' => 'tournaments', 'action' => 'edit'],
'tournaments/{id}/update' => ['module' => 'tournaments', 'action' => 'update'],
'tournaments/{id}/start' => ['module' => 'tournaments', 'action' => 'start'],
'tournaments/{id}/complete' => ['module' => 'tournaments', 'action' => 'complete'],
'tournaments/{id}/cancel' => ['module' => 'tournaments', 'action' => 'cancel'],
'tournaments/{id}/rounds/generate' => ['module' => 'tournaments', 'action' => 'generateRound'],
'tournaments/{id}/rounds/{roundId}/results' => ['module' => 'tournaments', 'action' => 'submitResults'],
'tournaments/{id}/standings' => ['module' => 'tournaments', 'action' => 'standings'],
'organizations' => ['module' => 'organizations', 'action' => 'list'],
'organizations/create' => ['module' => 'organizations', 'action' => 'create'],
'organizations/store' => ['module' => 'organizations', 'action' => 'store'],
'organizations/{id}' => ['module' => 'organizations', 'action' => 'show'],
'organizations/{id}/edit' => ['module' => 'organizations', 'action' => 'edit'],
'organizations/{id}/update' => ['module' => 'organizations', 'action' => 'update'],
'organizations/{id}/verify' => ['module' => 'organizations', 'action' => 'verify'],
'organizations/{id}/delete' => ['module' => 'organizations', 'action' => 'delete'],
'organizations/{id}/members' => ['module' => 'organizations', 'action' => 'members'],
'organizations/{id}/members/add' => ['module' => 'organizations', 'action' => 'addMember'],
'organizations/{id}/members/{memberId}/remove' => ['module' => 'organizations', 'action' => 'removeMember'],
'economy' => ['module' => 'economy', 'action' => 'index'],
'economy/transactions' => ['module' => 'economy', 'action' => 'transactions'],
'economy/grant' => ['module' => 'economy', 'action' => 'grant'],
'economy/revoke' => ['module' => 'economy', 'action' => 'revoke'],
'economy/bulk-grant' => ['module' => 'economy', 'action' => 'bulkGrant'],
'ads' => ['module' => 'ads', 'action' => 'list'],
'ads/create' => ['module' => 'ads', 'action' => 'create'],
'ads/store' => ['module' => 'ads', 'action' => 'store'],
'ads/{id}' => ['module' => 'ads', 'action' => 'show'],
'ads/{id}/edit' => ['module' => 'ads', 'action' => 'edit'],
'ads/{id}/update' => ['module' => 'ads', 'action' => 'update'],
'ads/{id}/toggle' => ['module' => 'ads', 'action' => 'toggle'],
'ads/{id}/delete' => ['module' => 'ads', 'action' => 'delete'],
'moderation' => ['module' => 'moderation', 'action' => 'list'],
'moderation/{id}' => ['module' => 'moderation', 'action' => 'show'],
'moderation/{id}/resolve' => ['module' => 'moderation', 'action' => 'resolve'],
'moderation/{id}/dismiss' => ['module' => 'moderation', 'action' => 'dismiss'],
'moderation/bulk-dismiss' => ['module' => 'moderation', 'action' => 'bulkDismiss'],
'feature-flags' => ['module' => 'feature-flags', 'action' => 'list'],
'feature-flags/create' => ['module' => 'feature-flags', 'action' => 'create'],
'feature-flags/store' => ['module' => 'feature-flags', 'action' => 'store'],
'feature-flags/{id}/edit' => ['module' => 'feature-flags', 'action' => 'edit'],
'feature-flags/{id}/update' => ['module' => 'feature-flags', 'action' => 'update'],
'feature-flags/{id}/toggle' => ['module' => 'feature-flags', 'action' => 'toggle'],
'feature-flags/{id}/delete' => ['module' => 'feature-flags', 'action' => 'delete'],
'settings' => ['module' => 'settings', 'action' => 'index'],
'settings/update' => ['module' => 'settings', 'action' => 'update'],
'branding' => ['module' => 'branding', 'action' => 'index'],
'branding/colors' => ['module' => 'branding', 'action' => 'colors'],
'branding/assets' => ['module' => 'branding', 'action' => 'assets'],
'branding/update-color' => ['module' => 'branding', 'action' => 'updateColor'],
'branding/upload-asset' => ['module' => 'branding', 'action' => 'uploadAsset'],
'analytics' => ['module' => 'analytics', 'action' => 'index'],
'notifications' => ['module' => 'notifications', 'action' => 'list'],
'notifications/send' => ['module' => 'notifications', 'action' => 'send'],
'notifications/broadcast' => ['module' => 'notifications', 'action' => 'broadcast'],
'notifications/{id}/delete' => ['module' => 'notifications', 'action' => 'delete'],
'audit-log' => ['module' => 'audit-log', 'action' => 'index'],
'audit-log/export' => ['module' => 'audit-log', 'action' => 'export'],
'api/health' => ['module' => 'api', 'action' => 'health'],
];
<?php
class ApiProxy
{
public static function stockfish(string $method, string $path, ?array $body = null): array
{
return self::request($method, STOCKFISH_API_URL . $path, $body, [
'X-API-Key: ' . STOCKFISH_API_KEY,
'Content-Type: application/json',
]);
}
public static function swiss(string $method, string $path, ?array $body = null, ?string $token = null): array
{
$headers = ['Content-Type: application/json'];
if ($token) {
$headers[] = 'Authorization: Bearer ' . $token;
}
return self::request($method, SWISS_API_URL . $path, $body, $headers);
}
public static function request(string $method, string $url, ?array $body = null, array $headers = []): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => true,
]);
switch (strtoupper($method)) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
break;
case 'PATCH':
case 'PUT':
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
break;
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
$time = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
if ($error) {
return ['status' => 0, 'body' => null, 'error' => $error, 'time_ms' => round($time * 1000)];
}
return [
'status' => $status,
'body' => json_decode($response, true) ?? $response,
'error' => null,
'time_ms' => round($time * 1000),
];
}
public static function healthCheck(string $url, array $headers = []): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$time = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
$error = curl_error($ch);
curl_close($ch);
return [
'online' => $status >= 200 && $status < 500 && !$error,
'status' => $status,
'latency_ms' => round($time * 1000),
'error' => $error ?: null,
];
}
}
<?php
class AuditLog
{
public static function log(string $action, string $entityType, ?string $entityId = null, ?array $oldValue = null, ?array $newValue = null): void
{
$db = Database::getInstance();
$db->insert('audit_log', [
'actor' => Auth::user()['username'] ?? 'system',
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'old_value' => $oldValue ? json_encode($oldValue) : null,
'new_value' => $newValue ? json_encode($newValue) : null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
}
}
<?php
class Auth
{
public static function check(): bool
{
if (!isset($_SESSION['user'])) {
return false;
}
if (time() - ($_SESSION['last_activity'] ?? 0) > SESSION_TIMEOUT) {
self::logout();
return false;
}
$_SESSION['last_activity'] = time();
return true;
}
public static function login(string $username, string $password): bool
{
if ($username === ADMIN_USERNAME && password_verify($password, ADMIN_PASSWORD_HASH)) {
$_SESSION['user'] = [
'username' => $username,
'role' => 'superadmin',
'display_name' => 'المسؤول',
];
$_SESSION['last_activity'] = time();
self::regenerateCsrf();
return true;
}
return false;
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
}
public static function user(): ?array
{
return $_SESSION['user'] ?? null;
}
public static function role(): string
{
return $_SESSION['user']['role'] ?? 'viewer';
}
public static function requireAuth(): void
{
if (!self::check()) {
header('Location: /login');
exit;
}
}
public static function requireRole(string $minRole): void
{
self::requireAuth();
$levels = ['viewer' => 10, 'moderator' => 50, 'admin' => 80, 'superadmin' => 100];
$userLevel = $levels[self::role()] ?? 0;
$requiredLevel = $levels[$minRole] ?? 100;
if ($userLevel < $requiredLevel) {
http_response_code(403);
View::render('errors/403');
exit;
}
}
public static function hasPermission(string $permission): bool
{
$permissions = require BASE_PATH . '/config/permissions.php';
$rolePerms = $permissions[self::role()] ?? [];
if (in_array('*', $rolePerms)) return true;
if (in_array($permission, $rolePerms)) return true;
$module = explode('.', $permission)[0];
return in_array($module, $rolePerms);
}
public static function csrfToken(): string
{
if (!isset($_SESSION['csrf_token'])) {
self::regenerateCsrf();
}
return $_SESSION['csrf_token'];
}
public static function validateCsrf(): bool
{
$token = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
return hash_equals(self::csrfToken(), $token);
}
public static function requireCsrf(): void
{
if (!self::validateCsrf()) {
http_response_code(403);
Response::json(['error' => 'Invalid CSRF token'], 403);
exit;
}
}
private static function regenerateCsrf(): void
{
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
<?php
class Database
{
private static ?Database $instance = null;
private string $baseUrl;
private string $serviceKey;
private function __construct()
{
$this->baseUrl = SUPABASE_URL . '/rest/v1';
$this->serviceKey = SUPABASE_SERVICE_KEY;
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function select(string $table, array $params = [], array $headers = []): array
{
$query = http_build_query($params);
$url = "{$this->baseUrl}/{$table}" . ($query ? "?{$query}" : '');
$response = $this->request('GET', $url, null, $headers);
return json_decode($response['body'], true) ?? [];
}
public function selectOne(string $table, array $params = []): ?array
{
$params['limit'] = 1;
$headers = ['Prefer' => 'return=representation'];
$result = $this->select($table, $params, $headers);
return $result[0] ?? null;
}
public function count(string $table, array $params = []): int
{
$params['select'] = 'count';
$headers = ['Prefer' => 'count=exact'];
$url = "{$this->baseUrl}/{$table}?" . http_build_query($params);
$response = $this->request('GET', $url, null, $headers);
$range = $response['headers']['content-range'] ?? '*/0';
$parts = explode('/', $range);
return (int)($parts[1] ?? 0);
}
public function insert(string $table, array $data): ?array
{
$url = "{$this->baseUrl}/{$table}";
$headers = ['Prefer' => 'return=representation'];
$response = $this->request('POST', $url, $data, $headers);
$result = json_decode($response['body'], true);
return $result[0] ?? $result ?? null;
}
public function update(string $table, array $match, array $data): ?array
{
$query = http_build_query($match);
$url = "{$this->baseUrl}/{$table}?{$query}";
$headers = ['Prefer' => 'return=representation'];
$response = $this->request('PATCH', $url, $data, $headers);
$result = json_decode($response['body'], true);
return $result[0] ?? $result ?? null;
}
public function delete(string $table, array $match): bool
{
$query = http_build_query($match);
$url = "{$this->baseUrl}/{$table}?{$query}";
$response = $this->request('DELETE', $url);
return $response['status'] >= 200 && $response['status'] < 300;
}
public function rpc(string $function, array $params = []): mixed
{
$url = SUPABASE_URL . "/rest/v1/rpc/{$function}";
$response = $this->request('POST', $url, $params);
return json_decode($response['body'], true);
}
private function request(string $method, string $url, ?array $body = null, array $extraHeaders = []): array
{
$ch = curl_init();
$headers = [
'apikey: ' . $this->serviceKey,
'Authorization: Bearer ' . $this->serviceKey,
'Content-Type: application/json',
];
foreach ($extraHeaders as $key => $value) {
$headers[] = "{$key}: {$value}";
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADER => true,
]);
switch ($method) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
break;
case 'PATCH':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
break;
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
break;
}
$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerStr = substr($response, 0, $headerSize);
$responseBody = substr($response, $headerSize);
curl_close($ch);
$responseHeaders = [];
foreach (explode("\r\n", $headerStr) as $line) {
if (str_contains($line, ':')) {
[$key, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($key))] = trim($value);
}
}
return [
'status' => $status,
'body' => $responseBody,
'headers' => $responseHeaders,
];
}
}
<?php
class Pagination
{
public int $page;
public int $perPage;
public int $total;
public int $totalPages;
public int $offset;
public function __construct(int $total, int $page = 1, int $perPage = 25)
{
$this->total = $total;
$this->perPage = in_array($perPage, PER_PAGE_OPTIONS) ? $perPage : PER_PAGE_DEFAULT;
$this->totalPages = max(1, ceil($total / $this->perPage));
$this->page = max(1, min($page, $this->totalPages));
$this->offset = ($this->page - 1) * $this->perPage;
}
public static function fromRequest(int $total): self
{
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = (int)($_GET['per_page'] ?? PER_PAGE_DEFAULT);
return new self($total, $page, $perPage);
}
public function rangeText(): string
{
$start = $this->offset + 1;
$end = min($this->offset + $this->perPage, $this->total);
return "عرض {$start}-{$end} من {$this->total}";
}
public function hasPrev(): bool
{
return $this->page > 1;
}
public function hasNext(): bool
{
return $this->page < $this->totalPages;
}
public function pages(): array
{
$pages = [];
$start = max(1, $this->page - 2);
$end = min($this->totalPages, $this->page + 2);
for ($i = $start; $i <= $end; $i++) {
$pages[] = $i;
}
return $pages;
}
public function toArray(): array
{
return [
'page' => $this->page,
'per_page' => $this->perPage,
'total' => $this->total,
'total_pages' => $this->totalPages,
'offset' => $this->offset,
];
}
}
<?php
class Response
{
public static function json(array $data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
public static function redirect(string $url, array $flash = []): void
{
if (!empty($flash)) {
$_SESSION['flash'] = $flash;
}
header("Location: {$url}");
exit;
}
public static function back(array $flash = []): void
{
$referer = $_SERVER['HTTP_REFERER'] ?? '/dashboard';
self::redirect($referer, $flash);
}
public static function flash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
public static function getFlash(): ?array
{
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $flash;
}
public static function success(string $message, ?string $redirect = null): void
{
self::flash('success', $message);
if ($redirect) {
header("Location: {$redirect}");
exit;
}
}
public static function error(string $message, ?string $redirect = null): void
{
self::flash('error', $message);
if ($redirect) {
header("Location: {$redirect}");
exit;
}
}
}
<?php
class Router
{
private array $routes;
public function __construct()
{
$this->routes = require BASE_PATH . '/config/routes.php';
}
public function dispatch(string $route, string $method): void
{
$match = $this->matchRoute($route);
if (!$match) {
http_response_code(404);
View::render('errors/404');
return;
}
$config = $match['config'];
$params = $match['params'];
$module = $config['module'];
$action = $config['action'];
if ($module === 'auth') {
$this->handleAuth($action, $method);
return;
}
if ($module === 'api') {
$this->handleApi($action);
return;
}
Auth::requireAuth();
$controllerPath = MODULES_PATH . "/{$module}/controller.php";
if (!file_exists($controllerPath)) {
http_response_code(404);
View::render('errors/404');
return;
}
require_once $controllerPath;
$controllerClass = $this->moduleToClassName($module) . 'Controller';
if (!class_exists($controllerClass)) {
http_response_code(500);
echo "Controller class {$controllerClass} not found";
return;
}
$controller = new $controllerClass();
if (!method_exists($controller, $action)) {
http_response_code(404);
View::render('errors/404');
return;
}
$controller->$action($params, $method);
}
private function matchRoute(string $route): ?array
{
if (isset($this->routes[$route])) {
return ['config' => $this->routes[$route], 'params' => []];
}
foreach ($this->routes as $pattern => $config) {
if (!str_contains($pattern, '{')) continue;
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
$regex = '#^' . $regex . '$#';
if (preg_match($regex, $route, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return ['config' => $config, 'params' => $params];
}
}
return null;
}
private function handleAuth(string $action, string $method): void
{
if ($action === 'login') {
if ($method === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (Auth::login($username, $password)) {
header('Location: /dashboard');
exit;
}
View::render('auth/login', ['error' => 'بيانات الدخول غير صحيحة']);
} else {
if (Auth::check()) {
header('Location: /dashboard');
exit;
}
View::render('auth/login');
}
} elseif ($action === 'logout') {
Auth::logout();
header('Location: /login');
exit;
}
}
private function handleApi(string $action): void
{
header('Content-Type: application/json');
if ($action === 'health') {
require_once BASE_PATH . '/api/health.php';
}
}
private function moduleToClassName(string $module): string
{
return str_replace(' ', '', ucwords(str_replace('-', ' ', $module)));
}
}
<?php
class Validator
{
private array $errors = [];
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public static function make(array $data): self
{
return new self($data);
}
public function required(string $field, string $label = ''): self
{
if (empty($this->data[$field]) && $this->data[$field] !== '0') {
$this->errors[$field] = ($label ?: $field) . ' مطلوب';
}
return $this;
}
public function email(string $field, string $label = ''): self
{
if (!empty($this->data[$field]) && !filter_var($this->data[$field], FILTER_VALIDATE_EMAIL)) {
$this->errors[$field] = ($label ?: $field) . ' بريد إلكتروني غير صحيح';
}
return $this;
}
public function numeric(string $field, string $label = ''): self
{
if (!empty($this->data[$field]) && !is_numeric($this->data[$field])) {
$this->errors[$field] = ($label ?: $field) . ' يجب أن يكون رقماً';
}
return $this;
}
public function min(string $field, int $min, string $label = ''): self
{
$value = $this->data[$field] ?? '';
if (is_numeric($value) && (float)$value < $min) {
$this->errors[$field] = ($label ?: $field) . " يجب أن يكون {$min} على الأقل";
}
return $this;
}
public function max(string $field, int $max, string $label = ''): self
{
$value = $this->data[$field] ?? '';
if (is_numeric($value) && (float)$value > $max) {
$this->errors[$field] = ($label ?: $field) . " يجب ألا يتجاوز {$max}";
}
return $this;
}
public function minLength(string $field, int $min, string $label = ''): self
{
$value = $this->data[$field] ?? '';
if (strlen($value) < $min) {
$this->errors[$field] = ($label ?: $field) . " يجب أن يكون {$min} حرف على الأقل";
}
return $this;
}
public function maxLength(string $field, int $max, string $label = ''): self
{
$value = $this->data[$field] ?? '';
if (strlen($value) > $max) {
$this->errors[$field] = ($label ?: $field) . " يجب ألا يتجاوز {$max} حرف";
}
return $this;
}
public function in(string $field, array $allowed, string $label = ''): self
{
if (!empty($this->data[$field]) && !in_array($this->data[$field], $allowed)) {
$this->errors[$field] = ($label ?: $field) . ' قيمة غير مسموحة';
}
return $this;
}
public function url(string $field, string $label = ''): self
{
if (!empty($this->data[$field]) && !filter_var($this->data[$field], FILTER_VALIDATE_URL)) {
$this->errors[$field] = ($label ?: $field) . ' رابط غير صحيح';
}
return $this;
}
public function fails(): bool
{
return !empty($this->errors);
}
public function passes(): bool
{
return empty($this->errors);
}
public function errors(): array
{
return $this->errors;
}
public function firstError(): string
{
return reset($this->errors) ?: '';
}
}
<?php
class View
{
private static string $layout = 'app';
private static array $sections = [];
public static function render(string $view, array $data = [], ?string $layout = null): void
{
extract($data);
if (str_starts_with($view, 'auth/')) {
$layout = 'auth';
}
if (str_starts_with($view, 'errors/')) {
$layout = 'error';
}
$viewPath = self::resolveViewPath($view);
if (!file_exists($viewPath)) {
http_response_code(500);
echo "View not found: {$view}";
return;
}
ob_start();
require $viewPath;
$content = ob_get_clean();
$layoutPath = LAYOUTS_PATH . '/' . ($layout ?? self::$layout) . '.php';
if (file_exists($layoutPath)) {
require $layoutPath;
} else {
echo $content;
}
}
public static function partial(string $partial, array $data = []): void
{
extract($data);
$path = LAYOUTS_PATH . '/partials/' . $partial . '.php';
if (file_exists($path)) {
require $path;
}
}
public static function component(string $component, array $data = []): void
{
extract($data);
$path = LAYOUTS_PATH . '/components/' . $component . '.php';
if (file_exists($path)) {
require $path;
}
}
public static function moduleView(string $module, string $view, array $data = []): string
{
extract($data);
$path = MODULES_PATH . "/{$module}/views/{$view}.php";
if (!file_exists($path)) return '';
ob_start();
require $path;
return ob_get_clean();
}
private static function resolveViewPath(string $view): string
{
if (str_contains($view, '/')) {
$parts = explode('/', $view, 2);
$module = $parts[0];
$file = $parts[1];
$modulePath = MODULES_PATH . "/{$module}/views/{$file}.php";
if (file_exists($modulePath)) return $modulePath;
$layoutPath = LAYOUTS_PATH . "/{$view}.php";
if (file_exists($layoutPath)) return $layoutPath;
}
return MODULES_PATH . "/{$view}.php";
}
public static function escape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
public static function e(string $value): string
{
return self::escape($value);
}
}
<?php
session_start();
require_once __DIR__ . '/config/app.php';
require_once __DIR__ . '/core/Database.php';
require_once __DIR__ . '/core/Auth.php';
require_once __DIR__ . '/core/Router.php';
require_once __DIR__ . '/core/View.php';
require_once __DIR__ . '/core/Validator.php';
require_once __DIR__ . '/core/ApiProxy.php';
require_once __DIR__ . '/core/AuditLog.php';
require_once __DIR__ . '/core/Pagination.php';
require_once __DIR__ . '/core/Response.php';
$route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD'];
if (str_starts_with($route, 'public/')) {
return false;
}
$router = new Router();
$router->dispatch($route, $method);
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?? 'لوحة التحكم' ?> — El3ab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/public/css/variables.css">
<link rel="stylesheet" href="/public/css/reset.css">
<link rel="stylesheet" href="/public/css/layout.css">
<link rel="stylesheet" href="/public/css/components.css">
<link rel="stylesheet" href="/public/css/animations.css">
<link rel="stylesheet" href="/public/css/utilities.css">
<link rel="stylesheet" href="/public/css/enhancements.css">
<?php if (isset($moduleCSS)): ?>
<link rel="stylesheet" href="/modules/<?= $moduleCSS ?>/assets/<?= basename($moduleCSS) ?>.css">
<?php endif; ?>
<meta name="csrf-token" content="<?= Auth::csrfToken() ?>">
</head>
<body>
<div class="app-layout">
<?php require LAYOUTS_PATH . '/partials/sidebar.php'; ?>
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
<?php require LAYOUTS_PATH . '/partials/topbar.php'; ?>
<main class="content animate-fade-in-up">
<?= $content ?>
</main>
</div>
<div class="toast-container" id="toastContainer"></div>
<?php require LAYOUTS_PATH . '/partials/modal.php'; ?>
<?php require LAYOUTS_PATH . '/partials/confirm-dialog.php'; ?>
<script src="/public/js/app.js"></script>
<script src="/public/js/sidebar.js"></script>
<script src="/public/js/enhancements.js"></script>
<?php if (isset($moduleJS)): ?>
<script src="/modules/<?= $moduleJS ?>/assets/<?= basename($moduleJS) ?>.js"></script>
<?php endif; ?>
<?php $flash = Response::getFlash(); ?>
<?php if ($flash): ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
showToast('<?= View::e($flash['message']) ?>', '<?= $flash['type'] ?>');
});
</script>
<?php endif; ?>
</body>
</html>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>تسجيل الدخول — El3ab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/public/css/variables.css">
<link rel="stylesheet" href="/public/css/reset.css">
<link rel="stylesheet" href="/public/css/components.css">
<link rel="stylesheet" href="/public/css/animations.css">
<link rel="stylesheet" href="/public/css/utilities.css">
<style>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
padding: var(--space-4);
position: relative;
overflow: hidden;
}
.auth-page::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(32, 130, 240, 0.08) 0%, transparent 70%);
top: -200px;
left: -200px;
pointer-events: none;
}
.auth-page::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(104, 52, 190, 0.06) 0%, transparent 70%);
bottom: -100px;
right: -100px;
pointer-events: none;
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--space-8);
animation: fadeInUp var(--duration-slow) var(--ease-out);
position: relative;
z-index: 1;
}
.auth-logo {
width: 64px;
height: 64px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--brand-blue), var(--brand-purple));
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--space-5);
font-size: 28px;
font-weight: 800;
color: white;
}
.auth-title {
text-align: center;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-2);
}
.auth-subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: var(--space-7);
font-size: var(--font-size-sm);
}
.auth-error {
background: var(--danger-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--danger);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-5);
font-size: var(--font-size-sm);
text-align: center;
}
</style>
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<?= $content ?>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>خطأ — El3ab</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/public/css/variables.css">
<link rel="stylesheet" href="/public/css/reset.css">
<link rel="stylesheet" href="/public/css/components.css">
<link rel="stylesheet" href="/public/css/utilities.css">
<style>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-6);
}
</style>
</head>
<body>
<div class="error-page">
<?= $content ?>
</div>
</body>
</html>
<div>
<h1 style="font-size: 72px; font-weight: 800; color: var(--text-muted); margin-bottom: var(--space-4);">403</h1>
<p style="font-size: var(--font-size-lg); margin-bottom: var(--space-2);">ليس لديك صلاحية</p>
<p style="color: var(--text-secondary); margin-bottom: var(--space-6);">لا تملك الصلاحيات الكافية للوصول لهذه الصفحة</p>
<a href="/dashboard" class="btn btn-primary">العودة للوحة التحكم</a>
</div>
<div>
<h1 style="font-size: 72px; font-weight: 800; color: var(--text-muted); margin-bottom: var(--space-4);">404</h1>
<p style="font-size: var(--font-size-lg); margin-bottom: var(--space-2);">الصفحة غير موجودة</p>
<p style="color: var(--text-secondary); margin-bottom: var(--space-6);">الرابط الذي تحاول الوصول إليه غير موجود</p>
<a href="/dashboard" class="btn btn-primary">العودة للوحة التحكم</a>
</div>
<div class="modal-overlay confirm-dialog" id="confirmDialog">
<div class="modal" style="max-width: 400px;">
<div class="modal-body" style="padding: var(--space-7) var(--space-5);">
<div class="confirm-icon danger" id="confirmIcon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<p class="confirm-text" id="confirmText"></p>
<p class="confirm-sub" id="confirmSub"></p>
</div>
<div class="modal-footer" style="justify-content: center;">
<button class="btn btn-ghost" onclick="closeConfirm()">إلغاء</button>
<button class="btn btn-danger" id="confirmAction">تأكيد</button>
</div>
</div>
</div>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="modalTitle"></h3>
<button class="modal-close" onclick="closeModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer" id="modalFooter"></div>
</div>
</div>
<?php
$currentRoute = trim($_GET['route'] ?? '', '/');
$currentModule = explode('/', $currentRoute)[0] ?: 'dashboard';
function navActive(string $module, string $current): string {
return $module === $current ? 'active' : '';
}
?>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo">E</div>
<span class="sidebar-title">El3ab إدارة</span>
</div>
<div class="favorites-bar" id="favoritesBar" style="display:none;"></div>
<nav class="sidebar-nav">
<div class="nav-section" data-section="main">
<div class="nav-section-header">
<span class="nav-section-title nav-text">الرئيسية</span>
<svg class="collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="nav-section-items">
<a href="/dashboard" class="nav-item <?= navActive('dashboard', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span class="nav-text">لوحة التحكم</span>
</a>
</div>
</div>
<div class="nav-section" data-section="manage">
<div class="nav-section-header">
<span class="nav-section-title nav-text">إدارة</span>
<svg class="collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="nav-section-items">
<a href="/players" class="nav-item <?= navActive('players', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span class="nav-text">اللاعبون</span>
</a>
<a href="/games" class="nav-item <?= navActive('games', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg>
<span class="nav-text">الألعاب</span>
</a>
<a href="/chess-bots" class="nav-item <?= navActive('chess-bots', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 16l-2 6h12l-2-6"/><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a3 3 0 0 1 3 3v1h1a2 2 0 0 1 0 4h-1v1a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-1H5a2 2 0 0 1 0-4h1v-1a3 3 0 0 1 3-3h1V5.73A2 2 0 0 1 12 2z"/></svg>
<span class="nav-text">بوتات الشطرنج</span>
</a>
<a href="/tournaments" class="nav-item <?= navActive('tournaments', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
<span class="nav-text">البطولات</span>
</a>
<a href="/organizations" class="nav-item <?= navActive('organizations', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/><path d="M9 9h.01"/><path d="M9 13h.01"/><path d="M9 17h.01"/></svg>
<span class="nav-text">المنظمات</span>
</a>
</div>
</div>
<div class="nav-section" data-section="ops">
<div class="nav-section-header">
<span class="nav-section-title nav-text">العمليات</span>
<svg class="collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="nav-section-items">
<a href="/economy" class="nav-item <?= navActive('economy', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
<span class="nav-text">الاقتصاد</span>
</a>
<a href="/ads" class="nav-item <?= navActive('ads', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<span class="nav-text">الإعلانات</span>
</a>
<a href="/moderation" class="nav-item <?= navActive('moderation', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span class="nav-text">الإشراف</span>
<?php
$db = Database::getInstance();
$pendingReports = $db->count('cheat_reports', ['status' => 'eq.pending']);
if ($pendingReports > 0):
?>
<span class="nav-badge"><?= $pendingReports ?></span>
<?php endif; ?>
</a>
<a href="/notifications" class="nav-item <?= navActive('notifications', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="nav-text">الإشعارات</span>
</a>
</div>
</div>
<div class="nav-section" data-section="system">
<div class="nav-section-header">
<span class="nav-section-title nav-text">النظام</span>
<svg class="collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="nav-section-items">
<a href="/feature-flags" class="nav-item <?= navActive('feature-flags', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
<span class="nav-text">الميزات</span>
</a>
<a href="/settings" class="nav-item <?= navActive('settings', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 9 3.17V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span class="nav-text">الإعدادات</span>
</a>
<a href="/branding" class="nav-item <?= navActive('branding', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span class="nav-text">الهوية</span>
</a>
<a href="/analytics" class="nav-item <?= navActive('analytics', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<span class="nav-text">التحليلات</span>
</a>
<a href="/audit-log" class="nav-item <?= navActive('audit-log', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="nav-text">سجل العمليات</span>
</a>
</div>
</div>
</nav>
<div class="sidebar-footer" style="padding: var(--space-4); border-top: 1px solid var(--border);">
<a href="/logout" class="nav-item" style="color: var(--danger);">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span class="nav-text">تسجيل الخروج</span>
</a>
</div>
</aside>
<header class="topbar">
<div class="topbar-right">
<button class="mobile-toggle" onclick="toggleSidebar()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<h1 class="page-title"><?= $pageTitle ?? 'لوحة التحكم' ?></h1>
</div>
<div class="topbar-left">
<div class="kbd-hint hide-mobile"><kbd>Ctrl</kbd><kbd>K</kbd> بحث</div>
<div class="theme-toggle" onclick="toggleTheme()" title="تبديل المظهر">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</div>
<div class="topbar-user">
<div class="topbar-avatar">
<?= mb_substr(Auth::user()['display_name'] ?? 'م', 0, 1) ?>
</div>
<span class="hide-mobile"><?= View::e(Auth::user()['display_name'] ?? 'المسؤول') ?></span>
</div>
</div>
</header>
/* Ads module styles */
<?php
class AdsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$status = $_GET['status'] ?? '';
$queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if ($status) $queryParams['status'] = "eq.{$status}";
$total = $this->db->count('ad_campaigns', $status ? ['status' => "eq.{$status}"] : []);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$campaigns = $this->db->select('ad_campaigns', $queryParams);
$pageTitle = 'الإعلانات';
$moduleCSS = 'ads';
$moduleJS = 'ads';
View::render('ads/list', compact('campaigns', 'pagination', 'status', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$campaign = [];
$games = $this->db->select('game_plugins', ['select' => 'game_key,name_ar', 'order' => 'name_ar.asc']);
$pageTitle = 'إنشاء حملة إعلانية';
$moduleCSS = 'ads';
View::render('ads/form', compact('campaign', 'games', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('name', 'اسم الحملة')
->required('advertiser', 'المعلن')
->required('placement', 'الموضع');
if ($validator->fails()) {
Response::error($validator->firstError(), '/ads/create');
return;
}
$data = [
'name' => trim($_POST['name']),
'advertiser' => trim($_POST['advertiser']),
'placement' => $_POST['placement'],
'status' => 'draft',
'title' => trim($_POST['title'] ?? ''),
'title_ar' => trim($_POST['title_ar'] ?? ''),
'body' => trim($_POST['body'] ?? ''),
'body_ar' => trim($_POST['body_ar'] ?? ''),
'click_url' => trim($_POST['click_url'] ?? ''),
'image_url' => trim($_POST['image_url'] ?? ''),
'target_countries' => $_POST['target_countries'] ?? '[]',
'target_games' => $_POST['target_games'] ?? '[]',
'starts_at' => $_POST['starts_at'] ?: null,
'ends_at' => $_POST['ends_at'] ?: null,
'budget_total' => (int)($_POST['budget_total'] ?? 0),
'cpm' => (float)($_POST['cpm'] ?? 0),
];
$this->db->insert('ad_campaigns', $data);
AuditLog::log('create', 'ad_campaign', null, null, $data);
Response::success('تم إنشاء الحملة', '/ads');
}
public function show(array $params, string $method): void
{
$campaign = $this->db->selectOne('ad_campaigns', ['id' => "eq.{$params['id']}"]);
if (!$campaign) { Response::error('الحملة غير موجودة', '/ads'); return; }
$pageTitle = $campaign['name'];
$moduleCSS = 'ads';
View::render('ads/show', compact('campaign', 'pageTitle', 'moduleCSS'));
}
public function edit(array $params, string $method): void
{
$campaign = $this->db->selectOne('ad_campaigns', ['id' => "eq.{$params['id']}"]);
if (!$campaign) { Response::error('الحملة غير موجودة', '/ads'); return; }
$games = $this->db->select('game_plugins', ['select' => 'game_key,name_ar', 'order' => 'name_ar.asc']);
$pageTitle = 'تعديل الحملة';
$moduleCSS = 'ads';
View::render('ads/form', compact('campaign', 'games', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$data = [
'name' => trim($_POST['name']),
'advertiser' => trim($_POST['advertiser']),
'placement' => $_POST['placement'],
'title' => trim($_POST['title'] ?? ''),
'title_ar' => trim($_POST['title_ar'] ?? ''),
'body' => trim($_POST['body'] ?? ''),
'body_ar' => trim($_POST['body_ar'] ?? ''),
'click_url' => trim($_POST['click_url'] ?? ''),
'image_url' => trim($_POST['image_url'] ?? ''),
'starts_at' => $_POST['starts_at'] ?: null,
'ends_at' => $_POST['ends_at'] ?: null,
'budget_total' => (int)($_POST['budget_total'] ?? 0),
'cpm' => (float)($_POST['cpm'] ?? 0),
'updated_at' => date('c'),
];
$this->db->update('ad_campaigns', ['id' => "eq.{$id}"], $data);
AuditLog::log('update', 'ad_campaign', $id);
Response::success('تم تحديث الحملة', '/ads');
}
public function toggle(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$campaign = $this->db->selectOne('ad_campaigns', ['id' => "eq.{$id}"]);
if (!$campaign) { Response::error('الحملة غير موجودة', '/ads'); return; }
$newStatus = $campaign['status'] === 'active' ? 'paused' : 'active';
$this->db->update('ad_campaigns', ['id' => "eq.{$id}"], ['status' => $newStatus]);
AuditLog::log('toggle', 'ad_campaign', $id, null, ['status' => $newStatus]);
Response::success($newStatus === 'active' ? 'تم تفعيل الحملة' : 'تم إيقاف الحملة', '/ads');
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
$this->db->delete('ad_campaigns', ['id' => "eq.{$params['id']}"]);
AuditLog::log('delete', 'ad_campaign', $params['id']);
Response::success('تم حذف الحملة', '/ads');
}
}
<?php $isEdit = !empty($campaign['id']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/ads" class="btn btn-icon btn-ghost"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></a>
<h1><?= $isEdit ? 'تعديل الحملة' : 'حملة جديدة' ?></h1>
</div>
</div>
<div class="card max-w-lg">
<form method="POST" action="<?= $isEdit ? "/ads/{$campaign['id']}/update" : '/ads/store' ?>" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="grid grid-2 gap-4">
<div class="form-group"><label class="form-label">اسم الحملة *</label><input type="text" name="name" class="form-input" value="<?= View::e($campaign['name'] ?? '') ?>" required><span class="form-error"></span></div>
<div class="form-group"><label class="form-label">المعلن *</label><input type="text" name="advertiser" class="form-input" value="<?= View::e($campaign['advertiser'] ?? '') ?>" required></div>
</div>
<div class="form-group">
<label class="form-label">الموضع *</label>
<select name="placement" class="form-select" required>
<?php $placements = ['banner_top' => 'بانر علوي', 'banner_bottom' => 'بانر سفلي', 'interstitial' => 'بيني', 'sidebar' => 'جانبي', 'in_game' => 'داخل اللعبة', 'reward_video' => 'فيديو مكافأة']; ?>
<?php foreach ($placements as $val => $label): ?>
<option value="<?= $val ?>" <?= ($campaign['placement'] ?? '') === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group"><label class="form-label">العنوان (English)</label><input type="text" name="title" class="form-input" value="<?= View::e($campaign['title'] ?? '') ?>" dir="ltr"></div>
<div class="form-group"><label class="form-label">العنوان (عربي)</label><input type="text" name="title_ar" class="form-input" value="<?= View::e($campaign['title_ar'] ?? '') ?>"></div>
</div>
<div class="form-group"><label class="form-label">رابط الصورة</label><input type="url" name="image_url" class="form-input" value="<?= View::e($campaign['image_url'] ?? '') ?>" dir="ltr"></div>
<div class="form-group"><label class="form-label">رابط النقر</label><input type="url" name="click_url" class="form-input" value="<?= View::e($campaign['click_url'] ?? '') ?>" dir="ltr"></div>
<div class="grid grid-2 gap-4">
<div class="form-group"><label class="form-label">تاريخ البدء</label><input type="datetime-local" name="starts_at" class="form-input" value="<?= $campaign['starts_at'] ? date('Y-m-d\TH:i', strtotime($campaign['starts_at'])) : '' ?>"></div>
<div class="form-group"><label class="form-label">تاريخ الانتهاء</label><input type="datetime-local" name="ends_at" class="form-input" value="<?= $campaign['ends_at'] ? date('Y-m-d\TH:i', strtotime($campaign['ends_at'])) : '' ?>"></div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group"><label class="form-label">الميزانية الكلية</label><input type="number" name="budget_total" class="form-input" value="<?= $campaign['budget_total'] ?? 0 ?>" min="0"></div>
<div class="form-group"><label class="form-label">CPM</label><input type="number" name="cpm" class="form-input" value="<?= $campaign['cpm'] ?? 0 ?>" min="0" step="0.01"></div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء الحملة' ?></button>
<a href="/ads" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<div class="content-header">
<h1>الإعلانات</h1>
<a href="/ads/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
حملة جديدة
</a>
</div>
<div class="filter-pills mb-5">
<a href="/ads" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<a href="/ads?status=active" class="filter-pill <?= $status === 'active' ? 'active' : '' ?>">نشطة</a>
<a href="/ads?status=paused" class="filter-pill <?= $status === 'paused' ? 'active' : '' ?>">متوقفة</a>
<a href="/ads?status=draft" class="filter-pill <?= $status === 'draft' ? 'active' : '' ?>">مسودة</a>
<a href="/ads?status=completed" class="filter-pill <?= $status === 'completed' ? 'active' : '' ?>">منتهية</a>
</div>
<?php if (empty($campaigns)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد حملات إعلانية</h3></div></div>
<?php else: ?>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>الحملة</th>
<th>المعلن</th>
<th>الموضع</th>
<th>الحالة</th>
<th>المشاهدات</th>
<th>النقرات</th>
<th>الميزانية</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($campaigns as $c): ?>
<tr>
<td class="font-medium"><?= View::e($c['name']) ?></td>
<td class="text-secondary"><?= View::e($c['advertiser']) ?></td>
<td><span class="badge badge-info"><?= View::e($c['placement']) ?></span></td>
<td>
<?php $sb = ['active' => 'success', 'paused' => 'warning', 'draft' => 'default', 'completed' => 'info', 'expired' => 'danger']; ?>
<span class="badge badge-<?= $sb[$c['status']] ?? 'default' ?> badge-dot"><?= View::e($c['status']) ?></span>
</td>
<td class="tabular-nums"><?= number_format($c['impressions'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($c['clicks'] ?? 0) ?></td>
<td class="tabular-nums text-xs"><?= number_format($c['budget_spent'] ?? 0) ?> / <?= number_format($c['budget_total'] ?? 0) ?></td>
<td>
<div class="flex gap-2">
<a href="/ads/<?= $c['id'] ?>/edit" class="btn btn-ghost btn-sm">تعديل</a>
<form method="POST" action="/ads/<?= $c['id'] ?>/toggle" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-ghost btn-sm"><?= $c['status'] === 'active' ? 'إيقاف' : 'تفعيل' ?></button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&status=<?= $status ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
/* Analytics module styles */
// Analytics module JS - charts animate via CSS
<?php
class AnalyticsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function index(array $params, string $method): void
{
$range = $_GET['range'] ?? 'today';
$dateFilter = match($range) {
'today' => date('Y-m-d') . 'T00:00:00',
'week' => date('Y-m-d', strtotime('-7 days')) . 'T00:00:00',
'month' => date('Y-m-d', strtotime('-30 days')) . 'T00:00:00',
'3months' => date('Y-m-d', strtotime('-90 days')) . 'T00:00:00',
default => date('Y-m-d') . 'T00:00:00',
};
$newPlayers = $this->db->count('profiles', ['created_at' => "gte.{$dateFilter}"]);
$newMatches = $this->db->count('matches', ['created_at' => "gte.{$dateFilter}"]);
$totalPlayers = $this->db->count('profiles', []);
$activePlayers = $this->db->count('profiles', ['last_active_at' => "gte." . date('Y-m-d', strtotime('-7 days')) . 'T00:00:00']);
$retention = $totalPlayers > 0 ? round(($activePlayers / $totalPlayers) * 100, 1) : 0;
$games = $this->db->select('game_plugins', ['select' => 'game_key,name_ar']);
$gameStats = [];
foreach ($games as $game) {
$count = $this->db->count('matches', [
'game_key' => "eq.{$game['game_key']}",
'created_at' => "gte.{$dateFilter}",
]);
$gameStats[] = ['name' => $game['name_ar'], 'count' => $count];
}
usort($gameStats, fn($a, $b) => $b['count'] - $a['count']);
$maxGameCount = max(array_column($gameStats, 'count') ?: [1]);
$pageTitle = 'التحليلات';
$moduleCSS = 'analytics';
$moduleJS = 'analytics';
View::render('analytics/index', compact('range', 'newPlayers', 'newMatches', 'totalPlayers', 'activePlayers', 'retention', 'gameStats', 'maxGameCount', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
}
<div class="content-header">
<h1>التحليلات</h1>
<div class="flex gap-2">
<a href="/analytics?range=today" class="filter-pill <?= $range === 'today' ? 'active' : '' ?>">اليوم</a>
<a href="/analytics?range=week" class="filter-pill <?= $range === 'week' ? 'active' : '' ?>">هذا الأسبوع</a>
<a href="/analytics?range=month" class="filter-pill <?= $range === 'month' ? 'active' : '' ?>">هذا الشهر</a>
<a href="/analytics?range=3months" class="filter-pill <?= $range === '3months' ? 'active' : '' ?>">آخر 3 أشهر</a>
</div>
</div>
<div class="grid grid-4 stagger mb-6">
<div class="stat-card">
<div class="stat-icon blue"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></div>
<div class="stat-content">
<div class="stat-label">اللاعبون الجدد</div>
<div class="stat-value" data-count="<?= $newPlayers ?>"><?= number_format($newPlayers) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/></svg></div>
<div class="stat-content">
<div class="stat-label">المباريات</div>
<div class="stat-value" data-count="<?= $newMatches ?>"><?= number_format($newMatches) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/></svg></div>
<div class="stat-content">
<div class="stat-label">اللاعبون النشطون (7 أيام)</div>
<div class="stat-value" data-count="<?= $activePlayers ?>"><?= number_format($activePlayers) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon purple"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
<div class="stat-content">
<div class="stat-label">معدل الاحتفاظ</div>
<div class="stat-value"><?= $retention ?>%</div>
</div>
</div>
</div>
<!-- Game Distribution -->
<div class="card">
<div class="card-header">
<h3 class="card-title">توزيع المباريات حسب اللعبة</h3>
</div>
<?php if (empty($gameStats) || $maxGameCount === 0): ?>
<p class="text-secondary text-center p-5">لا توجد بيانات كافية</p>
<?php else: ?>
<div class="flex flex-col gap-4 p-4">
<?php foreach ($gameStats as $gs): ?>
<div class="flex items-center gap-4">
<span class="text-sm" style="min-width: 100px;"><?= View::e($gs['name']) ?></span>
<div class="flex-1" style="height: 28px; background: var(--bg-primary); border-radius: var(--radius-md); overflow: hidden;">
<div style="height: 100%; width: <?= $maxGameCount > 0 ? round(($gs['count'] / $maxGameCount) * 100) : 0 ?>%; background: linear-gradient(90deg, var(--brand-blue), var(--brand-cyan)); border-radius: var(--radius-md); display: flex; align-items: center; padding-inline-start: var(--space-3); transition: width 1s var(--ease-out);">
<span class="text-xs font-bold" style="color: white;"><?= number_format($gs['count']) ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
function showLogDetail(log) {
let html = '';
if (log.old_value) {
const old = typeof log.old_value === 'string' ? JSON.parse(log.old_value) : log.old_value;
html += `<h4 class="text-sm font-medium mb-2">القيمة القديمة:</h4><pre style="background: var(--bg-primary); padding: var(--space-3); border-radius: var(--radius-md); font-size: 12px; overflow-x: auto; direction: ltr; max-height: 200px;" dir="ltr">${JSON.stringify(old, null, 2)}</pre>`;
}
if (log.new_value) {
const newVal = typeof log.new_value === 'string' ? JSON.parse(log.new_value) : log.new_value;
html += `<h4 class="text-sm font-medium mb-2 mt-4">القيمة الجديدة:</h4><pre style="background: var(--bg-primary); padding: var(--space-3); border-radius: var(--radius-md); font-size: 12px; overflow-x: auto; direction: ltr; max-height: 200px;" dir="ltr">${JSON.stringify(newVal, null, 2)}</pre>`;
}
openModal('تفاصيل العملية', html);
}
<?php
class AuditLogController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function index(array $params, string $method): void
{
$queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if (!empty($_GET['actor'])) {
$queryParams['actor'] = "eq." . $_GET['actor'];
}
if (!empty($_GET['action'])) {
$queryParams['action'] = "eq." . $_GET['action'];
}
if (!empty($_GET['entity_type'])) {
$queryParams['entity_type'] = "eq." . $_GET['entity_type'];
}
if (!empty($_GET['search'])) {
$queryParams['entity_id'] = "eq." . $_GET['search'];
}
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('audit_log', $countParams);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$logs = $this->db->select('audit_log', $queryParams);
$pageTitle = 'سجل العمليات';
$moduleCSS = 'audit-log';
$moduleJS = 'audit-log';
View::render('audit-log/index', compact('logs', 'pagination', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function export(array $params, string $method): void
{
Auth::requireRole('admin');
$logs = $this->db->select('audit_log', [
'select' => '*',
'order' => 'created_at.desc',
'limit' => 10000,
]);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d') . '.csv"');
$output = fopen('php://output', 'w');
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($output, ['التاريخ', 'المستخدم', 'الإجراء', 'النوع', 'المعرف', 'IP']);
foreach ($logs as $log) {
fputcsv($output, [
$log['created_at'],
$log['actor'],
$log['action'],
$log['entity_type'],
$log['entity_id'] ?? '',
$log['ip_address'] ?? '',
]);
}
fclose($output);
exit;
}
}
<div class="content-header">
<h1>سجل العمليات</h1>
<a href="/audit-log/export" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
تصدير CSV
</a>
</div>
<div class="data-table-wrapper">
<div class="table-toolbar">
<div class="table-search">
<svg class="table-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="بحث بالمعرف..." value="<?= View::e($_GET['search'] ?? '') ?>" onchange="location.href='/audit-log?search='+this.value">
</div>
<div class="flex gap-2">
<select class="form-select" style="width: auto; font-size: 12px;" onchange="location.href='/audit-log?entity_type='+this.value">
<option value="">كل الأنواع</option>
<option value="player" <?= ($_GET['entity_type'] ?? '') === 'player' ? 'selected' : '' ?>>لاعب</option>
<option value="game" <?= ($_GET['entity_type'] ?? '') === 'game' ? 'selected' : '' ?>>لعبة</option>
<option value="tournament" <?= ($_GET['entity_type'] ?? '') === 'tournament' ? 'selected' : '' ?>>بطولة</option>
<option value="report" <?= ($_GET['entity_type'] ?? '') === 'report' ? 'selected' : '' ?>>بلاغ</option>
<option value="feature_flag" <?= ($_GET['entity_type'] ?? '') === 'feature_flag' ? 'selected' : '' ?>>ميزة</option>
<option value="system_config" <?= ($_GET['entity_type'] ?? '') === 'system_config' ? 'selected' : '' ?>>إعداد</option>
</select>
</div>
</div>
<?php if (empty($logs)): ?>
<div class="empty-state"><h3 class="empty-state-title">لا توجد عمليات</h3><p class="empty-state-text">سجل العمليات فارغ</p></div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>المستخدم</th>
<th>الإجراء</th>
<th>النوع</th>
<th>المعرف</th>
<th>التفاصيل</th>
<th>IP</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $log): ?>
<tr>
<td class="text-xs tabular-nums text-muted"><?= date('m/d H:i:s', strtotime($log['created_at'])) ?></td>
<td class="font-medium"><?= View::e($log['actor']) ?></td>
<td>
<?php
$actionColors = ['create' => 'success', 'update' => 'info', 'delete' => 'danger', 'ban' => 'danger', 'unban' => 'success', 'toggle' => 'warning', 'grant' => 'success', 'revoke' => 'danger'];
?>
<span class="badge badge-<?= $actionColors[$log['action']] ?? 'default' ?>"><?= View::e($log['action']) ?></span>
</td>
<td class="text-sm"><?= View::e($log['entity_type']) ?></td>
<td class="text-xs text-muted truncate" style="max-width: 100px;"><?= View::e($log['entity_id'] ?? '-') ?></td>
<td>
<?php if ($log['new_value'] || $log['old_value']): ?>
<button class="btn btn-ghost btn-sm" onclick='showLogDetail(<?= htmlspecialchars(json_encode($log), ENT_QUOTES) ?>)'>عرض</button>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-xs text-muted" dir="ltr"><?= View::e($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&entity_type=<?= $_GET['entity_type'] ?? '' ?>&search=<?= $_GET['search'] ?? '' ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="auth-logo">E</div>
<h1 class="auth-title">مرحباً بك</h1>
<p class="auth-subtitle">سجل دخولك للوصول إلى لوحة التحكم</p>
<?php if (!empty($error)): ?>
<div class="auth-error"><?= View::e($error) ?></div>
<?php endif; ?>
<form method="POST" action="/login" data-validate>
<div class="form-group">
<label class="form-label">اسم المستخدم</label>
<input type="text" name="username" class="form-input" placeholder="admin" required autofocus>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">كلمة المرور</label>
<input type="password" name="password" class="form-input" placeholder="••••••••" required>
<span class="form-error"></span>
</div>
<button type="submit" class="btn btn-primary btn-lg w-full" style="margin-top: var(--space-4);">
<span class="btn-text">تسجيل الدخول</span>
<span class="btn-spinner"></span>
</button>
</form>
/* Branding module styles */
function switchBrandTab(btn, tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(tabId).classList.remove('hidden');
}
<?php
class BrandingController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function index(array $params, string $method): void
{
Auth::requireRole('superadmin');
$colors = $this->db->select('platform_theme', [
'select' => '*',
'category' => 'eq.color',
'order' => 'sort_order.asc',
]);
$assets = $this->db->select('platform_assets', [
'select' => '*',
'order' => 'sort_order.asc',
]);
$pageTitle = 'الهوية والعلامة التجارية';
$moduleCSS = 'branding';
$moduleJS = 'branding';
View::render('branding/index', compact('colors', 'assets', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function updateColor(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = $_POST['id'] ?? '';
$value = $_POST['value'] ?? '';
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
Response::error('قيمة لون غير صحيحة', '/branding');
return;
}
$this->db->update('platform_theme', ['id' => "eq.{$id}"], [
'value' => $value,
'updated_at' => date('c'),
]);
AuditLog::log('update', 'platform_theme', $id, null, ['value' => $value]);
Response::success('تم تحديث اللون', '/branding');
}
public function uploadAsset(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = $_POST['id'] ?? '';
if (empty($_FILES['asset']) || $_FILES['asset']['error'] !== UPLOAD_ERR_OK) {
Response::error('خطأ في رفع الملف', '/branding');
return;
}
$file = $_FILES['asset'];
$allowed = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'];
if (!in_array($file['type'], $allowed)) {
Response::error('نوع الملف غير مسموح (PNG/JPG/SVG/WebP)', '/branding');
return;
}
if ($file['size'] > 5 * 1024 * 1024) {
Response::error('حجم الملف يتجاوز 5MB', '/branding');
return;
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = $id . '_' . time() . '.' . $ext;
$dest = STORAGE_PATH . '/uploads/' . $filename;
move_uploaded_file($file['tmp_name'], $dest);
$assetUrl = '/storage/uploads/' . $filename;
$this->db->update('platform_assets', ['id' => "eq.{$id}"], [
'asset_url' => $assetUrl,
'updated_at' => date('c'),
]);
AuditLog::log('upload', 'platform_asset', $id);
Response::success('تم رفع الملف', '/branding');
}
}
<div class="content-header">
<h1>الهوية والعلامة التجارية</h1>
</div>
<div class="tabs mb-5">
<button class="tab active" onclick="switchBrandTab(this, 'colorsTab')">الألوان</button>
<button class="tab" onclick="switchBrandTab(this, 'assetsTab')">الأصول المرئية</button>
<button class="tab" onclick="switchBrandTab(this, 'previewTab')">معاينة حية</button>
</div>
<!-- Colors Tab -->
<div id="colorsTab" class="tab-content">
<?php if (empty($colors)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد ألوان محفوظة</h3><p class="empty-state-text">أضف ألوان العلامة التجارية من جدول platform_theme</p></div></div>
<?php else: ?>
<div class="grid grid-3">
<?php foreach ($colors as $color): ?>
<div class="card">
<div class="flex items-center gap-4">
<div style="width: 48px; height: 48px; border-radius: var(--radius-md); background: <?= View::e($color['value']) ?>; border: 1px solid var(--border); flex-shrink: 0;"></div>
<div class="flex-1">
<div class="font-medium text-sm"><?= View::e($color['label_ar'] ?? $color['label']) ?></div>
<div class="text-xs text-muted" dir="ltr"><?= View::e($color['id']) ?></div>
</div>
</div>
<form method="POST" action="/branding/update-color" class="flex items-center gap-2 mt-3">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($color['id']) ?>">
<input type="color" name="value" value="<?= View::e($color['value']) ?>" style="width: 40px; height: 32px; border: none; cursor: pointer;">
<input type="text" class="form-input" style="width: 100px; padding: var(--space-1) var(--space-2); font-size: 12px;" value="<?= View::e($color['value']) ?>" dir="ltr" readonly>
<button type="submit" class="btn btn-primary btn-sm">حفظ</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Assets Tab -->
<div id="assetsTab" class="tab-content hidden">
<?php if (empty($assets)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد أصول مرئية</h3></div></div>
<?php else: ?>
<div class="grid grid-3">
<?php foreach ($assets as $asset): ?>
<div class="card">
<div class="mb-3" style="height: 100px; border-radius: var(--radius-md); background: var(--bg-primary); display: flex; align-items: center; justify-content: center; overflow: hidden;">
<?php if ($asset['asset_url']): ?>
<img src="<?= View::e($asset['asset_url']) ?>" style="max-height: 100%; max-width: 100%; object-fit: contain;" alt="">
<?php else: ?>
<span class="text-muted text-xs">لا يوجد ملف</span>
<?php endif; ?>
</div>
<h4 class="font-medium text-sm"><?= View::e($asset['label_ar'] ?? $asset['label']) ?></h4>
<p class="text-xs text-muted mb-3"><?= View::e($asset['id']) ?></p>
<form method="POST" action="/branding/upload-asset" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($asset['id']) ?>">
<input type="file" name="asset" accept="image/*" class="form-input" style="font-size: 12px;">
<button type="submit" class="btn btn-primary btn-sm mt-2 w-full">رفع</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Preview Tab -->
<div id="previewTab" class="tab-content hidden">
<div class="card">
<h3 class="card-title mb-4">معاينة عناصر الواجهة</h3>
<div class="flex flex-wrap gap-3 mb-5">
<button class="btn btn-primary">زر رئيسي</button>
<button class="btn btn-danger">زر خطر</button>
<button class="btn btn-success">زر نجاح</button>
<button class="btn btn-ghost">زر شبحي</button>
</div>
<div class="flex gap-3 mb-5">
<span class="badge badge-success badge-dot">نشط</span>
<span class="badge badge-warning badge-dot">معلق</span>
<span class="badge badge-danger badge-dot">محظور</span>
<span class="badge badge-info">معلومات</span>
<span class="badge badge-purple">نادر</span>
</div>
<div class="grid grid-3 gap-4">
<div class="stat-card">
<div class="stat-icon blue"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg></div>
<div class="stat-content"><div class="stat-label">إحصائية</div><div class="stat-value">1,234</div></div>
</div>
<div class="stat-card">
<div class="stat-icon gold"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg></div>
<div class="stat-content"><div class="stat-label">ذهبي</div><div class="stat-value">567</div></div>
</div>
<div class="stat-card">
<div class="stat-icon purple"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg></div>
<div class="stat-content"><div class="stat-label">نادر</div><div class="stat-value">89</div></div>
</div>
</div>
</div>
</div>
/* Chess Bots Module Styles */
/* Bots Grid */
.bots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-5);
}
/* Bot Card */
.bot-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
.bot-card-header {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.bot-card-info {
flex: 1;
min-width: 0;
}
.bot-card-name {
font-size: var(--text-base);
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.bot-card-name-en {
font-size: var(--text-xs);
color: var(--text-muted);
margin: var(--space-1) 0;
direction: ltr;
text-align: right;
}
.bot-card-body {
padding: var(--space-4);
flex: 1;
}
.bot-card-actions {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
background: var(--bg-elevated);
}
/* Bot Portrait */
.bot-portrait {
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
overflow: hidden;
flex-shrink: 0;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
}
.bot-portrait img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bot-portrait-lg {
width: 96px;
height: 96px;
border-radius: var(--radius-xl);
}
.bot-portrait-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
color: var(--text-muted);
border: 2px dashed var(--border);
border-radius: inherit;
}
.bot-portrait-current {
display: flex;
justify-content: center;
}
.bot-portrait-preview {
width: 120px;
height: 120px;
border-radius: var(--radius-xl);
object-fit: cover;
border: 3px solid var(--border);
}
/* ELO Range Bar */
.bot-elo-section {
margin-bottom: var(--space-3);
}
.bot-elo-label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.elo-range-bar {
height: 6px;
background: var(--bg-elevated);
border-radius: var(--radius-full);
position: relative;
overflow: hidden;
direction: ltr;
}
.elo-range-fill {
position: absolute;
top: 0;
height: 100%;
border-radius: var(--radius-full);
background: linear-gradient(90deg, var(--color-info), var(--color-purple));
transition: width 0.3s ease;
}
/* Difficulty Stars */
.bot-difficulty {
display: flex;
align-items: center;
justify-content: space-between;
}
.bot-difficulty-label {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.bot-stars {
display: flex;
gap: 2px;
}
.star-filled {
color: var(--color-warning);
}
.star-empty {
color: var(--text-muted);
opacity: 0.4;
}
/* Test Move Panel */
.test-move-panel {
padding: var(--space-4);
}
.test-result {
background: var(--bg-elevated);
border-radius: var(--radius-lg);
padding: var(--space-4);
border: 1px solid var(--border);
}
.test-result-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.test-result-label {
font-size: var(--text-xs);
color: var(--text-muted);
font-weight: 500;
}
.test-result-value {
font-size: var(--text-lg);
font-weight: 700;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-primary);
}
.test-result-pv {
border-top: 1px solid var(--border);
padding-top: var(--space-3);
}
.test-result-pv-line {
display: block;
margin-top: var(--space-2);
padding: var(--space-3);
background: var(--bg-primary);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-family: 'IBM Plex Mono', monospace;
word-break: break-all;
color: var(--text-secondary);
}
.test-error {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-danger-bg);
color: var(--color-danger);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
/* Pool Monitor Stat Cards */
#poolMonitor .stat-card {
transition: transform 0.2s ease;
}
#poolMonitor .stat-card:hover {
transform: translateY(-2px);
}
/* Form Range */
.form-range {
width: 100%;
height: 6px;
appearance: none;
-webkit-appearance: none;
background: var(--bg-elevated);
border-radius: var(--radius-full);
outline: none;
margin: var(--space-2) 0;
cursor: pointer;
}
.form-range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: 2px solid var(--bg-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.form-range::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: 2px solid var(--bg-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.form-range-labels {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: var(--space-1);
}
.form-hint {
display: block;
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: var(--space-1);
}
/* Button danger text variant */
.btn-danger-text {
color: var(--color-danger);
}
.btn-danger-text:hover {
background: var(--color-danger-bg);
}
/* Responsive */
@media (max-width: 768px) {
.bots-grid {
grid-template-columns: 1fr;
}
.test-result .grid-2 {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.bot-card-actions {
flex-wrap: wrap;
}
}
// Chess Bots Module JS
// =============================
// Test Move Panel
// =============================
function setFen(fen) {
document.getElementById('fenInput').value = fen;
}
function testMove(botId) {
const fenInput = document.getElementById('fenInput');
const btn = document.getElementById('testMoveBtn');
const resultEl = document.getElementById('testResult');
const errorEl = document.getElementById('testError');
const statusEl = document.getElementById('testStatus');
const fen = fenInput.value.trim();
if (!fen) {
showTestError('يرجى إدخال وضعية FEN');
return;
}
// UI: loading state
btn.classList.add('loading');
btn.disabled = true;
resultEl.classList.add('hidden');
errorEl.classList.add('hidden');
statusEl.textContent = 'جاري الاختبار...';
statusEl.className = 'badge badge-warning';
const formData = new FormData();
formData.append('fen', fen);
formData.append('bot_id', botId);
formData.append('_csrf', CSRF_TOKEN);
fetch('/chess-bots/test-move', {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(data => {
btn.classList.remove('loading');
btn.disabled = false;
if (data.success && data.data) {
showTestResult(data.data, data.time_ms);
statusEl.textContent = 'نجح';
statusEl.className = 'badge badge-success';
} else {
showTestError(data.error || 'فشل في الحصول على النقلة');
statusEl.textContent = 'فشل';
statusEl.className = 'badge badge-danger';
}
})
.catch(err => {
btn.classList.remove('loading');
btn.disabled = false;
showTestError('خطأ في الاتصال: ' + err.message);
statusEl.textContent = 'خطأ';
statusEl.className = 'badge badge-danger';
});
}
function showTestResult(data, timeMs) {
const resultEl = document.getElementById('testResult');
const errorEl = document.getElementById('testError');
errorEl.classList.add('hidden');
resultEl.classList.remove('hidden');
document.getElementById('resultMove').textContent = data.best_move || data.move || '-';
document.getElementById('resultEval').textContent = formatEval(data.evaluation ?? data.score ?? data.eval);
document.getElementById('resultDepth').textContent = data.depth ?? '-';
document.getElementById('resultTime').textContent = timeMs ? timeMs + 'ms' : '-';
const pvLine = data.pv || data.pv_line || data.principal_variation || '';
document.getElementById('resultPV').textContent = pvLine || '-';
}
function formatEval(evalValue) {
if (evalValue === null || evalValue === undefined) return '-';
if (typeof evalValue === 'object') {
if (evalValue.type === 'mate') return 'مات في ' + evalValue.value;
if (evalValue.type === 'cp') return (evalValue.value / 100).toFixed(2);
return JSON.stringify(evalValue);
}
if (typeof evalValue === 'number') {
const formatted = (evalValue / 100).toFixed(2);
return evalValue >= 0 ? '+' + formatted : formatted;
}
return String(evalValue);
}
function showTestError(message) {
const resultEl = document.getElementById('testResult');
const errorEl = document.getElementById('testError');
resultEl.classList.add('hidden');
errorEl.classList.remove('hidden');
document.getElementById('testErrorMsg').textContent = message;
}
// =============================
// Pool Stats Auto-Refresh
// =============================
let poolRefreshInterval = null;
function refreshPoolStats() {
const poolMonitor = document.getElementById('poolMonitor');
if (!poolMonitor) return;
fetch('/chess-bots/pool', {
headers: { 'Accept': 'application/json' },
})
.then(res => res.json())
.then(data => {
if (data.success && data.data) {
const pool = data.data;
updatePoolStat('poolActive', pool.active_engines);
updatePoolStat('poolAvailable', pool.available_engines);
updatePoolStat('poolBusy', pool.busy_engines);
updatePoolStat('poolRpm', pool.requests_per_minute);
}
})
.catch(() => {
// Silently fail on pool refresh
});
}
function updatePoolStat(elementId, value) {
const el = document.getElementById(elementId);
if (el && value !== undefined && value !== null) {
el.textContent = value;
}
}
function startPoolRefresh() {
if (document.getElementById('poolMonitor')) {
poolRefreshInterval = setInterval(refreshPoolStats, 10000); // Every 10 seconds
}
}
function stopPoolRefresh() {
if (poolRefreshInterval) {
clearInterval(poolRefreshInterval);
poolRefreshInterval = null;
}
}
// =============================
// Portrait Upload Preview
// =============================
function initPortraitPreview() {
const input = document.getElementById('portraitInput');
const preview = document.getElementById('portraitPreview');
if (!input || !preview) return;
input.addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
// Validate file type
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.type)) {
showToast('نوع الصورة غير مدعوم', 'error');
this.value = '';
return;
}
// Validate file size (2MB)
if (file.size > 2 * 1024 * 1024) {
showToast('حجم الصورة يجب أن لا يتجاوز 2MB', 'error');
this.value = '';
return;
}
// Show preview
const reader = new FileReader();
reader.onload = function (e) {
if (preview.tagName === 'IMG') {
preview.src = e.target.result;
} else {
// Replace placeholder with image
const img = document.createElement('img');
img.src = e.target.result;
img.alt = 'معاينة الصورة';
img.className = 'bot-portrait-preview';
img.id = 'portraitPreview';
preview.parentNode.replaceChild(img, preview);
}
};
reader.readAsDataURL(file);
});
}
// =============================
// Initialization
// =============================
document.addEventListener('DOMContentLoaded', function () {
// Start pool stats auto-refresh on list page
startPoolRefresh();
// Initialize portrait preview on form page
initPortraitPreview();
// Handle visibility change to pause/resume refresh
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopPoolRefresh();
} else {
startPoolRefresh();
}
});
});
// Cleanup on page unload
window.addEventListener('beforeunload', function () {
stopPoolRefresh();
});
<?php
class ChessBotsController
{
public function list(array $params, string $method): void
{
$response = ApiProxy::stockfish('GET', '/api/manage/bots');
$bots = [];
if ($response['status'] === 200 && is_array($response['body'])) {
$bots = $response['body'];
}
$poolResponse = ApiProxy::stockfish('GET', '/api/manage/pool');
$pool = ($poolResponse['status'] === 200 && is_array($poolResponse['body'])) ? $poolResponse['body'] : [];
$pageTitle = 'بوتات الشطرنج';
$moduleCSS = 'chess-bots';
$moduleJS = 'chess-bots';
View::render('chess-bots/list', compact('bots', 'pool', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function show(array $params, string $method): void
{
$id = $params['id'];
$response = ApiProxy::stockfish('GET', "/api/manage/bots/{$id}");
if ($response['status'] !== 200 || !is_array($response['body'])) {
http_response_code(404);
View::render('errors/404');
return;
}
$bot = $response['body'];
$pageTitle = $bot['name'] ?? 'بوت الشطرنج';
$moduleCSS = 'chess-bots';
$moduleJS = 'chess-bots';
View::render('chess-bots/show', compact('bot', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$bot = [];
$pageTitle = 'إضافة بوت جديد';
$moduleCSS = 'chess-bots';
View::render('chess-bots/form', compact('bot', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('id', 'المعرف')
->required('name', 'الاسم بالإنجليزية')
->required('name_ar', 'الاسم بالعربية')
->required('style', 'أسلوب اللعب');
if ($validator->fails()) {
Response::error($validator->firstError(), '/chess-bots/create');
return;
}
$data = [
'id' => trim($_POST['id']),
'name' => trim($_POST['name']),
'name_ar' => trim($_POST['name_ar']),
'style' => trim($_POST['style']),
'style_ar' => trim($_POST['style_ar'] ?? ''),
'bio' => trim($_POST['bio'] ?? ''),
'bio_ar' => trim($_POST['bio_ar'] ?? ''),
'elo_min' => (int)($_POST['elo_min'] ?? 800),
'elo_max' => (int)($_POST['elo_max'] ?? 1200),
'skill_level' => (int)($_POST['skill_level'] ?? 10),
'depth' => (int)($_POST['depth'] ?? 10),
'contempt' => (int)($_POST['contempt'] ?? 0),
'blunder_chance' => (float)($_POST['blunder_chance'] ?? 0),
'think_time_min_ms' => (int)($_POST['think_time_min_ms'] ?? 500),
'think_time_max_ms' => (int)($_POST['think_time_max_ms'] ?? 2000),
];
$response = ApiProxy::stockfish('POST', '/api/manage/bots', $data);
if ($response['status'] >= 200 && $response['status'] < 300) {
AuditLog::log('create', 'chess_bot', $data['id'], null, $data);
Response::success('تم إنشاء البوت بنجاح', '/chess-bots');
} else {
$error = $response['body']['message'] ?? $response['body']['error'] ?? 'فشل في إنشاء البوت';
Response::error($error, '/chess-bots/create');
}
}
public function edit(array $params, string $method): void
{
$id = $params['id'];
$response = ApiProxy::stockfish('GET', "/api/manage/bots/{$id}");
if ($response['status'] !== 200 || !is_array($response['body'])) {
http_response_code(404);
View::render('errors/404');
return;
}
$bot = $response['body'];
$pageTitle = 'تعديل البوت';
$moduleCSS = 'chess-bots';
View::render('chess-bots/form', compact('bot', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$oldResponse = ApiProxy::stockfish('GET', "/api/manage/bots/{$id}");
if ($oldResponse['status'] !== 200) {
Response::error('البوت غير موجود', '/chess-bots');
return;
}
$old = $oldResponse['body'];
$data = [
'name' => trim($_POST['name']),
'name_ar' => trim($_POST['name_ar']),
'style' => trim($_POST['style']),
'style_ar' => trim($_POST['style_ar'] ?? ''),
'bio' => trim($_POST['bio'] ?? ''),
'bio_ar' => trim($_POST['bio_ar'] ?? ''),
'elo_min' => (int)($_POST['elo_min'] ?? 800),
'elo_max' => (int)($_POST['elo_max'] ?? 1200),
'skill_level' => (int)($_POST['skill_level'] ?? 10),
'depth' => (int)($_POST['depth'] ?? 10),
'contempt' => (int)($_POST['contempt'] ?? 0),
'blunder_chance' => (float)($_POST['blunder_chance'] ?? 0),
'think_time_min_ms' => (int)($_POST['think_time_min_ms'] ?? 500),
'think_time_max_ms' => (int)($_POST['think_time_max_ms'] ?? 2000),
];
$response = ApiProxy::stockfish('PATCH', "/api/manage/bots/{$id}", $data);
if ($response['status'] >= 200 && $response['status'] < 300) {
AuditLog::log('update', 'chess_bot', $id, $old, $data);
Response::success('تم تحديث البوت بنجاح', '/chess-bots');
} else {
$error = $response['body']['message'] ?? $response['body']['error'] ?? 'فشل في تحديث البوت';
Response::error($error, "/chess-bots/{$id}/edit");
}
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('admin');
$id = $params['id'];
$response = ApiProxy::stockfish('DELETE', "/api/manage/bots/{$id}");
if ($response['status'] >= 200 && $response['status'] < 300) {
AuditLog::log('delete', 'chess_bot', $id);
Response::success('تم حذف البوت', '/chess-bots');
} else {
$error = $response['body']['message'] ?? $response['body']['error'] ?? 'فشل في حذف البوت';
Response::error($error, '/chess-bots');
}
}
public function portrait(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
if (empty($_FILES['portrait']) || $_FILES['portrait']['error'] !== UPLOAD_ERR_OK) {
Response::error('يرجى اختيار صورة صحيحة', "/chess-bots/{$id}/edit");
return;
}
$file = $_FILES['portrait'];
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($file['type'], $allowed)) {
Response::error('نوع الصورة غير مدعوم (يدعم: JPG, PNG, WebP)', "/chess-bots/{$id}/edit");
return;
}
if ($file['size'] > 2 * 1024 * 1024) {
Response::error('حجم الصورة يجب أن لا يتجاوز 2MB', "/chess-bots/{$id}/edit");
return;
}
// Upload via multipart to the API
$ch = curl_init();
$postFields = [
'portrait' => new CURLFile($file['tmp_name'], $file['type'], $file['name']),
];
curl_setopt_array($ch, [
CURLOPT_URL => STOCKFISH_API_URL . "/api/manage/bots/{$id}/portrait",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . STOCKFISH_API_KEY,
],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 200 && $status < 300) {
AuditLog::log('upload_portrait', 'chess_bot', $id);
Response::success('تم رفع صورة البوت بنجاح', "/chess-bots/{$id}");
} else {
Response::error('فشل في رفع الصورة', "/chess-bots/{$id}/edit");
}
}
public function testMove(array $params, string $method): void
{
if ($method === 'POST') {
Auth::requireCsrf();
$fen = trim($_POST['fen'] ?? '');
$botId = trim($_POST['bot_id'] ?? '');
if (empty($fen) || empty($botId)) {
Response::json(['error' => 'يجب إدخال FEN ومعرف البوت'], 400);
return;
}
$response = ApiProxy::stockfish('POST', '/api/chess/move', [
'fen' => $fen,
'bot_id' => $botId,
]);
if ($response['status'] >= 200 && $response['status'] < 300) {
Response::json([
'success' => true,
'data' => $response['body'],
'time_ms' => $response['time_ms'],
]);
} else {
Response::json([
'success' => false,
'error' => $response['body']['message'] ?? $response['body']['error'] ?? 'فشل في الحصول على النقلة',
], $response['status'] ?: 500);
}
return;
}
// GET - render test panel standalone (not typically used directly)
Response::json(['error' => 'Method not allowed'], 405);
}
public function pool(array $params, string $method): void
{
$response = ApiProxy::stockfish('GET', '/api/manage/pool');
if ($response['status'] >= 200 && $response['status'] < 300) {
Response::json([
'success' => true,
'data' => $response['body'],
'time_ms' => $response['time_ms'],
]);
} else {
Response::json([
'success' => false,
'error' => $response['body']['message'] ?? 'فشل في جلب حالة المحرك',
], $response['status'] ?: 500);
}
}
}
<?php $isEdit = !empty($bot['id']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/chess-bots" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= $isEdit ? 'تعديل البوت' : 'إضافة بوت جديد' ?></h1>
</div>
</div>
<div class="card max-w-xl">
<form method="POST" action="<?= $isEdit ? "/chess-bots/{$bot['id']}/update" : '/chess-bots/store' ?>" enctype="multipart/form-data" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<!-- Basic Info -->
<div class="card-header" style="padding: 0; border: none;">
<h3 class="card-title text-sm">المعلومات الأساسية</h3>
</div>
<?php if (!$isEdit): ?>
<div class="form-group">
<label class="form-label">المعرف (ID) *</label>
<input type="text" name="id" class="form-input" value="<?= View::e($bot['id'] ?? '') ?>" required placeholder="bot_aggressive_01" dir="ltr" pattern="[a-z0-9_-]+">
<span class="form-hint">حروف إنجليزية صغيرة وأرقام وشرطات فقط</span>
<span class="form-error"></span>
</div>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الاسم (English) *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($bot['name'] ?? '') ?>" required placeholder="The Hawk" dir="ltr">
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">الاسم (عربي) *</label>
<input type="text" name="name_ar" class="form-input" value="<?= View::e($bot['name_ar'] ?? '') ?>" required placeholder="الصقر">
<span class="form-error"></span>
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">أسلوب اللعب *</label>
<select name="style" class="form-select" required>
<option value="">اختر الأسلوب</option>
<option value="aggressive" <?= ($bot['style'] ?? '') === 'aggressive' ? 'selected' : '' ?>>Aggressive</option>
<option value="defensive" <?= ($bot['style'] ?? '') === 'defensive' ? 'selected' : '' ?>>Defensive</option>
<option value="positional" <?= ($bot['style'] ?? '') === 'positional' ? 'selected' : '' ?>>Positional</option>
<option value="tactical" <?= ($bot['style'] ?? '') === 'tactical' ? 'selected' : '' ?>>Tactical</option>
<option value="balanced" <?= ($bot['style'] ?? '') === 'balanced' ? 'selected' : '' ?>>Balanced</option>
<option value="endgame" <?= ($bot['style'] ?? '') === 'endgame' ? 'selected' : '' ?>>Endgame Specialist</option>
<option value="opening" <?= ($bot['style'] ?? '') === 'opening' ? 'selected' : '' ?>>Opening Expert</option>
<option value="random" <?= ($bot['style'] ?? '') === 'random' ? 'selected' : '' ?>>Random/Chaotic</option>
</select>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">أسلوب اللعب (عربي)</label>
<input type="text" name="style_ar" class="form-input" value="<?= View::e($bot['style_ar'] ?? '') ?>" placeholder="هجومي">
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">نبذة (English)</label>
<textarea name="bio" class="form-input" rows="3" dir="ltr" placeholder="A fierce attacker who never backs down..."><?= View::e($bot['bio'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">نبذة (عربي)</label>
<textarea name="bio_ar" class="form-input" rows="3" placeholder="مهاجم شرس لا يتراجع أبدا..."><?= View::e($bot['bio_ar'] ?? '') ?></textarea>
</div>
</div>
<!-- Engine Parameters -->
<div class="card-header mt-6" style="padding: 0; border: none;">
<h3 class="card-title text-sm">إعدادات المحرك</h3>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">أقل ELO</label>
<input type="number" name="elo_min" class="form-input" value="<?= $bot['elo_min'] ?? 800 ?>" min="200" max="3000">
</div>
<div class="form-group">
<label class="form-label">أعلى ELO</label>
<input type="number" name="elo_max" class="form-input" value="<?= $bot['elo_max'] ?? 1200 ?>" min="200" max="3500">
</div>
</div>
<div class="form-group">
<label class="form-label">مستوى المهارة (Skill Level): <strong id="skillLevelValue"><?= $bot['skill_level'] ?? 10 ?></strong></label>
<input type="range" name="skill_level" class="form-range" value="<?= $bot['skill_level'] ?? 10 ?>" min="0" max="20" step="1" oninput="document.getElementById('skillLevelValue').textContent = this.value">
<div class="form-range-labels">
<span>0 (مبتدئ)</span>
<span>20 (محترف)</span>
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">العمق (Depth)</label>
<input type="number" name="depth" class="form-input" value="<?= $bot['depth'] ?? 10 ?>" min="1" max="30">
<span class="form-hint">عدد النقلات التي يحسبها المحرك مقدما</span>
</div>
<div class="form-group">
<label class="form-label">الازدراء (Contempt): <strong id="contemptValue"><?= $bot['contempt'] ?? 0 ?></strong></label>
<input type="range" name="contempt" class="form-range" value="<?= $bot['contempt'] ?? 0 ?>" min="-100" max="100" step="5" oninput="document.getElementById('contemptValue').textContent = this.value">
<div class="form-range-labels">
<span>-100 (يفضل التعادل)</span>
<span>+100 (يتجنب التعادل)</span>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">احتمال الخطأ (Blunder Chance): <strong id="blunderValue"><?= $bot['blunder_chance'] ?? 0 ?></strong></label>
<input type="range" name="blunder_chance" class="form-range" value="<?= $bot['blunder_chance'] ?? 0 ?>" min="0" max="1" step="0.05" oninput="document.getElementById('blunderValue').textContent = this.value">
<div class="form-range-labels">
<span>0 (لا أخطاء)</span>
<span>1 (كثير الأخطاء)</span>
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">أقل وقت تفكير (ms)</label>
<input type="number" name="think_time_min_ms" class="form-input" value="<?= $bot['think_time_min_ms'] ?? 500 ?>" min="100" max="30000" step="100">
</div>
<div class="form-group">
<label class="form-label">أعلى وقت تفكير (ms)</label>
<input type="number" name="think_time_max_ms" class="form-input" value="<?= $bot['think_time_max_ms'] ?? 2000 ?>" min="100" max="60000" step="100">
</div>
</div>
<!-- Portrait Upload -->
<div class="card-header mt-6" style="padding: 0; border: none;">
<h3 class="card-title text-sm">الصورة الشخصية</h3>
</div>
<?php if ($isEdit && !empty($bot['portrait_url'])): ?>
<div class="bot-portrait-current mb-4">
<img src="<?= View::e($bot['portrait_url']) ?>" alt="<?= View::e($bot['name']) ?>" class="bot-portrait-preview" id="portraitPreview">
</div>
<?php else: ?>
<div class="bot-portrait-current mb-4">
<div class="bot-portrait-preview bot-portrait-placeholder" id="portraitPreview">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
</div>
<?php endif; ?>
<div class="form-group">
<label class="form-label">رفع صورة جديدة</label>
<input type="file" name="portrait" class="form-input" accept="image/jpeg,image/png,image/webp" id="portraitInput">
<span class="form-hint">JPG, PNG, أو WebP - حد أقصى 2MB</span>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<span class="btn-text"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء البوت' ?></span>
<span class="btn-spinner"></span>
</button>
<a href="/chess-bots" class="btn btn-ghost">إلغاء</a>
</div>
</form>
<?php if ($isEdit): ?>
<!-- Separate portrait upload form -->
<form method="POST" action="/chess-bots/<?= View::e($bot['id']) ?>/portrait" enctype="multipart/form-data" id="portraitForm" class="hidden">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="file" name="portrait" id="portraitFileInput" accept="image/jpeg,image/png,image/webp">
</form>
<?php endif; ?>
</div>
<div class="content-header">
<h1>بوتات الشطرنج</h1>
<a href="/chess-bots/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة بوت
</a>
</div>
<!-- Pool Monitor -->
<div class="grid grid-4 mb-6" id="poolMonitor">
<div class="stat-card">
<div class="stat-card-icon bg-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">المحركات النشطة</span>
<span class="stat-card-value" id="poolActive"><?= $pool['active_engines'] ?? '-' ?></span>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon bg-success">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">المحركات المتاحة</span>
<span class="stat-card-value" id="poolAvailable"><?= $pool['available_engines'] ?? '-' ?></span>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon bg-warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">المحركات المشغولة</span>
<span class="stat-card-value" id="poolBusy"><?= $pool['busy_engines'] ?? '-' ?></span>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon bg-purple">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">الطلبات/دقيقة</span>
<span class="stat-card-value" id="poolRpm"><?= $pool['requests_per_minute'] ?? '-' ?></span>
</div>
</div>
</div>
<!-- Bots Grid -->
<?php if (empty($bots)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<h3 class="empty-state-title">لا توجد بوتات</h3>
<p class="empty-state-text">لم يتم إنشاء أي بوتات شطرنج بعد</p>
<a href="/chess-bots/create" class="btn btn-primary mt-4">إنشاء أول بوت</a>
</div>
<?php else: ?>
<div class="bots-grid">
<?php foreach ($bots as $bot): ?>
<div class="card card-hover bot-card">
<div class="bot-card-header">
<div class="bot-portrait">
<?php if (!empty($bot['portrait_url'])): ?>
<img src="<?= View::e($bot['portrait_url']) ?>" alt="<?= View::e($bot['name']) ?>">
<?php else: ?>
<div class="bot-portrait-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a3 3 0 0 0-3 3v1a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10H5a2 2 0 0 0-2 2v1a7 7 0 0 0 14 0v-1a2 2 0 0 0-2-2z"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
</div>
<?php endif; ?>
</div>
<div class="bot-card-info">
<h3 class="bot-card-name"><?= View::e($bot['name_ar'] ?? $bot['name']) ?></h3>
<p class="bot-card-name-en"><?= View::e($bot['name']) ?></p>
<span class="badge badge-info"><?= View::e($bot['style_ar'] ?? $bot['style'] ?? '') ?></span>
</div>
</div>
<div class="bot-card-body">
<!-- ELO Range -->
<div class="bot-elo-section">
<div class="bot-elo-label">
<span>نطاق ELO</span>
<span class="tabular-nums"><?= $bot['elo_min'] ?? 800 ?> - <?= $bot['elo_max'] ?? 1200 ?></span>
</div>
<div class="elo-range-bar">
<?php
$eloMin = $bot['elo_min'] ?? 800;
$eloMax = $bot['elo_max'] ?? 1200;
$leftPct = max(0, ($eloMin - 400) / 2600 * 100);
$widthPct = max(5, ($eloMax - $eloMin) / 2600 * 100);
?>
<div class="elo-range-fill" style="right: <?= $leftPct ?>%; width: <?= $widthPct ?>%;"></div>
</div>
</div>
<!-- Difficulty Stars -->
<div class="bot-difficulty">
<span class="bot-difficulty-label">الصعوبة</span>
<div class="bot-stars">
<?php
$skillLevel = $bot['skill_level'] ?? 10;
$stars = max(1, min(5, ceil($skillLevel / 4)));
for ($i = 1; $i <= 5; $i++):
?>
<svg width="14" height="14" viewBox="0 0 24 24" fill="<?= $i <= $stars ? 'currentColor' : 'none' ?>" stroke="currentColor" stroke-width="2" class="<?= $i <= $stars ? 'star-filled' : 'star-empty' ?>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<?php endfor; ?>
</div>
</div>
</div>
<div class="bot-card-actions">
<a href="/chess-bots/<?= View::e($bot['id']) ?>" class="btn btn-ghost btn-sm" title="عرض واختبار">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
عرض
</a>
<a href="/chess-bots/<?= View::e($bot['id']) ?>/edit" class="btn btn-ghost btn-sm" title="تعديل">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
تعديل
</a>
<button class="btn btn-ghost btn-sm btn-danger-text" onclick="confirmDelete('/chess-bots/<?= View::e($bot['id']) ?>/delete', '<?= View::e($bot['name_ar'] ?? $bot['name']) ?>')" title="حذف">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
حذف
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/chess-bots" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= View::e($bot['name_ar'] ?? $bot['name']) ?></h1>
<span class="badge badge-info"><?= View::e($bot['style_ar'] ?? $bot['style'] ?? '') ?></span>
</div>
<div class="flex gap-3">
<a href="/chess-bots/<?= View::e($bot['id']) ?>/edit" class="btn btn-ghost">تعديل</a>
<button class="btn btn-danger" onclick="confirmDelete('/chess-bots/<?= View::e($bot['id']) ?>/delete', '<?= View::e($bot['name_ar'] ?? $bot['name']) ?>')">حذف</button>
</div>
</div>
<!-- Bot Profile -->
<div class="grid grid-3 mb-6">
<div class="card" style="grid-column: span 1;">
<div class="flex flex-col items-center text-center p-5">
<div class="bot-portrait bot-portrait-lg mb-4">
<?php if (!empty($bot['portrait_url'])): ?>
<img src="<?= View::e($bot['portrait_url']) ?>" alt="<?= View::e($bot['name']) ?>">
<?php else: ?>
<div class="bot-portrait-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a3 3 0 0 0-3 3v1a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10H5a2 2 0 0 0-2 2v1a7 7 0 0 0 14 0v-1a2 2 0 0 0-2-2z"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
</div>
<?php endif; ?>
</div>
<h3 class="text-lg font-semibold"><?= View::e($bot['name_ar'] ?? $bot['name']) ?></h3>
<p class="text-secondary text-sm"><?= View::e($bot['name']) ?></p>
<p class="text-muted text-xs mt-2"><?= View::e($bot['bio_ar'] ?? $bot['bio'] ?? '') ?></p>
</div>
</div>
<div class="card" style="grid-column: span 2;">
<div class="card-header">
<h3 class="card-title">إعدادات المحرك</h3>
</div>
<div class="grid grid-3 gap-4">
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1">نطاق ELO</div>
<div class="text-lg font-bold tabular-nums"><?= $bot['elo_min'] ?? 800 ?> - <?= $bot['elo_max'] ?? 1200 ?></div>
</div>
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1">مستوى المهارة</div>
<div class="text-lg font-bold tabular-nums"><?= $bot['skill_level'] ?? 10 ?> / 20</div>
</div>
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1">العمق</div>
<div class="text-lg font-bold tabular-nums"><?= $bot['depth'] ?? 10 ?></div>
</div>
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1">الازدراء</div>
<div class="text-lg font-bold tabular-nums"><?= $bot['contempt'] ?? 0 ?></div>
</div>
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1">احتمال الخطأ</div>
<div class="text-lg font-bold tabular-nums"><?= $bot['blunder_chance'] ?? 0 ?></div>
</div>
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1">وقت التفكير</div>
<div class="text-lg font-bold tabular-nums"><?= $bot['think_time_min_ms'] ?? 500 ?> - <?= $bot['think_time_max_ms'] ?? 2000 ?>ms</div>
</div>
</div>
</div>
</div>
<!-- Test Move Panel -->
<div class="card">
<div class="card-header">
<h3 class="card-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
لوحة الاختبار
</h3>
<span class="badge badge-default" id="testStatus">جاهز</span>
</div>
<div class="test-move-panel">
<div class="form-group">
<label class="form-label">وضعية FEN</label>
<div class="flex gap-3">
<input type="text" class="form-input" id="fenInput" dir="ltr" value="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" style="font-family: monospace; font-size: 0.85rem;">
<button class="btn btn-primary" id="testMoveBtn" onclick="testMove('<?= View::e($bot['id']) ?>')">
<span class="btn-text">اختبار النقلة</span>
<span class="btn-spinner"></span>
</button>
</div>
<span class="form-hint">أدخل وضعية FEN صحيحة أو اترك الوضعية الافتراضية (بداية اللعبة)</span>
</div>
<!-- Quick FEN Positions -->
<div class="flex gap-2 flex-wrap mb-4">
<button class="btn btn-ghost btn-sm" onclick="setFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1')">بداية اللعبة</button>
<button class="btn btn-ghost btn-sm" onclick="setFen('r1bqkbnr/pppppppp/2n5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2')">بعد 1...Nc6</button>
<button class="btn btn-ghost btn-sm" onclick="setFen('rnbqkb1r/pppppppp/5n2/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2')">بعد 1...Nf6</button>
<button class="btn btn-ghost btn-sm" onclick="setFen('r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3')">الإسبانية</button>
<button class="btn btn-ghost btn-sm" onclick="setFen('8/8/4k3/8/8/4K3/4P3/8 w - - 0 1')">نهاية بسيطة</button>
</div>
<!-- Result Display -->
<div class="test-result hidden" id="testResult">
<div class="grid grid-2 gap-4">
<div class="test-result-item">
<span class="test-result-label">أفضل نقلة</span>
<span class="test-result-value" id="resultMove" dir="ltr">-</span>
</div>
<div class="test-result-item">
<span class="test-result-label">التقييم</span>
<span class="test-result-value" id="resultEval">-</span>
</div>
<div class="test-result-item">
<span class="test-result-label">العمق المحسوب</span>
<span class="test-result-value" id="resultDepth">-</span>
</div>
<div class="test-result-item">
<span class="test-result-label">زمن الاستجابة</span>
<span class="test-result-value" id="resultTime">-</span>
</div>
</div>
<div class="test-result-pv mt-4">
<span class="test-result-label">خط التحليل (PV)</span>
<code class="test-result-pv-line" id="resultPV" dir="ltr">-</code>
</div>
</div>
<!-- Error Display -->
<div class="test-error hidden" id="testError">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span id="testErrorMsg"></span>
</div>
</div>
</div>
/* Dashboard specific styles - minimal since most is covered by components */
// Auto-refresh service health every 30s
setInterval(async () => {
try {
const response = await fetch('/api/health');
const data = await response.json();
if (data) {
document.querySelectorAll('.health-item').forEach((item, i) => {
const services = ['supabase', 'stockfish', 'swiss'];
const service = data[services[i]];
if (service) {
const dot = item.querySelector('.health-dot');
dot.className = `health-dot ${service.online ? 'up' : 'down'}`;
item.querySelector('.health-latency').textContent = service.latency_ms + 'ms';
}
});
}
} catch {}
}, 30000);
<?php
class DashboardController
{
public function index(array $params, string $method): void
{
$db = Database::getInstance();
$totalPlayers = $db->count('profiles', []);
$onlinePlayers = $db->count('profiles', ['is_online' => 'eq.true']);
$totalMatches = $db->count('matches', []);
$activeMatches = $db->count('matches', ['status' => 'eq.in_progress']);
$activeTournaments = $db->count('el3ab_tournaments', ['status' => 'eq.in_progress']);
$pendingReports = $db->count('cheat_reports', ['status' => 'eq.pending']);
$recentActivity = $db->select('audit_log', [
'select' => '*',
'order' => 'created_at.desc',
'limit' => 10,
]);
$supabaseHealth = ApiProxy::healthCheck(
SUPABASE_URL . '/rest/v1/',
['apikey: ' . SUPABASE_SERVICE_KEY]
);
$stockfishHealth = ApiProxy::healthCheck(STOCKFISH_API_URL . '/health');
$swissHealth = ApiProxy::healthCheck(SWISS_API_URL . '/health');
$pageTitle = 'لوحة التحكم';
$moduleCSS = 'dashboard';
$moduleJS = 'dashboard';
View::render('dashboard/index', compact(
'totalPlayers', 'onlinePlayers', 'totalMatches', 'activeMatches',
'activeTournaments', 'pendingReports', 'recentActivity',
'supabaseHealth', 'stockfishHealth', 'swissHealth',
'pageTitle', 'moduleCSS', 'moduleJS'
));
}
}
<div class="content-header">
<h1>لوحة التحكم</h1>
<div class="flex gap-3">
<a href="/tournaments/create" class="btn btn-primary btn-sm">إنشاء بطولة</a>
<a href="/moderation" class="btn btn-ghost btn-sm">مراجعة البلاغات</a>
</div>
</div>
<!-- Stat Cards -->
<div class="grid grid-4 stagger mb-6">
<div class="stat-card">
<div class="stat-icon blue">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">اللاعبون المتصلون / الإجمالي</div>
<div class="stat-value"><span data-count="<?= $onlinePlayers ?>">0</span> / <?= number_format($totalPlayers) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">المباريات النشطة / الإجمالي</div>
<div class="stat-value"><span data-count="<?= $activeMatches ?>">0</span> / <?= number_format($totalMatches) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">البطولات الجارية</div>
<div class="stat-value" data-count="<?= $activeTournaments ?>">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon red">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">البلاغات المعلقة</div>
<div class="stat-value" data-count="<?= $pendingReports ?>">0</div>
</div>
</div>
</div>
<!-- Service Health -->
<div class="grid grid-2 mb-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">حالة الخدمات</h3>
</div>
<div class="flex flex-col gap-3">
<div class="health-item">
<div class="health-dot <?= $supabaseHealth['online'] ? 'up' : 'down' ?>"></div>
<span class="health-name">Supabase REST</span>
<span class="health-latency"><?= $supabaseHealth['latency_ms'] ?>ms</span>
</div>
<div class="health-item">
<div class="health-dot <?= $stockfishHealth['online'] ? 'up' : 'down' ?>"></div>
<span class="health-name">Stockfish API</span>
<span class="health-latency"><?= $stockfishHealth['latency_ms'] ?>ms</span>
</div>
<div class="health-item">
<div class="health-dot <?= $swissHealth['online'] ? 'up' : 'down' ?>"></div>
<span class="health-name">Swiss API</span>
<span class="health-latency"><?= $swissHealth['latency_ms'] ?>ms</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">اختصارات سريعة</h3>
</div>
<div class="grid grid-2 gap-3">
<a href="/tournaments/create" class="btn btn-ghost w-full">إنشاء بطولة</a>
<a href="/moderation" class="btn btn-ghost w-full">مراجعة البلاغات</a>
<a href="/games" class="btn btn-ghost w-full">إدارة الألعاب</a>
<a href="/analytics" class="btn btn-ghost w-full">التحليلات</a>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card">
<div class="card-header">
<h3 class="card-title">آخر النشاطات</h3>
<a href="/audit-log" class="btn btn-ghost btn-sm">عرض الكل</a>
</div>
<?php if (empty($recentActivity)): ?>
<div class="empty-state" style="padding: var(--space-6);">
<p class="text-secondary">لا توجد نشاطات بعد</p>
</div>
<?php else: ?>
<div class="data-table-wrapper" style="border: none;">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>المستخدم</th>
<th>الإجراء</th>
<th>النوع</th>
<th>المعرف</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentActivity as $activity): ?>
<tr>
<td class="text-muted text-xs tabular-nums"><?= date('Y-m-d H:i', strtotime($activity['created_at'])) ?></td>
<td><?= View::e($activity['actor']) ?></td>
<td><span class="badge badge-info"><?= View::e($activity['action']) ?></span></td>
<td><?= View::e($activity['entity_type']) ?></td>
<td class="text-xs text-muted truncate" style="max-width: 120px;"><?= View::e($activity['entity_id'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
/* Economy module styles */
function showEconomyGrant() {
openModal('منح عملات', `
<form method="POST" action="/economy/grant" id="economyGrantForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<div class="form-group">
<label class="form-label">معرف اللاعب (UUID)</label>
<input type="text" name="user_id" class="form-input" required dir="ltr" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div>
<div class="form-group">
<label class="form-label">العملة</label>
<select name="currency" class="form-select">
<option value="coins">عملات</option>
<option value="gems">جواهر</option>
</select>
</div>
<div class="form-group">
<label class="form-label">المبلغ</label>
<input type="number" name="amount" class="form-input" min="1" max="1000000" required>
</div>
<div class="form-group">
<label class="form-label">السبب</label>
<input type="text" name="reason" class="form-input" placeholder="سبب المنح">
</div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-success" onclick="document.getElementById('economyGrantForm').submit()">منح</button>
`);
}
<?php
class EconomyController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function index(array $params, string $method): void
{
$totalCoins = 0;
$totalGems = 0;
$stats = $this->db->select('profiles', ['select' => 'coins,gems']);
foreach ($stats as $p) {
$totalCoins += ($p['coins'] ?? 0);
$totalGems += ($p['gems'] ?? 0);
}
$todayEarned = $this->db->count('transactions', [
'type' => 'eq.earn',
'created_at' => 'gte.' . date('Y-m-d') . 'T00:00:00',
]);
$todaySpent = $this->db->count('transactions', [
'type' => 'eq.spend',
'created_at' => 'gte.' . date('Y-m-d') . 'T00:00:00',
]);
$recentTransactions = $this->db->select('transactions', [
'select' => '*',
'order' => 'created_at.desc',
'limit' => 20,
]);
$pageTitle = 'الاقتصاد';
$moduleCSS = 'economy';
$moduleJS = 'economy';
View::render('economy/index', compact('totalCoins', 'totalGems', 'todayEarned', 'todaySpent', 'recentTransactions', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function transactions(array $params, string $method): void
{
$queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if (!empty($_GET['currency'])) {
$queryParams['currency'] = 'eq.' . $_GET['currency'];
}
if (!empty($_GET['type'])) {
$queryParams['type'] = 'eq.' . $_GET['type'];
}
if (!empty($_GET['user_id'])) {
$queryParams['user_id'] = 'eq.' . $_GET['user_id'];
}
$total = $this->db->count('transactions', array_diff_key($queryParams, ['select' => 1, 'order' => 1]));
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$transactions = $this->db->select('transactions', $queryParams);
$pageTitle = 'المعاملات';
$moduleCSS = 'economy';
View::render('economy/transactions', compact('transactions', 'pagination', 'pageTitle', 'moduleCSS'));
}
public function grant(array $params, string $method): void
{
Auth::requireCsrf();
$userId = $_POST['user_id'] ?? '';
$currency = $_POST['currency'] ?? 'coins';
$amount = (int)($_POST['amount'] ?? 0);
if (empty($userId) || $amount <= 0) {
Response::error('بيانات غير صحيحة', '/economy');
return;
}
if ($amount > 1000000) {
Response::error('مبلغ كبير جداً - الحد الأقصى 1,000,000', '/economy');
return;
}
$player = $this->db->selectOne('profiles', ['id' => "eq.{$userId}"]);
if (!$player) {
Response::error('اللاعب غير موجود', '/economy');
return;
}
$newBalance = ($player[$currency] ?? 0) + $amount;
$this->db->update('profiles', ['id' => "eq.{$userId}"], [$currency => $newBalance]);
$this->db->insert('transactions', [
'user_id' => $userId,
'type' => 'admin_grant',
'currency' => $currency,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => trim($_POST['reason'] ?? 'منح بواسطة الإدارة'),
'created_by' => Auth::user()['username'],
]);
AuditLog::log('grant', 'economy', $userId, null, ['currency' => $currency, 'amount' => $amount]);
Response::success("تم منح {$amount} {$currency} بنجاح", '/economy');
}
public function revoke(array $params, string $method): void
{
Auth::requireCsrf();
$userId = $_POST['user_id'] ?? '';
$currency = $_POST['currency'] ?? 'coins';
$amount = (int)($_POST['amount'] ?? 0);
if (empty($userId) || $amount <= 0) {
Response::error('بيانات غير صحيحة', '/economy');
return;
}
$player = $this->db->selectOne('profiles', ['id' => "eq.{$userId}"]);
if (!$player) {
Response::error('اللاعب غير موجود', '/economy');
return;
}
$currentBalance = $player[$currency] ?? 0;
if ($amount > $currentBalance) {
Response::error('الرصيد غير كاف', '/economy');
return;
}
$newBalance = $currentBalance - $amount;
$this->db->update('profiles', ['id' => "eq.{$userId}"], [$currency => $newBalance]);
$this->db->insert('transactions', [
'user_id' => $userId,
'type' => 'admin_revoke',
'currency' => $currency,
'amount' => -$amount,
'balance_after' => $newBalance,
'description' => trim($_POST['reason'] ?? 'سحب بواسطة الإدارة'),
'created_by' => Auth::user()['username'],
]);
AuditLog::log('revoke', 'economy', $userId, null, ['currency' => $currency, 'amount' => $amount]);
Response::success("تم سحب {$amount} {$currency}", '/economy');
}
public function bulkGrant(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('admin');
$userIds = array_filter(explode(',', $_POST['user_ids'] ?? ''));
$currency = $_POST['currency'] ?? 'coins';
$amount = (int)($_POST['amount'] ?? 0);
if (empty($userIds) || $amount <= 0) {
Response::error('بيانات غير صحيحة', '/economy');
return;
}
$count = 0;
foreach ($userIds as $userId) {
$player = $this->db->selectOne('profiles', ['id' => "eq.{$userId}"]);
if (!$player) continue;
$newBalance = ($player[$currency] ?? 0) + $amount;
$this->db->update('profiles', ['id' => "eq.{$userId}"], [$currency => $newBalance]);
$this->db->insert('transactions', [
'user_id' => $userId,
'type' => 'admin_grant',
'currency' => $currency,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => 'منح جماعي',
'created_by' => Auth::user()['username'],
]);
$count++;
}
AuditLog::log('bulk_grant', 'economy', null, null, ['count' => $count, 'amount' => $amount, 'currency' => $currency]);
Response::success("تم منح {$amount} {$currency} لـ {$count} لاعب", '/economy');
}
}
<div class="content-header">
<h1>الاقتصاد</h1>
<div class="flex gap-3">
<button class="btn btn-success" onclick="showEconomyGrant()">منح عملات</button>
<a href="/economy/transactions" class="btn btn-ghost">كل المعاملات</a>
</div>
</div>
<div class="grid grid-4 stagger mb-6">
<div class="stat-card">
<div class="stat-icon gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">إجمالي العملات</div>
<div class="stat-value tabular-nums" data-count="<?= $totalCoins ?>"><?= number_format($totalCoins) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon purple">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9z"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">إجمالي الجواهر</div>
<div class="stat-value tabular-nums" data-count="<?= $totalGems ?>"><?= number_format($totalGems) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">مكتسبات اليوم</div>
<div class="stat-value tabular-nums"><?= number_format($todayEarned) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon red">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">مصروفات اليوم</div>
<div class="stat-value tabular-nums"><?= number_format($todaySpent) ?></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">آخر المعاملات</h3>
<a href="/economy/transactions" class="btn btn-ghost btn-sm">عرض الكل</a>
</div>
<?php if (empty($recentTransactions)): ?>
<div class="empty-state" style="padding: var(--space-6);">
<p class="text-secondary">لا توجد معاملات</p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>اللاعب</th>
<th>النوع</th>
<th>العملة</th>
<th>المبلغ</th>
<th>الرصيد بعد</th>
<th>الوصف</th>
<th>التاريخ</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentTransactions as $tx): ?>
<tr>
<td class="text-xs"><?= View::e(substr($tx['user_id'], 0, 8)) ?>...</td>
<td><span class="badge badge-<?= in_array($tx['type'], ['earn', 'admin_grant', 'tournament_prize']) ? 'success' : 'danger' ?>"><?= View::e($tx['type']) ?></span></td>
<td><?= $tx['currency'] === 'coins' ? 'عملات' : 'جواهر' ?></td>
<td class="tabular-nums <?= $tx['amount'] > 0 ? 'text-success' : 'text-danger' ?>"><?= $tx['amount'] > 0 ? '+' : '' ?><?= number_format($tx['amount']) ?></td>
<td class="tabular-nums"><?= number_format($tx['balance_after']) ?></td>
<td class="text-xs text-muted truncate" style="max-width: 150px;"><?= View::e($tx['description'] ?? '-') ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($tx['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/economy" class="btn btn-icon btn-ghost"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></a>
<h1>المعاملات</h1>
</div>
</div>
<div class="data-table-wrapper">
<div class="table-toolbar">
<div class="flex gap-3">
<select class="form-select" style="width: auto;" onchange="filterTransactions('currency', this.value)">
<option value="">كل العملات</option>
<option value="coins" <?= ($_GET['currency'] ?? '') === 'coins' ? 'selected' : '' ?>>عملات</option>
<option value="gems" <?= ($_GET['currency'] ?? '') === 'gems' ? 'selected' : '' ?>>جواهر</option>
</select>
<select class="form-select" style="width: auto;" onchange="filterTransactions('type', this.value)">
<option value="">كل الأنواع</option>
<option value="earn" <?= ($_GET['type'] ?? '') === 'earn' ? 'selected' : '' ?>>مكتسب</option>
<option value="spend" <?= ($_GET['type'] ?? '') === 'spend' ? 'selected' : '' ?>>مصروف</option>
<option value="admin_grant" <?= ($_GET['type'] ?? '') === 'admin_grant' ? 'selected' : '' ?>>منح إداري</option>
<option value="admin_revoke" <?= ($_GET['type'] ?? '') === 'admin_revoke' ? 'selected' : '' ?>>سحب إداري</option>
<option value="tournament_prize" <?= ($_GET['type'] ?? '') === 'tournament_prize' ? 'selected' : '' ?>>جائزة بطولة</option>
</select>
</div>
</div>
<?php if (empty($transactions)): ?>
<div class="empty-state"><h3 class="empty-state-title">لا توجد معاملات</h3></div>
<?php else: ?>
<table class="data-table">
<thead><tr><th>اللاعب</th><th>النوع</th><th>العملة</th><th>المبلغ</th><th>الرصيد بعد</th><th>الوصف</th><th>التاريخ</th></tr></thead>
<tbody>
<?php foreach ($transactions as $tx): ?>
<tr>
<td><a href="/players/<?= $tx['user_id'] ?>" class="text-blue text-xs"><?= substr($tx['user_id'], 0, 8) ?>...</a></td>
<td><span class="badge badge-<?= in_array($tx['type'], ['earn', 'admin_grant', 'tournament_prize']) ? 'success' : 'danger' ?>"><?= View::e($tx['type']) ?></span></td>
<td><?= $tx['currency'] === 'coins' ? 'عملات' : 'جواهر' ?></td>
<td class="tabular-nums <?= $tx['amount'] > 0 ? 'text-success' : 'text-danger' ?>"><?= $tx['amount'] > 0 ? '+' : '' ?><?= number_format($tx['amount']) ?></td>
<td class="tabular-nums"><?= number_format($tx['balance_after']) ?></td>
<td class="text-xs text-muted truncate" style="max-width: 150px;"><?= View::e($tx['description'] ?? '-') ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($tx['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&currency=<?= $_GET['currency'] ?? '' ?>&type=<?= $_GET['type'] ?? '' ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<script>
function filterTransactions(key, value) {
const url = new URL(window.location);
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
url.searchParams.set('page', '1');
window.location.href = url.toString();
}
</script>
<?php
class FeatureFlagsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$category = $_GET['category'] ?? '';
$queryParams = ['select' => '*', 'order' => 'category.asc,id.asc'];
if ($category) $queryParams['category'] = "eq.{$category}";
$flags = $this->db->select('feature_flags', $queryParams);
$categories = array_unique(array_column($flags, 'category'));
$pageTitle = 'الميزات';
$moduleCSS = 'feature-flags';
$moduleJS = 'feature-flags';
View::render('feature-flags/list', compact('flags', 'categories', 'category', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$flag = [];
$pageTitle = 'إضافة ميزة';
$moduleCSS = 'feature-flags';
View::render('feature-flags/form', compact('flag', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('id', 'المعرف')
->required('label', 'التسمية')
->required('label_ar', 'التسمية بالعربية');
if ($validator->fails()) {
Response::error($validator->firstError(), '/feature-flags/create');
return;
}
$data = [
'id' => strtolower(str_replace(' ', '_', trim($_POST['id']))),
'label' => trim($_POST['label']),
'label_ar' => trim($_POST['label_ar']),
'description' => trim($_POST['description'] ?? ''),
'is_enabled' => isset($_POST['is_enabled']),
'target' => $_POST['target'] ?? 'all',
'category' => trim($_POST['category'] ?? 'general'),
];
if ($_POST['target'] === 'percentage') {
$data['target_value'] = json_encode(['percentage' => (int)($_POST['percentage'] ?? 0)]);
}
$this->db->insert('feature_flags', $data);
AuditLog::log('create', 'feature_flag', $data['id'], null, $data);
Response::success('تم إضافة الميزة', '/feature-flags');
}
public function edit(array $params, string $method): void
{
$flag = $this->db->selectOne('feature_flags', ['id' => "eq.{$params['id']}"]);
if (!$flag) { Response::error('الميزة غير موجودة', '/feature-flags'); return; }
$pageTitle = 'تعديل الميزة';
$moduleCSS = 'feature-flags';
View::render('feature-flags/form', compact('flag', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$data = [
'label' => trim($_POST['label']),
'label_ar' => trim($_POST['label_ar']),
'description' => trim($_POST['description'] ?? ''),
'target' => $_POST['target'] ?? 'all',
'category' => trim($_POST['category'] ?? 'general'),
'updated_at' => date('c'),
];
if ($_POST['target'] === 'percentage') {
$data['target_value'] = json_encode(['percentage' => (int)($_POST['percentage'] ?? 0)]);
}
$this->db->update('feature_flags', ['id' => "eq.{$id}"], $data);
AuditLog::log('update', 'feature_flag', $id);
Response::success('تم تحديث الميزة', '/feature-flags');
}
public function toggle(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$flag = $this->db->selectOne('feature_flags', ['id' => "eq.{$id}"]);
if (!$flag) { Response::error('الميزة غير موجودة', '/feature-flags'); return; }
$newState = !$flag['is_enabled'];
$this->db->update('feature_flags', ['id' => "eq.{$id}"], ['is_enabled' => $newState, 'updated_at' => date('c')]);
AuditLog::log('toggle', 'feature_flag', $id, null, ['is_enabled' => $newState]);
Response::success($newState ? 'تم تفعيل الميزة' : 'تم تعطيل الميزة', '/feature-flags');
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
$this->db->delete('feature_flags', ['id' => "eq.{$params['id']}"]);
AuditLog::log('delete', 'feature_flag', $params['id']);
Response::success('تم حذف الميزة', '/feature-flags');
}
}
<?php $isEdit = !empty($flag['id']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/feature-flags" class="btn btn-icon btn-ghost"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></a>
<h1><?= $isEdit ? 'تعديل الميزة' : 'إضافة ميزة' ?></h1>
</div>
</div>
<div class="card max-w-md">
<form method="POST" action="<?= $isEdit ? "/feature-flags/{$flag['id']}/update" : '/feature-flags/store' ?>" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<?php if (!$isEdit): ?>
<div class="form-group"><label class="form-label">المعرف * (حروف صغيرة، _ للفصل)</label><input type="text" name="id" class="form-input" required dir="ltr" placeholder="enable_new_matchmaking"><span class="form-error"></span></div>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group"><label class="form-label">التسمية (English) *</label><input type="text" name="label" class="form-input" value="<?= View::e($flag['label'] ?? '') ?>" required dir="ltr"><span class="form-error"></span></div>
<div class="form-group"><label class="form-label">التسمية (عربي) *</label><input type="text" name="label_ar" class="form-input" value="<?= View::e($flag['label_ar'] ?? '') ?>" required></div>
</div>
<div class="form-group"><label class="form-label">الوصف</label><textarea name="description" class="form-input"><?= View::e($flag['description'] ?? '') ?></textarea></div>
<div class="form-group"><label class="form-label">الفئة</label><input type="text" name="category" class="form-input" value="<?= View::e($flag['category'] ?? 'general') ?>" placeholder="general"></div>
<div class="form-group">
<label class="form-label">الاستهداف</label>
<select name="target" class="form-select" id="targetSelect" onchange="togglePercentage()">
<option value="all" <?= ($flag['target'] ?? 'all') === 'all' ? 'selected' : '' ?>>الجميع</option>
<option value="percentage" <?= ($flag['target'] ?? '') === 'percentage' ? 'selected' : '' ?>>نسبة مئوية</option>
<option value="user_list" <?= ($flag['target'] ?? '') === 'user_list' ? 'selected' : '' ?>>مستخدمون محددون</option>
<option value="org_list" <?= ($flag['target'] ?? '') === 'org_list' ? 'selected' : '' ?>>منظمات محددة</option>
</select>
</div>
<div class="form-group hidden" id="percentageGroup">
<label class="form-label">النسبة %</label>
<input type="number" name="percentage" class="form-input" min="0" max="100" value="<?php $tv = json_decode($flag['target_value'] ?? '{}', true); echo $tv['percentage'] ?? 50; ?>">
</div>
<?php if (!$isEdit): ?>
<label class="toggle mb-5"><input type="checkbox" name="is_enabled"><span class="toggle-track"></span><span>مفعّلة عند الإنشاء</span></label>
<?php endif; ?>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'حفظ' : 'إنشاء' ?></button>
<a href="/feature-flags" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<script>
function togglePercentage() {
document.getElementById('percentageGroup').classList.toggle('hidden', document.getElementById('targetSelect').value !== 'percentage');
}
togglePercentage();
</script>
<div class="content-header">
<h1>الميزات</h1>
<a href="/feature-flags/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة ميزة
</a>
</div>
<?php if (!empty($categories)): ?>
<div class="filter-pills mb-5">
<a href="/feature-flags" class="filter-pill <?= empty($category) ? 'active' : '' ?>">الكل</a>
<?php foreach ($categories as $cat): ?>
<a href="/feature-flags?category=<?= urlencode($cat) ?>" class="filter-pill <?= $category === $cat ? 'active' : '' ?>"><?= View::e($cat) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (empty($flags)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد ميزات</h3><p class="empty-state-text">لم يتم إضافة أي ميزات بعد</p></div></div>
<?php else: ?>
<div class="grid grid-2 stagger">
<?php foreach ($flags as $flag): ?>
<div class="card">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold"><?= View::e($flag['label_ar']) ?></h3>
<span class="badge badge-default"><?= View::e($flag['category']) ?></span>
</div>
<p class="text-xs text-muted mb-1" dir="ltr"><?= View::e($flag['id']) ?></p>
<?php if ($flag['description']): ?>
<p class="text-sm text-secondary mt-2"><?= View::e($flag['description']) ?></p>
<?php endif; ?>
<div class="flex items-center gap-3 mt-3">
<span class="text-xs text-muted">
<?php
$targets = ['all' => 'الجميع', 'percentage' => 'نسبة مئوية', 'user_list' => 'مستخدمون محددون', 'org_list' => 'منظمات محددة'];
echo $targets[$flag['target']] ?? $flag['target'];
?>
</span>
<?php if ($flag['target'] === 'percentage'): ?>
<?php $tv = json_decode($flag['target_value'] ?? '{}', true); ?>
<span class="badge badge-info"><?= $tv['percentage'] ?? 0 ?>%</span>
<?php endif; ?>
</div>
</div>
<form method="POST" action="/feature-flags/<?= View::e($flag['id']) ?>/toggle">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<label class="toggle">
<input type="checkbox" <?= $flag['is_enabled'] ? 'checked' : '' ?> onchange="this.closest('form').submit()">
<span class="toggle-track"></span>
</label>
</form>
</div>
<div class="flex gap-2 mt-4 pt-3" style="border-top: 1px solid var(--border);">
<a href="/feature-flags/<?= View::e($flag['id']) ?>/edit" class="btn btn-ghost btn-sm">تعديل</a>
<button class="btn btn-ghost btn-sm" style="color: var(--danger);" onclick="confirmDelete('/feature-flags/<?= View::e($flag['id']) ?>/delete', '<?= View::e($flag['label_ar']) ?>')">حذف</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
/* Games module styles */
// Games module JS - toggle is handled inline
<?php
class GamesController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$games = $this->db->select('game_plugins', [
'select' => '*',
'order' => 'sort_order.asc,name.asc',
]);
$pageTitle = 'الألعاب';
$moduleCSS = 'games';
$moduleJS = 'games';
View::render('games/list', compact('games', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$game = [];
$pageTitle = 'إضافة لعبة';
$moduleCSS = 'games';
View::render('games/form', compact('game', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('game_key', 'مفتاح اللعبة')
->required('name', 'الاسم بالإنجليزية')
->required('name_ar', 'الاسم بالعربية');
if ($validator->fails()) {
Response::error($validator->firstError(), '/games/create');
return;
}
$data = [
'game_key' => strtolower(trim($_POST['game_key'])),
'name' => trim($_POST['name']),
'name_ar' => trim($_POST['name_ar']),
'description' => trim($_POST['description'] ?? ''),
'description_ar' => trim($_POST['description_ar'] ?? ''),
'icon' => trim($_POST['icon'] ?? ''),
'min_players' => (int)($_POST['min_players'] ?? 2),
'max_players' => (int)($_POST['max_players'] ?? 2),
'supports_ranked' => isset($_POST['supports_ranked']),
'supports_tournament' => isset($_POST['supports_tournament']),
'is_enabled' => isset($_POST['is_enabled']),
'sort_order' => (int)($_POST['sort_order'] ?? 0),
];
if (!empty($_POST['matchmaking_config'])) {
$data['matchmaking_config'] = $_POST['matchmaking_config'];
}
$this->db->insert('game_plugins', $data);
AuditLog::log('create', 'game', $data['game_key'], null, $data);
Response::success('تم إضافة اللعبة بنجاح', '/games');
}
public function edit(array $params, string $method): void
{
$id = $params['id'];
$game = $this->db->selectOne('game_plugins', ['game_key' => "eq.{$id}"]);
if (!$game) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'تعديل اللعبة';
$moduleCSS = 'games';
View::render('games/form', compact('game', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('game_plugins', ['game_key' => "eq.{$id}"]);
if (!$old) {
Response::error('اللعبة غير موجودة', '/games');
return;
}
$data = [
'name' => trim($_POST['name']),
'name_ar' => trim($_POST['name_ar']),
'description' => trim($_POST['description'] ?? ''),
'description_ar' => trim($_POST['description_ar'] ?? ''),
'icon' => trim($_POST['icon'] ?? ''),
'min_players' => (int)($_POST['min_players'] ?? 2),
'max_players' => (int)($_POST['max_players'] ?? 2),
'supports_ranked' => isset($_POST['supports_ranked']),
'supports_tournament' => isset($_POST['supports_tournament']),
'sort_order' => (int)($_POST['sort_order'] ?? 0),
'updated_at' => date('c'),
];
if (!empty($_POST['matchmaking_config'])) {
$data['matchmaking_config'] = $_POST['matchmaking_config'];
}
$this->db->update('game_plugins', ['game_key' => "eq.{$id}"], $data);
AuditLog::log('update', 'game', $id, $old, $data);
Response::success('تم تحديث اللعبة', '/games');
}
public function toggle(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$game = $this->db->selectOne('game_plugins', ['game_key' => "eq.{$id}"]);
if (!$game) {
Response::error('اللعبة غير موجودة', '/games');
return;
}
$newState = !$game['is_enabled'];
$this->db->update('game_plugins', ['game_key' => "eq.{$id}"], ['is_enabled' => $newState]);
AuditLog::log('toggle', 'game', $id, null, ['is_enabled' => $newState]);
Response::success($newState ? 'تم تفعيل اللعبة' : 'تم تعطيل اللعبة', '/games');
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('admin');
$id = $params['id'];
$matchCount = $this->db->count('matches', ['game_key' => "eq.{$id}"]);
if ($matchCount > 0) {
$this->db->update('game_plugins', ['game_key' => "eq.{$id}"], ['is_enabled' => false]);
Response::success('تم تعطيل اللعبة (لا يمكن حذفها لوجود مباريات)', '/games');
return;
}
$this->db->delete('game_plugins', ['game_key' => "eq.{$id}"]);
AuditLog::log('delete', 'game', $id);
Response::success('تم حذف اللعبة', '/games');
}
}
<?php $isEdit = !empty($game['game_key']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/games" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= $isEdit ? 'تعديل اللعبة' : 'إضافة لعبة' ?></h1>
</div>
</div>
<div class="card max-w-lg">
<form method="POST" action="<?= $isEdit ? "/games/{$game['game_key']}/update" : '/games/store' ?>" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<?php if (!$isEdit): ?>
<div class="form-group">
<label class="form-label">مفتاح اللعبة * (حروف صغيرة، بدون مسافات)</label>
<input type="text" name="game_key" class="form-input" required pattern="[a-z0-9_-]+" placeholder="chess" dir="ltr">
<span class="form-error"></span>
</div>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الاسم (English) *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($game['name'] ?? '') ?>" required dir="ltr">
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">الاسم (عربي) *</label>
<input type="text" name="name_ar" class="form-input" value="<?= View::e($game['name_ar'] ?? '') ?>" required>
<span class="form-error"></span>
</div>
</div>
<div class="form-group">
<label class="form-label">الوصف (English)</label>
<textarea name="description" class="form-input" dir="ltr"><?= View::e($game['description'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">الوصف (عربي)</label>
<textarea name="description_ar" class="form-input"><?= View::e($game['description_ar'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">أيقونة (emoji أو نص)</label>
<input type="text" name="icon" class="form-input" value="<?= View::e($game['icon'] ?? '') ?>" placeholder="♟️">
</div>
<div class="grid grid-3 gap-4">
<div class="form-group">
<label class="form-label">أقل عدد لاعبين</label>
<input type="number" name="min_players" class="form-input" value="<?= $game['min_players'] ?? 2 ?>" min="1" max="100">
</div>
<div class="form-group">
<label class="form-label">أكثر عدد لاعبين</label>
<input type="number" name="max_players" class="form-input" value="<?= $game['max_players'] ?? 2 ?>" min="1" max="100">
</div>
<div class="form-group">
<label class="form-label">الترتيب</label>
<input type="number" name="sort_order" class="form-input" value="<?= $game['sort_order'] ?? 0 ?>">
</div>
</div>
<div class="flex gap-6 mb-5">
<label class="toggle">
<input type="checkbox" name="supports_ranked" <?= ($game['supports_ranked'] ?? true) ? 'checked' : '' ?>>
<span class="toggle-track"></span>
<span>يدعم التصنيف</span>
</label>
<label class="toggle">
<input type="checkbox" name="supports_tournament" <?= ($game['supports_tournament'] ?? true) ? 'checked' : '' ?>>
<span class="toggle-track"></span>
<span>يدعم البطولات</span>
</label>
<?php if (!$isEdit): ?>
<label class="toggle">
<input type="checkbox" name="is_enabled" checked>
<span class="toggle-track"></span>
<span>مفعّلة</span>
</label>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">إعدادات التوفيق (JSON)</label>
<textarea name="matchmaking_config" class="form-input" dir="ltr" style="font-family: monospace; min-height: 120px;"><?= View::e(is_string($game['matchmaking_config'] ?? '') ? ($game['matchmaking_config'] ?? '') : json_encode($game['matchmaking_config'] ?? new stdClass(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></textarea>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<span class="btn-text"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء اللعبة' ?></span>
<span class="btn-spinner"></span>
</button>
<a href="/games" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<div class="content-header">
<h1>الألعاب</h1>
<a href="/games/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة لعبة
</a>
</div>
<?php if (empty($games)): ?>
<div class="card">
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg>
<h3 class="empty-state-title">لا توجد ألعاب</h3>
<p class="empty-state-text">لم يتم إضافة أي ألعاب بعد</p>
<a href="/games/create" class="btn btn-primary">إضافة لعبة</a>
</div>
</div>
<?php else: ?>
<div class="grid grid-3 stagger">
<?php foreach ($games as $game): ?>
<div class="card card-hover">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="stat-icon blue" style="width: 40px; height: 40px;">
<?php if (!empty($game['icon'])): ?>
<span style="font-size: 20px;"><?= $game['icon'] ?></span>
<?php else: ?>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/></svg>
<?php endif; ?>
</div>
<div>
<h3 class="font-semibold"><?= View::e($game['name_ar']) ?></h3>
<p class="text-xs text-muted"><?= View::e($game['name']) ?></p>
</div>
</div>
<form method="POST" action="/games/<?= View::e($game['game_key']) ?>/toggle">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<label class="toggle">
<input type="checkbox" <?= $game['is_enabled'] ? 'checked' : '' ?> onchange="this.closest('form').submit()">
<span class="toggle-track"></span>
</label>
</form>
</div>
<div class="flex gap-4 text-xs text-secondary mb-4">
<span><?= $game['min_players'] ?>-<?= $game['max_players'] ?> لاعبين</span>
<?php if ($game['supports_ranked']): ?>
<span class="badge badge-info">مصنف</span>
<?php endif; ?>
<?php if ($game['supports_tournament']): ?>
<span class="badge badge-purple">بطولات</span>
<?php endif; ?>
</div>
<div class="flex items-center justify-between">
<span class="badge <?= $game['is_enabled'] ? 'badge-success' : 'badge-default' ?> badge-dot">
<?= $game['is_enabled'] ? 'مفعّلة' : 'معطّلة' ?>
</span>
<div class="flex gap-2">
<a href="/games/<?= View::e($game['game_key']) ?>/edit" class="btn btn-ghost btn-sm">تعديل</a>
<button class="btn btn-ghost btn-sm" style="color: var(--danger);" onclick="confirmDelete('/games/<?= View::e($game['game_key']) ?>/delete', '<?= View::e($game['name_ar']) ?>')">حذف</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
/* Moderation module styles */
function banFromReport(playerId, username) {
openModal('حظر اللاعب', `
<form method="POST" action="/players/${playerId}/ban" id="banFromReportForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<p class="mb-4">هل تريد حظر اللاعب <strong>${username}</strong>؟</p>
<div class="form-group">
<label class="form-label">سبب الحظر *</label>
<textarea name="ban_reason" class="form-input" required placeholder="بناء على البلاغ..."></textarea>
</div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-danger" onclick="document.getElementById('banFromReportForm').submit()">حظر</button>
`);
}
<?php
class ModerationController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$status = $_GET['status'] ?? '';
$queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if ($status) {
$queryParams['status'] = "eq.{$status}";
}
$countParams = array_diff_key($queryParams, ['select' => 1, 'order' => 1]);
$total = $this->db->count('cheat_reports', $countParams);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$reports = $this->db->select('cheat_reports', $queryParams);
$pendingCount = $this->db->count('cheat_reports', ['status' => 'eq.pending']);
$reviewingCount = $this->db->count('cheat_reports', ['status' => 'eq.reviewing']);
$pageTitle = 'الإشراف والبلاغات';
$moduleCSS = 'moderation';
$moduleJS = 'moderation';
View::render('moderation/list', compact('reports', 'pagination', 'status', 'pendingCount', 'reviewingCount', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function show(array $params, string $method): void
{
$id = $params['id'];
$report = $this->db->selectOne('cheat_reports', ['id' => "eq.{$id}"]);
if (!$report) {
http_response_code(404);
View::render('errors/404');
return;
}
$reported = $this->db->selectOne('profiles', ['id' => "eq.{$report['reported_id']}"]);
$reporter = $report['reporter_id'] ? $this->db->selectOne('profiles', ['id' => "eq.{$report['reporter_id']}"]) : null;
if ($report['status'] === 'pending') {
$this->db->update('cheat_reports', ['id' => "eq.{$id}"], ['status' => 'reviewing']);
}
$pageTitle = 'تفاصيل البلاغ';
$moduleCSS = 'moderation';
$moduleJS = 'moderation';
View::render('moderation/show', compact('report', 'reported', 'reporter', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function resolve(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$resolution = trim($_POST['resolution'] ?? '');
if (empty($resolution)) {
Response::error('يجب كتابة الإجراء المتخذ', "/moderation/{$id}");
return;
}
$this->db->update('cheat_reports', ['id' => "eq.{$id}"], [
'status' => 'resolved',
'resolution' => $resolution,
'resolved_by' => Auth::user()['username'],
'resolved_at' => date('c'),
]);
AuditLog::log('resolve', 'report', $id, null, ['resolution' => $resolution]);
Response::success('تم حل البلاغ', '/moderation');
}
public function dismiss(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$resolution = trim($_POST['resolution'] ?? 'تم الرفض');
$this->db->update('cheat_reports', ['id' => "eq.{$id}"], [
'status' => 'dismissed',
'resolution' => $resolution,
'resolved_by' => Auth::user()['username'],
'resolved_at' => date('c'),
]);
AuditLog::log('dismiss', 'report', $id);
Response::success('تم رفض البلاغ', '/moderation');
}
public function bulkDismiss(array $params, string $method): void
{
Auth::requireCsrf();
$ids = array_filter(explode(',', $_POST['ids'] ?? ''));
$reason = trim($_POST['resolution'] ?? 'رفض جماعي');
foreach ($ids as $id) {
$this->db->update('cheat_reports', ['id' => "eq.{$id}"], [
'status' => 'dismissed',
'resolution' => $reason,
'resolved_by' => Auth::user()['username'],
'resolved_at' => date('c'),
]);
}
AuditLog::log('bulk_dismiss', 'report', null, null, ['count' => count($ids)]);
Response::success('تم رفض ' . count($ids) . ' بلاغ', '/moderation');
}
}
<div class="content-header">
<h1>الإشراف والبلاغات</h1>
<div class="flex gap-3">
<span class="badge badge-warning"><?= $pendingCount ?> معلق</span>
<span class="badge badge-info"><?= $reviewingCount ?> قيد المراجعة</span>
</div>
</div>
<div class="filter-pills mb-5">
<a href="/moderation" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<a href="/moderation?status=pending" class="filter-pill <?= $status === 'pending' ? 'active' : '' ?>">معلق</a>
<a href="/moderation?status=reviewing" class="filter-pill <?= $status === 'reviewing' ? 'active' : '' ?>">قيد المراجعة</a>
<a href="/moderation?status=resolved" class="filter-pill <?= $status === 'resolved' ? 'active' : '' ?>">تم الحل</a>
<a href="/moderation?status=dismissed" class="filter-pill <?= $status === 'dismissed' ? 'active' : '' ?>">مرفوض</a>
</div>
<div class="data-table-wrapper">
<?php if (empty($reports)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<h3 class="empty-state-title">لا توجد بلاغات</h3>
<p class="empty-state-text">لم يتم تقديم أي بلاغات<?= $status ? ' بهذه الحالة' : '' ?></p>
</div>
<?php else: ?>
<table class="data-table" id="reportsTable">
<thead>
<tr>
<th style="width: 40px;"><input type="checkbox" class="check-all"></th>
<th>المبلغ عنه</th>
<th>السبب</th>
<th>الحالة</th>
<th>التاريخ</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($reports as $report): ?>
<tr>
<td><input type="checkbox" class="row-check" value="<?= $report['id'] ?>"></td>
<td class="text-xs"><?= View::e(substr($report['reported_id'], 0, 8)) ?>...</td>
<td>
<?php
$reasons = ['cheating' => 'غش', 'harassment' => 'تحرش', 'inappropriate_name' => 'اسم غير لائق', 'spam' => 'إزعاج', 'other' => 'أخرى'];
?>
<span class="badge badge-danger"><?= $reasons[$report['reason']] ?? $report['reason'] ?></span>
</td>
<td>
<?php
$statusBadges = ['pending' => 'warning', 'reviewing' => 'info', 'resolved' => 'success', 'dismissed' => 'default'];
$statusLabels = ['pending' => 'معلق', 'reviewing' => 'قيد المراجعة', 'resolved' => 'تم الحل', 'dismissed' => 'مرفوض'];
?>
<span class="badge badge-<?= $statusBadges[$report['status']] ?? 'default' ?> badge-dot"><?= $statusLabels[$report['status']] ?? $report['status'] ?></span>
</td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($report['created_at'])) ?></td>
<td>
<a href="/moderation/<?= $report['id'] ?>" class="btn btn-ghost btn-sm">عرض</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&status=<?= $status ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/moderation" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1>تفاصيل البلاغ</h1>
<?php
$statusBadges = ['pending' => 'warning', 'reviewing' => 'info', 'resolved' => 'success', 'dismissed' => 'default'];
$statusLabels = ['pending' => 'معلق', 'reviewing' => 'قيد المراجعة', 'resolved' => 'تم الحل', 'dismissed' => 'مرفوض'];
?>
<span class="badge badge-<?= $statusBadges[$report['status']] ?? 'default' ?>"><?= $statusLabels[$report['status']] ?? $report['status'] ?></span>
</div>
</div>
<div class="grid grid-2 mb-6">
<div class="card">
<div class="card-header"><h3 class="card-title">معلومات البلاغ</h3></div>
<div class="flex flex-col gap-3 text-sm">
<div class="flex justify-between"><span class="text-secondary">السبب</span>
<?php $reasons = ['cheating' => 'غش', 'harassment' => 'تحرش', 'inappropriate_name' => 'اسم غير لائق', 'spam' => 'إزعاج', 'other' => 'أخرى']; ?>
<span class="badge badge-danger"><?= $reasons[$report['reason']] ?? $report['reason'] ?></span>
</div>
<div class="flex justify-between"><span class="text-secondary">تاريخ البلاغ</span><span class="tabular-nums"><?= date('Y-m-d H:i', strtotime($report['created_at'])) ?></span></div>
<?php if ($report['match_id']): ?>
<div class="flex justify-between"><span class="text-secondary">المباراة</span><span class="text-xs"><?= View::e(substr($report['match_id'], 0, 8)) ?>...</span></div>
<?php endif; ?>
<?php if ($report['description']): ?>
<div class="mt-3">
<span class="text-secondary text-xs">الوصف:</span>
<p class="mt-1"><?= View::e($report['description']) ?></p>
</div>
<?php endif; ?>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">الأطراف</h3></div>
<div class="flex flex-col gap-4">
<div>
<span class="text-xs text-muted">المبلغ عنه</span>
<div class="flex items-center gap-3 mt-2">
<div class="avatar"><?= $reported ? mb_substr($reported['username'], 0, 1) : '?' ?></div>
<div>
<div class="font-medium"><?= View::e($reported['display_name'] ?? $reported['username'] ?? 'غير معروف') ?></div>
<?php if ($reported): ?>
<a href="/players/<?= $reported['id'] ?>" class="text-xs text-blue">عرض الملف</a>
<?php endif; ?>
</div>
</div>
</div>
<?php if ($reporter): ?>
<div>
<span class="text-xs text-muted">المبلغ</span>
<div class="flex items-center gap-3 mt-2">
<div class="avatar"><?= mb_substr($reporter['username'], 0, 1) ?></div>
<div>
<div class="font-medium"><?= View::e($reporter['display_name'] ?? $reporter['username']) ?></div>
<a href="/players/<?= $reporter['id'] ?>" class="text-xs text-blue">عرض الملف</a>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php if (in_array($report['status'], ['pending', 'reviewing'])): ?>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3 class="card-title">حل البلاغ</h3></div>
<form method="POST" action="/moderation/<?= $report['id'] ?>/resolve">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group">
<label class="form-label">الإجراء المتخذ *</label>
<textarea name="resolution" class="form-input" required placeholder="اكتب الإجراء الذي تم اتخاذه..."></textarea>
</div>
<button type="submit" class="btn btn-success">حل البلاغ</button>
</form>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">إجراءات سريعة</h3></div>
<div class="flex flex-col gap-3">
<?php if ($reported && !($reported['is_banned'] ?? false)): ?>
<button class="btn btn-danger w-full" onclick="banFromReport('<?= $reported['id'] ?>', '<?= View::e($reported['username'] ?? '') ?>')">حظر اللاعب</button>
<?php endif; ?>
<form method="POST" action="/moderation/<?= $report['id'] ?>/dismiss">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="resolution" value="بلاغ غير مبرر">
<button type="submit" class="btn btn-ghost w-full">رفض البلاغ</button>
</form>
</div>
</div>
</div>
<?php else: ?>
<div class="card">
<div class="card-header"><h3 class="card-title">نتيجة البلاغ</h3></div>
<div class="text-sm">
<p><strong>القرار:</strong> <?= View::e($report['resolution'] ?? '-') ?></p>
<p class="text-muted mt-2">بواسطة <?= View::e($report['resolved_by'] ?? '-') ?> في <?= $report['resolved_at'] ? date('Y-m-d H:i', strtotime($report['resolved_at'])) : '-' ?></p>
</div>
</div>
<?php endif; ?>
function showSendNotif() {
openModal('إرسال إشعار', `
<form method="POST" action="/notifications/send" id="sendNotifForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<div class="form-group"><label class="form-label">معرف اللاعب *</label><input type="text" name="user_id" class="form-input" required dir="ltr"></div>
<div class="form-group"><label class="form-label">النوع *</label>
<select name="type" class="form-select">
<option value="system">نظام</option>
<option value="tournament">بطولة</option>
<option value="economy">اقتصاد</option>
<option value="moderation">إشراف</option>
<option value="social">اجتماعي</option>
</select>
</div>
<div class="form-group"><label class="form-label">العنوان (English)</label><input type="text" name="title" class="form-input" required dir="ltr"></div>
<div class="form-group"><label class="form-label">العنوان (عربي)</label><input type="text" name="title_ar" class="form-input"></div>
<div class="form-group"><label class="form-label">المحتوى</label><textarea name="body_ar" class="form-input"></textarea></div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-primary" onclick="document.getElementById('sendNotifForm').submit()">إرسال</button>
`);
}
function showBroadcast() {
openModal('إرسال للجميع', `
<form method="POST" action="/notifications/broadcast" id="broadcastForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<div class="form-group"><label class="form-label">النوع</label>
<select name="type" class="form-select">
<option value="system">نظام</option>
<option value="tournament">بطولة</option>
<option value="economy">اقتصاد</option>
</select>
</div>
<div class="form-group"><label class="form-label">العنوان *</label><input type="text" name="title" class="form-input" required dir="ltr"></div>
<div class="form-group"><label class="form-label">العنوان (عربي)</label><input type="text" name="title_ar" class="form-input"></div>
<div class="form-group"><label class="form-label">المحتوى (عربي)</label><textarea name="body_ar" class="form-input"></textarea></div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-primary" onclick="document.getElementById('broadcastForm').submit()">إرسال للجميع</button>
`);
}
<?php
class NotificationsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$queryParams = ['select' => '*', 'order' => 'created_at.desc'];
$total = $this->db->count('notifications', []);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$notifications = $this->db->select('notifications', $queryParams);
$pageTitle = 'الإشعارات';
$moduleCSS = 'notifications';
$moduleJS = 'notifications';
View::render('notifications/list', compact('notifications', 'pagination', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function send(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('user_id', 'معرف اللاعب')
->required('title', 'العنوان')
->required('type', 'النوع');
if ($validator->fails()) {
Response::error($validator->firstError(), '/notifications');
return;
}
$this->db->insert('notifications', [
'user_id' => $_POST['user_id'],
'type' => $_POST['type'],
'title' => trim($_POST['title']),
'title_ar' => trim($_POST['title_ar'] ?? ''),
'body' => trim($_POST['body'] ?? ''),
'body_ar' => trim($_POST['body_ar'] ?? ''),
'is_broadcast' => false,
]);
AuditLog::log('send', 'notification', $_POST['user_id']);
Response::success('تم إرسال الإشعار', '/notifications');
}
public function broadcast(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('admin');
$validator = Validator::make($_POST)
->required('title', 'العنوان')
->required('type', 'النوع');
if ($validator->fails()) {
Response::error($validator->firstError(), '/notifications');
return;
}
$this->db->insert('notifications', [
'user_id' => null,
'type' => $_POST['type'],
'title' => trim($_POST['title']),
'title_ar' => trim($_POST['title_ar'] ?? ''),
'body' => trim($_POST['body'] ?? ''),
'body_ar' => trim($_POST['body_ar'] ?? ''),
'is_broadcast' => true,
]);
AuditLog::log('broadcast', 'notification', null, null, ['title' => $_POST['title']]);
Response::success('تم إرسال الإشعار للجميع', '/notifications');
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
$this->db->delete('notifications', ['id' => "eq.{$params['id']}"]);
Response::success('تم حذف الإشعار', '/notifications');
}
}
<div class="content-header">
<h1>الإشعارات</h1>
<div class="flex gap-3">
<button class="btn btn-primary" onclick="showSendNotif()">إرسال إشعار</button>
<button class="btn btn-ghost" onclick="showBroadcast()">إرسال للجميع</button>
</div>
</div>
<div class="data-table-wrapper">
<?php if (empty($notifications)): ?>
<div class="empty-state"><h3 class="empty-state-title">لا توجد إشعارات</h3></div>
<?php else: ?>
<table class="data-table">
<thead><tr><th>النوع</th><th>العنوان</th><th>المستلم</th><th>بث عام</th><th>التاريخ</th><th>إجراء</th></tr></thead>
<tbody>
<?php foreach ($notifications as $n): ?>
<tr>
<td><span class="badge badge-info"><?= View::e($n['type']) ?></span></td>
<td><?= View::e($n['title_ar'] ?: $n['title']) ?></td>
<td class="text-xs"><?= $n['user_id'] ? substr($n['user_id'], 0, 8) . '...' : '-' ?></td>
<td><?= $n['is_broadcast'] ? '<span class="badge badge-purple">بث عام</span>' : '' ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($n['created_at'])) ?></td>
<td><button class="btn btn-ghost btn-sm" style="color:var(--danger);" onclick="confirmDelete('/notifications/<?= $n['id'] ?>/delete','إشعار')">حذف</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
/* Organizations module styles */
/* Organization Logo */
.org-logo-sm {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
}
.org-logo-sm img {
width: 100%;
height: 100%;
object-fit: cover;
}
.org-logo-lg {
width: 80px;
height: 80px;
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-elevated);
border: 2px solid var(--color-border);
}
.org-logo-lg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.org-logo-placeholder {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
}
.org-logo-lg .org-logo-placeholder {
font-size: 2rem;
}
.org-logo-preview {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
object-fit: cover;
border: 1px solid var(--color-border);
}
/* Verification Badge */
.verification-badge {
display: inline-flex;
align-items: center;
gap: 4px;
}
.verification-badge svg {
flex-shrink: 0;
}
.verification-badge-lg {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--color-bg-elevated);
border: 2px solid var(--color-border);
}
/* Organization Card styles */
.org-card {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.org-card:hover {
border-color: var(--color-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.org-card-info {
flex: 1;
min-width: 0;
}
.org-card-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text-primary);
margin-bottom: 2px;
}
.org-card-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.org-card-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* File Upload Area */
.file-upload-area {
position: relative;
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
text-align: center;
transition: border-color 0.2s ease, background-color 0.2s ease;
cursor: pointer;
}
.file-upload-area:hover,
.file-upload-area.dragover {
border-color: var(--color-primary);
background: var(--color-bg-elevated);
}
.file-upload-area .file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-upload-content {
display: flex;
flex-direction: column;
align-items: center;
color: var(--color-text-secondary);
}
.file-upload-preview {
display: flex;
justify-content: center;
}
/* Members section */
.member-role-select {
min-width: 120px;
}
/* RTL adjustments */
[dir="rtl"] .org-card {
flex-direction: row-reverse;
}
// Tab switching
function switchTab(btn, tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(tabId).classList.remove('hidden');
}
// Verify organization
function verifyOrg(id, name) {
showConfirm(
`هل تريد توثيق المنظمة "${name}"؟`,
'سيتم منح هذه المنظمة شارة التوثيق',
() => {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/organizations/${id}/verify`;
form.innerHTML = `<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">`;
document.body.appendChild(form);
form.submit();
},
'توثيق'
);
}
// Add member modal
function showAddMemberModal() {
const orgId = window.location.pathname.split('/')[2];
openModal('إضافة عضو', `
<form method="POST" action="/organizations/${orgId}/members/add" id="addMemberForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<div class="form-group">
<label class="form-label">معرف اللاعب *</label>
<input type="text" name="user_id" class="form-input" required placeholder="أدخل معرف اللاعب (UUID)">
</div>
<div class="form-group">
<label class="form-label">الدور</label>
<select name="role" class="form-select member-role-select">
<option value="member">عضو</option>
<option value="moderator">مشرف</option>
<option value="admin">مدير</option>
<option value="owner">مالك</option>
</select>
</div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-primary" onclick="document.getElementById('addMemberForm').submit()">إضافة</button>
`);
}
// Remove member
function removeMember(memberId, memberName) {
const orgId = window.location.pathname.split('/')[2];
showConfirm(
`هل تريد إزالة العضو "${memberName}"؟`,
'سيتم إزالة هذا العضو من المنظمة',
() => {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/organizations/${orgId}/members/${memberId}/remove`;
form.innerHTML = `<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">`;
document.body.appendChild(form);
form.submit();
},
'إزالة'
);
}
// Search with debounce
const searchInput = document.getElementById('orgSearch');
if (searchInput) {
let timeout;
searchInput.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const url = new URL(window.location);
if (searchInput.value) {
url.searchParams.set('search', searchInput.value);
} else {
url.searchParams.delete('search');
}
url.searchParams.set('page', '1');
window.location.href = url.toString();
}, 300);
});
}
// File upload drag & drop
const uploadArea = document.getElementById('logoUploadArea');
if (uploadArea) {
const fileInput = uploadArea.querySelector('.file-input');
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
showLogoPreview(e.dataTransfer.files[0]);
}
});
if (fileInput) {
fileInput.addEventListener('change', () => {
if (fileInput.files.length) {
showLogoPreview(fileInput.files[0]);
}
});
}
}
function showLogoPreview(file) {
const uploadArea = document.getElementById('logoUploadArea');
if (!uploadArea || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
let preview = uploadArea.querySelector('.file-upload-preview');
if (!preview) {
preview = document.createElement('div');
preview.className = 'file-upload-preview mb-3';
uploadArea.insertBefore(preview, uploadArea.querySelector('.file-upload-content'));
}
preview.innerHTML = `<img src="${e.target.result}" alt="معاينة الشعار" class="org-logo-preview">`;
};
reader.readAsDataURL(file);
}
<?php
class OrganizationsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$search = $_GET['search'] ?? '';
$sort = $_GET['sort'] ?? 'created_at';
$dir = $_GET['dir'] ?? 'desc';
$filter = $_GET['filter'] ?? '';
$queryParams = ['select' => '*', 'order' => "{$sort}.{$dir}"];
if ($search) {
$queryParams['or'] = "(name.ilike.*{$search}*,name_ar.ilike.*{$search}*,slug.ilike.*{$search}*)";
}
if ($filter === 'verified') {
$queryParams['is_verified'] = 'eq.true';
} elseif ($filter === 'unverified') {
$queryParams['is_verified'] = 'eq.false';
}
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('organizations', $countParams);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$organizations = $this->db->select('organizations', $queryParams);
$pageTitle = 'المنظمات';
$moduleCSS = 'organizations';
$moduleJS = 'organizations';
View::render('organizations/list', compact('organizations', 'pagination', 'search', 'sort', 'dir', 'filter', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function show(array $params, string $method): void
{
$id = $params['id'];
$org = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$members = $this->db->select('org_members', [
'select' => '*',
'org_id' => "eq.{$id}",
'order' => 'joined_at.desc',
]);
$tournaments = $this->db->select('tournaments', [
'select' => '*',
'org_id' => "eq.{$id}",
'order' => 'created_at.desc',
'limit' => 20,
]);
$pageTitle = $org['name_ar'] ?? $org['name'];
$moduleCSS = 'organizations';
$moduleJS = 'organizations';
View::render('organizations/show', compact('org', 'members', 'tournaments', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$pageTitle = 'إنشاء منظمة';
$org = [];
$moduleCSS = 'organizations';
View::render('organizations/form', compact('org', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('name', 'اسم المنظمة')
->minLength('name', 2, 'اسم المنظمة')
->maxLength('name', 100, 'اسم المنظمة')
->required('slug', 'المعرف')
->minLength('slug', 2, 'المعرف');
if ($validator->fails()) {
Response::error($validator->firstError(), '/organizations/create');
return;
}
$data = [
'name' => trim($_POST['name']),
'name_ar' => trim($_POST['name_ar'] ?? ''),
'slug' => trim($_POST['slug']),
'description' => trim($_POST['description'] ?? ''),
'description_ar' => trim($_POST['description_ar'] ?? ''),
'contact_email' => trim($_POST['contact_email'] ?? ''),
'website' => trim($_POST['website'] ?? ''),
'country_code' => trim($_POST['country_code'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'is_verified' => false,
'is_active' => true,
];
if (!empty($_FILES['logo']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK) {
$data['logo_url'] = $this->uploadLogo($_FILES['logo']);
}
$result = $this->db->insert('organizations', $data);
AuditLog::log('create', 'organization', $result['id'] ?? null, null, $data);
Response::success('تم إنشاء المنظمة بنجاح', '/organizations');
}
public function edit(array $params, string $method): void
{
$id = $params['id'];
$org = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'تعديل المنظمة';
$moduleCSS = 'organizations';
View::render('organizations/form', compact('org', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
}
$data = [
'name' => trim($_POST['name'] ?? $old['name']),
'name_ar' => trim($_POST['name_ar'] ?? ''),
'slug' => trim($_POST['slug'] ?? $old['slug']),
'description' => trim($_POST['description'] ?? ''),
'description_ar' => trim($_POST['description_ar'] ?? ''),
'contact_email' => trim($_POST['contact_email'] ?? ''),
'website' => trim($_POST['website'] ?? ''),
'country_code' => trim($_POST['country_code'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'updated_at' => date('c'),
];
if (!empty($_FILES['logo']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK) {
$data['logo_url'] = $this->uploadLogo($_FILES['logo']);
}
$this->db->update('organizations', ['id' => "eq.{$id}"], $data);
AuditLog::log('update', 'organization', $id, $old, $data);
Response::success('تم تحديث بيانات المنظمة', "/organizations/{$id}");
}
public function verify(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
}
$data = [
'is_verified' => true,
'verified_at' => date('c'),
'verified_by' => Auth::user()['username'],
];
$this->db->update('organizations', ['id' => "eq.{$id}"], $data);
AuditLog::log('verify', 'organization', $id, $old, $data);
Response::success('تم توثيق المنظمة بنجاح', "/organizations/{$id}");
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('admin');
$id = $params['id'];
$old = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
}
$this->db->update('organizations', ['id' => "eq.{$id}"], [
'is_active' => false,
'deleted_at' => date('c'),
]);
AuditLog::log('delete', 'organization', $id, $old);
Response::success('تم حذف المنظمة', '/organizations');
}
public function members(array $params, string $method): void
{
$id = $params['id'];
$org = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$members = $this->db->select('org_members', [
'select' => '*',
'org_id' => "eq.{$id}",
'order' => 'joined_at.desc',
]);
$pageTitle = 'أعضاء ' . ($org['name_ar'] ?? $org['name']);
$moduleCSS = 'organizations';
$moduleJS = 'organizations';
View::render('organizations/show', compact('org', 'members', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function addMember(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$org = $this->db->selectOne('organizations', ['id' => "eq.{$id}"]);
if (!$org) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
}
$userId = trim($_POST['user_id'] ?? '');
$role = trim($_POST['role'] ?? 'member');
if (empty($userId)) {
Response::error('يجب تحديد اللاعب', "/organizations/{$id}");
return;
}
$existing = $this->db->selectOne('org_members', [
'org_id' => "eq.{$id}",
'user_id' => "eq.{$userId}",
]);
if ($existing) {
Response::error('هذا اللاعب عضو بالفعل', "/organizations/{$id}");
return;
}
$data = [
'org_id' => $id,
'user_id' => $userId,
'role' => $role,
'joined_at' => date('c'),
];
$this->db->insert('org_members', $data);
AuditLog::log('add_member', 'organization', $id, null, $data);
Response::success('تم إضافة العضو بنجاح', "/organizations/{$id}");
}
public function removeMember(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$memberId = $params['member_id'] ?? ($_POST['member_id'] ?? '');
if (empty($memberId)) {
Response::error('يجب تحديد العضو', "/organizations/{$id}");
return;
}
$member = $this->db->selectOne('org_members', [
'org_id' => "eq.{$id}",
'id' => "eq.{$memberId}",
]);
if (!$member) {
Response::error('العضو غير موجود', "/organizations/{$id}");
return;
}
$this->db->delete('org_members', [
'org_id' => "eq.{$id}",
'id' => "eq.{$memberId}",
]);
AuditLog::log('remove_member', 'organization', $id, $member);
Response::success('تم إزالة العضو', "/organizations/{$id}");
}
private function uploadLogo(array $file): string
{
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
if (!in_array($file['type'], $allowed)) {
return '';
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'org_' . uniqid() . '.' . $ext;
$destination = STORAGE_PATH . '/logos/' . $filename;
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0755, true);
}
move_uploaded_file($file['tmp_name'], $destination);
return '/storage/logos/' . $filename;
}
}
<?php $isEdit = !empty($org['id']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= $isEdit ? 'تعديل المنظمة' : 'إنشاء منظمة' ?></h1>
</div>
</div>
<div class="card max-w-lg">
<form method="POST" action="<?= $isEdit ? "/organizations/{$org['id']}/update" : '/organizations/store' ?>" enctype="multipart/form-data" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">اسم المنظمة (English) *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($org['name'] ?? '') ?>" required minlength="2" maxlength="100" placeholder="Organization Name">
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">اسم المنظمة (عربي)</label>
<input type="text" name="name_ar" class="form-input" value="<?= View::e($org['name_ar'] ?? '') ?>" placeholder="اسم المنظمة بالعربي">
</div>
</div>
<div class="form-group">
<label class="form-label">المعرف (Slug) *</label>
<input type="text" name="slug" class="form-input" value="<?= View::e($org['slug'] ?? '') ?>" required minlength="2" maxlength="50" placeholder="organization-slug" dir="ltr">
<span class="form-hint">معرف فريد للمنظمة (حروف إنجليزية صغيرة وشرطات فقط)</span>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">الوصف (English)</label>
<textarea name="description" class="form-input" rows="3" placeholder="Description in English"><?= View::e($org['description'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">الوصف (عربي)</label>
<textarea name="description_ar" class="form-input" rows="3" placeholder="وصف المنظمة بالعربي"><?= View::e($org['description_ar'] ?? '') ?></textarea>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="contact_email" class="form-input" value="<?= View::e($org['contact_email'] ?? '') ?>" dir="ltr" placeholder="contact@org.com">
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">الموقع الإلكتروني</label>
<input type="url" name="website" class="form-input" value="<?= View::e($org['website'] ?? '') ?>" dir="ltr" placeholder="https://example.com">
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الدولة</label>
<input type="text" name="country_code" class="form-input" value="<?= View::e($org['country_code'] ?? '') ?>" maxlength="2" placeholder="SA">
</div>
<div class="form-group">
<label class="form-label">المدينة</label>
<input type="text" name="city" class="form-input" value="<?= View::e($org['city'] ?? '') ?>" placeholder="الرياض">
</div>
</div>
<div class="form-group">
<label class="form-label">شعار المنظمة</label>
<div class="file-upload-area" id="logoUploadArea">
<?php if (!empty($org['logo_url'])): ?>
<div class="file-upload-preview mb-3">
<img src="<?= View::e($org['logo_url']) ?>" alt="الشعار الحالي" class="org-logo-preview">
</div>
<?php endif; ?>
<input type="file" name="logo" id="logoInput" accept="image/jpeg,image/png,image/webp,image/svg+xml" class="file-input">
<div class="file-upload-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="text-sm text-secondary mt-2">اسحب الصورة هنا أو اضغط للاختيار</p>
<p class="text-xs text-muted">PNG, JPG, WebP, SVG - حد أقصى 2MB</p>
</div>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<span class="btn-text"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء المنظمة' ?></span>
<span class="btn-spinner"></span>
</button>
<a href="/organizations" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<div class="content-header">
<h1>المنظمات</h1>
<a href="/organizations/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إنشاء منظمة
</a>
</div>
<!-- Filter Pills -->
<div class="filter-pills mb-5">
<a href="/organizations" class="filter-pill <?= empty($filter) ? 'active' : '' ?>">الكل</a>
<a href="/organizations?filter=verified" class="filter-pill <?= $filter === 'verified' ? 'active' : '' ?>">موثقة</a>
<a href="/organizations?filter=unverified" class="filter-pill <?= $filter === 'unverified' ? 'active' : '' ?>">غير موثقة</a>
</div>
<div class="data-table-wrapper">
<div class="table-toolbar">
<div class="table-search">
<svg class="table-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="بحث بالاسم أو المعرف..." value="<?= View::e($search) ?>" id="orgSearch">
</div>
<div class="table-actions">
<select class="form-select" style="width: auto; padding: var(--space-2) var(--space-4);" onchange="location.href='/organizations?per_page='+this.value">
<option value="25" <?= ($pagination->perPage == 25) ? 'selected' : '' ?>>25</option>
<option value="50" <?= ($pagination->perPage == 50) ? 'selected' : '' ?>>50</option>
<option value="100" <?= ($pagination->perPage == 100) ? 'selected' : '' ?>>100</option>
</select>
</div>
</div>
<?php if (empty($organizations)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"/></svg>
<h3 class="empty-state-title">لا توجد منظمات</h3>
<p class="empty-state-text">لم يتم العثور على أي منظمات<?= $search ? ' لبحثك "' . View::e($search) . '"' : '' ?></p>
</div>
<?php else: ?>
<table class="data-table" id="orgsTable">
<thead>
<tr>
<th>الشعار</th>
<th data-sort="name">الاسم</th>
<th data-sort="name_ar">الاسم العربي</th>
<th data-sort="country_code">الدولة</th>
<th>التحقق</th>
<th>الحالة</th>
<th style="width: 60px;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($organizations as $org): ?>
<tr>
<td>
<div class="org-logo-sm">
<?php if (!empty($org['logo_url'])): ?>
<img src="<?= View::e($org['logo_url']) ?>" alt="<?= View::e($org['name']) ?>">
<?php else: ?>
<span class="org-logo-placeholder"><?= mb_substr($org['name'] ?? '?', 0, 1) ?></span>
<?php endif; ?>
</div>
</td>
<td>
<div class="font-medium"><?= View::e($org['name']) ?></div>
<div class="text-xs text-muted"><?= View::e($org['slug'] ?? '') ?></div>
</td>
<td><?= View::e($org['name_ar'] ?? '-') ?></td>
<td>
<?php if (!empty($org['country_code'])): ?>
<span class="badge badge-default"><?= View::e($org['country_code']) ?></span>
<?php else: ?>
-
<?php endif; ?>
</td>
<td>
<?php if ($org['is_verified'] ?? false): ?>
<span class="badge badge-success verification-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
موثقة
</span>
<?php else: ?>
<span class="badge badge-warning">غير موثقة</span>
<?php endif; ?>
</td>
<td>
<?php if ($org['is_active'] ?? true): ?>
<span class="badge badge-success badge-dot">نشطة</span>
<?php else: ?>
<span class="badge badge-danger badge-dot">معطلة</span>
<?php endif; ?>
</td>
<td>
<div class="dropdown">
<button class="btn btn-icon btn-ghost" onclick="toggleDropdown(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
<div class="dropdown-menu">
<a href="/organizations/<?= $org['id'] ?>" class="dropdown-item">عرض</a>
<a href="/organizations/<?= $org['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php if (!($org['is_verified'] ?? false)): ?>
<button class="dropdown-item" onclick="verifyOrg('<?= $org['id'] ?>', '<?= View::e($org['name']) ?>')">توثيق</button>
<?php endif; ?>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/delete', '<?= View::e($org['name']) ?>')">حذف</button>
</div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<a href="?page=<?= $pagination->page - 1 ?>&per_page=<?= $pagination->perPage ?>&search=<?= urlencode($search) ?>&filter=<?= $filter ?>" class="pagination-btn <?= !$pagination->hasPrev() ? 'disabled' : '' ?>" <?= !$pagination->hasPrev() ? 'disabled' : '' ?>>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&per_page=<?= $pagination->perPage ?>&search=<?= urlencode($search) ?>&filter=<?= $filter ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
<a href="?page=<?= $pagination->page + 1 ?>&per_page=<?= $pagination->perPage ?>&search=<?= urlencode($search) ?>&filter=<?= $filter ?>" class="pagination-btn <?= !$pagination->hasNext() ? 'disabled' : '' ?>" <?= !$pagination->hasNext() ? 'disabled' : '' ?>>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</a>
</div>
</div>
<?php endif; ?>
</div>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= View::e($org['name_ar'] ?? $org['name']) ?></h1>
<?php if ($org['is_verified'] ?? false): ?>
<span class="badge badge-success verification-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
موثقة
</span>
<?php endif; ?>
<?php if (!($org['is_active'] ?? true)): ?>
<span class="badge badge-danger">معطلة</span>
<?php endif; ?>
</div>
<div class="flex gap-3">
<a href="/organizations/<?= $org['id'] ?>/edit" class="btn btn-ghost">تعديل</a>
<?php if (!($org['is_verified'] ?? false)): ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/verify" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل تريد توثيق هذه المنظمة؟')">توثيق</button>
</form>
<?php endif; ?>
</div>
</div>
<!-- Organization Info Card -->
<div class="grid grid-3 mb-6">
<div class="card" style="grid-column: span 1;">
<div class="flex flex-col items-center text-center p-5">
<div class="org-logo-lg mb-4">
<?php if (!empty($org['logo_url'])): ?>
<img src="<?= View::e($org['logo_url']) ?>" alt="<?= View::e($org['name']) ?>">
<?php else: ?>
<span class="org-logo-placeholder"><?= mb_substr($org['name'] ?? '?', 0, 1) ?></span>
<?php endif; ?>
</div>
<h3 class="text-lg font-semibold"><?= View::e($org['name']) ?></h3>
<?php if (!empty($org['name_ar'])): ?>
<p class="text-secondary text-sm"><?= View::e($org['name_ar']) ?></p>
<?php endif; ?>
<div class="flex gap-4 mt-4">
<div class="text-center">
<div class="text-xl font-bold"><?= count($members ?? []) ?></div>
<div class="text-xs text-muted">الأعضاء</div>
</div>
<div class="text-center">
<div class="text-xl font-bold"><?= count($tournaments ?? []) ?></div>
<div class="text-xs text-muted">البطولات</div>
</div>
</div>
</div>
</div>
<div class="card" style="grid-column: span 2;">
<div class="card-header">
<h3 class="card-title">معلومات المنظمة</h3>
</div>
<div class="flex flex-col gap-3 text-sm">
<div class="flex justify-between"><span class="text-secondary">المعرف (Slug)</span><span dir="ltr"><?= View::e($org['slug'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">البريد الإلكتروني</span><span dir="ltr"><?= View::e($org['contact_email'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الموقع</span><span dir="ltr"><?= !empty($org['website']) ? '<a href="' . View::e($org['website']) . '" target="_blank" class="text-primary">' . View::e($org['website']) . '</a>' : '-' ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الدولة</span><span><?= View::e($org['country_code'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">المدينة</span><span><?= View::e($org['city'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">تاريخ الإنشاء</span><span class="tabular-nums"><?= !empty($org['created_at']) ? date('Y-m-d', strtotime($org['created_at'])) : '-' ?></span></div>
</div>
<?php if (!empty($org['description']) || !empty($org['description_ar'])): ?>
<div class="mt-4 pt-4 border-t">
<div class="text-xs text-muted mb-1">الوصف</div>
<p class="text-sm"><?= View::e($org['description_ar'] ?? $org['description'] ?? '') ?></p>
</div>
<?php endif; ?>
</div>
</div>
<!-- Tabs -->
<div class="card">
<div class="tabs">
<button class="tab active" onclick="switchTab(this, 'infoTab')">معلومات عامة</button>
<button class="tab" onclick="switchTab(this, 'membersTab')">الأعضاء</button>
<button class="tab" onclick="switchTab(this, 'tournamentsTab')">البطولات</button>
<button class="tab" onclick="switchTab(this, 'verificationTab')">التحقق</button>
</div>
<!-- General Info Tab -->
<div id="infoTab" class="tab-content">
<div class="grid grid-2 gap-4 p-4">
<div>
<div class="text-xs text-muted mb-1">الاسم (English)</div>
<div class="font-medium"><?= View::e($org['name'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الاسم (عربي)</div>
<div class="font-medium"><?= View::e($org['name_ar'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">المعرف</div>
<div class="font-medium" dir="ltr"><?= View::e($org['slug'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">البريد الإلكتروني</div>
<div class="font-medium" dir="ltr"><?= View::e($org['contact_email'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الموقع الإلكتروني</div>
<div class="font-medium" dir="ltr"><?= View::e($org['website'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الدولة</div>
<div class="font-medium"><?= View::e($org['country_code'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">المدينة</div>
<div class="font-medium"><?= View::e($org['city'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الحالة</div>
<div class="font-medium">
<?php if ($org['is_active'] ?? true): ?>
<span class="badge badge-success">نشطة</span>
<?php else: ?>
<span class="badge badge-danger">معطلة</span>
<?php endif; ?>
</div>
</div>
<div style="grid-column: span 2;">
<div class="text-xs text-muted mb-1">الوصف (English)</div>
<div class="text-sm"><?= View::e($org['description'] ?? '-') ?></div>
</div>
<div style="grid-column: span 2;">
<div class="text-xs text-muted mb-1">الوصف (عربي)</div>
<div class="text-sm"><?= View::e($org['description_ar'] ?? '-') ?></div>
</div>
</div>
</div>
<!-- Members Tab -->
<div id="membersTab" class="tab-content hidden">
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h4 class="font-medium">أعضاء المنظمة (<?= count($members ?? []) ?>)</h4>
<button class="btn btn-primary btn-sm" onclick="showAddMemberModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة عضو
</button>
</div>
</div>
<?php if (empty($members)): ?>
<p class="text-secondary text-center p-5">لا يوجد أعضاء</p>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>العضو</th>
<th>الدور</th>
<th>تاريخ الانضمام</th>
<th style="width: 60px;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($members as $member): ?>
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar avatar-sm">
<?= mb_substr($member['display_name'] ?? $member['user_id'] ?? '?', 0, 1) ?>
</div>
<div>
<div class="font-medium"><?= View::e($member['display_name'] ?? $member['username'] ?? $member['user_id']) ?></div>
</div>
</div>
</td>
<td>
<?php
$roleLabels = [
'owner' => 'مالك',
'admin' => 'مدير',
'moderator' => 'مشرف',
'member' => 'عضو',
];
$roleBadge = [
'owner' => 'badge-purple',
'admin' => 'badge-danger',
'moderator' => 'badge-warning',
'member' => 'badge-default',
];
$role = $member['role'] ?? 'member';
?>
<span class="badge <?= $roleBadge[$role] ?? 'badge-default' ?>"><?= $roleLabels[$role] ?? $role ?></span>
</td>
<td class="text-xs tabular-nums"><?= !empty($member['joined_at']) ? date('Y-m-d', strtotime($member['joined_at'])) : '-' ?></td>
<td>
<button class="btn btn-icon btn-ghost btn-danger-hover" onclick="removeMember('<?= $member['id'] ?>', '<?= View::e($member['display_name'] ?? $member['user_id']) ?>')" title="إزالة العضو">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Tournaments Tab -->
<div id="tournamentsTab" class="tab-content hidden">
<?php if (empty($tournaments)): ?>
<p class="text-secondary text-center p-5">لا توجد بطولات</p>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>البطولة</th>
<th>اللعبة</th>
<th>الحالة</th>
<th>المشاركين</th>
<th>التاريخ</th>
</tr>
</thead>
<tbody>
<?php foreach ($tournaments as $tournament): ?>
<tr>
<td>
<a href="/tournaments/<?= $tournament['id'] ?>" class="font-medium text-primary">
<?= View::e($tournament['name_ar'] ?? $tournament['name'] ?? '-') ?>
</a>
</td>
<td><?= View::e($tournament['game_key'] ?? '-') ?></td>
<td>
<?php
$statusLabels = [
'draft' => 'مسودة',
'open' => 'مفتوحة',
'in_progress' => 'جارية',
'completed' => 'مكتملة',
'cancelled' => 'ملغية',
];
$statusBadge = [
'draft' => 'badge-default',
'open' => 'badge-info',
'in_progress' => 'badge-warning',
'completed' => 'badge-success',
'cancelled' => 'badge-danger',
];
$tStatus = $tournament['status'] ?? 'draft';
?>
<span class="badge <?= $statusBadge[$tStatus] ?? 'badge-default' ?>"><?= $statusLabels[$tStatus] ?? $tStatus ?></span>
</td>
<td class="tabular-nums"><?= $tournament['participants_count'] ?? 0 ?></td>
<td class="text-xs tabular-nums"><?= !empty($tournament['start_date']) ? date('Y-m-d', strtotime($tournament['start_date'])) : '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Verification Tab -->
<div id="verificationTab" class="tab-content hidden">
<div class="p-5">
<div class="flex flex-col items-center text-center">
<?php if ($org['is_verified'] ?? false): ?>
<div class="verification-badge-lg mb-4">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h3 class="text-lg font-semibold text-success mb-2">منظمة موثقة</h3>
<div class="flex flex-col gap-2 text-sm text-secondary">
<?php if (!empty($org['verified_at'])): ?>
<p>تاريخ التوثيق: <?= date('Y-m-d H:i', strtotime($org['verified_at'])) ?></p>
<?php endif; ?>
<?php if (!empty($org['verified_by'])): ?>
<p>بواسطة: <?= View::e($org['verified_by']) ?></p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="verification-badge-lg mb-4">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<h3 class="text-lg font-semibold text-warning mb-2">غير موثقة</h3>
<p class="text-sm text-secondary mb-4">هذه المنظمة لم يتم توثيقها بعد</p>
<form method="POST" action="/organizations/<?= $org['id'] ?>/verify">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل أنت متأكد من توثيق هذه المنظمة؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
توثيق المنظمة
</button>
</form>
<?php endif; ?>
</div>
</div>
</div>
</div>
/* Players module specific styles */
// Tab switching
function switchTab(btn, tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(tabId).classList.remove('hidden');
}
// Ban player
function banPlayer(id, name) {
openModal('حظر اللاعب', `
<form method="POST" action="/players/${id}/ban" id="banForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<p class="mb-4">هل تريد حظر اللاعب <strong>${name}</strong>؟</p>
<div class="form-group">
<label class="form-label">سبب الحظر *</label>
<textarea name="ban_reason" class="form-input" required placeholder="اكتب سبب الحظر..."></textarea>
</div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-danger" onclick="document.getElementById('banForm').submit()">حظر</button>
`);
}
// Unban player
function unbanPlayer(id) {
showConfirm('هل تريد إلغاء حظر هذا اللاعب؟', '', () => {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/players/${id}/unban`;
form.innerHTML = `<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">`;
document.body.appendChild(form);
form.submit();
}, 'إلغاء الحظر');
}
// Grant currency modal
function showGrantModal() {
const playerId = window.location.pathname.split('/')[2];
openModal('منح عملات', `
<form method="POST" action="/players/${playerId}/grant" id="grantForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<div class="form-group">
<label class="form-label">العملة</label>
<select name="currency" class="form-select">
<option value="coins">عملات</option>
<option value="gems">جواهر</option>
</select>
</div>
<div class="form-group">
<label class="form-label">المبلغ</label>
<input type="number" name="amount" class="form-input" min="1" required>
</div>
<div class="form-group">
<label class="form-label">السبب</label>
<input type="text" name="reason" class="form-input" placeholder="سبب المنح">
</div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-success" onclick="document.getElementById('grantForm').submit()">منح</button>
`);
}
// Revoke currency modal
function showRevokeModal() {
const playerId = window.location.pathname.split('/')[2];
openModal('سحب عملات', `
<form method="POST" action="/players/${playerId}/revoke" id="revokeForm">
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">
<div class="form-group">
<label class="form-label">العملة</label>
<select name="currency" class="form-select">
<option value="coins">عملات</option>
<option value="gems">جواهر</option>
</select>
</div>
<div class="form-group">
<label class="form-label">المبلغ</label>
<input type="number" name="amount" class="form-input" min="1" required>
</div>
<div class="form-group">
<label class="form-label">السبب</label>
<input type="text" name="reason" class="form-input" placeholder="سبب السحب">
</div>
</form>
`, `
<button class="btn btn-ghost" onclick="closeModal()">إلغاء</button>
<button class="btn btn-danger" onclick="document.getElementById('revokeForm').submit()">سحب</button>
`);
}
// Bulk ban
function bulkBan() {
const ids = new DataTable('playersTable').getSelectedIds();
if (!ids.length) return;
showConfirm(`هل تريد حظر ${ids.length} لاعب؟`, 'سيتم حظر جميع اللاعبين المحددين', () => {
// Submit bulk ban
const form = document.createElement('form');
form.method = 'POST';
form.action = '/players/bulk-ban';
form.innerHTML = `<input type="hidden" name="_csrf" value="${CSRF_TOKEN}"><input type="hidden" name="ids" value="${ids.join(',')}">`;
document.body.appendChild(form);
form.submit();
});
}
// Search with debounce
const searchInput = document.getElementById('playerSearch');
if (searchInput) {
let timeout;
searchInput.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const url = new URL(window.location);
if (searchInput.value) {
url.searchParams.set('search', searchInput.value);
} else {
url.searchParams.delete('search');
}
url.searchParams.set('page', '1');
window.location.href = url.toString();
}, 300);
});
}
<?php
class PlayersController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$search = $_GET['search'] ?? '';
$sort = $_GET['sort'] ?? 'created_at';
$dir = $_GET['dir'] ?? 'desc';
$status = $_GET['status'] ?? '';
$queryParams = ['select' => '*', 'order' => "{$sort}.{$dir}"];
if ($search) {
$queryParams['or'] = "(username.ilike.*{$search}*,display_name.ilike.*{$search}*)";
}
if ($status === 'online') {
$queryParams['is_online'] = 'eq.true';
} elseif ($status === 'banned') {
$queryParams['is_banned'] = 'eq.true';
}
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('profiles', $countParams);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$players = $this->db->select('profiles', $queryParams);
$pageTitle = 'اللاعبون';
$moduleCSS = 'players';
$moduleJS = 'players';
View::render('players/list', compact('players', 'pagination', 'search', 'sort', 'dir', 'status', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function show(array $params, string $method): void
{
$id = $params['id'];
$player = $this->db->selectOne('profiles', ['id' => "eq.{$id}"]);
if (!$player) {
http_response_code(404);
View::render('errors/404');
return;
}
$matches = $this->db->select('matches', [
'select' => '*',
'or' => "(players->cs.[{\"id\":\"{$id}\"}])",
'order' => 'created_at.desc',
'limit' => 20,
]);
$transactions = $this->db->select('transactions', [
'select' => '*',
'user_id' => "eq.{$id}",
'order' => 'created_at.desc',
'limit' => 20,
]);
$reports = $this->db->select('cheat_reports', [
'select' => '*',
'reported_id' => "eq.{$id}",
'order' => 'created_at.desc',
]);
$pageTitle = $player['display_name'] ?? $player['username'];
$moduleCSS = 'players';
$moduleJS = 'players';
View::render('players/show', compact('player', 'matches', 'transactions', 'reports', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$pageTitle = 'إضافة لاعب';
$player = [];
$moduleCSS = 'players';
View::render('players/form', compact('player', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('username', 'اسم المستخدم')
->minLength('username', 3, 'اسم المستخدم')
->maxLength('username', 30, 'اسم المستخدم');
if ($validator->fails()) {
Response::error($validator->firstError(), '/players/create');
return;
}
$data = [
'username' => trim($_POST['username']),
'display_name' => trim($_POST['display_name'] ?? ''),
'display_name_ar' => trim($_POST['display_name_ar'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'country_code' => trim($_POST['country_code'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'bio' => trim($_POST['bio'] ?? ''),
];
$result = $this->db->insert('profiles', $data);
AuditLog::log('create', 'player', $result['id'] ?? null, null, $data);
Response::success('تم إنشاء اللاعب بنجاح', '/players');
}
public function edit(array $params, string $method): void
{
$id = $params['id'];
$player = $this->db->selectOne('profiles', ['id' => "eq.{$id}"]);
if (!$player) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'تعديل اللاعب';
$moduleCSS = 'players';
View::render('players/form', compact('player', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('profiles', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('اللاعب غير موجود', '/players');
return;
}
$data = [
'display_name' => trim($_POST['display_name'] ?? ''),
'display_name_ar' => trim($_POST['display_name_ar'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'country_code' => trim($_POST['country_code'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'bio' => trim($_POST['bio'] ?? ''),
'updated_at' => date('c'),
];
if (!empty($_POST['elo_blitz'])) $data['elo_blitz'] = (int)$_POST['elo_blitz'];
if (!empty($_POST['elo_rapid'])) $data['elo_rapid'] = (int)$_POST['elo_rapid'];
if (!empty($_POST['elo_classical'])) $data['elo_classical'] = (int)$_POST['elo_classical'];
$this->db->update('profiles', ['id' => "eq.{$id}"], $data);
AuditLog::log('update', 'player', $id, $old, $data);
Response::success('تم تحديث بيانات اللاعب', "/players/{$id}");
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('admin');
$id = $params['id'];
$this->db->update('profiles', ['id' => "eq.{$id}"], ['is_banned' => true, 'ban_reason' => 'حذف بواسطة الإدارة']);
AuditLog::log('delete', 'player', $id);
Response::success('تم حذف اللاعب', '/players');
}
public function ban(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$reason = trim($_POST['ban_reason'] ?? '');
if (empty($reason)) {
Response::error('يجب إدخال سبب الحظر', "/players/{$id}");
return;
}
$this->db->update('profiles', ['id' => "eq.{$id}"], [
'is_banned' => true,
'ban_reason' => $reason,
'banned_at' => date('c'),
'banned_by' => Auth::user()['username'],
]);
AuditLog::log('ban', 'player', $id, null, ['reason' => $reason]);
Response::success('تم حظر اللاعب', "/players/{$id}");
}
public function unban(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$this->db->update('profiles', ['id' => "eq.{$id}"], [
'is_banned' => false,
'ban_reason' => null,
'banned_at' => null,
'banned_by' => null,
]);
AuditLog::log('unban', 'player', $id);
Response::success('تم إلغاء حظر اللاعب', "/players/{$id}");
}
public function grant(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$currency = $_POST['currency'] ?? 'coins';
$amount = (int)($_POST['amount'] ?? 0);
if ($amount <= 0) {
Response::error('يجب إدخال مبلغ صحيح', "/players/{$id}");
return;
}
$player = $this->db->selectOne('profiles', ['id' => "eq.{$id}"]);
$newBalance = ($player[$currency] ?? 0) + $amount;
$this->db->update('profiles', ['id' => "eq.{$id}"], [$currency => $newBalance]);
$this->db->insert('transactions', [
'user_id' => $id,
'type' => 'admin_grant',
'currency' => $currency,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => trim($_POST['reason'] ?? 'منح بواسطة الإدارة'),
'created_by' => Auth::user()['username'],
]);
AuditLog::log('grant', 'player', $id, null, ['currency' => $currency, 'amount' => $amount]);
Response::success("تم منح {$amount} {$currency}", "/players/{$id}");
}
public function revoke(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$currency = $_POST['currency'] ?? 'coins';
$amount = (int)($_POST['amount'] ?? 0);
if ($amount <= 0) {
Response::error('يجب إدخال مبلغ صحيح', "/players/{$id}");
return;
}
$player = $this->db->selectOne('profiles', ['id' => "eq.{$id}"]);
$currentBalance = $player[$currency] ?? 0;
if ($amount > $currentBalance) {
Response::error('الرصيد غير كاف', "/players/{$id}");
return;
}
$newBalance = $currentBalance - $amount;
$this->db->update('profiles', ['id' => "eq.{$id}"], [$currency => $newBalance]);
$this->db->insert('transactions', [
'user_id' => $id,
'type' => 'admin_revoke',
'currency' => $currency,
'amount' => -$amount,
'balance_after' => $newBalance,
'description' => trim($_POST['reason'] ?? 'سحب بواسطة الإدارة'),
'created_by' => Auth::user()['username'],
]);
AuditLog::log('revoke', 'player', $id, null, ['currency' => $currency, 'amount' => $amount]);
Response::success("تم سحب {$amount} {$currency}", "/players/{$id}");
}
}
<?php $isEdit = !empty($player['id']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/players" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= $isEdit ? 'تعديل اللاعب' : 'إضافة لاعب' ?></h1>
</div>
</div>
<div class="card max-w-lg">
<form method="POST" action="<?= $isEdit ? "/players/{$player['id']}/update" : '/players/store' ?>" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<?php if (!$isEdit): ?>
<div class="form-group">
<label class="form-label">اسم المستخدم *</label>
<input type="text" name="username" class="form-input" value="<?= View::e($player['username'] ?? '') ?>" required minlength="3" maxlength="30" placeholder="username">
<span class="form-error"></span>
</div>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الاسم المعروض (English)</label>
<input type="text" name="display_name" class="form-input" value="<?= View::e($player['display_name'] ?? '') ?>">
</div>
<div class="form-group">
<label class="form-label">الاسم المعروض (عربي)</label>
<input type="text" name="display_name_ar" class="form-input" value="<?= View::e($player['display_name_ar'] ?? '') ?>">
</div>
</div>
<div class="form-group">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" class="form-input" value="<?= View::e($player['email'] ?? '') ?>" dir="ltr">
<span class="form-error"></span>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الدولة</label>
<input type="text" name="country_code" class="form-input" value="<?= View::e($player['country_code'] ?? '') ?>" maxlength="2" placeholder="SA">
</div>
<div class="form-group">
<label class="form-label">المدينة</label>
<input type="text" name="city" class="form-input" value="<?= View::e($player['city'] ?? '') ?>">
</div>
</div>
<div class="form-group">
<label class="form-label">نبذة</label>
<textarea name="bio" class="form-input"><?= View::e($player['bio'] ?? '') ?></textarea>
</div>
<?php if ($isEdit): ?>
<div class="card-header mt-5 mb-4" style="padding: 0; border: none;">
<h3 class="card-title text-sm">التصنيفات</h3>
</div>
<div class="grid grid-3 gap-4">
<div class="form-group">
<label class="form-label">خاطف</label>
<input type="number" name="elo_blitz" class="form-input" value="<?= $player['elo_blitz'] ?? 1200 ?>" min="0" max="3500">
</div>
<div class="form-group">
<label class="form-label">سريع</label>
<input type="number" name="elo_rapid" class="form-input" value="<?= $player['elo_rapid'] ?? 1200 ?>" min="0" max="3500">
</div>
<div class="form-group">
<label class="form-label">كلاسيك</label>
<input type="number" name="elo_classical" class="form-input" value="<?= $player['elo_classical'] ?? 1200 ?>" min="0" max="3500">
</div>
</div>
<?php endif; ?>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<span class="btn-text"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء اللاعب' ?></span>
<span class="btn-spinner"></span>
</button>
<a href="/players" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<div class="content-header">
<h1>اللاعبون</h1>
<a href="/players/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة لاعب
</a>
</div>
<!-- Filter Pills -->
<div class="filter-pills mb-5">
<a href="/players" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<a href="/players?status=online" class="filter-pill <?= $status === 'online' ? 'active' : '' ?>">متصل</a>
<a href="/players?status=banned" class="filter-pill <?= $status === 'banned' ? 'active' : '' ?>">محظور</a>
</div>
<div class="data-table-wrapper">
<div class="table-toolbar">
<div class="table-search">
<svg class="table-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="بحث بالاسم أو اسم المستخدم..." value="<?= View::e($search) ?>" id="playerSearch">
</div>
<div class="table-actions flex gap-2">
<button class="btn btn-ghost btn-sm" onclick="exportTableCSV('.data-table', 'players-export')" title="تصدير CSV">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
CSV
</button>
<select class="form-select" style="width: auto; padding: var(--space-2) var(--space-4);" onchange="location.href='/players?per_page='+this.value">
<option value="25" <?= ($pagination->perPage == 25) ? 'selected' : '' ?>>25</option>
<option value="50" <?= ($pagination->perPage == 50) ? 'selected' : '' ?>>50</option>
<option value="100" <?= ($pagination->perPage == 100) ? 'selected' : '' ?>>100</option>
</select>
</div>
</div>
<!-- Bulk Actions Bar -->
<div class="hidden flex items-center gap-3 p-3 bg-elevated border-b" id="bulkActions">
<span class="text-sm text-secondary">تم تحديد <strong class="bulk-count">0</strong> لاعب</span>
<button class="btn btn-danger btn-sm" onclick="bulkBan()">حظر المحدد</button>
</div>
<?php if (empty($players)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<h3 class="empty-state-title">لا يوجد لاعبون</h3>
<p class="empty-state-text">لم يتم العثور على أي لاعبين<?= $search ? ' لبحثك "' . View::e($search) . '"' : '' ?></p>
</div>
<?php else: ?>
<table class="data-table" id="playersTable">
<thead>
<tr>
<th style="width: 40px;"><input type="checkbox" class="check-all"></th>
<th>اللاعب</th>
<th data-sort="level">المستوى</th>
<th data-sort="coins">العملات</th>
<th>الحالة</th>
<th data-sort="last_active_at">آخر نشاط</th>
<th style="width: 60px;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($players as $player): ?>
<tr>
<td><input type="checkbox" class="row-check" value="<?= $player['id'] ?>"></td>
<td>
<div class="flex items-center gap-3">
<div class="avatar">
<?php if (!empty($player['avatar_url'])): ?>
<img src="<?= View::e($player['avatar_url']) ?>" alt="">
<?php else: ?>
<?= mb_substr($player['username'] ?? '?', 0, 1) ?>
<?php endif; ?>
</div>
<div>
<div class="font-medium"><?= View::e($player['display_name'] ?? $player['username']) ?></div>
<div class="text-xs text-muted">@<?= View::e($player['username']) ?></div>
</div>
</div>
</td>
<td><span class="badge badge-purple">Lv. <?= $player['level'] ?? 1 ?></span></td>
<td class="tabular-nums"><?= number_format($player['coins'] ?? 0) ?></td>
<td>
<?php if ($player['is_banned'] ?? false): ?>
<span class="badge badge-danger badge-dot">محظور</span>
<?php elseif ($player['is_online'] ?? false): ?>
<span class="badge badge-success badge-dot badge-pulse">متصل</span>
<?php else: ?>
<span class="badge badge-default badge-dot">غير متصل</span>
<?php endif; ?>
</td>
<td class="text-xs text-muted tabular-nums">
<?= $player['last_active_at'] ? date('m/d H:i', strtotime($player['last_active_at'])) : '-' ?>
</td>
<td>
<div class="dropdown">
<button class="btn btn-icon btn-ghost" onclick="toggleDropdown(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
<div class="dropdown-menu">
<a href="/players/<?= $player['id'] ?>" class="dropdown-item">عرض</a>
<a href="/players/<?= $player['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php if ($player['is_banned'] ?? false): ?>
<button class="dropdown-item" onclick="unbanPlayer('<?= $player['id'] ?>')">إلغاء الحظر</button>
<?php else: ?>
<button class="dropdown-item danger" onclick="banPlayer('<?= $player['id'] ?>', '<?= View::e($player['username']) ?>')">حظر</button>
<?php endif; ?>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/players/<?= $player['id'] ?>/delete', '<?= View::e($player['username']) ?>')">حذف</button>
</div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<a href="?page=<?= $pagination->page - 1 ?>&per_page=<?= $pagination->perPage ?>&search=<?= urlencode($search) ?>&status=<?= $status ?>" class="pagination-btn <?= !$pagination->hasPrev() ? 'disabled' : '' ?>" <?= !$pagination->hasPrev() ? 'disabled' : '' ?>>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&per_page=<?= $pagination->perPage ?>&search=<?= urlencode($search) ?>&status=<?= $status ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
<a href="?page=<?= $pagination->page + 1 ?>&per_page=<?= $pagination->perPage ?>&search=<?= urlencode($search) ?>&status=<?= $status ?>" class="pagination-btn <?= !$pagination->hasNext() ? 'disabled' : '' ?>" <?= !$pagination->hasNext() ? 'disabled' : '' ?>>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</a>
</div>
</div>
<?php endif; ?>
</div>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/players" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= View::e($player['display_name'] ?? $player['username']) ?></h1>
<?php if ($player['is_banned'] ?? false): ?>
<span class="badge badge-danger">محظور</span>
<?php endif; ?>
</div>
<div class="flex gap-3">
<a href="/players/<?= $player['id'] ?>/edit" class="btn btn-ghost">تعديل</a>
<?php if ($player['is_banned'] ?? false): ?>
<form method="POST" action="/players/<?= $player['id'] ?>/unban" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success">إلغاء الحظر</button>
</form>
<?php endif; ?>
</div>
</div>
<!-- Player Info Card -->
<div class="grid grid-3 mb-6">
<div class="card" style="grid-column: span 1;">
<div class="flex flex-col items-center text-center p-5">
<div class="avatar avatar-xl mb-4">
<?php if (!empty($player['avatar_url'])): ?>
<img src="<?= View::e($player['avatar_url']) ?>" alt="">
<?php else: ?>
<?= mb_substr($player['username'] ?? '?', 0, 1) ?>
<?php endif; ?>
</div>
<h3 class="text-lg font-semibold"><?= View::e($player['display_name'] ?? $player['username']) ?></h3>
<p class="text-secondary text-sm">@<?= View::e($player['username']) ?></p>
<div class="flex gap-4 mt-4">
<div class="text-center">
<div class="text-xl font-bold text-gold"><?= $player['level'] ?? 1 ?></div>
<div class="text-xs text-muted">المستوى</div>
</div>
<div class="text-center">
<div class="text-xl font-bold"><?= number_format($player['total_matches'] ?? 0) ?></div>
<div class="text-xs text-muted">المباريات</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-success"><?= number_format($player['total_wins'] ?? 0) ?></div>
<div class="text-xs text-muted">الانتصارات</div>
</div>
</div>
</div>
</div>
<div class="card" style="grid-column: span 2;">
<div class="card-header">
<h3 class="card-title">التصنيفات</h3>
</div>
<div class="grid grid-3 gap-4">
<?php
$ratings = [
'elo_blitz' => 'خاطف',
'elo_rapid' => 'سريع',
'elo_classical' => 'كلاسيك',
'elo_backgammon' => 'طاولة',
'elo_dominoes' => 'دومينو',
'elo_ludo' => 'لودو',
'elo_trivia' => 'معلومات',
];
foreach ($ratings as $key => $label):
?>
<div class="p-3 rounded bg-primary">
<div class="text-xs text-muted mb-1"><?= $label ?></div>
<div class="text-lg font-bold tabular-nums"><?= $player[$key] ?? 1200 ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Economy -->
<div class="grid grid-2 mb-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">الرصيد</h3>
</div>
<div class="flex gap-6">
<div>
<div class="text-xs text-muted mb-1">العملات</div>
<div class="text-xl font-bold tabular-nums"><?= number_format($player['coins'] ?? 0) ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الجواهر</div>
<div class="text-xl font-bold tabular-nums text-purple"><?= number_format($player['gems'] ?? 0) ?></div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-success btn-sm" onclick="showGrantModal()">منح</button>
<button class="btn btn-danger btn-sm" onclick="showRevokeModal()">سحب</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">معلومات</h3>
</div>
<div class="flex flex-col gap-3 text-sm">
<div class="flex justify-between"><span class="text-secondary">البريد</span><span><?= View::e($player['email'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الدولة</span><span><?= View::e($player['country_code'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">المدينة</span><span><?= View::e($player['city'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">تاريخ الانضمام</span><span class="tabular-nums"><?= $player['created_at'] ? date('Y-m-d', strtotime($player['created_at'])) : '-' ?></span></div>
<div class="flex justify-between"><span class="text-secondary">آخر نشاط</span><span class="tabular-nums"><?= $player['last_active_at'] ? date('Y-m-d H:i', strtotime($player['last_active_at'])) : '-' ?></span></div>
</div>
</div>
</div>
<!-- Tabs: Matches, Transactions, Reports -->
<div class="card">
<div class="tabs">
<button class="tab active" onclick="switchTab(this, 'matchesTab')">المباريات</button>
<button class="tab" onclick="switchTab(this, 'transactionsTab')">المعاملات</button>
<button class="tab" onclick="switchTab(this, 'reportsTab')">البلاغات</button>
</div>
<div id="matchesTab" class="tab-content">
<?php if (empty($matches)): ?>
<p class="text-secondary text-center p-5">لا توجد مباريات</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>اللعبة</th><th>النتيجة</th><th>النوع</th><th>التاريخ</th></tr></thead>
<tbody>
<?php foreach ($matches as $match): ?>
<tr>
<td><?= View::e($match['game_key']) ?></td>
<td><span class="badge badge-<?= ($match['winner_id'] ?? '') === $player['id'] ? 'success' : 'danger' ?>"><?= ($match['winner_id'] ?? '') === $player['id'] ? 'فوز' : 'خسارة' ?></span></td>
<td><?= View::e($match['mode'] ?? '-') ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($match['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div id="transactionsTab" class="tab-content hidden">
<?php if (empty($transactions)): ?>
<p class="text-secondary text-center p-5">لا توجد معاملات</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>النوع</th><th>العملة</th><th>المبلغ</th><th>الرصيد بعد</th><th>التاريخ</th></tr></thead>
<tbody>
<?php foreach ($transactions as $tx): ?>
<tr>
<td><span class="badge badge-info"><?= View::e($tx['type']) ?></span></td>
<td><?= $tx['currency'] === 'coins' ? 'عملات' : 'جواهر' ?></td>
<td class="tabular-nums <?= $tx['amount'] > 0 ? 'text-success' : 'text-danger' ?>"><?= $tx['amount'] > 0 ? '+' : '' ?><?= number_format($tx['amount']) ?></td>
<td class="tabular-nums"><?= number_format($tx['balance_after']) ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($tx['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div id="reportsTab" class="tab-content hidden">
<?php if (empty($reports)): ?>
<p class="text-secondary text-center p-5">لا توجد بلاغات</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>السبب</th><th>الحالة</th><th>التاريخ</th></tr></thead>
<tbody>
<?php foreach ($reports as $report): ?>
<tr>
<td><?= View::e($report['reason']) ?></td>
<td><span class="badge badge-<?= $report['status'] === 'pending' ? 'warning' : ($report['status'] === 'resolved' ? 'success' : 'default') ?>"><?= View::e($report['status']) ?></span></td>
<td class="text-xs tabular-nums"><?= date('Y-m-d', strtotime($report['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php if (!($player['is_banned'] ?? false)): ?>
<!-- Ban Form (hidden) -->
<div class="hidden" id="banFormTemplate">
<form method="POST" action="/players/<?= $player['id'] ?>/ban">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group">
<label class="form-label">سبب الحظر</label>
<textarea name="ban_reason" class="form-input" required placeholder="اكتب سبب الحظر..."></textarea>
</div>
</form>
</div>
<?php endif; ?>
/* Settings module styles */
<?php
class SettingsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function index(array $params, string $method): void
{
Auth::requireRole('superadmin');
$settings = $this->db->select('system_config', [
'select' => '*',
'order' => 'category.asc,key.asc',
]);
$grouped = [];
foreach ($settings as $s) {
$grouped[$s['category']][] = $s;
}
$pageTitle = 'الإعدادات';
$moduleCSS = 'settings';
$moduleJS = 'settings';
View::render('settings/index', compact('grouped', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$key = $_POST['key'] ?? '';
$value = $_POST['value'] ?? '';
if (empty($key)) {
Response::error('مفتاح غير صحيح', '/settings');
return;
}
$setting = $this->db->selectOne('system_config', ['key' => "eq.{$key}"]);
if (!$setting) {
Response::error('الإعداد غير موجود', '/settings');
return;
}
if (!$setting['is_editable']) {
Response::error('هذا الإعداد غير قابل للتعديل', '/settings');
return;
}
$old = $setting['value'];
$this->db->update('system_config', ['key' => "eq.{$key}"], [
'value' => $value,
'updated_at' => date('c'),
]);
AuditLog::log('update', 'system_config', $key, ['value' => $old], ['value' => $value]);
Response::success('تم تحديث الإعداد', '/settings');
}
}
<div class="content-header">
<h1>إعدادات النظام</h1>
</div>
<?php if (empty($grouped)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد إعدادات</h3><p class="empty-state-text">لم يتم تكوين أي إعدادات بعد</p></div></div>
<?php else: ?>
<?php
$categoryLabels = [
'matchmaking' => 'التوفيق',
'economy' => 'الاقتصاد',
'moderation' => 'الإشراف',
'platform' => 'المنصة',
'limits' => 'الحدود',
'general' => 'عام',
];
?>
<?php foreach ($grouped as $category => $settings): ?>
<div class="card mb-5">
<div class="card-header">
<h3 class="card-title"><?= View::e($categoryLabels[$category] ?? $category) ?></h3>
<span class="badge badge-default"><?= count($settings) ?> إعداد</span>
</div>
<div class="flex flex-col">
<?php foreach ($settings as $setting): ?>
<div class="flex items-center justify-between p-4" style="border-bottom: 1px solid var(--border);">
<div class="flex-1">
<div class="font-medium text-sm"><?= View::e($setting['label_ar'] ?? $setting['label'] ?? $setting['key']) ?></div>
<div class="text-xs text-muted" dir="ltr"><?= View::e($setting['key']) ?></div>
<?php if ($setting['description']): ?>
<div class="text-xs text-secondary mt-1"><?= View::e($setting['description']) ?></div>
<?php endif; ?>
</div>
<div class="flex items-center gap-3">
<?php if ($setting['value_type'] === 'boolean'): ?>
<form method="POST" action="/settings/update">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="key" value="<?= View::e($setting['key']) ?>">
<input type="hidden" name="value" value="<?= $setting['value'] === 'true' ? 'false' : 'true' ?>">
<label class="toggle">
<input type="checkbox" <?= $setting['value'] === 'true' ? 'checked' : '' ?> <?= !$setting['is_editable'] ? 'disabled' : '' ?> onchange="this.closest('form').submit()">
<span class="toggle-track"></span>
</label>
</form>
<?php else: ?>
<form method="POST" action="/settings/update" class="flex items-center gap-2">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="key" value="<?= View::e($setting['key']) ?>">
<input type="<?= $setting['value_type'] === 'number' ? 'number' : 'text' ?>" name="value" class="form-input" style="width: 200px; padding: var(--space-2) var(--space-3);" value="<?= View::e($setting['value']) ?>" <?= !$setting['is_editable'] ? 'disabled' : '' ?> dir="ltr">
<?php if ($setting['is_editable']): ?>
<button type="submit" class="btn btn-primary btn-sm">حفظ</button>
<?php endif; ?>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
/* Tournaments module styles */
/* ===== Wizard Steps ===== */
.wizard-steps {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-4) 0;
}
.wizard-step {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
opacity: 0.5;
transition: opacity 0.2s, background 0.2s;
}
.wizard-step.active {
opacity: 1;
background: var(--bg-elevated);
}
.wizard-step.completed {
opacity: 0.8;
}
.wizard-step-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
transition: background 0.2s, color 0.2s;
}
.wizard-step.active .wizard-step-number {
background: var(--color-primary);
color: #fff;
}
.wizard-step.completed .wizard-step-number {
background: var(--color-success);
color: #fff;
}
.wizard-step-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.wizard-step.active .wizard-step-label {
color: var(--text-primary);
}
/* Connector between steps */
.wizard-step + .wizard-step::before {
content: '';
display: block;
width: 24px;
height: 2px;
background: var(--border-default);
margin-inline-end: var(--space-2);
}
.wizard-step.completed + .wizard-step::before,
.wizard-step.active + .wizard-step::before {
background: var(--color-primary);
}
/* Wizard Panels */
.wizard-panel {
display: none;
}
.wizard-panel.active {
display: block;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.wizard-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-6);
padding-top: var(--space-4);
border-top: 1px solid var(--border-default);
}
.form-section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: var(--space-5);
color: var(--text-primary);
}
/* ===== Round Cards ===== */
.rounds-container {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.round-card {
border: 1px solid var(--border-default);
}
.round-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.round-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 1rem;
font-weight: 600;
}
.round-actions {
display: flex;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-default);
}
/* ===== Standings ===== */
.standing-rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.rank-gold {
background: #fbbf24;
color: #78350f;
}
.rank-silver {
background: #d1d5db;
color: #374151;
}
.rank-bronze {
background: #d97706;
color: #fff;
}
.standing-top td {
background: var(--bg-elevated);
}
/* ===== Review Summary ===== */
.review-summary {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.review-section {
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.review-section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-3);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.review-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
.review-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.review-label {
font-size: 0.75rem;
color: var(--text-muted);
}
.review-value {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
/* ===== Prize Preview ===== */
.card-inner {
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.prize-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-3);
background: var(--bg-primary);
border-radius: var(--radius-sm);
text-align: center;
}
.prize-rank {
font-size: 0.75rem;
color: var(--text-secondary);
}
.prize-amount {
font-size: 1rem;
font-weight: 700;
color: var(--color-primary);
}
/* ===== Info List ===== */
.info-list {
display: flex;
flex-direction: column;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border-subtle);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.info-value {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
/* ===== Banner Preview ===== */
.banner-preview {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
}
/* ===== Results Modal ===== */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-default);
}
.modal-header h3 {
font-size: 1.125rem;
font-weight: 600;
}
.modal-body {
padding: var(--space-5);
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border-default);
}
/* Result Entry Row */
.result-entry {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.result-entry:last-child {
border-bottom: none;
}
.result-player {
font-size: 0.875rem;
font-weight: 500;
}
.result-player:first-child {
text-align: end;
}
.result-select {
min-width: 100px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.wizard-steps {
flex-wrap: wrap;
gap: var(--space-1);
}
.wizard-step-label {
display: none;
}
.wizard-step + .wizard-step::before {
width: 12px;
}
.review-grid {
grid-template-columns: 1fr;
}
.result-entry {
grid-template-columns: 1fr;
text-align: center;
}
.result-player:first-child {
text-align: center;
}
}
/**
* Tournaments Module JS
* Multi-step wizard navigation + result entry forms
*/
(function () {
'use strict';
// ===== Multi-Step Wizard =====
const wizardSteps = document.querySelectorAll('.wizard-step');
const wizardPanels = document.querySelectorAll('.wizard-panel');
const nextButtons = document.querySelectorAll('.wizard-next');
const prevButtons = document.querySelectorAll('.wizard-prev');
let currentStep = 1;
function goToStep(step) {
// Validate current step before moving forward
if (step > currentStep) {
const currentPanel = document.querySelector(`.wizard-panel[data-panel="${currentStep}"]`);
if (currentPanel) {
const requiredFields = currentPanel.querySelectorAll('[required]');
let valid = true;
requiredFields.forEach(function (field) {
if (!field.value.trim()) {
field.classList.add('is-invalid');
valid = false;
} else {
field.classList.remove('is-invalid');
}
});
if (!valid) return;
}
}
// Update step indicators
wizardSteps.forEach(function (stepEl) {
const stepNum = parseInt(stepEl.dataset.step);
stepEl.classList.remove('active', 'completed');
if (stepNum === step) {
stepEl.classList.add('active');
} else if (stepNum < step) {
stepEl.classList.add('completed');
}
});
// Show/hide panels
wizardPanels.forEach(function (panel) {
panel.classList.remove('active');
if (parseInt(panel.dataset.panel) === step) {
panel.classList.add('active');
}
});
currentStep = step;
// Update review panel if going to step 5
if (step === 5) {
updateReviewSummary();
}
}
// Next buttons
nextButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
var nextStep = parseInt(this.dataset.next);
goToStep(nextStep);
});
});
// Previous buttons
prevButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
var prevStep = parseInt(this.dataset.prev);
goToStep(prevStep);
});
});
// ===== Review Summary Update =====
function updateReviewSummary() {
var form = document.getElementById('tournamentForm');
if (!form) return;
var formatLabels = {
'swiss': 'سويسري',
'round_robin': 'دوري كامل',
'single_elimination': 'خروج مباشر',
'double_elimination': 'خروج مزدوج'
};
setText('reviewName', getFieldValue(form, 'name') || '-');
// Game - show selected text
var gameSelect = form.querySelector('[name="game_key"]');
setText('reviewGame', gameSelect && gameSelect.selectedIndex > 0 ? gameSelect.options[gameSelect.selectedIndex].text : '-');
// Organization
var orgSelect = form.querySelector('[name="organization_id"]');
setText('reviewOrg', orgSelect && orgSelect.selectedIndex > 0 ? orgSelect.options[orgSelect.selectedIndex].text : 'بدون منظمة');
// Format
var formatVal = getFieldValue(form, 'format');
setText('reviewFormat', formatLabels[formatVal] || formatVal || '-');
setText('reviewRounds', getFieldValue(form, 'rounds_count') || '-');
setText('reviewMaxPlayers', getFieldValue(form, 'max_players') || '-');
setText('reviewTimeControl', getFieldValue(form, 'time_control') || '-');
// Economy
var fee = parseInt(getFieldValue(form, 'entry_fee')) || 0;
setText('reviewFee', fee > 0 ? fee.toLocaleString() + ' عملة' : 'مجاني');
var prize = parseInt(getFieldValue(form, 'prize_pool')) || 0;
setText('reviewPrize', prize > 0 ? prize.toLocaleString() + ' عملة' : '-');
// Dates
var startDate = getFieldValue(form, 'start_date');
setText('reviewStartDate', startDate ? formatDate(startDate) : '-');
var endDate = getFieldValue(form, 'end_date');
setText('reviewEndDate', endDate ? formatDate(endDate) : '-');
}
function getFieldValue(form, name) {
var field = form.querySelector('[name="' + name + '"]');
return field ? field.value : '';
}
function setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
var d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('ar-EG', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
// ===== Prize Distribution Preview =====
var prizePoolInput = document.querySelector('[name="prize_pool"]');
if (prizePoolInput) {
prizePoolInput.addEventListener('input', updatePrizePreview);
updatePrizePreview();
}
function updatePrizePreview() {
var total = parseInt(prizePoolInput.value) || 0;
// Default distribution: 50% / 30% / 20%
setText('prize1', total > 0 ? Math.floor(total * 0.5).toLocaleString() : '-');
setText('prize2', total > 0 ? Math.floor(total * 0.3).toLocaleString() : '-');
setText('prize3', total > 0 ? Math.floor(total * 0.2).toLocaleString() : '-');
}
// ===== Tournament Search =====
var searchInput = document.getElementById('tournamentSearch');
if (searchInput) {
var searchTimeout = null;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
var val = this.value;
searchTimeout = setTimeout(function () {
var url = new URL(window.location.href);
if (val) {
url.searchParams.set('search', val);
} else {
url.searchParams.delete('search');
}
url.searchParams.delete('page');
window.location.href = url.toString();
}, 500);
});
}
// ===== Results Entry Modal =====
window.openResultsForm = function (roundId, pairings) {
var modal = document.getElementById('resultsModal');
var roundIdInput = document.getElementById('resultsRoundId');
var body = document.getElementById('resultsBody');
if (!modal || !roundIdInput || !body) return;
roundIdInput.value = roundId;
body.innerHTML = '';
if (!pairings || !pairings.length) {
body.innerHTML = '<p class="text-secondary">لا توجد مواجهات في هذه الجولة</p>';
modal.style.display = 'flex';
return;
}
pairings.forEach(function (pairing, idx) {
var whiteName = (pairing.white && (pairing.white.name || pairing.white.id)) || 'لاعب ' + (idx * 2 + 1);
var blackName = (pairing.black && (pairing.black.name || pairing.black.id)) || 'لاعب ' + (idx * 2 + 2);
var row = document.createElement('div');
row.className = 'result-entry';
row.innerHTML =
'<span class="result-player">' + escapeHtml(whiteName) + '</span>' +
'<select name="results[' + idx + ']" class="form-input result-select">' +
' <option value="">-</option>' +
' <option value="1-0">1-0</option>' +
' <option value="0.5-0.5">0.5-0.5</option>' +
' <option value="0-1">0-1</option>' +
'</select>' +
'<span class="result-player">' + escapeHtml(blackName) + '</span>';
body.appendChild(row);
});
modal.style.display = 'flex';
};
window.closeResultsModal = function () {
var modal = document.getElementById('resultsModal');
if (modal) {
modal.style.display = 'none';
}
};
// Close modal on Escape key
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeResultsModal();
}
});
// ===== Utility =====
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
})();
<?php
class TournamentsController
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{
$status = $_GET['status'] ?? '';
$search = $_GET['search'] ?? '';
$queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if ($status) {
$queryParams['status'] = "eq.{$status}";
}
if ($search) {
$queryParams['name'] = "ilike.*{$search}*";
}
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('el3ab_tournaments', $countParams);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
$tournaments = $this->db->select('el3ab_tournaments', $queryParams);
$pageTitle = 'البطولات';
$moduleCSS = 'tournaments';
$moduleJS = 'tournaments';
View::render('tournaments/list', compact('tournaments', 'pagination', 'search', 'status', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function show(array $params, string $method): void
{
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
http_response_code(404);
View::render('errors/404');
return;
}
// Fetch rounds from local DB
$rounds = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$id}",
'order' => 'round_number.asc',
]);
// Fetch standings from Swiss API if tournament is in progress or completed
$standings = [];
if (in_array($tournament['status'], ['in_progress', 'completed']) && !empty($tournament['swiss_tournament_id'])) {
$response = ApiProxy::swiss('GET', '/tournaments/' . $tournament['swiss_tournament_id'] . '/standings');
if ($response['status'] === 200 && is_array($response['body'])) {
$standings = $response['body'];
}
}
// Fetch registered players
$players = $this->db->select('el3ab_tournament_players', [
'tournament_id' => "eq.{$id}",
'order' => 'registered_at.asc',
]);
$tab = $_GET['tab'] ?? 'info';
$pageTitle = $tournament['name'];
$moduleCSS = 'tournaments';
$moduleJS = 'tournaments';
View::render('tournaments/show', compact('tournament', 'rounds', 'standings', 'players', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function create(array $params, string $method): void
{
$tournament = [];
// Fetch games and organizations for the form
$games = $this->db->select('game_plugins', [
'select' => 'game_key,name_ar',
'is_enabled' => 'eq.true',
'order' => 'name_ar.asc',
]);
$organizations = $this->db->select('el3ab_organizations', [
'select' => 'id,name',
'order' => 'name.asc',
]);
$pageTitle = 'إنشاء بطولة';
$moduleCSS = 'tournaments';
$moduleJS = 'tournaments';
View::render('tournaments/form', compact('tournament', 'games', 'organizations', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('name', 'اسم البطولة')
->required('game_key', 'اللعبة')
->required('format', 'نظام البطولة')
->required('max_players', 'الحد الأقصى للاعبين')
->required('start_date', 'تاريخ البدء');
if ($validator->fails()) {
Response::error($validator->firstError(), '/tournaments/create');
return;
}
// Create organization in Swiss API if needed
$orgId = trim($_POST['organization_id'] ?? '');
$swissOrgId = null;
if ($orgId) {
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
$swissOrgId = $org['swiss_org_id'] ?? null;
if (!$swissOrgId) {
$orgResponse = ApiProxy::swiss('POST', '/organizations', [
'name' => $org['name'],
]);
if ($orgResponse['status'] === 201 || $orgResponse['status'] === 200) {
$swissOrgId = $orgResponse['body']['id'] ?? null;
$this->db->update('el3ab_organizations', ['id' => "eq.{$orgId}"], [
'swiss_org_id' => $swissOrgId,
]);
}
}
}
// Create event in Swiss API
$swissEventId = null;
$swissTournamentId = null;
if ($swissOrgId) {
$eventResponse = ApiProxy::swiss('POST', '/organizations/' . $swissOrgId . '/events', [
'name' => trim($_POST['name']),
'start_date' => $_POST['start_date'],
]);
if ($eventResponse['status'] === 201 || $eventResponse['status'] === 200) {
$swissEventId = $eventResponse['body']['id'] ?? null;
}
// Create tournament in Swiss API
if ($swissEventId) {
$tournamentResponse = ApiProxy::swiss('POST', '/events/' . $swissEventId . '/tournaments', [
'name' => trim($_POST['name']),
'format' => $_POST['format'],
'rounds' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''),
]);
if ($tournamentResponse['status'] === 201 || $tournamentResponse['status'] === 200) {
$swissTournamentId = $tournamentResponse['body']['id'] ?? null;
}
}
}
// Save tournament locally
$data = [
'name' => trim($_POST['name']),
'description' => trim($_POST['description'] ?? ''),
'game_key' => $_POST['game_key'],
'organization_id' => $orgId ?: null,
'format' => $_POST['format'],
'rounds_count' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''),
'max_players' => (int)$_POST['max_players'],
'entry_fee' => (int)($_POST['entry_fee'] ?? 0),
'prize_pool' => (int)($_POST['prize_pool'] ?? 0),
'start_date' => $_POST['start_date'],
'end_date' => $_POST['end_date'] ?? null,
'banner_url' => trim($_POST['banner_url'] ?? ''),
'status' => 'draft',
'swiss_org_id' => $swissOrgId,
'swiss_event_id' => $swissEventId,
'swiss_tournament_id' => $swissTournamentId,
'created_by' => $_SESSION['user']['username'] ?? 'system',
];
$this->db->insert('el3ab_tournaments', $data);
AuditLog::log('create', 'tournament', $data['name'], null, $data);
Response::success('تم إنشاء البطولة بنجاح', '/tournaments');
}
public function edit(array $params, string $method): void
{
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
http_response_code(404);
View::render('errors/404');
return;
}
if ($tournament['status'] !== 'draft') {
Response::error('لا يمكن تعديل بطولة بدأت بالفعل', '/tournaments/' . $id);
return;
}
$games = $this->db->select('game_plugins', [
'select' => 'game_key,name_ar',
'is_enabled' => 'eq.true',
'order' => 'name_ar.asc',
]);
$organizations = $this->db->select('el3ab_organizations', [
'select' => 'id,name',
'order' => 'name.asc',
]);
$pageTitle = 'تعديل البطولة';
$moduleCSS = 'tournaments';
$moduleJS = 'tournaments';
View::render('tournaments/form', compact('tournament', 'games', 'organizations', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if ($old['status'] !== 'draft') {
Response::error('لا يمكن تعديل بطولة بدأت بالفعل', '/tournaments/' . $id);
return;
}
$data = [
'name' => trim($_POST['name']),
'description' => trim($_POST['description'] ?? ''),
'game_key' => $_POST['game_key'],
'organization_id' => $_POST['organization_id'] ?: null,
'format' => $_POST['format'],
'rounds_count' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''),
'max_players' => (int)$_POST['max_players'],
'entry_fee' => (int)($_POST['entry_fee'] ?? 0),
'prize_pool' => (int)($_POST['prize_pool'] ?? 0),
'start_date' => $_POST['start_date'],
'end_date' => $_POST['end_date'] ?? null,
'banner_url' => trim($_POST['banner_url'] ?? ''),
'updated_at' => date('c'),
];
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], $data);
AuditLog::log('update', 'tournament', $id, $old, $data);
Response::success('تم تحديث البطولة', '/tournaments/' . $id);
}
public function start(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if (!in_array($tournament['status'], ['draft', 'registration'])) {
Response::error('لا يمكن بدء البطولة في حالتها الحالية', '/tournaments/' . $id);
return;
}
// Register players in Swiss API
if (!empty($tournament['swiss_tournament_id'])) {
$players = $this->db->select('el3ab_tournament_players', [
'tournament_id' => "eq.{$id}",
]);
foreach ($players as $player) {
ApiProxy::swiss('POST', '/tournaments/' . $tournament['swiss_tournament_id'] . '/players', [
'id' => $player['player_id'],
'name' => $player['player_name'] ?? $player['player_id'],
'rating' => $player['rating'] ?? 1500,
]);
}
}
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'in_progress',
'started_at' => date('c'),
'updated_at' => date('c'),
]);
AuditLog::log('start', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'in_progress']);
Response::success('تم بدء البطولة', '/tournaments/' . $id);
}
public function complete(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if ($tournament['status'] !== 'in_progress') {
Response::error('لا يمكن إنهاء بطولة غير جارية', '/tournaments/' . $id);
return;
}
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'completed',
'completed_at' => date('c'),
'updated_at' => date('c'),
]);
AuditLog::log('complete', 'tournament', $id, ['status' => 'in_progress'], ['status' => 'completed']);
Response::success('تم إنهاء البطولة بنجاح', '/tournaments/' . $id);
}
public function cancel(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if ($tournament['status'] === 'completed') {
Response::error('لا يمكن إلغاء بطولة مكتملة', '/tournaments/' . $id);
return;
}
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'cancelled',
'updated_at' => date('c'),
]);
AuditLog::log('cancel', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'cancelled']);
Response::success('تم إلغاء البطولة', '/tournaments/' . $id);
}
public function generateRound(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if ($tournament['status'] !== 'in_progress') {
Response::error('البطولة ليست جارية', '/tournaments/' . $id);
return;
}
if (empty($tournament['swiss_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id);
return;
}
// Call Swiss API to generate round
$response = ApiProxy::swiss('POST', '/tournaments/' . $tournament['swiss_tournament_id'] . '/rounds/generate');
if ($response['status'] !== 200 && $response['status'] !== 201) {
$errorMsg = $response['body']['message'] ?? 'فشل في إنشاء الجولة';
Response::error($errorMsg, '/tournaments/' . $id . '?tab=rounds');
return;
}
$roundData = $response['body'];
// Save round locally
$currentRounds = $this->db->count('el3ab_tournament_rounds', ['tournament_id' => "eq.{$id}"]);
$roundNumber = $currentRounds + 1;
$this->db->insert('el3ab_tournament_rounds', [
'tournament_id' => $id,
'round_number' => $roundNumber,
'swiss_round_id' => $roundData['id'] ?? null,
'pairings' => json_encode($roundData['pairings'] ?? []),
'status' => 'in_progress',
'created_at' => date('c'),
]);
// Update current round in tournament
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'current_round' => $roundNumber,
'updated_at' => date('c'),
]);
AuditLog::log('generate_round', 'tournament', $id, null, ['round_number' => $roundNumber]);
Response::success("تم إنشاء الجولة {$roundNumber}", '/tournaments/' . $id . '?tab=rounds');
}
public function submitResults(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$roundId = $params['round_id'] ?? $_POST['round_id'] ?? null;
if (!$roundId) {
Response::error('لم يتم تحديد الجولة', '/tournaments/' . $id . '?tab=rounds');
return;
}
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || $tournament['status'] !== 'in_progress') {
Response::error('البطولة غير جارية', '/tournaments/' . $id);
return;
}
$round = $this->db->selectOne('el3ab_tournament_rounds', [
'id' => "eq.{$roundId}",
'tournament_id' => "eq.{$id}",
]);
if (!$round) {
Response::error('الجولة غير موجودة', '/tournaments/' . $id . '?tab=rounds');
return;
}
// Collect results from POST data
$results = [];
if (!empty($_POST['results']) && is_array($_POST['results'])) {
foreach ($_POST['results'] as $pairingIndex => $result) {
$results[] = [
'pairing_index' => (int)$pairingIndex,
'result' => $result, // '1-0', '0-1', '0.5-0.5'
];
}
}
if (empty($results)) {
Response::error('لم يتم إدخال أي نتائج', '/tournaments/' . $id . '?tab=rounds');
return;
}
// Submit results to Swiss API
if (!empty($round['swiss_round_id'])) {
$response = ApiProxy::swiss('PATCH', '/rounds/' . $round['swiss_round_id'] . '/pairings', [
'results' => $results,
]);
if ($response['status'] !== 200) {
$errorMsg = $response['body']['message'] ?? 'فشل في إرسال النتائج';
Response::error($errorMsg, '/tournaments/' . $id . '?tab=rounds');
return;
}
}
// Update round locally
$this->db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [
'results' => json_encode($results),
'status' => 'completed',
'completed_at' => date('c'),
]);
AuditLog::log('submit_results', 'tournament', $id, null, [
'round_id' => $roundId,
'results_count' => count($results),
]);
Response::success('تم حفظ النتائج', '/tournaments/' . $id . '?tab=rounds');
}
public function standings(array $params, string $method): void
{
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
Response::json(['error' => 'البطولة غير موجودة'], 404);
return;
}
if (empty($tournament['swiss_tournament_id'])) {
Response::json(['error' => 'لا يوجد ربط مع نظام السويسري'], 400);
return;
}
$response = ApiProxy::swiss('GET', '/tournaments/' . $tournament['swiss_tournament_id'] . '/standings');
if ($response['status'] === 200) {
Response::json([
'standings' => $response['body'],
'tournament_id' => $id,
'current_round' => $tournament['current_round'] ?? 0,
]);
} else {
Response::json(['error' => 'فشل في جلب الترتيب', 'details' => $response['body']], 500);
}
}
}
<?php $isEdit = !empty($tournament['id']); ?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/tournaments" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= $isEdit ? 'تعديل البطولة' : 'إنشاء بطولة' ?></h1>
</div>
</div>
<!-- Wizard Step Indicator -->
<div class="wizard-steps mb-6">
<div class="wizard-step active" data-step="1">
<span class="wizard-step-number">1</span>
<span class="wizard-step-label">أساسيات</span>
</div>
<div class="wizard-step" data-step="2">
<span class="wizard-step-number">2</span>
<span class="wizard-step-label">النظام</span>
</div>
<div class="wizard-step" data-step="3">
<span class="wizard-step-number">3</span>
<span class="wizard-step-label">الاقتصاد</span>
</div>
<div class="wizard-step" data-step="4">
<span class="wizard-step-number">4</span>
<span class="wizard-step-label">الجدول</span>
</div>
<div class="wizard-step" data-step="5">
<span class="wizard-step-number">5</span>
<span class="wizard-step-label">مراجعة</span>
</div>
</div>
<div class="card">
<form method="POST" action="<?= $isEdit ? '/tournaments/' . $tournament['id'] . '/update' : '/tournaments/store' ?>" id="tournamentForm" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<!-- Step 1: Basics -->
<div class="wizard-panel active" data-panel="1">
<h2 class="form-section-title">أساسيات البطولة</h2>
<div class="form-group">
<label class="form-label">اسم البطولة *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($tournament['name'] ?? '') ?>" required placeholder="مثال: بطولة الشطرنج الأسبوعية">
<span class="form-error"></span>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">اللعبة *</label>
<select name="game_key" class="form-input" required>
<option value="">اختر اللعبة</option>
<?php foreach ($games ?? [] as $game): ?>
<option value="<?= View::e($game['game_key']) ?>" <?= ($tournament['game_key'] ?? '') === $game['game_key'] ? 'selected' : '' ?>>
<?= View::e($game['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">المنظمة</label>
<select name="organization_id" class="form-input">
<option value="">بدون منظمة</option>
<?php foreach ($organizations ?? [] as $org): ?>
<option value="<?= View::e($org['id']) ?>" <?= ($tournament['organization_id'] ?? '') === $org['id'] ? 'selected' : '' ?>>
<?= View::e($org['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">الوصف</label>
<textarea name="description" class="form-input" rows="3" placeholder="وصف مختصر عن البطولة..."><?= View::e($tournament['description'] ?? '') ?></textarea>
</div>
<div class="wizard-nav">
<span></span>
<button type="button" class="btn btn-primary wizard-next" data-next="2">
التالي
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
</div>
<!-- Step 2: System -->
<div class="wizard-panel" data-panel="2">
<h2 class="form-section-title">نظام البطولة</h2>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">نظام المنافسة *</label>
<select name="format" class="form-input" required>
<option value="">اختر النظام</option>
<option value="swiss" <?= ($tournament['format'] ?? '') === 'swiss' ? 'selected' : '' ?>>سويسري</option>
<option value="round_robin" <?= ($tournament['format'] ?? '') === 'round_robin' ? 'selected' : '' ?>>دوري كامل</option>
<option value="single_elimination" <?= ($tournament['format'] ?? '') === 'single_elimination' ? 'selected' : '' ?>>خروج مباشر</option>
<option value="double_elimination" <?= ($tournament['format'] ?? '') === 'double_elimination' ? 'selected' : '' ?>>خروج مزدوج</option>
</select>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">عدد الجولات</label>
<input type="number" name="rounds_count" class="form-input" value="<?= $tournament['rounds_count'] ?? 5 ?>" min="1" max="30">
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">التحكم بالوقت</label>
<input type="text" name="time_control" class="form-input" value="<?= View::e($tournament['time_control'] ?? '') ?>" placeholder="مثال: 10+5" dir="ltr">
</div>
<div class="form-group">
<label class="form-label">الحد الأقصى للاعبين *</label>
<input type="number" name="max_players" class="form-input" value="<?= $tournament['max_players'] ?? 32 ?>" min="4" max="1024" required>
<span class="form-error"></span>
</div>
</div>
<div class="wizard-nav">
<button type="button" class="btn btn-ghost wizard-prev" data-prev="1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
السابق
</button>
<button type="button" class="btn btn-primary wizard-next" data-next="3">
التالي
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
</div>
<!-- Step 3: Economy -->
<div class="wizard-panel" data-panel="3">
<h2 class="form-section-title">الاقتصاد والجوائز</h2>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">رسوم الاشتراك (عملات)</label>
<input type="number" name="entry_fee" class="form-input" value="<?= $tournament['entry_fee'] ?? 0 ?>" min="0">
<span class="form-hint">0 = مجاني</span>
</div>
<div class="form-group">
<label class="form-label">مجموع الجوائز (عملات)</label>
<input type="number" name="prize_pool" class="form-input" value="<?= $tournament['prize_pool'] ?? 0 ?>" min="0">
</div>
</div>
<div class="prize-preview card card-inner mt-4">
<h3 class="text-sm font-medium mb-3">معاينة توزيع الجوائز</h3>
<div class="grid grid-3 gap-3" id="prizeDistribution">
<div class="prize-slot">
<span class="prize-rank">المركز الأول</span>
<span class="prize-amount" id="prize1">-</span>
</div>
<div class="prize-slot">
<span class="prize-rank">المركز الثاني</span>
<span class="prize-amount" id="prize2">-</span>
</div>
<div class="prize-slot">
<span class="prize-rank">المركز الثالث</span>
<span class="prize-amount" id="prize3">-</span>
</div>
</div>
</div>
<div class="wizard-nav">
<button type="button" class="btn btn-ghost wizard-prev" data-prev="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
السابق
</button>
<button type="button" class="btn btn-primary wizard-next" data-next="4">
التالي
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
</div>
<!-- Step 4: Schedule -->
<div class="wizard-panel" data-panel="4">
<h2 class="form-section-title">الجدول الزمني</h2>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">تاريخ البدء *</label>
<input type="datetime-local" name="start_date" class="form-input" value="<?= $tournament['start_date'] ?? '' ?>" required dir="ltr">
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">تاريخ الانتهاء</label>
<input type="datetime-local" name="end_date" class="form-input" value="<?= $tournament['end_date'] ?? '' ?>" dir="ltr">
</div>
</div>
<div class="form-group">
<label class="form-label">رابط البانر (صورة)</label>
<input type="url" name="banner_url" class="form-input" value="<?= View::e($tournament['banner_url'] ?? '') ?>" placeholder="https://..." dir="ltr">
</div>
<?php if (!empty($tournament['banner_url'])): ?>
<div class="form-group">
<img src="<?= View::e($tournament['banner_url']) ?>" alt="بانر البطولة" class="banner-preview">
</div>
<?php endif; ?>
<div class="wizard-nav">
<button type="button" class="btn btn-ghost wizard-prev" data-prev="3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
السابق
</button>
<button type="button" class="btn btn-primary wizard-next" data-next="5">
التالي
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
</div>
<!-- Step 5: Review -->
<div class="wizard-panel" data-panel="5">
<h2 class="form-section-title">مراجعة وتأكيد</h2>
<div class="review-summary">
<div class="review-section">
<h3 class="review-section-title">أساسيات</h3>
<div class="review-grid">
<div class="review-item">
<span class="review-label">الاسم:</span>
<span class="review-value" id="reviewName">-</span>
</div>
<div class="review-item">
<span class="review-label">اللعبة:</span>
<span class="review-value" id="reviewGame">-</span>
</div>
<div class="review-item">
<span class="review-label">المنظمة:</span>
<span class="review-value" id="reviewOrg">-</span>
</div>
</div>
</div>
<div class="review-section">
<h3 class="review-section-title">النظام</h3>
<div class="review-grid">
<div class="review-item">
<span class="review-label">نظام المنافسة:</span>
<span class="review-value" id="reviewFormat">-</span>
</div>
<div class="review-item">
<span class="review-label">عدد الجولات:</span>
<span class="review-value" id="reviewRounds">-</span>
</div>
<div class="review-item">
<span class="review-label">الحد الأقصى:</span>
<span class="review-value" id="reviewMaxPlayers">-</span>
</div>
<div class="review-item">
<span class="review-label">التحكم بالوقت:</span>
<span class="review-value" id="reviewTimeControl">-</span>
</div>
</div>
</div>
<div class="review-section">
<h3 class="review-section-title">الاقتصاد</h3>
<div class="review-grid">
<div class="review-item">
<span class="review-label">رسوم الاشتراك:</span>
<span class="review-value" id="reviewFee">-</span>
</div>
<div class="review-item">
<span class="review-label">مجموع الجوائز:</span>
<span class="review-value" id="reviewPrize">-</span>
</div>
</div>
</div>
<div class="review-section">
<h3 class="review-section-title">الجدول</h3>
<div class="review-grid">
<div class="review-item">
<span class="review-label">تاريخ البدء:</span>
<span class="review-value" id="reviewStartDate">-</span>
</div>
<div class="review-item">
<span class="review-label">تاريخ الانتهاء:</span>
<span class="review-value" id="reviewEndDate">-</span>
</div>
</div>
</div>
</div>
<div class="wizard-nav">
<button type="button" class="btn btn-ghost wizard-prev" data-prev="4">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
السابق
</button>
<button type="submit" class="btn btn-primary">
<span class="btn-text"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء البطولة' ?></span>
<span class="btn-spinner"></span>
</button>
</div>
</div>
</form>
</div>
<div class="content-header">
<h1>البطولات</h1>
<a href="/tournaments/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إنشاء بطولة
</a>
</div>
<!-- Filter Pills -->
<div class="filter-pills mb-5">
<a href="/tournaments" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<a href="/tournaments?status=draft" class="filter-pill <?= $status === 'draft' ? 'active' : '' ?>">مسودة</a>
<a href="/tournaments?status=registration" class="filter-pill <?= $status === 'registration' ? 'active' : '' ?>">التسجيل</a>
<a href="/tournaments?status=in_progress" class="filter-pill <?= $status === 'in_progress' ? 'active' : '' ?>">جارية</a>
<a href="/tournaments?status=completed" class="filter-pill <?= $status === 'completed' ? 'active' : '' ?>">مكتملة</a>
</div>
<div class="data-table-wrapper">
<div class="table-toolbar">
<div class="table-search">
<svg class="table-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="بحث باسم البطولة..." value="<?= View::e($search) ?>" id="tournamentSearch">
</div>
</div>
<?php if (empty($tournaments)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 6 9 6 9z"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5C17 4 18 9 18 9z"/><path d="M4 22h16"/><path d="M10 22V8a2 2 0 0 1 4 0v14"/><path d="M8 22h8"/><path d="M12 2v2"/></svg>
<h3 class="empty-state-title">لا توجد بطولات</h3>
<p class="empty-state-text">لم يتم العثور على أي بطولات<?= $search ? ' لبحثك "' . View::e($search) . '"' : '' ?></p>
</div>
<?php else: ?>
<table class="data-table" id="tournamentsTable">
<thead>
<tr>
<th>الاسم</th>
<th>اللعبة</th>
<th>النظام</th>
<th>الحالة</th>
<th>اللاعبين</th>
<th>تاريخ البدء</th>
<th style="width: 60px;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($tournaments as $t): ?>
<tr>
<td>
<a href="/tournaments/<?= $t['id'] ?>" class="font-medium text-link">
<?= View::e($t['name']) ?>
</a>
</td>
<td><?= View::e($t['game_key'] ?? '-') ?></td>
<td>
<?php
$formats = [
'swiss' => 'سويسري',
'round_robin' => 'دوري',
'single_elimination' => 'خروج مباشر',
'double_elimination' => 'خروج مزدوج',
];
echo $formats[$t['format'] ?? ''] ?? View::e($t['format'] ?? '-');
?>
</td>
<td>
<?php
$statusLabels = [
'draft' => ['مسودة', 'badge-default'],
'registration' => ['التسجيل', 'badge-info'],
'in_progress' => ['جارية', 'badge-warning'],
'completed' => ['مكتملة', 'badge-success'],
'cancelled' => ['ملغاة', 'badge-danger'],
];
$s = $statusLabels[$t['status'] ?? ''] ?? ['غير معروف', 'badge-default'];
?>
<span class="badge <?= $s[1] ?>"><?= $s[0] ?></span>
</td>
<td class="tabular-nums">
<?= $t['players_count'] ?? 0 ?> / <?= $t['max_players'] ?? '-' ?>
</td>
<td class="text-xs text-muted tabular-nums">
<?= $t['start_date'] ? date('Y/m/d', strtotime($t['start_date'])) : '-' ?>
</td>
<td>
<div class="dropdown">
<button class="btn btn-icon btn-ghost" onclick="toggleDropdown(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
<div class="dropdown-menu">
<a href="/tournaments/<?= $t['id'] ?>" class="dropdown-item">عرض</a>
<?php if ($t['status'] === 'draft'): ?>
<a href="/tournaments/<?= $t['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php endif; ?>
<?php if (in_array($t['status'], ['draft', 'registration'])): ?>
<form method="POST" action="/tournaments/<?= $t['id'] ?>/start" style="display:contents;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="dropdown-item">بدء البطولة</button>
</form>
<?php endif; ?>
<?php if ($t['status'] !== 'completed' && $t['status'] !== 'cancelled'): ?>
<div class="dropdown-divider"></div>
<form method="POST" action="/tournaments/<?= $t['id'] ?>/cancel" style="display:contents;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="dropdown-item danger" onclick="return confirm('هل أنت متأكد من إلغاء البطولة؟')">إلغاء</button>
</form>
<?php endif; ?>
</div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (isset($pagination) && $pagination->totalPages > 1): ?>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<a href="?page=<?= $pagination->page - 1 ?>&status=<?= $status ?>&search=<?= urlencode($search) ?>" class="pagination-btn <?= !$pagination->hasPrev() ? 'disabled' : '' ?>" <?= !$pagination->hasPrev() ? 'disabled' : '' ?>>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&status=<?= $status ?>&search=<?= urlencode($search) ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
<a href="?page=<?= $pagination->page + 1 ?>&status=<?= $status ?>&search=<?= urlencode($search) ?>" class="pagination-btn <?= !$pagination->hasNext() ? 'disabled' : '' ?>" <?= !$pagination->hasNext() ? 'disabled' : '' ?>>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</a>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php
$statusLabels = [
'draft' => ['مسودة', 'badge-default'],
'registration' => ['التسجيل', 'badge-info'],
'in_progress' => ['جارية', 'badge-warning'],
'completed' => ['مكتملة', 'badge-success'],
'cancelled' => ['ملغاة', 'badge-danger'],
];
$s = $statusLabels[$tournament['status'] ?? ''] ?? ['غير معروف', 'badge-default'];
$formatLabels = [
'swiss' => 'سويسري',
'round_robin' => 'دوري كامل',
'single_elimination' => 'خروج مباشر',
'double_elimination' => 'خروج مزدوج',
];
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/tournaments" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<div>
<h1><?= View::e($tournament['name']) ?></h1>
<span class="badge <?= $s[1] ?> mt-1"><?= $s[0] ?></span>
</div>
</div>
<div class="flex gap-2">
<?php if (in_array($tournament['status'], ['draft', 'registration'])): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/start" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل أنت متأكد من بدء البطولة؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
بدء البطولة
</button>
</form>
<?php endif; ?>
<?php if ($tournament['status'] === 'in_progress'): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/generate-round" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg>
إنشاء جولة
</button>
</form>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/complete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل أنت متأكد من إنهاء البطولة؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
إنهاء البطولة
</button>
</form>
<?php endif; ?>
<?php if (!in_array($tournament['status'], ['completed', 'cancelled'])): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/cancel" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-danger" onclick="return confirm('هل أنت متأكد من إلغاء البطولة؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
إلغاء
</button>
</form>
<?php endif; ?>
<?php if ($tournament['status'] === 'draft'): ?>
<a href="/tournaments/<?= $tournament['id'] ?>/edit" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
تعديل
</a>
<?php endif; ?>
</div>
</div>
<!-- Tabs -->
<div class="tabs mb-5">
<a href="/tournaments/<?= $tournament['id'] ?>?tab=info" class="tab <?= $tab === 'info' ? 'active' : '' ?>">معلومات</a>
<a href="/tournaments/<?= $tournament['id'] ?>?tab=rounds" class="tab <?= $tab === 'rounds' ? 'active' : '' ?>">الجولات</a>
<a href="/tournaments/<?= $tournament['id'] ?>?tab=standings" class="tab <?= $tab === 'standings' ? 'active' : '' ?>">الترتيب</a>
</div>
<!-- Tab: Info -->
<?php if ($tab === 'info'): ?>
<div class="grid grid-2 gap-5">
<div class="card">
<h3 class="card-title">تفاصيل البطولة</h3>
<div class="info-list">
<div class="info-item">
<span class="info-label">اللعبة</span>
<span class="info-value"><?= View::e($tournament['game_key'] ?? '-') ?></span>
</div>
<div class="info-item">
<span class="info-label">النظام</span>
<span class="info-value"><?= $formatLabels[$tournament['format'] ?? ''] ?? '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">عدد الجولات</span>
<span class="info-value"><?= $tournament['rounds_count'] ?? '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">الجولة الحالية</span>
<span class="info-value"><?= $tournament['current_round'] ?? 0 ?> / <?= $tournament['rounds_count'] ?? '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">التحكم بالوقت</span>
<span class="info-value" dir="ltr"><?= View::e($tournament['time_control'] ?? '-') ?></span>
</div>
<div class="info-item">
<span class="info-label">الحد الأقصى</span>
<span class="info-value"><?= $tournament['max_players'] ?? '-' ?> لاعب</span>
</div>
<div class="info-item">
<span class="info-label">اللاعبون المسجلون</span>
<span class="info-value"><?= count($players) ?></span>
</div>
</div>
</div>
<div class="card">
<h3 class="card-title">الاقتصاد والجدول</h3>
<div class="info-list">
<div class="info-item">
<span class="info-label">رسوم الاشتراك</span>
<span class="info-value"><?= $tournament['entry_fee'] ? number_format($tournament['entry_fee']) . ' عملة' : 'مجاني' ?></span>
</div>
<div class="info-item">
<span class="info-label">مجموع الجوائز</span>
<span class="info-value"><?= $tournament['prize_pool'] ? number_format($tournament['prize_pool']) . ' عملة' : '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">تاريخ البدء</span>
<span class="info-value"><?= $tournament['start_date'] ? date('Y/m/d H:i', strtotime($tournament['start_date'])) : '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">تاريخ الانتهاء</span>
<span class="info-value"><?= !empty($tournament['end_date']) ? date('Y/m/d H:i', strtotime($tournament['end_date'])) : '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">تاريخ الإنشاء</span>
<span class="info-value"><?= $tournament['created_at'] ? date('Y/m/d H:i', strtotime($tournament['created_at'])) : '-' ?></span>
</div>
<div class="info-item">
<span class="info-label">أنشأها</span>
<span class="info-value"><?= View::e($tournament['created_by'] ?? '-') ?></span>
</div>
</div>
</div>
<?php if (!empty($tournament['description'])): ?>
<div class="card" style="grid-column: 1 / -1;">
<h3 class="card-title">الوصف</h3>
<p class="text-secondary"><?= nl2br(View::e($tournament['description'])) ?></p>
</div>
<?php endif; ?>
<?php if (!empty($tournament['banner_url'])): ?>
<div class="card" style="grid-column: 1 / -1;">
<h3 class="card-title">البانر</h3>
<img src="<?= View::e($tournament['banner_url']) ?>" alt="بانر البطولة" class="banner-preview">
</div>
<?php endif; ?>
</div>
<!-- Players List -->
<?php if (!empty($players)): ?>
<div class="card mt-5">
<h3 class="card-title">اللاعبون المسجلون (<?= count($players) ?>)</h3>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>اللاعب</th>
<th>التقييم</th>
<th>تاريخ التسجيل</th>
</tr>
</thead>
<tbody>
<?php foreach ($players as $i => $player): ?>
<tr>
<td><?= $i + 1 ?></td>
<td><?= View::e($player['player_name'] ?? $player['player_id']) ?></td>
<td class="tabular-nums"><?= $player['rating'] ?? 1500 ?></td>
<td class="text-xs text-muted"><?= $player['registered_at'] ? date('Y/m/d H:i', strtotime($player['registered_at'])) : '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- Tab: Rounds -->
<?php if ($tab === 'rounds'): ?>
<div class="rounds-container">
<?php if (empty($rounds)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<h3 class="empty-state-title">لا توجد جولات</h3>
<p class="empty-state-text">لم يتم إنشاء أي جولات بعد</p>
</div>
<?php else: ?>
<?php foreach ($rounds as $round): ?>
<div class="round-card card mb-4">
<div class="round-header">
<h3 class="round-title">
الجولة <?= $round['round_number'] ?>
<?php if ($round['status'] === 'completed'): ?>
<span class="badge badge-success">مكتملة</span>
<?php else: ?>
<span class="badge badge-warning">جارية</span>
<?php endif; ?>
</h3>
</div>
<?php
$pairings = json_decode($round['pairings'] ?? '[]', true);
$results = json_decode($round['results'] ?? '[]', true);
$resultsMap = [];
foreach ($results as $r) {
$resultsMap[$r['pairing_index']] = $r['result'];
}
?>
<?php if (!empty($pairings)): ?>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>اللاعب الأبيض</th>
<th>النتيجة</th>
<th>اللاعب الأسود</th>
</tr>
</thead>
<tbody>
<?php foreach ($pairings as $idx => $pairing): ?>
<tr>
<td><?= $idx + 1 ?></td>
<td><?= View::e($pairing['white']['name'] ?? $pairing['white']['id'] ?? '-') ?></td>
<td class="text-center font-medium">
<?= $resultsMap[$idx] ?? '-' ?>
</td>
<td><?= View::e($pairing['black']['name'] ?? $pairing['black']['id'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($round['status'] !== 'completed' && $tournament['status'] === 'in_progress'): ?>
<div class="round-actions mt-4">
<button type="button" class="btn btn-primary btn-sm" onclick="openResultsForm('<?= $round['id'] ?>', <?= htmlspecialchars(json_encode($pairings), ENT_QUOTES) ?>)">
إدخال النتائج
</button>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Results Entry Modal -->
<div class="modal" id="resultsModal" style="display:none;">
<div class="modal-overlay" onclick="closeResultsModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>إدخال نتائج الجولة</h3>
<button type="button" class="btn btn-icon btn-ghost" onclick="closeResultsModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/submit-results" id="resultsForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="round_id" id="resultsRoundId" value="">
<div class="modal-body" id="resultsBody">
<!-- Filled dynamically by JS -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeResultsModal()">إلغاء</button>
<button type="submit" class="btn btn-primary">حفظ النتائج</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<!-- Tab: Standings -->
<?php if ($tab === 'standings'): ?>
<div class="card">
<?php if (empty($standings)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 6 9 6 9z"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5C17 4 18 9 18 9z"/><path d="M4 22h16"/><path d="M10 22V8a2 2 0 0 1 4 0v14"/></svg>
<h3 class="empty-state-title">لا يوجد ترتيب</h3>
<p class="empty-state-text">سيظهر الترتيب بعد بدء البطولة وإنشاء الجولات</p>
</div>
<?php else: ?>
<h3 class="card-title">ترتيب اللاعبين</h3>
<table class="data-table">
<thead>
<tr>
<th>المركز</th>
<th>اللاعب</th>
<th>النقاط</th>
<th>المباريات</th>
<th>فوز</th>
<th>تعادل</th>
<th>خسارة</th>
<th>نقاط التعادل</th>
</tr>
</thead>
<tbody>
<?php foreach ($standings as $i => $standing): ?>
<tr class="<?= $i < 3 ? 'standing-top' : '' ?>">
<td>
<span class="standing-rank <?= $i === 0 ? 'rank-gold' : ($i === 1 ? 'rank-silver' : ($i === 2 ? 'rank-bronze' : '')) ?>">
<?= $standing['rank'] ?? ($i + 1) ?>
</span>
</td>
<td class="font-medium"><?= View::e($standing['player_name'] ?? $standing['player_id'] ?? '-') ?></td>
<td class="tabular-nums font-medium"><?= $standing['points'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['games_played'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['wins'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['draws'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['losses'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['tiebreak'] ?? 0 ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes countUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes rowRemove {
to {
opacity: 0;
transform: scaleY(0);
height: 0;
padding: 0;
margin: 0;
}
}
@keyframes checkmark {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(32, 130, 240, 0.3); }
50% { box-shadow: 0 0 20px rgba(32, 130, 240, 0.6); }
}
/* Animation utilities */
.animate-fade-in {
animation: fadeIn var(--duration-normal) var(--ease-out);
}
.animate-fade-in-up {
animation: fadeInUp var(--duration-normal) var(--ease-out);
}
.animate-slide-in {
animation: slideIn var(--duration-normal) var(--ease-spring);
}
.animate-scale-in {
animation: scaleIn var(--duration-normal) var(--ease-spring);
}
/* Staggered animations */
.stagger > * {
animation: fadeInUp var(--duration-normal) var(--ease-out) both;
}
.stagger > *:nth-child(1) { animation-delay: 0ms; }
.stagger > *:nth-child(2) { animation-delay: 50ms; }
.stagger > *:nth-child(3) { animation-delay: 100ms; }
.stagger > *:nth-child(4) { animation-delay: 150ms; }
.stagger > *:nth-child(5) { animation-delay: 200ms; }
.stagger > *:nth-child(6) { animation-delay: 250ms; }
.stagger > *:nth-child(7) { animation-delay: 300ms; }
.stagger > *:nth-child(8) { animation-delay: 350ms; }
/* Transition classes for JS */
.transition-fade {
transition: opacity var(--duration-normal) var(--ease-out);
}
.transition-slide {
transition: transform var(--duration-normal) var(--ease-out),
opacity var(--duration-normal) var(--ease-out);
}
.transition-all {
transition: all var(--duration-normal) var(--ease-out);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
transition: all var(--duration-fast) var(--ease-out);
white-space: nowrap;
position: relative;
overflow: hidden;
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--brand-blue);
color: white;
}
.btn-primary:hover {
background: #1a6fd4;
box-shadow: 0 0 20px rgba(32, 130, 240, 0.4);
color: white;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #dc2626;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.4);
color: white;
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover {
background: #059669;
color: white;
}
.btn-warning {
background: var(--warning);
color: var(--text-inverse);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
color: var(--text-primary);
}
.btn-sm {
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-xs);
}
.btn-lg {
padding: var(--space-3) var(--space-6);
font-size: var(--font-size-md);
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
border-radius: var(--radius-md);
}
.btn .btn-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: none;
}
.btn.loading .btn-spinner {
display: block;
}
.btn.loading .btn-text {
opacity: 0.5;
}
/* Cards */
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
transition: all var(--duration-normal) var(--ease-out);
}
.card:hover {
border-color: var(--border-hover);
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.card-title {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
/* Stat Cards */
.stat-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
align-items: flex-start;
gap: var(--space-4);
transition: all var(--duration-normal) var(--ease-out);
}
.stat-card:hover {
border-color: var(--border-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon.blue { background: rgba(32, 130, 240, 0.15); color: var(--brand-blue); }
.stat-icon.orange { background: rgba(232, 77, 30, 0.15); color: var(--brand-orange); }
.stat-icon.gold { background: rgba(228, 172, 56, 0.15); color: var(--brand-gold); }
.stat-icon.purple { background: rgba(104, 52, 190, 0.15); color: var(--brand-purple); }
.stat-icon.green { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.stat-icon.red { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
.stat-content {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.stat-value {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
font-feature-settings: "tnum";
}
.stat-sub {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--space-1);
}
/* Forms */
.form-group {
margin-bottom: var(--space-5);
}
.form-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.form-input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
transition: all var(--duration-fast) var(--ease-out);
font-size: var(--font-size-sm);
}
.form-input:focus {
border-color: var(--brand-blue);
box-shadow: 0 0 0 3px rgba(32, 130, 240, 0.1);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input.error {
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.form-error {
font-size: var(--font-size-xs);
color: var(--danger);
margin-top: var(--space-1);
}
.form-select {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='rgba(255,255,255,0.6)' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: left 12px center;
padding-inline-start: var(--space-4);
}
.form-select:focus {
border-color: var(--brand-blue);
box-shadow: 0 0 0 3px rgba(32, 130, 240, 0.1);
}
textarea.form-input {
min-height: 100px;
resize: vertical;
}
/* Toggle Switch */
.toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
gap: var(--space-3);
}
.toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-track {
width: 44px;
height: 24px;
background: var(--bg-hover);
border-radius: var(--radius-pill);
border: 1px solid var(--border);
transition: all var(--duration-normal) var(--ease-out);
position: relative;
}
.toggle-track::after {
content: '';
position: absolute;
top: 2px;
inset-inline-start: 2px;
width: 18px;
height: 18px;
background: var(--text-secondary);
border-radius: var(--radius-full);
transition: all var(--duration-normal) var(--ease-spring);
}
.toggle input:checked + .toggle-track {
background: var(--brand-blue);
border-color: var(--brand-blue);
}
.toggle input:checked + .toggle-track::after {
inset-inline-start: calc(100% - 20px);
background: white;
}
/* Tables */
.data-table-wrapper {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.data-table {
width: 100%;
}
.data-table th {
padding: var(--space-3) var(--space-4);
text-align: start;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: color var(--duration-fast);
}
.data-table th:hover {
color: var(--text-primary);
}
.data-table th.sorted {
color: var(--brand-blue);
}
.data-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
vertical-align: middle;
}
.data-table tr {
transition: background var(--duration-fast);
}
.data-table tbody tr:hover {
background: var(--bg-hover);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
gap: var(--space-4);
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
}
.table-search {
position: relative;
flex: 1;
max-width: 320px;
}
.table-search input {
width: 100%;
padding: var(--space-2) var(--space-4);
padding-inline-start: var(--space-9);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
}
.table-search input:focus {
border-color: var(--brand-blue);
}
.table-search-icon {
position: absolute;
inset-inline-start: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
width: 18px;
height: 18px;
}
.table-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.badge-success { background: var(--success-bg); color: var(--success); }
.badge-warning { background: var(--warning-bg); color: var(--warning); }
.badge-danger { background: var(--danger-bg); color: var(--danger); }
.badge-info { background: var(--info-bg); color: var(--info); }
.badge-purple { background: rgba(104, 52, 190, 0.15); color: var(--brand-purple); }
.badge-default { background: var(--bg-hover); color: var(--text-secondary); }
.badge-dot::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge-pulse::before {
animation: pulse 2s infinite;
}
/* Dropdown */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
inset-inline-end: 0;
min-width: 180px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: var(--space-2);
z-index: 50;
opacity: 0;
visibility: hidden;
transform: scale(0.95) translateY(-4px);
transform-origin: top right;
transition: all var(--duration-fast) var(--ease-out);
}
[dir="rtl"] .dropdown-menu {
transform-origin: top left;
}
.dropdown.open .dropdown-menu {
opacity: 1;
visibility: visible;
transform: scale(1) translateY(0);
}
.dropdown-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: all var(--duration-fast);
cursor: pointer;
width: 100%;
text-align: start;
}
.dropdown-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.dropdown-item.danger {
color: var(--danger);
}
.dropdown-item.danger:hover {
background: var(--danger-bg);
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: var(--space-2) 0;
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
gap: var(--space-1);
}
.pagination-btn {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
transition: all var(--duration-fast);
}
.pagination-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.pagination-btn.active {
background: var(--brand-blue);
color: white;
}
.pagination-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all var(--duration-normal);
padding: var(--space-4);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
transform: translateY(20px) scale(0.95);
transition: transform var(--duration-normal) var(--ease-spring);
}
.modal-overlay.active .modal {
transform: translateY(0) scale(1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5);
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-muted);
transition: all var(--duration-fast);
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.modal-body {
padding: var(--space-5);
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border);
}
/* Toast */
.toast-container {
position: fixed;
top: var(--space-5);
inset-inline-start: var(--space-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-3);
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
min-width: 300px;
max-width: 450px;
pointer-events: all;
animation: slideIn var(--duration-normal) var(--ease-spring);
transition: all var(--duration-normal) var(--ease-out);
}
.toast.removing {
opacity: 0;
transform: translateX(-100%);
}
.toast-success { border-inline-start: 3px solid var(--success); }
.toast-error { border-inline-start: 3px solid var(--danger); }
.toast-warning { border-inline-start: 3px solid var(--warning); }
.toast-info { border-inline-start: 3px solid var(--info); }
.toast-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.toast-success .toast-icon { color: var(--success); }
.toast-error .toast-icon { color: var(--danger); }
.toast-warning .toast-icon { color: var(--warning); }
.toast-info .toast-icon { color: var(--info); }
.toast-message {
flex: 1;
font-size: var(--font-size-sm);
}
.toast-close {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
}
.toast-close:hover {
color: var(--text-primary);
}
/* Avatar */
.avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--bg-hover);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
color: var(--text-secondary);
flex-shrink: 0;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-sm { width: 28px; height: 28px; font-size: 10px; }
.avatar-lg { width: 48px; height: 48px; font-size: var(--font-size-md); }
.avatar-xl { width: 72px; height: 72px; font-size: var(--font-size-lg); }
/* Tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-5);
overflow-x: auto;
}
.tab {
padding: var(--space-3) var(--space-5);
font-size: var(--font-size-sm);
color: var(--text-secondary);
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
white-space: nowrap;
cursor: pointer;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--brand-blue);
border-bottom-color: var(--brand-blue);
font-weight: var(--font-weight-medium);
}
/* Skeleton */
.skeleton {
background: linear-gradient(90deg, var(--bg-elevated) 25%, var(--bg-hover) 50%, var(--bg-elevated) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
.skeleton-text {
height: 14px;
margin-bottom: var(--space-2);
}
.skeleton-title {
height: 24px;
width: 60%;
margin-bottom: var(--space-3);
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
}
.skeleton-card {
height: 120px;
border-radius: var(--radius-lg);
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--space-10) var(--space-6);
}
.empty-state-icon {
width: 80px;
height: 80px;
margin: 0 auto var(--space-5);
color: var(--text-muted);
opacity: 0.5;
}
.empty-state-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-2);
}
.empty-state-text {
color: var(--text-secondary);
margin-bottom: var(--space-5);
max-width: 400px;
margin-inline: auto;
}
/* Status Dot */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.offline { background: var(--text-muted); }
.status-dot.banned { background: var(--danger); }
/* Grid */
.grid {
display: grid;
gap: var(--space-5);
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 1200px) {
.grid-4 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
}
/* Health Status */
.health-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-primary);
}
.health-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.health-dot.up { background: var(--success); box-shadow: 0 0 8px var(--success); }
.health-dot.down { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.health-name {
flex: 1;
font-weight: var(--font-weight-medium);
}
.health-latency {
font-size: var(--font-size-xs);
color: var(--text-muted);
font-feature-settings: "tnum";
}
/* Confirm Dialog */
.confirm-dialog .modal-body {
text-align: center;
}
.confirm-dialog .confirm-icon {
width: 64px;
height: 64px;
margin: 0 auto var(--space-4);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-dialog .confirm-icon.danger {
background: var(--danger-bg);
color: var(--danger);
}
.confirm-dialog .confirm-text {
font-size: var(--font-size-md);
margin-bottom: var(--space-2);
}
.confirm-dialog .confirm-sub {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Filter Pills */
.filter-pills {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.filter-pill {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
background: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--duration-fast);
}
.filter-pill:hover {
border-color: var(--border-hover);
color: var(--text-primary);
}
.filter-pill.active {
background: var(--brand-blue);
border-color: var(--brand-blue);
color: white;
}
/* Checkbox */
.checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.checkbox input {
width: 18px;
height: 18px;
accent-color: var(--brand-blue);
cursor: pointer;
}
/* 1. Command Palette / Global Search */
.command-palette-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 9999;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
opacity: 0;
visibility: hidden;
transition: all var(--duration-fast);
}
.command-palette-overlay.active {
opacity: 1;
visibility: visible;
}
.command-palette {
width: 100%;
max-width: 580px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
overflow: hidden;
transform: scale(0.95) translateY(-10px);
transition: transform var(--duration-normal) var(--ease-spring);
}
.command-palette-overlay.active .command-palette {
transform: scale(1) translateY(0);
}
.command-palette-input {
width: 100%;
padding: var(--space-5);
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-size: var(--font-size-md);
}
.command-palette-input::placeholder {
color: var(--text-muted);
}
.command-palette-results {
max-height: 400px;
overflow-y: auto;
padding: var(--space-2);
}
.command-palette-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--duration-fast);
}
.command-palette-item:hover,
.command-palette-item.focused {
background: var(--bg-hover);
}
.command-palette-item .cp-icon {
width: 20px;
height: 20px;
color: var(--text-muted);
flex-shrink: 0;
}
.command-palette-item .cp-label {
flex: 1;
font-size: var(--font-size-sm);
}
.command-palette-item .cp-hint {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.command-palette-footer {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.command-palette-footer kbd {
padding: 2px 6px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 10px;
font-family: inherit;
}
/* 2. Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: var(--space-4);
}
.breadcrumbs a {
color: var(--text-secondary);
transition: color var(--duration-fast);
}
.breadcrumbs a:hover {
color: var(--brand-blue);
}
.breadcrumbs .separator {
color: var(--text-muted);
transform: scaleX(-1);
}
.breadcrumbs .current {
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
/* 3. Keyboard Shortcuts Hint */
.kbd-hint {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--text-muted);
margin-inline-start: var(--space-2);
}
.kbd-hint kbd {
padding: 1px 5px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 3px;
font-family: inherit;
font-size: 10px;
}
/* 4. Session Timeout Warning */
.session-warning {
position: fixed;
bottom: var(--space-5);
inset-inline-start: var(--space-5);
background: var(--bg-elevated);
border: 1px solid var(--warning);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-xl);
z-index: 9998;
display: none;
animation: slideIn var(--duration-normal) var(--ease-spring);
max-width: 320px;
}
.session-warning.show {
display: block;
}
.session-warning-title {
font-weight: var(--font-weight-semibold);
color: var(--warning);
margin-bottom: var(--space-2);
display: flex;
align-items: center;
gap: var(--space-2);
}
.session-warning-text {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.session-countdown {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--warning);
font-feature-settings: "tnum";
}
/* 5. Theme Toggle */
.theme-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--duration-fast);
color: var(--text-secondary);
}
.theme-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Light theme overrides */
[data-theme="light"] {
--bg-primary: #f8f9fc;
--bg-secondary: #ffffff;
--bg-elevated: #ffffff;
--bg-hover: #f1f3f9;
--bg-active: #e8ecf4;
--border: rgba(0, 0, 0, 0.08);
--border-hover: rgba(0, 0, 0, 0.15);
--text-primary: #1a1a2e;
--text-secondary: rgba(26, 26, 46, 0.6);
--text-muted: rgba(26, 26, 46, 0.35);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.12);
}
[data-theme="light"] .sidebar {
box-shadow: -1px 0 0 var(--border);
}
[data-theme="light"] .topbar {
box-shadow: 0 1px 0 var(--border);
}
/* 10. Copy Button */
.copy-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all var(--duration-fast);
opacity: 0;
}
*:hover > .copy-btn,
.copy-btn:focus {
opacity: 1;
}
.copy-btn:hover {
background: var(--bg-hover);
color: var(--brand-blue);
}
.copy-btn.copied {
color: var(--success);
opacity: 1;
}
/* 11. Column Visibility Menu */
.column-toggle-menu {
position: absolute;
top: calc(100% + 4px);
inset-inline-end: 0;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: var(--space-3);
z-index: 50;
min-width: 200px;
display: none;
}
.column-toggle-menu.show {
display: block;
animation: fadeInUp var(--duration-fast) var(--ease-out);
}
.column-toggle-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
cursor: pointer;
}
.column-toggle-item:hover {
background: var(--bg-hover);
}
/* 12. Print Styles */
@media print {
.sidebar, .topbar, .mobile-toggle, .sidebar-overlay,
.toast-container, .modal-overlay, .btn, .table-toolbar,
.table-footer, .pagination, .filter-pills, .command-palette-overlay,
.session-warning, .breadcrumbs {
display: none !important;
}
.app-layout {
display: block;
}
.content {
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
body {
background: white;
color: black;
}
.card {
border: 1px solid #ddd;
box-shadow: none;
break-inside: avoid;
}
.data-table th, .data-table td {
border: 1px solid #ddd;
padding: 6px 10px;
}
.badge {
border: 1px solid currentColor;
}
.stat-card {
border: 1px solid #ddd;
}
}
/* 13. Unsaved Changes Indicator */
.form-unsaved-indicator {
position: fixed;
bottom: var(--space-5);
inset-inline-end: var(--space-5);
background: var(--bg-elevated);
border: 1px solid var(--warning);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
display: none;
align-items: center;
gap: var(--space-3);
box-shadow: var(--shadow-lg);
z-index: 100;
animation: slideIn var(--duration-normal) var(--ease-spring);
font-size: var(--font-size-sm);
color: var(--warning);
}
.form-unsaved-indicator.show {
display: flex;
}
/* 15. Relative Time Tooltip */
.time-relative {
cursor: help;
border-bottom: 1px dotted var(--text-muted);
}
/* 16. Hover Preview Card */
.hover-preview {
position: absolute;
z-index: 60;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: var(--space-4);
min-width: 250px;
max-width: 320px;
pointer-events: none;
opacity: 0;
transform: translateY(4px);
transition: all var(--duration-fast) var(--ease-out);
}
.hover-preview.visible {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
/* 17. Progress Bar for Batch Ops */
.batch-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 99999;
background: var(--bg-secondary);
display: none;
}
.batch-progress.active {
display: block;
}
.batch-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--brand-blue), var(--brand-cyan));
border-radius: 0 2px 2px 0;
transition: width 0.3s var(--ease-out);
box-shadow: 0 0 10px var(--brand-blue);
}
/* 18. Sparklines */
.sparkline {
display: inline-block;
vertical-align: middle;
}
.sparkline svg {
width: 80px;
height: 24px;
}
.sparkline path {
fill: none;
stroke: var(--brand-blue);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.sparkline .sparkline-fill {
fill: rgba(32, 130, 240, 0.1);
stroke: none;
}
/* 19. Collapsible Sidebar Sections */
.nav-section-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: var(--space-2) var(--space-3);
}
.nav-section-header .collapse-icon {
width: 14px;
height: 14px;
color: var(--text-muted);
transition: transform var(--duration-fast);
}
.nav-section.collapsed .collapse-icon {
transform: rotate(-90deg);
}
.nav-section.collapsed .nav-section-items {
display: none;
}
/* 20. Tooltips */
.tooltip-wrapper {
position: relative;
display: inline-flex;
}
.tooltip-icon {
width: 16px;
height: 16px;
color: var(--text-muted);
cursor: help;
margin-inline-start: var(--space-2);
}
.tooltip-content {
position: absolute;
bottom: calc(100% + 8px);
inset-inline-start: 50%;
transform: translateX(50%);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
color: var(--text-secondary);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration-fast);
z-index: 100;
box-shadow: var(--shadow-md);
}
.tooltip-wrapper:hover .tooltip-content {
opacity: 1;
}
/* Favorites Star */
.favorite-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all var(--duration-fast);
}
.favorite-btn:hover {
color: var(--brand-gold);
background: rgba(228, 172, 56, 0.1);
}
.favorite-btn.active {
color: var(--brand-gold);
}
.favorite-btn.active svg {
fill: var(--brand-gold);
}
/* Quick Favorites Bar */
.favorites-bar {
display: flex;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
.favorites-bar::-webkit-scrollbar {
display: none;
}
.favorite-chip {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
white-space: nowrap;
color: var(--text-secondary);
transition: all var(--duration-fast);
cursor: pointer;
}
.favorite-chip:hover {
border-color: var(--brand-gold);
color: var(--text-primary);
}
/* Maintenance Mode Banner */
.maintenance-banner {
background: linear-gradient(90deg, var(--brand-orange), #dc2626);
color: white;
padding: var(--space-2) var(--space-4);
text-align: center;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
position: sticky;
top: 0;
z-index: 200;
animation: pulse 3s infinite;
}
.app-layout {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-rows: var(--topbar-height) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar content";
min-height: 100vh;
}
.sidebar {
grid-area: sidebar;
background: var(--bg-secondary);
border-inline-start: 1px solid var(--border);
position: fixed;
inset-inline-end: 0;
top: 0;
bottom: 0;
width: var(--sidebar-width);
display: flex;
flex-direction: column;
z-index: 100;
transition: width var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-header {
padding: var(--space-5) var(--space-6);
display: flex;
align-items: center;
gap: var(--space-3);
border-bottom: 1px solid var(--border);
min-height: var(--topbar-height);
}
.sidebar-logo {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--brand-blue), var(--brand-purple));
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
color: white;
flex-shrink: 0;
}
.sidebar-title {
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-nav {
flex: 1;
padding: var(--space-4) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.nav-section {
margin-bottom: var(--space-4);
}
.nav-section-title {
font-size: var(--font-size-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-1);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
color: var(--text-secondary);
transition: all var(--duration-fast) var(--ease-out);
position: relative;
white-space: nowrap;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item:hover .nav-icon {
transform: rotate(-5deg);
}
.nav-item.active {
background: rgba(32, 130, 240, 0.1);
color: var(--brand-blue);
font-weight: var(--font-weight-medium);
}
.nav-item.active::before {
content: '';
position: absolute;
inset-inline-end: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: var(--brand-blue);
border-radius: var(--radius-pill);
box-shadow: 0 0 8px var(--brand-blue);
}
.nav-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
transition: transform var(--duration-fast) var(--ease-spring);
}
.nav-badge {
margin-inline-start: auto;
background: var(--danger);
color: white;
font-size: 11px;
font-weight: var(--font-weight-bold);
padding: 2px 8px;
border-radius: var(--radius-pill);
min-width: 20px;
text-align: center;
}
.topbar {
grid-area: topbar;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
position: sticky;
top: 0;
z-index: 90;
margin-inline-end: var(--sidebar-width);
}
.topbar-right {
display: flex;
align-items: center;
gap: var(--space-4);
}
.topbar-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.page-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
.topbar-user {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
transition: background var(--duration-fast);
}
.topbar-user:hover {
background: var(--bg-hover);
}
.topbar-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--brand-blue), var(--brand-purple));
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
}
.content {
grid-area: content;
padding: var(--space-6);
margin-inline-end: var(--sidebar-width);
min-height: calc(100vh - var(--topbar-height));
max-width: var(--content-max-width);
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
flex-wrap: wrap;
gap: var(--space-4);
}
.content-header h1 {
font-size: var(--font-size-xl);
}
.mobile-toggle {
display: none;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
@media (max-width: 1200px) {
.app-layout {
grid-template-columns: var(--sidebar-collapsed) 1fr;
}
.sidebar {
width: var(--sidebar-collapsed);
}
.sidebar .nav-text,
.sidebar .nav-section-title,
.sidebar .sidebar-title,
.sidebar .nav-badge {
display: none;
}
.sidebar .nav-item {
justify-content: center;
padding: var(--space-3);
}
.sidebar .sidebar-header {
justify-content: center;
}
.topbar, .content {
margin-inline-end: var(--sidebar-collapsed);
}
}
@media (max-width: 768px) {
.app-layout {
grid-template-columns: 1fr;
grid-template-areas:
"topbar"
"content";
}
.sidebar {
transform: translateX(100%);
width: var(--sidebar-width);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar.open + .sidebar-overlay {
display: block;
}
.sidebar .nav-text,
.sidebar .nav-section-title,
.sidebar .sidebar-title,
.sidebar .nav-badge {
display: block;
}
.sidebar .nav-item {
justify-content: flex-start;
padding: var(--space-3) var(--space-4);
}
.topbar, .content {
margin-inline-end: 0;
}
.mobile-toggle {
display: flex;
}
.content {
padding: var(--space-4);
}
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-sm);
line-height: var(--line-height);
color: var(--text-primary);
background-color: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
a {
color: var(--brand-blue);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
a:hover {
color: var(--brand-cyan);
}
img, svg {
display: block;
max-width: 100%;
}
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
background: none;
border: none;
outline: none;
}
button {
cursor: pointer;
}
table {
border-collapse: collapse;
width: 100%;
}
ul, ol {
list-style: none;
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-weight-semibold);
line-height: 1.3;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
::selection {
background: rgba(32, 130, 240, 0.3);
color: var(--text-primary);
}
input[type="number"] {
direction: ltr;
text-align: right;
}
[dir="rtl"] input[type="number"] {
text-align: left;
}
/* Flexbox */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.flex-1 { flex: 1; }
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-5 { gap: var(--space-5); }
.gap-6 { gap: var(--space-6); }
/* Text */
.text-center { text-align: center; }
.text-start { text-align: start; }
.text-end { text-align: end; }
.text-xs { font-size: var(--font-size-xs); }
.text-sm { font-size: var(--font-size-sm); }
.text-md { font-size: var(--font-size-md); }
.text-lg { font-size: var(--font-size-lg); }
.text-xl { font-size: var(--font-size-xl); }
.text-2xl { font-size: var(--font-size-2xl); }
.font-medium { font-weight: var(--font-weight-medium); }
.font-semibold { font-weight: var(--font-weight-semibold); }
.font-bold { font-weight: var(--font-weight-bold); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-muted { color: var(--text-muted); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
.text-warning { color: var(--warning); }
.text-blue { color: var(--brand-blue); }
.text-gold { color: var(--brand-gold); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Spacing */
.m-0 { margin: 0; }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-5 { margin-top: var(--space-5); }
.mt-6 { margin-top: var(--space-6); }
.mb-1 { margin-bottom: var(--space-1); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-5 { margin-bottom: var(--space-5); }
.mb-6 { margin-bottom: var(--space-6); }
.p-0 { padding: 0; }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-5 { padding: var(--space-5); }
.p-6 { padding: var(--space-6); }
/* Display */
.hidden { display: none; }
.block { display: block; }
.inline-block { display: inline-block; }
.relative { position: relative; }
/* Width */
.w-full { width: 100%; }
.w-auto { width: auto; }
.max-w-sm { max-width: 400px; }
.max-w-md { max-width: 600px; }
.max-w-lg { max-width: 800px; }
/* Borders */
.border { border: 1px solid var(--border); }
.border-b { border-bottom: 1px solid var(--border); }
.rounded { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
/* Background */
.bg-primary { background: var(--bg-primary); }
.bg-elevated { background: var(--bg-elevated); }
/* Overflow */
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
/* Cursor */
.cursor-pointer { cursor: pointer; }
.cursor-not-allowed { cursor: not-allowed; }
/* Opacity */
.opacity-50 { opacity: 0.5; }
.opacity-75 { opacity: 0.75; }
/* Tabular numbers */
.tabular-nums { font-feature-settings: "tnum"; }
/* Direction */
.ltr { direction: ltr; }
.rtl { direction: rtl; }
/* Responsive visibility */
@media (max-width: 768px) {
.hide-mobile { display: none !important; }
}
@media (min-width: 769px) {
.show-mobile { display: none !important; }
}
:root {
/* Brand Colors */
--brand-blue: #2082F0;
--brand-orange: #E84D1E;
--brand-gold: #E4AC38;
--brand-sand: #FFCC66;
--brand-navy: #152132;
--brand-cyan: #00FFFF;
--brand-purple: #6834BE;
/* Surface Colors (Dark Theme) */
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-elevated: #1a2235;
--bg-hover: #1f2937;
--bg-active: #253348;
--border: rgba(255, 255, 255, 0.06);
--border-hover: rgba(255, 255, 255, 0.12);
--border-focus: rgba(32, 130, 240, 0.5);
/* Text Colors */
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.6);
--text-muted: rgba(255, 255, 255, 0.3);
--text-inverse: #0a0e1a;
/* Status Colors */
--success: #10B981;
--success-bg: rgba(16, 185, 129, 0.1);
--warning: #F59E0B;
--warning-bg: rgba(245, 158, 11, 0.1);
--danger: #EF4444;
--danger-bg: rgba(239, 68, 68, 0.1);
--info: #3B82F6;
--info-bg: rgba(59, 130, 246, 0.1);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 40px;
--space-9: 48px;
--space-10: 64px;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 50%;
--radius-pill: 999px;
/* Typography */
--font-family: 'IBM Plex Sans Arabic', sans-serif;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 20px;
--font-size-xl: 28px;
--font-size-2xl: 36px;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height: 1.6;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(32, 130, 240, 0.3);
/* Motion */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
/* Layout */
--sidebar-width: 280px;
--sidebar-collapsed: 72px;
--topbar-height: 64px;
--content-max-width: 1400px;
}
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content;
// Toast notifications
function showToast(message, type = 'success', duration = 4000) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = {
success: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
error: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>'
};
toast.innerHTML = `
<div class="toast-icon">${icons[type] || icons.info}</div>
<span class="toast-message">${message}</span>
<button class="toast-close" onclick="this.closest('.toast').remove()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
setTimeout(() => toast.remove(), 300);
}, duration);
}
// Modal
function openModal(title, body, footer = '') {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').innerHTML = body;
document.getElementById('modalFooter').innerHTML = footer;
document.getElementById('modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('modal').classList.remove('active');
document.body.style.overflow = '';
}
// Confirm Dialog
let confirmCallback = null;
function showConfirm(text, sub, callback, btnText = 'تأكيد') {
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmSub').textContent = sub || '';
document.getElementById('confirmAction').textContent = btnText;
confirmCallback = callback;
document.getElementById('confirmDialog').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeConfirm() {
document.getElementById('confirmDialog').classList.remove('active');
document.body.style.overflow = '';
confirmCallback = null;
}
document.getElementById('confirmAction')?.addEventListener('click', () => {
if (confirmCallback) confirmCallback();
closeConfirm();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.classList.remove('active');
document.body.style.overflow = '';
}
});
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => {
m.classList.remove('active');
});
document.body.style.overflow = '';
}
});
// Fetch wrapper
async function api(url, options = {}) {
const defaults = {
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
};
const config = { ...defaults, ...options };
if (options.headers) {
config.headers = { ...defaults.headers, ...options.headers };
}
try {
const response = await fetch(url, config);
if (response.status === 401) {
window.location.href = '/login';
return null;
}
if (response.status === 403) {
showToast('ليس لديك صلاحية لهذا الإجراء', 'error');
return null;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const msg = data?.error || data?.message || 'حدث خطأ غير متوقع';
showToast(msg, 'error');
return null;
}
return data;
} catch (error) {
showToast('فشل الاتصال بالخادم', 'error');
return null;
}
}
// Form submission with loading state
function setupForms() {
document.querySelectorAll('form[data-ajax]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('[type="submit"]');
btn?.classList.add('loading');
btn && (btn.disabled = true);
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
const response = await fetch(form.action, {
method: form.method || 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
showToast(result.message || 'تم بنجاح', 'success');
if (result.redirect) window.location.href = result.redirect;
} else {
showToast(result.error || 'حدث خطأ', 'error');
if (result.errors) {
Object.entries(result.errors).forEach(([field, msg]) => {
const input = form.querySelector(`[name="${field}"]`);
if (input) {
input.classList.add('error');
const errorEl = input.parentElement.querySelector('.form-error');
if (errorEl) errorEl.textContent = msg;
}
});
}
}
} catch {
showToast('فشل الاتصال', 'error');
} finally {
btn?.classList.remove('loading');
btn && (btn.disabled = false);
}
});
});
}
// Dropdown toggle
function toggleDropdown(el) {
const dropdown = el.closest('.dropdown');
const wasOpen = dropdown.classList.contains('open');
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
if (!wasOpen) dropdown.classList.add('open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown')) {
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
}
});
// Count-up animation for stat numbers
function animateCountUp(element, target) {
const duration = 1000;
const start = 0;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(start + (target - start) * eased);
element.textContent = current.toLocaleString();
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
// Init count-up on load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-count]').forEach(el => {
const target = parseInt(el.dataset.count);
animateCountUp(el, target);
});
setupForms();
});
// Delete with confirmation
function confirmDelete(url, name) {
showConfirm(
`هل تريد حذف "${name}"؟`,
'لا يمكن التراجع عن هذا الإجراء',
async () => {
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.innerHTML = `<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">`;
document.body.appendChild(form);
form.submit();
},
'حذف'
);
}
// Toggle action (feature flags, games, etc.)
async function toggleItem(url) {
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.innerHTML = `<input type="hidden" name="_csrf" value="${CSRF_TOKEN}">`;
document.body.appendChild(form);
form.submit();
}
class DataTable {
constructor(tableId) {
this.table = document.getElementById(tableId);
if (!this.table) return;
this.searchInput = this.table.closest('.data-table-wrapper')?.querySelector('.table-search input');
this.sortHeaders = this.table.querySelectorAll('th[data-sort]');
this.checkAll = this.table.querySelector('.check-all');
this.checkboxes = this.table.querySelectorAll('.row-check');
this.currentSort = { field: null, dir: 'asc' };
this.searchTimeout = null;
this.init();
}
init() {
if (this.searchInput) {
this.searchInput.addEventListener('input', () => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => this.search(), 300);
});
}
this.sortHeaders.forEach(th => {
th.addEventListener('click', () => this.sort(th));
});
if (this.checkAll) {
this.checkAll.addEventListener('change', () => {
this.checkboxes.forEach(cb => {
cb.checked = this.checkAll.checked;
});
this.updateBulkActions();
});
}
this.checkboxes.forEach(cb => {
cb.addEventListener('change', () => this.updateBulkActions());
});
}
search() {
const query = this.searchInput.value.trim();
const url = new URL(window.location);
if (query) {
url.searchParams.set('search', query);
} else {
url.searchParams.delete('search');
}
url.searchParams.set('page', '1');
window.location.href = url.toString();
}
sort(th) {
const field = th.dataset.sort;
let dir = 'asc';
if (this.currentSort.field === field && this.currentSort.dir === 'asc') {
dir = 'desc';
}
const url = new URL(window.location);
url.searchParams.set('sort', field);
url.searchParams.set('dir', dir);
window.location.href = url.toString();
}
updateBulkActions() {
const checked = this.table.querySelectorAll('.row-check:checked');
const bulkBar = document.getElementById('bulkActions');
if (bulkBar) {
if (checked.length > 0) {
bulkBar.classList.remove('hidden');
bulkBar.querySelector('.bulk-count').textContent = checked.length;
} else {
bulkBar.classList.add('hidden');
}
}
}
getSelectedIds() {
return Array.from(this.table.querySelectorAll('.row-check:checked')).map(cb => cb.value);
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.data-table').forEach(table => {
if (table.id) new DataTable(table.id);
});
});
/**
* El3ab Management — QoL Enhancements
*/
(function() {
'use strict';
// ─── 1. Command Palette (Ctrl+K) ───────────────────────────────────────
const commandPaletteRoutes = [
{ label: 'لوحة التحكم', url: '/dashboard', icon: 'home', hint: 'الرئيسية' },
{ label: 'اللاعبون', url: '/players', icon: 'users', hint: 'إدارة اللاعبين' },
{ label: 'الألعاب', url: '/games', icon: 'gamepad', hint: 'إدارة الألعاب' },
{ label: 'بوتات الشطرنج', url: '/chess-bots', icon: 'bot', hint: 'Chess Bots' },
{ label: 'البطولات', url: '/tournaments', icon: 'trophy', hint: 'Swiss System' },
{ label: 'المنظمات', url: '/organizations', icon: 'building', hint: 'الأندية' },
{ label: 'الاقتصاد', url: '/economy', icon: 'coins', hint: 'عملات وجواهر' },
{ label: 'الإشراف', url: '/moderation', icon: 'shield', hint: 'البلاغات' },
{ label: 'الإعلانات', url: '/ads', icon: 'megaphone', hint: 'حملات إعلانية' },
{ label: 'أعلام الميزات', url: '/feature-flags', icon: 'flag', hint: 'Feature Flags' },
{ label: 'الإشعارات', url: '/notifications', icon: 'bell', hint: 'إرسال إشعارات' },
{ label: 'الإعدادات', url: '/settings', icon: 'settings', hint: 'إعدادات النظام' },
{ label: 'الهوية البصرية', url: '/branding', icon: 'palette', hint: 'Branding' },
{ label: 'التحليلات', url: '/analytics', icon: 'chart', hint: 'Analytics' },
{ label: 'سجل العمليات', url: '/audit-log', icon: 'history', hint: 'Audit Log' },
];
function createCommandPalette() {
const overlay = document.createElement('div');
overlay.className = 'command-palette-overlay';
overlay.id = 'commandPalette';
overlay.innerHTML = `
<div class="command-palette">
<input type="text" class="command-palette-input" placeholder="ابحث عن صفحة أو أمر..." id="cpInput">
<div class="command-palette-results" id="cpResults"></div>
<div class="command-palette-footer">
<span><kbd>↑↓</kbd> للتنقل</span>
<span><kbd>Enter</kbd> للفتح</span>
<span><kbd>Esc</kbd> للإغلاق</span>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = document.getElementById('cpInput');
const results = document.getElementById('cpResults');
let focusedIndex = -1;
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeCommandPalette();
});
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase();
const filtered = q ? commandPaletteRoutes.filter(r =>
r.label.includes(q) || r.hint.toLowerCase().includes(q) || r.url.includes(q)
) : commandPaletteRoutes;
renderResults(filtered);
focusedIndex = -1;
});
input.addEventListener('keydown', (e) => {
const items = results.querySelectorAll('.command-palette-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
focusedIndex = Math.min(focusedIndex + 1, items.length - 1);
updateFocus(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
focusedIndex = Math.max(focusedIndex - 1, 0);
updateFocus(items);
} else if (e.key === 'Enter' && focusedIndex >= 0) {
e.preventDefault();
items[focusedIndex].click();
} else if (e.key === 'Escape') {
closeCommandPalette();
}
});
function renderResults(items) {
results.innerHTML = items.map((item, i) => `
<div class="command-palette-item" data-url="${item.url}">
<span class="cp-icon">●</span>
<span class="cp-label">${item.label}</span>
<span class="cp-hint">${item.hint}</span>
</div>
`).join('');
results.querySelectorAll('.command-palette-item').forEach(el => {
el.addEventListener('click', () => {
window.location.href = el.dataset.url;
});
});
}
function updateFocus(items) {
items.forEach((el, i) => el.classList.toggle('focused', i === focusedIndex));
if (items[focusedIndex]) items[focusedIndex].scrollIntoView({ block: 'nearest' });
}
renderResults(commandPaletteRoutes);
}
function openCommandPalette() {
const overlay = document.getElementById('commandPalette');
overlay.classList.add('active');
setTimeout(() => document.getElementById('cpInput').focus(), 50);
}
function closeCommandPalette() {
const overlay = document.getElementById('commandPalette');
overlay.classList.remove('active');
document.getElementById('cpInput').value = '';
document.getElementById('cpResults').querySelectorAll('.command-palette-item').forEach(el => el.classList.remove('focused'));
}
// ─── 2. Keyboard Shortcuts ─────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, select, [contenteditable]')) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openCommandPalette();
return;
}
if (e.key === 'Escape') {
closeCommandPalette();
return;
}
if (e.altKey) {
const shortcuts = {
'd': '/dashboard',
'p': '/players',
'g': '/games',
't': '/tournaments',
'a': '/analytics',
's': '/settings',
};
if (shortcuts[e.key]) {
e.preventDefault();
window.location.href = shortcuts[e.key];
}
}
});
// ─── 3. Session Timeout Warning ────────────────────────────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
const WARNING_BEFORE = 5 * 60 * 1000; // 5 minutes before
let sessionTimer, warningTimer;
function createSessionWarning() {
const el = document.createElement('div');
el.className = 'session-warning';
el.id = 'sessionWarning';
el.innerHTML = `
<div class="session-warning-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
انتهاء الجلسة
</div>
<div class="session-warning-text">ستنتهي جلستك قريباً. هل تريد تمديدها؟</div>
<div class="session-countdown" id="sessionCountdown">5:00</div>
<button class="btn btn-sm btn-primary mt-3" onclick="extendSession()">تمديد الجلسة</button>
`;
document.body.appendChild(el);
}
function resetSessionTimers() {
clearTimeout(sessionTimer);
clearTimeout(warningTimer);
hideSessionWarning();
warningTimer = setTimeout(() => {
showSessionWarning();
}, SESSION_TIMEOUT - WARNING_BEFORE);
sessionTimer = setTimeout(() => {
window.location.href = '/auth/logout';
}, SESSION_TIMEOUT);
}
function showSessionWarning() {
const el = document.getElementById('sessionWarning');
if (el) el.classList.add('show');
startCountdown();
}
function hideSessionWarning() {
const el = document.getElementById('sessionWarning');
if (el) el.classList.remove('show');
}
let countdownInterval;
function startCountdown() {
let remaining = WARNING_BEFORE / 1000;
const el = document.getElementById('sessionCountdown');
clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
remaining--;
if (remaining <= 0) { clearInterval(countdownInterval); return; }
const m = Math.floor(remaining / 60);
const s = remaining % 60;
if (el) el.textContent = `${m}:${String(s).padStart(2, '0')}`;
}, 1000);
}
window.extendSession = function() {
fetch('/api/health.php').then(() => resetSessionTimers());
};
['click', 'keydown', 'mousemove', 'scroll'].forEach(evt => {
document.addEventListener(evt, () => resetSessionTimers(), { passive: true, once: false });
});
// ─── 4. Theme Toggle (Dark/Light) ──────────────────────────────────────
function initTheme() {
const saved = localStorage.getItem('el3ab-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
}
window.toggleTheme = function() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('el3ab-theme', next);
};
// ─── 5. Copy to Clipboard ──────────────────────────────────────────────
window.copyToClipboard = function(text, btn) {
navigator.clipboard.writeText(text).then(() => {
if (btn) {
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 2000);
}
if (typeof showToast === 'function') showToast('تم النسخ', 'success');
});
};
// ─── 6. Relative Timestamps ────────────────────────────────────────────
function initRelativeTimestamps() {
document.querySelectorAll('[data-timestamp]').forEach(el => {
const ts = el.dataset.timestamp;
const date = new Date(ts);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
let text;
if (minutes < 1) text = 'الآن';
else if (minutes < 60) text = `منذ ${minutes} دقيقة`;
else if (hours < 24) text = `منذ ${hours} ساعة`;
else if (days < 7) text = `منذ ${days} يوم`;
else text = date.toLocaleDateString('ar-EG');
el.setAttribute('title', date.toLocaleString('ar-EG'));
el.textContent = text;
el.classList.add('time-relative');
});
}
// ─── 7. Sparkline Charts ───────────────────────────────────────────────
window.createSparkline = function(container, data, options = {}) {
if (!data || !data.length) return;
const width = options.width || 80;
const height = options.height || 24;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((v - min) / range) * height;
return `${x},${y}`;
});
const pathD = 'M' + points.join(' L');
const fillD = pathD + ` L${width},${height} L0,${height} Z`;
const svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<path class="sparkline-fill" d="${fillD}"/>
<path d="${pathD}"/>
</svg>`;
if (typeof container === 'string') {
document.querySelector(container).innerHTML = svg;
} else {
container.innerHTML = svg;
}
};
// ─── 8. Collapsible Sidebar Sections ───────────────────────────────────
function initCollapsibleSections() {
document.querySelectorAll('.nav-section-header').forEach(header => {
header.addEventListener('click', () => {
const section = header.closest('.nav-section');
if (section) {
section.classList.toggle('collapsed');
const key = section.dataset.section;
if (key) {
const collapsed = JSON.parse(localStorage.getItem('el3ab-collapsed-sections') || '{}');
collapsed[key] = section.classList.contains('collapsed');
localStorage.setItem('el3ab-collapsed-sections', JSON.stringify(collapsed));
}
}
});
});
const collapsed = JSON.parse(localStorage.getItem('el3ab-collapsed-sections') || '{}');
Object.entries(collapsed).forEach(([key, val]) => {
if (val) {
const sec = document.querySelector(`.nav-section[data-section="${key}"]`);
if (sec) sec.classList.add('collapsed');
}
});
}
// ─── 9. Unsaved Changes Detection ──────────────────────────────────────
function initUnsavedChanges() {
const forms = document.querySelectorAll('form[data-track-changes]');
forms.forEach(form => {
const initial = new FormData(form);
const initialMap = {};
initial.forEach((v, k) => { initialMap[k] = v; });
form.addEventListener('input', () => {
const current = new FormData(form);
let changed = false;
current.forEach((v, k) => {
if (initialMap[k] !== v) changed = true;
});
const indicator = document.getElementById('unsavedIndicator');
if (indicator) {
indicator.classList.toggle('show', changed);
}
});
});
}
function createUnsavedIndicator() {
const el = document.createElement('div');
el.className = 'form-unsaved-indicator';
el.id = 'unsavedIndicator';
el.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
تغييرات غير محفوظة
`;
document.body.appendChild(el);
}
// ─── 10. Column Visibility Toggle ──────────────────────────────────────
window.toggleColumnMenu = function(btn) {
const menu = btn.parentElement.querySelector('.column-toggle-menu');
if (menu) menu.classList.toggle('show');
};
window.toggleColumn = function(index, checkbox) {
const table = document.querySelector('.data-table');
if (!table) return;
const visible = checkbox.checked;
table.querySelectorAll(`tr > *:nth-child(${index + 1})`).forEach(cell => {
cell.style.display = visible ? '' : 'none';
});
};
// ─── 11. Batch Progress Bar ────────────────────────────────────────────
window.showBatchProgress = function() {
let bar = document.getElementById('batchProgress');
if (!bar) {
bar = document.createElement('div');
bar.className = 'batch-progress';
bar.id = 'batchProgress';
bar.innerHTML = '<div class="batch-progress-bar" id="batchProgressBar" style="width: 0%"></div>';
document.body.appendChild(bar);
}
bar.classList.add('active');
};
window.updateBatchProgress = function(percent) {
const bar = document.getElementById('batchProgressBar');
if (bar) bar.style.width = percent + '%';
};
window.hideBatchProgress = function() {
const bar = document.getElementById('batchProgress');
if (bar) bar.classList.remove('active');
};
// ─── 12. Favorites System ──────────────────────────────────────────────
window.toggleFavorite = function(url, label, btn) {
let favs = JSON.parse(localStorage.getItem('el3ab-favorites') || '[]');
const exists = favs.findIndex(f => f.url === url);
if (exists >= 0) {
favs.splice(exists, 1);
if (btn) btn.classList.remove('active');
} else {
favs.push({ url, label });
if (btn) btn.classList.add('active');
}
localStorage.setItem('el3ab-favorites', JSON.stringify(favs));
renderFavoritesBar();
};
function renderFavoritesBar() {
const bar = document.getElementById('favoritesBar');
if (!bar) return;
const favs = JSON.parse(localStorage.getItem('el3ab-favorites') || '[]');
if (favs.length === 0) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
bar.innerHTML = favs.map(f => `
<a href="${f.url}" class="favorite-chip">${f.label}</a>
`).join('');
}
// ─── 13. Hover Preview ─────────────────────────────────────────────────
let previewTimeout;
document.addEventListener('mouseover', (e) => {
const link = e.target.closest('[data-preview-url]');
if (!link) return;
previewTimeout = setTimeout(() => {
const url = link.dataset.previewUrl;
fetch(url, { headers: { 'X-Preview': '1' } })
.then(r => r.text())
.then(html => {
let preview = document.getElementById('hoverPreview');
if (!preview) {
preview = document.createElement('div');
preview.className = 'hover-preview';
preview.id = 'hoverPreview';
document.body.appendChild(preview);
}
preview.innerHTML = html;
const rect = link.getBoundingClientRect();
preview.style.top = (rect.bottom + window.scrollY + 8) + 'px';
preview.style.left = rect.left + 'px';
preview.classList.add('visible');
});
}, 500);
});
document.addEventListener('mouseout', (e) => {
const link = e.target.closest('[data-preview-url]');
if (!link) return;
clearTimeout(previewTimeout);
const preview = document.getElementById('hoverPreview');
if (preview) preview.classList.remove('visible');
});
// ─── 14. CSV Export Helper ─────────────────────────────────────────────
window.exportTableCSV = function(tableSelector, filename) {
const table = document.querySelector(tableSelector || '.data-table');
if (!table) return;
const rows = [];
table.querySelectorAll('tr').forEach(tr => {
const cols = [];
tr.querySelectorAll('th, td').forEach(cell => {
cols.push('"' + cell.textContent.trim().replace(/"/g, '""') + '"');
});
rows.push(cols.join(','));
});
const bom = '';
const blob = new Blob([bom + rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (filename || 'export') + '.csv';
a.click();
URL.revokeObjectURL(url);
};
// ─── 15. Table Row Quick Actions on Hover ──────────────────────────────
function initRowQuickActions() {
document.querySelectorAll('.data-table tbody tr[data-href]').forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', (e) => {
if (e.target.closest('a, button, input, .btn')) return;
window.location.href = row.dataset.href;
});
});
}
// ─── 16. Scroll-to-Top Button ──────────────────────────────────────────
function createScrollTop() {
const btn = document.createElement('button');
btn.className = 'btn btn-icon btn-ghost';
btn.id = 'scrollTopBtn';
btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>';
btn.style.cssText = 'position:fixed;bottom:20px;inset-inline-end:20px;z-index:90;opacity:0;transition:opacity .2s;pointer-events:none;';
btn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
document.body.appendChild(btn);
window.addEventListener('scroll', () => {
const show = window.scrollY > 400;
btn.style.opacity = show ? '1' : '0';
btn.style.pointerEvents = show ? 'auto' : 'none';
}, { passive: true });
}
// ─── 17. Number Formatting ─────────────────────────────────────────────
window.formatNumber = function(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
};
// ─── 18. Auto-refresh Dashboard ────────────────────────────────────────
if (window.location.pathname === '/dashboard' || window.location.pathname === '/') {
setInterval(() => {
const healthItems = document.querySelectorAll('.health-item[data-endpoint]');
healthItems.forEach(item => {
const endpoint = item.dataset.endpoint;
fetch(endpoint)
.then(r => r.json())
.then(data => {
const dot = item.querySelector('.health-dot');
if (dot) {
dot.className = 'health-dot ' + (data.status === 'ok' ? 'healthy' : 'unhealthy');
}
})
.catch(() => {});
});
}, 60000);
}
// ─── 19. Maintenance Mode Banner ───────────────────────────────────────
window.showMaintenanceBanner = function(message) {
let banner = document.getElementById('maintenanceBanner');
if (!banner) {
banner = document.createElement('div');
banner.className = 'maintenance-banner';
banner.id = 'maintenanceBanner';
document.body.prepend(banner);
}
banner.textContent = message || 'النظام في وضع الصيانة';
banner.style.display = 'block';
};
window.hideMaintenanceBanner = function() {
const banner = document.getElementById('maintenanceBanner');
if (banner) banner.style.display = 'none';
};
// ─── 20. Inline Edit (Double-click to edit) ────────────────────────────
window.initInlineEdit = function(selector, saveCallback) {
document.querySelectorAll(selector).forEach(cell => {
cell.addEventListener('dblclick', () => {
if (cell.querySelector('input')) return;
const original = cell.textContent.trim();
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.className = 'form-input';
input.style.cssText = 'padding:2px 6px;font-size:inherit;width:100%;';
cell.textContent = '';
cell.appendChild(input);
input.focus();
input.select();
const finish = () => {
const newVal = input.value.trim();
cell.textContent = newVal || original;
if (newVal && newVal !== original && saveCallback) {
saveCallback(cell, newVal, original);
}
};
input.addEventListener('blur', finish);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = original; input.blur(); }
});
});
});
};
// ─── Init Everything ───────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initTheme();
createCommandPalette();
createSessionWarning();
createUnsavedIndicator();
createScrollTop();
initRelativeTimestamps();
initCollapsibleSections();
initUnsavedChanges();
initRowQuickActions();
renderFavoritesBar();
resetSessionTimers();
});
})();
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('form[data-validate]').forEach(form => {
form.addEventListener('submit', (e) => {
let valid = true;
form.querySelectorAll('.form-error').forEach(el => el.textContent = '');
form.querySelectorAll('.form-input.error').forEach(el => el.classList.remove('error'));
form.querySelectorAll('[required]').forEach(input => {
if (!input.value.trim()) {
valid = false;
input.classList.add('error');
const error = input.parentElement.querySelector('.form-error');
if (error) error.textContent = 'هذا الحقل مطلوب';
}
});
form.querySelectorAll('[type="email"]').forEach(input => {
if (input.value && !input.value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
valid = false;
input.classList.add('error');
const error = input.parentElement.querySelector('.form-error');
if (error) error.textContent = 'بريد إلكتروني غير صحيح';
}
});
form.querySelectorAll('[data-min]').forEach(input => {
if (input.value && Number(input.value) < Number(input.dataset.min)) {
valid = false;
input.classList.add('error');
const error = input.parentElement.querySelector('.form-error');
if (error) error.textContent = `القيمة الأدنى ${input.dataset.min}`;
}
});
form.querySelectorAll('[data-max]').forEach(input => {
if (input.value && Number(input.value) > Number(input.dataset.max)) {
valid = false;
input.classList.add('error');
const error = input.parentElement.querySelector('.form-error');
if (error) error.textContent = `القيمة القصوى ${input.dataset.max}`;
}
});
if (!valid) {
e.preventDefault();
const firstError = form.querySelector('.form-input.error');
if (firstError) firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
form.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('input', () => {
input.classList.remove('error');
const error = input.parentElement.querySelector('.form-error');
if (error) error.textContent = '';
});
});
});
});
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('open');
}
document.querySelector('.sidebar-overlay')?.addEventListener('click', () => {
document.getElementById('sidebar').classList.remove('open');
});
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