Commit 9b743e4f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(branding): edit the colours the game reads; say which images it uses

Both apps keep their theme in platform_theme, but the game only applies the
keys in its theme.js COLOR_KEYS (back_*, card, ink, gold, board_*, ludo_*,
bg_* ...). The palette this page edited (color_primary, surface_* ...) is
the manager's own theme, so no change here ever reached a player. A new
"app colours" tab edits exactly the game's keys, in groups with Arabic
names, showing each one's default read from the game's tokens.css; reset
removes the override so the app falls back to its design. The manager
palette stays, labelled as the manager's.

Every asset slot is marked as read by the game, read only for uploads made
after 2026-10-19 (logo, tab icons), or ignored (the old emoji slots and
the game-icon slots the app now draws itself), and new uploads may create
any slot the game reads (icon_*, chess_piece_*, chess_bot_*,
emoji_dice_1..6). Uploads go through SupabaseStorage: the hand-written
request lacked the apikey header and Kong refused every upload.

Flash messages are JSON-encoded into the toast script; a multi-line
message (such as a storage error) used to break the script.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent c0f05e75
......@@ -195,6 +195,8 @@ return [
'branding/update-color' => ['module' => 'branding', 'action' => 'updateColor'],
'branding/reset-colors' => ['module' => 'branding', 'action' => 'resetColors'],
'branding/upload-asset' => ['module' => 'branding', 'action' => 'uploadAsset'],
'branding/game-color' => ['module' => 'branding', 'action' => 'updateGameColor'],
'branding/game-color/reset' => ['module' => 'branding', 'action' => 'resetGameColor'],
// Analytics
'analytics' => ['module' => 'analytics', 'action' => 'index'],
......
......@@ -25,6 +25,12 @@ class ThemeService
'text_tertiary' => '--text-muted',
];
/** Is this platform_theme key part of the manager's own palette? */
public static function isManagerKey(string $id): bool
{
return isset(self::$idToCssVar[$id]);
}
public static function getColors(): array
{
if (self::$colors !== null) {
......
......@@ -48,7 +48,7 @@
<?php if ($flash): ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
showToast('<?= View::e($flash['message']) ?>', '<?= $flash['type'] ?>');
showToast(<?= json_encode((string)$flash['message'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, <?= json_encode((string)$flash['type']) ?>);
});
</script>
<?php endif; ?>
......
<?php
require_once MODULES_PATH . '/tournaments/services/PlayerEngineService.php';
class BrandingController
{
private Database $db;
......@@ -9,25 +11,214 @@ class BrandingController
$this->db = Database::getInstance();
}
/**
* The colours the game lets staff override, exactly the keys of COLOR_KEYS
* in El3ab public/js/core/theme.js (key = CSS custom property, '_' for '-').
* Both apps share platform_theme, but the game ignores every other key in it:
* the color_primary / surface_* palette this page used to edit is the
* manager's own theme (core/ThemeService.php), so changing it never reached
* a player.
*/
public const GAME_COLORS = [
'back_950' => ['أعمق خلفية', 'الخلفيات'],
'back_900' => ['خلفية الشاشات', 'الخلفيات'],
'back_800' => ['خلفية الأقسام', 'الخلفيات'],
'back_700' => ['خلفية مرتفعة', 'الخلفيات'],
'back_600' => ['حدود داكنة', 'الخلفيات'],
'back_500' => ['خطوط فاصلة داكنة', 'الخلفيات'],
'card' => ['البطاقة', 'البطاقات والحبر'],
'card_2' => ['البطاقة الثانوية', 'البطاقات والحبر'],
'card_line' => ['حدود البطاقة', 'البطاقات والحبر'],
'ink' => ['الحبر', 'البطاقات والحبر'],
'ink_2' => ['الحبر الثانوي', 'البطاقات والحبر'],
'ink_3' => ['الحبر الخافت', 'البطاقات والحبر'],
'red_ink' => ['الحبر الأحمر', 'البطاقات والحبر'],
'red_ink_deep' => ['الحبر الأحمر الغامق', 'البطاقات والحبر'],
'gold' => ['الذهبي', 'ألوان الهوية'],
'gold_soft' => ['الذهبي الفاتح', 'ألوان الهوية'],
'gold_dark' => ['الذهبي الغامق', 'ألوان الهوية'],
'red' => ['الأحمر', 'ألوان الهوية'],
'win' => ['لون الفوز', 'ألوان الهوية'],
'loss' => ['لون الخسارة', 'ألوان الهوية'],
'sky' => ['الأزرق', 'ألوان الهوية'],
'text_primary' => ['النص الأساسي', 'النصوص'],
'text_secondary' => ['النص الثانوي', 'النصوص'],
'text_muted' => ['النص الخافت', 'النصوص'],
'ludo_red' => ['الأحمر', 'اللودو'],
'ludo_blue' => ['الأزرق', 'اللودو'],
'ludo_green' => ['الأخضر', 'اللودو'],
'ludo_yellow' => ['الأصفر', 'اللودو'],
'board_light' => ['المربع الفاتح', 'رقعة الشطرنج'],
'board_dark' => ['المربع الغامق', 'رقعة الشطرنج'],
'board_highlight_light' => ['تمييز المربع الفاتح', 'رقعة الشطرنج'],
'board_highlight_dark' => ['تمييز المربع الغامق', 'رقعة الشطرنج'],
'board_check' => ['تمييز الكش', 'رقعة الشطرنج'],
'bg_point_dark' => ['المثلث الغامق', 'الطاولة'],
'bg_point_light' => ['المثلث الفاتح', 'الطاولة'],
'bg_checker_white' => ['القطع البيضاء', 'الطاولة'],
'bg_checker_black' => ['القطع السوداء', 'الطاولة'],
'bg_board_border' => ['إطار اللوحة', 'الطاولة'],
'bg_board_light' => ['أرضية اللوحة', 'الطاولة'],
];
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',
]);
// The manager's own palette: only the keys ThemeService maps.
$colors = array_values(array_filter(
$this->db->select('platform_theme', ['select' => '*', 'category' => 'eq.color', 'order' => 'sort_order.asc']),
fn($c) => ThemeService::isManagerKey($c['id'])
));
$stored = [];
foreach ($this->db->select('platform_theme', [
'select' => 'id,value,updated_at',
'id' => 'in.(' . implode(',', array_keys(self::GAME_COLORS)) . ')',
]) as $row) {
$stored[$row['id']] = $row;
}
$defaults = $this->gameTokenDefaults();
$gameColors = [];
foreach (self::GAME_COLORS as $key => [$label, $group]) {
$gameColors[$group][] = [
'id' => $key,
'label' => $label,
'value' => $stored[$key]['value'] ?? null,
'default' => $defaults[$key] ?? null,
];
}
$assets = $this->db->select('platform_assets', [
'select' => '*',
'order' => 'sort_order.asc',
]);
foreach ($assets as &$a) {
[$a['game_state'], $a['game_note']] = self::slotStatus($a['id']);
}
unset($a);
usort($assets, fn($x, $y) => [self::STATE_ORDER[$x['game_state']], $x['id']] <=> [self::STATE_ORDER[$y['game_state']], $y['id']]);
$gameAdminUrl = PlayerEngineService::baseUrl() . '/admin/branding.php';
$pageTitle = 'الهوية والعلامة التجارية';
$moduleCSS = 'branding';
$moduleJS = 'branding';
View::render('branding/index', compact('colors', 'assets', 'pageTitle', 'moduleCSS', 'moduleJS'));
View::render('branding/index', compact('colors', 'gameColors', 'assets', 'gameAdminUrl', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
private const STATE_ORDER = ['live' => 0, 'conditional' => 1, 'ignored' => 2];
/**
* Does the game read this asset slot? From El3ab public/js/core/theme.js,
* icons.js, reactions.js, juice.js, audio.js, ludo/ui/die.js and
* chess/canvas/board.js.
*
* @return array{0:string,1:string} [live|conditional|ignored, note]
*/
public static function slotStatus(string $id): array
{
if (preg_match('/^chess_piece_[wb][KQRBNP]$/', $id)) return ['live', 'قطعة الشطرنج على الرقعة'];
if (str_starts_with($id, 'chess_bot_')) return ['live', 'صورة البوت في مباراة الشطرنج'];
if (str_starts_with($id, 'icon_emote_')) return ['live', 'صورة التفاعل في المباراة'];
if (str_starts_with($id, 'icon_')) return ['live', 'تحل محل الأيقونة التي بالاسم نفسه'];
if (preg_match('/^emoji_dice_[1-6]$/', $id)) return ['live', 'وجه نرد اللودو'];
if (str_starts_with($id, 'sfx_')) return ['live', 'صوت في اللعبة'];
if (in_array($id, ['logo', 'logo_splash', 'logo_icon'], true) || str_starts_with($id, 'tab_')) {
return ['conditional', 'يُقرأ فقط إن رُفع بعد 19 أكتوبر 2026؛ الملفات الأقدم من التصميم السابق ويتجاهلها التطبيق'];
}
if (in_array($id, ['chess_icon', 'ludo_icon', 'domino_icon', 'backgammon_icon', 'coin_icon', 'gem_icon', 'notification_bell'], true)) {
return ['ignored', 'التطبيق يرسم أيقونته الخاصة هنا؛ لتغييرها ارفع لخانة icon_ بالاسم المطلوب'];
}
if (str_starts_with($id, 'emoji_')) return ['ignored', 'خانات الرموز التعبيرية القديمة أُلغيت؛ التطبيق يعرض أيقونات'];
return ['ignored', 'لا يقرؤها التطبيق'];
}
/** Slots a new upload may create: only ones the game reads. */
private static function isGameSlot(string $id): bool
{
return (bool)preg_match('/^(icon_[a-z0-9_-]{2,40}|chess_piece_[wb][KQRBNP]|chess_bot_[a-z0-9_-]{2,40}|emoji_dice_[1-6]|logo|logo_splash|logo_icon|tab_(play|rank|social|tournaments|profile|shop))$/', $id);
}
/**
* The game's default colours, read from its tokens.css (cached ten
* minutes), so a reset can show what the app falls back to.
*/
private function gameTokenDefaults(): array
{
$cache = STORAGE_PATH . '/cache/game_tokens.json';
if (is_file($cache) && time() - filemtime($cache) < 600) {
$v = json_decode((string)file_get_contents($cache), true);
if (is_array($v)) return $v;
}
$ch = curl_init(PlayerEngineService::baseUrl() . '/public/css/tokens.css');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
$css = curl_exec($ch);
if (!is_string($css) || (int)curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) return [];
$out = [];
foreach (array_keys(self::GAME_COLORS) as $key) {
$prop = '--' . str_replace('_', '-', $key);
if (preg_match('/' . preg_quote($prop, '/') . '\s*:\s*(#[0-9A-Fa-f]{6})\b/', $css, $m)) $out[$key] = strtoupper($m[1]);
}
@file_put_contents($cache, json_encode($out));
return $out;
}
/** Set one of the game's colours (same rows the game's own branding admin writes). */
public function updateGameColor(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = (string)($_POST['id'] ?? '');
$value = strtoupper(trim((string)($_POST['value'] ?? '')));
if (!isset(self::GAME_COLORS[$id])) {
Response::error('هذا اللون لا يقرؤه التطبيق', '/branding');
return;
}
if (!preg_match('/^#[0-9A-F]{6}$/', $value)) {
Response::error('قيمة لون غير صحيحة', '/branding');
return;
}
$existing = $this->db->selectOne('platform_theme', ['id' => "eq.{$id}", 'select' => 'id,value']);
if ($existing) {
$ok = $this->db->update('platform_theme', ['id' => "eq.{$id}"], ['value' => $value, 'updated_at' => date('c')]);
} else {
$defaults = $this->gameTokenDefaults();
$ok = $this->db->insert('platform_theme', [
'id' => $id,
'category' => 'game',
'group_name' => 'player_app',
'label' => $id,
'label_ar' => self::GAME_COLORS[$id][0],
'value' => $value,
'default_value' => $defaults[$id] ?? $value,
'value_type' => 'hex',
]);
}
if ($ok === null) {
Response::error('لم يُحفظ اللون: ' . $this->db->lastErrorMessage(), '/branding');
return;
}
AuditLog::log('update', 'platform_theme', $id, $existing ? ['value' => $existing['value']] : null, ['value' => $value]);
Response::success('تم حفظ اللون. يراه اللاعبون عند فتح التطبيق', '/branding');
}
/** Remove an override: the game falls back to its tokens.css colour. */
public function resetGameColor(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = (string)($_POST['id'] ?? '');
if (!isset(self::GAME_COLORS[$id])) {
Response::error('هذا اللون لا يقرؤه التطبيق', '/branding');
return;
}
$this->db->delete('platform_theme', ['id' => "eq.{$id}"]);
AuditLog::log('reset', 'platform_theme', $id);
Response::success('أُعيد اللون إلى تصميم التطبيق الافتراضي', '/branding');
}
public function updateColor(array $params, string $method): void
......@@ -58,7 +249,12 @@ class BrandingController
Auth::requireCsrf();
Auth::requireRole('superadmin');
$id = $_POST['id'] ?? '';
$id = trim((string)($_POST['id'] ?? ''));
$existing = $id !== '' ? $this->db->selectOne('platform_assets', ['id' => "eq.{$id}", 'select' => 'id']) : null;
if (!$existing && !self::isGameSlot($id)) {
Response::error('اسم الخانة غير معروف للتطبيق (مثال: icon_coin، chess_piece_wK، emoji_dice_6)', '/branding');
return;
}
if (empty($_FILES['asset']) || $_FILES['asset']['error'] !== UPLOAD_ERR_OK) {
Response::error('خطأ في رفع الملف', '/branding');
......@@ -81,37 +277,42 @@ class BrandingController
$filename = $id . '.' . $ext;
$storagePath = 'branding/' . $filename;
$fileContents = file_get_contents($file['tmp_name']);
$storageUrl = SUPABASE_URL . '/storage/v1/object/profile-images/' . $storagePath;
$ch = curl_init($storageUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContents);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $file['type'],
'x-upsert: true',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode >= 400) {
Response::error('فشل رفع الملف إلى التخزين: ' . ($result ?: $httpCode), '/branding');
// Through SupabaseStorage, which sends the apikey header Kong requires;
// the hand-rolled request here sent only Authorization and every upload
// was refused with "No API key found in request".
try {
$url = SupabaseStorage::getInstance()->upload('profile-images', $storagePath, $file['tmp_name'], $file['type']);
} catch (RuntimeException $e) {
Response::error('فشل رفع الملف إلى التخزين: ' . $e->getMessage(), '/branding');
return;
}
$assetUrl = $url . '&v=' . time();
$assetUrl = SUPABASE_URL . '/storage/v1/object/public/profile-images/' . $storagePath
. '?apikey=' . SUPABASE_ANON_KEY . '&v=' . time();
$this->db->update('platform_assets', ['id' => "eq.{$id}"], [
'asset_url' => $assetUrl,
'updated_at' => date('c'),
]);
if ($existing) {
$ok = $this->db->update('platform_assets', ['id' => "eq.{$id}"], [
'asset_url' => $assetUrl,
'mime_type' => $file['type'],
'file_size_kb' => (int)ceil($file['size'] / 1024),
'updated_at' => date('c'),
]);
} else {
$ok = $this->db->insert('platform_assets', [
'id' => $id,
'category' => 'custom',
'label' => $id,
'asset_url' => $assetUrl,
'mime_type' => $file['type'],
'file_size_kb' => (int)ceil($file['size'] / 1024),
]);
}
if ($ok === null) {
Response::error('رُفع الملف لكن لم يُربط بالخانة: ' . $this->db->lastErrorMessage(), '/branding');
return;
}
AuditLog::log('upload', 'platform_asset', $id);
Response::success('تم رفع الملف', '/branding');
[$state] = self::slotStatus($id);
Response::success($state === 'ignored' ? 'تم رفع الملف، لكن التطبيق لا يقرأ هذه الخانة' : 'تم رفع الملف. يظهر للاعبين عند فتح التطبيق', '/branding');
}
public function resetColors(array $params, string $method): void
......
......@@ -3,14 +3,61 @@
</div>
<div class="tabs mb-5">
<button class="tab active" onclick="switchBrandTab(this, 'customizeTab')">تخصيص الواجهة</button>
<button class="tab" onclick="switchBrandTab(this, 'colorsTab')">ألوان المنصة</button>
<button class="tab" onclick="switchBrandTab(this, 'assetsTab')">الأصول المرئية</button>
<button class="tab" onclick="switchBrandTab(this, 'previewTab')">معاينة حية</button>
<button class="tab active" onclick="switchBrandTab(this, 'gameColorsTab')">ألوان التطبيق</button>
<button class="tab" onclick="switchBrandTab(this, 'assetsTab')">صور وأيقونات التطبيق</button>
<button class="tab" onclick="switchBrandTab(this, 'colorsTab')">ألوان لوحة الإدارة</button>
<button class="tab" onclick="switchBrandTab(this, 'customizeTab')">تخصيص لوحة الإدارة</button>
<button class="tab" onclick="switchBrandTab(this, 'previewTab')">معاينة</button>
</div>
<!-- Game Colors Tab -->
<div id="gameColorsTab" class="tab-content">
<div class="card mb-4 p-4">
<p class="text-sm">هذه هي الألوان التي يسمح التطبيق بتغييرها (تصميم Night Arcade). اللون غير المحدد يأخذ قيمته من تصميم التطبيق. يرى اللاعبون التغيير عند فتح التطبيق.</p>
<p class="text-sm text-muted mt-2">يمكن أيضًا تعديلها من لوحة الهوية في التطبيق نفسه: <a href="<?= View::e($gameAdminUrl) ?>" target="_blank" rel="noopener" class="text-link" dir="ltr"><?= View::e($gameAdminUrl) ?></a> — الطرفان يكتبان القيم نفسها.</p>
</div>
<?php foreach ($gameColors as $group => $items): ?>
<div class="card mb-4">
<div class="card-header"><h3 class="card-title"><?= View::e($group) ?></h3></div>
<div class="grid grid-3 gap-3 p-4">
<?php foreach ($items as $c): ?>
<?php $shown = $c['value'] ?: ($c['default'] ?: '#000000'); ?>
<div class="card card-inner p-3">
<div class="flex items-center gap-3">
<div style="width: 40px; height: 40px; border-radius: var(--radius-md); background: <?= View::e($shown) ?>; border: 1px solid var(--border); flex-shrink: 0;"></div>
<div class="flex-1">
<div class="font-medium text-sm"><?= View::e($c['label']) ?></div>
<div class="text-xs text-muted" dir="ltr"><?= View::e($c['id']) ?></div>
<div class="text-xs <?= $c['value'] ? 'text-warning' : 'text-muted' ?>">
<?= $c['value'] ? 'مخصص' : 'الافتراضي' ?><?= $c['default'] ? ' — الافتراضي ' . View::e($c['default']) : '' ?>
</div>
</div>
</div>
<div class="flex items-center gap-2 mt-3">
<form method="POST" action="/branding/game-color" class="flex items-center gap-2">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($c['id']) ?>">
<input type="color" name="value" value="<?= View::e($shown) ?>" aria-label="<?= View::e($c['label']) ?>" style="width: 40px; height: 32px; border: none; cursor: pointer;">
<button type="submit" class="btn btn-primary btn-sm">حفظ</button>
</form>
<?php if ($c['value']): ?>
<form method="POST" action="/branding/game-color/reset">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="id" value="<?= View::e($c['id']) ?>">
<button type="submit" class="btn btn-ghost btn-sm">الافتراضي</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Customization Tab -->
<div id="customizeTab" class="tab-content">
<div id="customizeTab" class="tab-content hidden">
<p class="text-sm text-muted mb-4">تفضيلات شكل لوحة الإدارة على هذا المتصفح فقط.</p>
<div class="customize-grid">
<!-- 1. Sidebar Width -->
<div class="customize-card">
......@@ -276,7 +323,7 @@
<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="flex items-center justify-between mb-4">
<p class="text-sm text-secondary">ألوان هوية El3ab — عدّل أي لون وسيُطبّق مباشرة على الواجهة</p>
<p class="text-sm text-secondary">ألوان لوحة الإدارة هذه فقط. التطبيق لا يقرأ هذه المفاتيح؛ ألوانه في تبويب "ألوان التطبيق".</p>
<form method="POST" action="/branding/reset-colors" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-ghost btn-sm" onclick="return confirm('هل أنت متأكد؟ سيتم إعادة جميع الألوان إلى الافتراضي.')">
......@@ -310,12 +357,27 @@
<!-- Assets Tab -->
<div id="assetsTab" class="tab-content hidden">
<?php $stateLabels = ['live' => ['يقرؤه التطبيق', 'badge-success'], 'conditional' => ['بشرط', 'badge-warning'], 'ignored' => ['لا يقرؤه التطبيق', 'badge-default']]; ?>
<div class="card mb-4 p-4">
<p class="text-sm">الخانات التي يقرؤها التطبيق: قطع الشطرنج (chess_piece_wK ...)، صور البوتات (chess_bot_المعرف)، أي أيقونة (icon_الاسم، مثل icon_coin و icon_crown)، صور التفاعلات (icon_emote_...)، وجوه نرد اللودو (emoji_dice_1 إلى 6)، والأصوات (sfx_...). الشعار وأيقونات الشريط السفلي (tab_...) تُقرأ فقط إن رُفعت بعد 19 أكتوبر 2026. باقي الخانات القديمة، ومنها خانات الرموز التعبيرية، لا يقرؤها التطبيق.</p>
<form method="POST" action="/branding/upload-asset" enctype="multipart/form-data" class="flex gap-2 items-end flex-wrap mt-3">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">رفع لخانة جديدة</label>
<input type="text" name="id" class="form-input" required dir="ltr" placeholder="icon_coin" pattern="[a-zA-Z0-9_-]+">
</div>
<input type="file" name="asset" accept="image/png,image/jpeg,image/svg+xml,image/webp" class="form-input" style="font-size: 12px; max-width: 260px;" required>
<button type="submit" class="btn btn-primary btn-sm">رفع</button>
</form>
<p class="text-xs text-muted mt-2">الأصوات تُرفع من لوحة الهوية في التطبيق لأنها ليست صورًا.</p>
</div>
<?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">
<?php $st = $stateLabels[$asset['game_state']] ?? $stateLabels['ignored']; ?>
<div class="card" style="<?= $asset['game_state'] === 'ignored' ? 'opacity: 0.7;' : '' ?>">
<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="">
......@@ -323,8 +385,12 @@
<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>
<div class="flex items-center justify-between gap-2">
<h4 class="font-medium text-sm"><?= View::e($asset['label_ar'] ?? $asset['label']) ?></h4>
<span class="badge <?= $st[1] ?> badge-sm"><?= $st[0] ?></span>
</div>
<p class="text-xs text-muted" dir="ltr"><?= View::e($asset['id']) ?></p>
<p class="text-xs text-secondary mb-3"><?= View::e($asset['game_note']) ?><?php if (!empty($asset['updated_at'])): ?> — آخر رفع <?= Time::show($asset['updated_at'], 'Y/m/d') ?><?php endif; ?></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']) ?>">
......
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