Commit a945b003 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(game-content): manager pages for seasons, shop, achievements, challenges, puzzles

The game reads all five tables and the manager had no screen for any of
them; the only battle-pass season ended on 2026-07-01, so the pass has been
dead since. One small config-driven CRUD covers them, every field a real
column, with Arabic labels and a note on what the game actually uses:

- seasons: create the next one (Cairo times), and "activate", which makes
  it the single is_active row the game's battlepass.php reads (an ended
  season cannot be activated, the active one cannot be deleted);
- shop items (cosmetics) with their enum types and rarities;
- achievements, with the condition written as {type, count} and the
  condition types the game never counts marked as such;
- daily challenge templates, marking the types the game does not count;
- puzzles, validating the FEN and the UCI solution.

Deleting something players own or have earned is refused by the foreign
key and says so instead of failing silently.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 52d5591d
...@@ -188,6 +188,16 @@ return [ ...@@ -188,6 +188,16 @@ return [
'settings/update' => ['module' => 'settings', 'action' => 'update'], 'settings/update' => ['module' => 'settings', 'action' => 'update'],
'settings/rewards' => ['module' => 'settings', 'action' => 'updateRewards'], 'settings/rewards' => ['module' => 'settings', 'action' => 'updateRewards'],
// Game content the app reads (seasons, shop, achievements, daily challenges, puzzles)
'game-content' => ['module' => 'game-content', 'action' => 'index'],
'game-content/{resource}' => ['module' => 'game-content', 'action' => 'list'],
'game-content/{resource}/create' => ['module' => 'game-content', 'action' => 'create'],
'game-content/{resource}/store' => ['module' => 'game-content', 'action' => 'store'],
'game-content/{resource}/{key}/edit' => ['module' => 'game-content', 'action' => 'edit'],
'game-content/{resource}/{key}/update' => ['module' => 'game-content', 'action' => 'update'],
'game-content/{resource}/{key}/delete' => ['module' => 'game-content', 'action' => 'delete'],
'game-content/{resource}/{key}/activate' => ['module' => 'game-content', 'action' => 'activate'],
// Branding // Branding
'branding' => ['module' => 'branding', 'action' => 'index'], 'branding' => ['module' => 'branding', 'action' => 'index'],
'branding/colors' => ['module' => 'branding', 'action' => 'colors'], 'branding/colors' => ['module' => 'branding', 'action' => 'colors'],
......
...@@ -171,6 +171,10 @@ try { $pendingWorkflows = $isAdmin ? $db->count('approval_requests', ['status' = ...@@ -171,6 +171,10 @@ try { $pendingWorkflows = $isAdmin ? $db->count('approval_requests', ['status' =
<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> <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> <span class="nav-text">الميزات</span>
</a> </a>
<a href="/game-content" class="nav-item <?= navActive('game-content', $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"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><circle cx="15" cy="11" r="1"/><circle cx="18" cy="13" r="1"/></svg>
<span class="nav-text">محتوى اللعبة</span>
</a>
<a href="/settings" class="nav-item <?= navActive('settings', $currentModule) ?>"> <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> <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> <span class="nav-text">الإعدادات</span>
......
<?php
/**
* Game content the app reads but the manager had no screen for:
*
* seasons battle-pass seasons (api/battlepass.php reads the one active row)
* cosmetics shop items (api/shop.php)
* achievements achievements and their conditions (api/achievements.php, game.php)
* challenges daily challenge templates (api/challenges.php)
* puzzles chess puzzles (api/puzzles.php)
*
* One small CRUD driven by RESOURCES, because the five are the same shape: a
* text primary key and a handful of typed columns. Each field names a real
* column of its table. Notes say what the game does and does not use, as read
* from the game's code on 2026-10-26.
*/
class GameContentController
{
private Database $db;
private const SLUG = '/^[a-z0-9_]{3,40}$/';
public const RESOURCES = [
'seasons' => [
'table' => 'seasons', 'title' => 'المواسم', 'singular' => 'موسم', 'order' => 'starts_at.desc',
'note' => 'يعرض التطبيق الموسم المفعّل وحده (واحد فقط في كل وقت)، بعدد مستوياته وخبرة كل مستوى وتاريخ نهايته. جوائز المستويات يحددها التطبيق نفسه، والتقدم يُحسب من مجموع خبرة اللاعب.',
'fields' => [
'id' => ['type' => 'slug', 'label' => 'المعرف', 'hint' => 'حروف إنجليزية صغيرة وأرقام و _، مثل season_2. لا يتغير بعد الإنشاء.'],
'name_ar' => ['type' => 'text', 'label' => 'الاسم بالعربية', 'required' => true],
'name' => ['type' => 'text', 'label' => 'الاسم بالإنجليزية', 'required' => true, 'dir' => 'ltr'],
'starts_at' => ['type' => 'datetime', 'label' => 'البداية (بتوقيت القاهرة)', 'required' => true],
'ends_at' => ['type' => 'datetime', 'label' => 'النهاية (بتوقيت القاهرة)', 'required' => true],
'total_tiers' => ['type' => 'int', 'label' => 'عدد المستويات', 'min' => 1, 'max' => 200, 'default' => 30],
'xp_per_tier' => ['type' => 'int', 'label' => 'الخبرة لكل مستوى', 'min' => 1, 'max' => 100000, 'default' => 100],
],
'columns' => ['name_ar', 'id', 'starts_at', 'ends_at', 'total_tiers', 'xp_per_tier'],
],
'cosmetics' => [
'table' => 'cosmetics', 'title' => 'عناصر المتجر', 'singular' => 'عنصر', 'order' => 'price_coins.asc',
'note' => 'يعرض متجر التطبيق كل العناصر هنا بالاسم والنوع والندرة والسعر والوصف. لا يتحقق المتجر بعد من "متاح للشراء" ولا من تاريخ انتهاء العرض.',
'fields' => [
'id' => ['type' => 'slug', 'label' => 'المعرف', 'hint' => 'مثل frame_gold_star'],
'name_ar' => ['type' => 'text', 'label' => 'الاسم بالعربية', 'required' => true],
'name' => ['type' => 'text', 'label' => 'الاسم بالإنجليزية', 'required' => true, 'dir' => 'ltr'],
'description' => ['type' => 'textarea', 'label' => 'الوصف'],
'type' => ['type' => 'select', 'label' => 'النوع', 'required' => true, 'options' => [
'avatar_frame' => 'إطار الصورة', 'board_theme' => 'شكل الرقعة', 'piece_set' => 'طقم القطع',
'profile_banner' => 'غلاف الملف', 'chat_emoji' => 'ملصق دردشة', 'victory_animation' => 'حركة الفوز',
'title_badge' => 'لقب', 'trail_effect' => 'أثر الحركة', 'sound_pack' => 'حزمة أصوات',
]],
'rarity' => ['type' => 'select', 'label' => 'الندرة', 'required' => true, 'options' => [
'common' => 'عادي', 'uncommon' => 'غير شائع', 'rare' => 'نادر', 'epic' => 'ملحمي', 'legendary' => 'أسطوري',
]],
'price_coins' => ['type' => 'int', 'label' => 'السعر بالعملات', 'min' => 0, 'max' => 10000000, 'default' => 0],
'price_gems' => ['type' => 'int', 'label' => 'السعر بالجواهر', 'min' => 0, 'max' => 1000000, 'default' => 0],
'is_purchasable' => ['type' => 'bool', 'label' => 'متاح للشراء', 'default' => true],
'is_limited_edition' => ['type' => 'bool', 'label' => 'إصدار محدود'],
'available_until' => ['type' => 'datetime', 'label' => 'متاح حتى (بتوقيت القاهرة)'],
'preview_url' => ['type' => 'url', 'label' => 'رابط صورة المعاينة'],
'asset_url' => ['type' => 'url', 'label' => 'رابط الملف'],
],
'columns' => ['name_ar', 'id', 'type', 'rarity', 'price_coins', 'price_gems', 'is_purchasable'],
],
'achievements' => [
'table' => 'achievements', 'title' => 'الإنجازات', 'singular' => 'إنجاز', 'order' => 'tier.asc,category.asc',
'note' => 'يمنح التطبيق الإنجاز عندما يبلغ اللاعب العدد المطلوب من الشرط، ويصرف العملات والخبرة. الشروط المعلّمة "لا يحسبها التطبيق" تبقى عند صفر ولا تُمنح.',
'fields' => [
'id' => ['type' => 'slug', 'label' => 'المعرف', 'hint' => 'مثل wins_50'],
'name_ar' => ['type' => 'text', 'label' => 'الاسم بالعربية', 'required' => true],
'name' => ['type' => 'text', 'label' => 'الاسم بالإنجليزية', 'required' => true, 'dir' => 'ltr'],
'description_ar' => ['type' => 'textarea', 'label' => 'الوصف بالعربية'],
'description' => ['type' => 'textarea', 'label' => 'الوصف بالإنجليزية', 'dir' => 'ltr'],
'category' => ['type' => 'select', 'label' => 'الفئة', 'required' => true, 'options' => [
'gameplay' => 'اللعب', 'social' => 'اجتماعي', 'progression' => 'التقدم', 'collection' => 'المقتنيات',
]],
'tier' => ['type' => 'int', 'label' => 'الدرجة', 'min' => 1, 'max' => 10, 'default' => 1],
'condition' => ['type' => 'condition', 'label' => 'الشرط', 'options' => [
'wins' => 'عدد الانتصارات', 'games_played' => 'عدد المباريات', 'win_streak' => 'سلسلة انتصارات',
'level' => 'المستوى', 'daily_streak' => 'أيام الدخول المتتالية', 'friends' => 'عدد الأصدقاء',
'cosmetics_owned' => 'عناصر المتجر المملوكة', 'tournaments_played' => 'البطولات التي شارك فيها',
'elo' => 'أعلى تقييم', 'tournaments_won' => 'البطولات التي فاز بها (لا يحسبها التطبيق)',
'ludo_wins' => 'انتصارات اللودو (لا يحسبها التطبيق)', 'beat_all_bots' => 'هزيمة كل البوتات (لا يحسبها التطبيق)',
]],
'coins_reward' => ['type' => 'int', 'label' => 'مكافأة العملات', 'min' => 0, 'max' => 1000000, 'default' => 0],
'xp_reward' => ['type' => 'int', 'label' => 'مكافأة الخبرة', 'min' => 0, 'max' => 1000000, 'default' => 0],
'is_hidden' => ['type' => 'bool', 'label' => 'مخفي حتى يُنال'],
'icon_url' => ['type' => 'url', 'label' => 'رابط الأيقونة'],
],
'columns' => ['name_ar', 'id', 'category', 'tier', 'condition', 'coins_reward', 'xp_reward'],
],
'challenges' => [
'table' => 'challenge_templates', 'title' => 'التحديات اليومية', 'singular' => 'تحدٍ', 'order' => 'id.asc',
'note' => 'يعرض التطبيق كل التحديات المفعّلة كل يوم ويحسب التقدم من مباريات اليوم. "حل الألغاز" و"المطالبة اليومية" لا يحسبهما التطبيق بعد، و"سلسلة انتصارات" تُحسب كعدد انتصارات اليوم.',
'fields' => [
'id' => ['type' => 'slug', 'label' => 'المعرف', 'hint' => 'مثل win_3'],
'title_ar' => ['type' => 'text', 'label' => 'العنوان بالعربية', 'required' => true],
'title' => ['type' => 'text', 'label' => 'العنوان بالإنجليزية', 'required' => true, 'dir' => 'ltr'],
'type' => ['type' => 'select', 'label' => 'ما يُحسب', 'required' => true, 'options' => [
'games_played' => 'مباريات اليوم', 'wins' => 'انتصارات اليوم', 'chess_played' => 'مباريات شطرنج',
'ludo_played' => 'مباريات لودو', 'domino_played' => 'مباريات دومينو', 'bot_wins' => 'الفوز على بوت',
'win_streak' => 'سلسلة انتصارات (تُحسب كانتصارات)', 'puzzles_solved' => 'حل الألغاز (لا يحسبه التطبيق)',
'daily_claimed' => 'المطالبة اليومية (لا يحسبها التطبيق)',
]],
'target' => ['type' => 'int', 'label' => 'العدد المطلوب', 'min' => 1, 'max' => 1000, 'default' => 1],
'reward_coins' => ['type' => 'int', 'label' => 'مكافأة العملات', 'min' => 0, 'max' => 1000000, 'default' => 0],
'reward_xp' => ['type' => 'int', 'label' => 'مكافأة الخبرة', 'min' => 0, 'max' => 1000000, 'default' => 0],
'icon' => ['type' => 'select', 'label' => 'الأيقونة', 'options' => [
'cards' => 'أوراق', 'trophy' => 'كأس', 'chess' => 'شطرنج', 'ludo' => 'لودو', 'domino' => 'دومينو',
'backgammon' => 'طاولة', 'lightning' => 'برق', 'bot' => 'بوت', 'puzzle' => 'لغز', 'star' => 'نجمة',
'flame' => 'شعلة', 'medal' => 'ميدالية',
]],
'is_active' => ['type' => 'bool', 'label' => 'مفعّل', 'default' => true],
],
'columns' => ['title_ar', 'id', 'type', 'target', 'reward_coins', 'reward_xp', 'is_active'],
],
'puzzles' => [
'table' => 'puzzles', 'title' => 'الألغاز', 'singular' => 'لغز', 'order' => 'rating.asc',
'note' => 'يختار التطبيق الألغاز حسب تقييمها القريب من تقييم اللاعب. الحل نقلات بصيغة UCI مفصولة بمسافات (مثل e2e4 e7e5)، أولها نقلة اللاعب.',
'fields' => [
'id' => ['type' => 'slug', 'label' => 'المعرف', 'hint' => 'مثل p021'],
'title' => ['type' => 'text', 'label' => 'العنوان'],
'fen' => ['type' => 'fen', 'label' => 'الوضعية (FEN)', 'required' => true, 'dir' => 'ltr'],
'moves' => ['type' => 'uci', 'label' => 'الحل (UCI)', 'required' => true, 'dir' => 'ltr'],
'rating' => ['type' => 'int', 'label' => 'التقييم', 'min' => 100, 'max' => 3500, 'default' => 1200],
'themes' => ['type' => 'text', 'label' => 'الموضوعات', 'hint' => 'مفصولة بفواصل، مثل mate,short', 'dir' => 'ltr'],
],
'columns' => ['title', 'id', 'rating', 'themes', 'moves'],
],
];
public function __construct()
{
$this->db = Database::getInstance();
}
private function resource(array $params): array
{
$key = $params['resource'] ?? '';
if (!isset(self::RESOURCES[$key])) {
http_response_code(404);
View::render('errors/404');
exit;
}
Auth::requireRole('admin');
return [$key, self::RESOURCES[$key]];
}
public function index(array $params, string $method): void
{
header('Location: /game-content/seasons');
exit;
}
public function list(array $params, string $method): void
{
[$key, $res] = $this->resource($params);
$rows = $this->db->select($res['table'], ['select' => '*', 'order' => $res['order'], 'limit' => 500]);
$pageTitle = $res['title'];
$moduleCSS = null;
View::render('game-content/list', compact('key', 'res', 'rows', 'pageTitle'));
}
public function create(array $params, string $method): void
{
[$key, $res] = $this->resource($params);
$row = [];
$pageTitle = 'إضافة ' . $res['singular'];
View::render('game-content/form', compact('key', 'res', 'row', 'pageTitle'));
}
public function edit(array $params, string $method): void
{
[$key, $res] = $this->resource($params);
$row = $this->db->selectOne($res['table'], ['id' => 'eq.' . ($params['key'] ?? '')]);
if (!$row) {
Response::error('غير موجود', "/game-content/{$key}");
return;
}
$pageTitle = 'تعديل ' . $res['singular'];
View::render('game-content/form', compact('key', 'res', 'row', 'pageTitle'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
[$key, $res] = $this->resource($params);
$data = $this->collect($res, true);
if (is_string($data)) {
Response::error($data, "/game-content/{$key}/create");
return;
}
if ($this->db->selectOne($res['table'], ['id' => 'eq.' . $data['id'], 'select' => 'id'])) {
Response::error('يوجد عنصر بهذا المعرف', "/game-content/{$key}/create");
return;
}
if ($key === 'seasons') $data['is_active'] = false;
if ($this->db->insert($res['table'], $data) === null) {
Response::error('لم يُحفظ: ' . $this->db->lastErrorMessage(), "/game-content/{$key}/create");
return;
}
AuditLog::log('create', $res['table'], $data['id'], null, $data);
Response::success('تمت الإضافة', "/game-content/{$key}");
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
[$key, $res] = $this->resource($params);
$id = (string)($params['key'] ?? '');
$old = $this->db->selectOne($res['table'], ['id' => 'eq.' . $id]);
if (!$old) {
Response::error('غير موجود', "/game-content/{$key}");
return;
}
$data = $this->collect($res, false);
if (is_string($data)) {
Response::error($data, "/game-content/{$key}/" . rawurlencode($id) . '/edit');
return;
}
if ($this->db->update($res['table'], ['id' => 'eq.' . $id], $data) === null) {
Response::error('لم يُحفظ: ' . $this->db->lastErrorMessage(), "/game-content/{$key}/" . rawurlencode($id) . '/edit');
return;
}
AuditLog::log('update', $res['table'], $id, $old, $data);
Response::success('تم الحفظ', "/game-content/{$key}");
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
[$key, $res] = $this->resource($params);
$id = (string)($params['key'] ?? '');
$row = $this->db->selectOne($res['table'], ['id' => 'eq.' . $id]);
if (!$row) {
Response::error('غير موجود', "/game-content/{$key}");
return;
}
if ($key === 'seasons' && !empty($row['is_active'])) {
Response::error('لا يُحذف الموسم المفعّل؛ فعّل موسمًا آخر أولًا', "/game-content/{$key}");
return;
}
if (!$this->db->delete($res['table'], ['id' => 'eq.' . $id])) {
$why = $this->db->lastError()['code'] ?? '';
$this->db->clearWriteErrors();
Response::error($why === '23503'
? 'لا يُحذف: لاعبون يملكونه أو أحرزوه. أخفِه أو عطّله بدلًا من الحذف'
: 'لم يُحذف: ' . $this->db->lastErrorMessage(), "/game-content/{$key}");
return;
}
AuditLog::log('delete', $res['table'], $id, $row);
Response::success('تم الحذف', "/game-content/{$key}");
}
/**
* Make one season the battle pass. The game reads the single row with
* is_active = true, so every other season is switched off first.
*/
public function activate(array $params, string $method): void
{
Auth::requireCsrf();
[$key, $res] = $this->resource($params);
if ($key !== 'seasons') {
Response::error('غير متاح', "/game-content/{$key}");
return;
}
$id = (string)($params['key'] ?? '');
$season = $this->db->selectOne('seasons', ['id' => 'eq.' . $id]);
if (!$season) {
Response::error('الموسم غير موجود', '/game-content/seasons');
return;
}
if (!empty($season['ends_at']) && strtotime($season['ends_at']) < time()) {
Response::error('انتهى هذا الموسم؛ عدّل تاريخ نهايته أولًا', '/game-content/seasons');
return;
}
$this->db->update('seasons', ['id' => 'neq.' . $id, 'is_active' => 'is.true'], ['is_active' => false]);
$ok = $this->db->update('seasons', ['id' => 'eq.' . $id], ['is_active' => true]);
if ($ok === null) {
Response::error('لم يُفعّل: ' . $this->db->lastErrorMessage(), '/game-content/seasons');
return;
}
AuditLog::log('activate', 'seasons', $id);
Response::success('أصبح "' . ($season['name_ar'] ?: $season['name']) . '" موسم التطبيق الآن', '/game-content/seasons');
}
/**
* Read and validate the form into table columns.
*
* @return array|string the row, or an error for staff
*/
private function collect(array $res, bool $creating)
{
$data = [];
foreach ($res['fields'] as $name => $f) {
$label = $f['label'];
$raw = $_POST[$name] ?? null;
switch ($f['type']) {
case 'slug':
if (!$creating) break;
$v = strtolower(trim((string)$raw));
if (!preg_match(self::SLUG, $v)) return "{$label}: حروف إنجليزية صغيرة وأرقام و _ (3 إلى 40)";
$data[$name] = $v;
break;
case 'text':
case 'textarea':
$v = trim((string)$raw);
if (!empty($f['required']) && $v === '') return "{$label} مطلوب";
$data[$name] = $v === '' ? null : $v;
break;
case 'int':
$v = trim((string)$raw);
if ($v === '') $v = (string)($f['default'] ?? 0);
if (!preg_match('/^-?\d+$/', $v)) return "{$label}: عدد صحيح";
$v = (int)$v;
if (isset($f['min']) && $v < $f['min']) return "{$label}: لا يقل عن {$f['min']}";
if (isset($f['max']) && $v > $f['max']) return "{$label}: لا يزيد عن {$f['max']}";
$data[$name] = $v;
break;
case 'select':
$v = (string)$raw;
if ($v === '' && empty($f['required'])) { $data[$name] = null; break; }
if (!isset($f['options'][$v])) return "{$label}: اختر من القائمة";
$data[$name] = $v;
break;
case 'bool':
$data[$name] = !empty($raw);
break;
case 'datetime':
$v = trim((string)$raw);
if ($v === '') {
if (!empty($f['required'])) return "{$label} مطلوب";
$data[$name] = null;
break;
}
$utc = Time::toUtc($v);
if (!$utc) return "{$label}: تاريخ غير صالح";
$data[$name] = $utc;
break;
case 'url':
$v = trim((string)$raw);
if ($v !== '' && !filter_var($v, FILTER_VALIDATE_URL)) return "{$label}: رابط غير صالح";
$data[$name] = $v === '' ? null : $v;
break;
case 'condition':
$type = (string)($_POST['condition_type'] ?? '');
$count = (int)($_POST['condition_count'] ?? 0);
if (!isset($f['options'][$type])) return "{$label}: اختر نوع الشرط";
if ($count < 1) return "{$label}: العدد 1 على الأقل";
$data[$name] = ['type' => $type, 'count' => $count];
break;
case 'fen':
$v = trim(preg_replace('/\s+/', ' ', (string)$raw));
$parts = explode(' ', $v);
$rows = explode('/', $parts[0] ?? '');
if (count($rows) !== 8 || !preg_match('#^[rnbqkpRNBQKP1-8/]+$#', $parts[0])) return "{$label}: وضعية FEN غير صالحة";
if (isset($parts[1]) && !in_array($parts[1], ['w', 'b'], true)) return "{$label}: دور اللعب يجب أن يكون w أو b";
$data[$name] = $v;
break;
case 'uci':
$v = trim(preg_replace('/\s+/', ' ', strtolower((string)$raw)));
if (!preg_match('/^[a-h][1-8][a-h][1-8][qrbn]?( [a-h][1-8][a-h][1-8][qrbn]?)*$/', $v)) return "{$label}: نقلات UCI مفصولة بمسافات مثل e2e4 e7e5";
$data[$name] = $v;
break;
}
}
if (isset($data['starts_at'], $data['ends_at']) && strtotime($data['ends_at']) <= strtotime($data['starts_at'])) {
return 'النهاية يجب أن تكون بعد البداية';
}
return $data;
}
}
<?php
/**
* Expects: $key, $res, $row ([] when creating)
*/
$isEdit = !empty($row['id']);
$action = $isEdit ? "/game-content/{$key}/" . rawurlencode($row['id']) . '/update' : "/game-content/{$key}/store";
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/game-content/<?= $key ?>" class="btn btn-icon btn-ghost" aria-label="رجوع">
<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(($isEdit ? 'تعديل ' : 'إضافة ') . $res['singular']) ?></h1>
</div>
</div>
<p class="text-sm text-muted mb-4"><?= View::e($res['note']) ?></p>
<div class="card max-w-lg">
<form method="POST" action="<?= $action ?>">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<?php foreach ($res['fields'] as $name => $f): ?>
<?php
$value = $row[$name] ?? ($f['default'] ?? null);
$dir = isset($f['dir']) ? ' dir="' . $f['dir'] . '"' : '';
$req = !empty($f['required']) ? ' required' : '';
?>
<?php if ($f['type'] === 'bool'): ?>
<label class="flex items-center gap-2 mb-4">
<input type="checkbox" name="<?= $name ?>" value="1" <?= $value ? 'checked' : '' ?>>
<span><?= View::e($f['label']) ?></span>
</label>
<?php continue; ?>
<?php endif; ?>
<div class="form-group">
<label class="form-label"><?= View::e($f['label']) ?><?= !empty($f['required']) || $f['type'] === 'slug' ? ' *' : '' ?></label>
<?php switch ($f['type']):
case 'slug': ?>
<?php if ($isEdit): ?>
<input type="text" class="form-input" value="<?= View::e($value) ?>" dir="ltr" disabled>
<?php else: ?>
<input type="text" name="<?= $name ?>" class="form-input" required dir="ltr" pattern="[a-z0-9_]{3,40}">
<?php endif; ?>
<?php break;
case 'textarea': ?>
<textarea name="<?= $name ?>" class="form-input" rows="3"<?= $dir ?><?= $req ?>><?= View::e($value) ?></textarea>
<?php break;
case 'int': ?>
<input type="number" name="<?= $name ?>" class="form-input" value="<?= View::e($value) ?>" step="1"<?= isset($f['min']) ? ' min="' . (int)$f['min'] . '"' : '' ?><?= isset($f['max']) ? ' max="' . (int)$f['max'] . '"' : '' ?><?= $req ?>>
<?php break;
case 'select': ?>
<select name="<?= $name ?>" class="form-select"<?= $req ?>>
<?php if (empty($f['required'])): ?><option value="">بدون</option><?php endif; ?>
<?php foreach ($f['options'] as $ov => $ol): ?>
<option value="<?= View::e($ov) ?>" <?= (string)$value === (string)$ov ? 'selected' : '' ?>><?= View::e($ol) ?></option>
<?php endforeach; ?>
</select>
<?php break;
case 'datetime': ?>
<input type="datetime-local" name="<?= $name ?>" class="form-input" value="<?= Time::toInput(is_string($value) ? $value : null) ?>" dir="ltr"<?= $req ?>>
<?php break;
case 'url': ?>
<input type="url" name="<?= $name ?>" class="form-input" value="<?= View::e($value) ?>" dir="ltr" placeholder="https://...">
<?php break;
case 'condition':
$c = is_string($value) ? json_decode($value, true) : $value;
$c = is_array($c) ? $c : []; ?>
<div class="flex gap-2 items-center flex-wrap">
<select name="condition_type" class="form-select" style="width:auto;" required>
<option value="">اختر</option>
<?php foreach ($f['options'] as $ov => $ol): ?>
<option value="<?= View::e($ov) ?>" <?= ($c['type'] ?? '') === $ov ? 'selected' : '' ?>><?= View::e($ol) ?></option>
<?php endforeach; ?>
</select>
<span class="text-sm text-muted">يبلغ</span>
<input type="number" name="condition_count" class="form-input" style="width: 110px;" min="1" value="<?= (int)($c['count'] ?? 1) ?>" required>
</div>
<?php break;
default: ?>
<input type="text" name="<?= $name ?>" class="form-input" value="<?= View::e($value) ?>"<?= $dir ?><?= $req ?>>
<?php endswitch; ?>
<?php if (!empty($f['hint'])): ?><span class="form-hint"><?= View::e($f['hint']) ?></span><?php endif; ?>
</div>
<?php endforeach; ?>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'حفظ' : 'إضافة' ?></button>
<a href="/game-content/<?= $key ?>" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<?php
/**
* Expects: $key, $res, $rows
*/
$tabs = [];
foreach (GameContentController::RESOURCES as $k => $r) $tabs[$k] = $r['title'];
$fields = $res['fields'];
$csrf = Auth::csrfToken();
$cell = function (string $name, array $row) use ($fields): string {
$f = $fields[$name] ?? ['type' => 'text'];
$v = $row[$name] ?? null;
switch ($f['type']) {
case 'datetime':
return '<span class="tabular-nums">' . Time::show($v) . '</span>';
case 'bool':
return $v ? '<span class="badge badge-success badge-sm">نعم</span>' : '<span class="badge badge-default badge-sm">لا</span>';
case 'select':
return View::e($f['options'][$v] ?? ($v ?? '-'));
case 'condition':
$c = is_string($v) ? json_decode($v, true) : $v;
if (!is_array($c)) return '-';
return View::e(($f['options'][$c['type'] ?? ''] ?? ($c['type'] ?? '?')) . ': ' . ($c['count'] ?? '?'));
case 'int':
return '<span class="tabular-nums">' . View::e($v ?? '-') . '</span>';
case 'slug':
case 'uci':
return '<span class="text-xs" dir="ltr">' . View::e($v ?? '-') . '</span>';
default:
return View::e($v ?? '-');
}
};
?>
<div class="content-header">
<h1>محتوى اللعبة</h1>
<a href="/game-content/<?= $key ?>/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>
إضافة <?= View::e($res['singular']) ?>
</a>
</div>
<div class="tabs mb-5">
<?php foreach ($tabs as $k => $label): ?>
<a href="/game-content/<?= $k ?>" class="tab <?= $k === $key ? 'active' : '' ?>"><?= View::e($label) ?></a>
<?php endforeach; ?>
<a href="/settings" class="tab">المكافآت</a>
</div>
<p class="text-sm text-muted mb-4"><?= View::e($res['note']) ?></p>
<div class="data-table-wrapper">
<?php if (empty($rows)): ?>
<div class="empty-state">
<h3 class="empty-state-title">لا يوجد شيء بعد</h3>
<p class="empty-state-text">أضف أول <?= View::e($res['singular']) ?></p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<?php foreach ($res['columns'] as $c): ?><th><?= View::e($fields[$c]['label'] ?? $c) ?></th><?php endforeach; ?>
<?php if ($key === 'seasons'): ?><th>الحالة</th><?php endif; ?>
<th style="width: 1%;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<?php foreach ($res['columns'] as $i => $c): ?>
<td class="<?= $i === 0 ? 'font-medium' : '' ?>"><?= $cell($c, $row) ?></td>
<?php endforeach; ?>
<?php if ($key === 'seasons'): ?>
<td>
<?php
$now = time();
$ended = !empty($row['ends_at']) && strtotime($row['ends_at']) < $now;
$upcoming = !empty($row['starts_at']) && strtotime($row['starts_at']) > $now;
?>
<?php if (!empty($row['is_active']) && !$ended): ?>
<span class="badge badge-success">في التطبيق الآن</span>
<?php elseif (!empty($row['is_active']) && $ended): ?>
<span class="badge badge-danger">مفعّل لكنه انتهى</span>
<?php elseif ($ended): ?>
<span class="badge badge-default">انتهى</span>
<?php elseif ($upcoming): ?>
<span class="badge badge-info">قادم</span>
<?php else: ?>
<span class="badge badge-warning">غير مفعّل</span>
<?php endif; ?>
</td>
<?php endif; ?>
<td>
<div class="flex gap-2">
<?php if ($key === 'seasons' && empty($row['is_active']) && !(!empty($row['ends_at']) && strtotime($row['ends_at']) < time())): ?>
<form method="POST" action="/game-content/seasons/<?= rawurlencode($row['id']) ?>/activate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-xs btn-success" onclick="return confirm('جعل هذا الموسم موسم التطبيق؟ سيتوقف الموسم المفعّل الحالي.')">تفعيل</button>
</form>
<?php endif; ?>
<a href="/game-content/<?= $key ?>/<?= rawurlencode($row['id']) ?>/edit" class="btn btn-xs btn-ghost">تعديل</a>
<form method="POST" action="/game-content/<?= $key ?>/<?= rawurlencode($row['id']) ?>/delete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-xs btn-ghost text-danger" onclick="return confirm('حذف نهائي؟')">حذف</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
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