Commit 00ea47bc authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: branding admin fully connected to live system

Theme loader (theme.js):
- Reads theme.json on boot, applies CSS custom properties
- All colors, animations, button shapes, and radii are live-editable
- assetImg() helper renders uploaded assets at exact expected size
  with object-fit:contain (prevents pixelation/oversizing)

Admin panel updates:
- Button shapes section: radius, height, font weight, shadow, borders
- 25 asset upload slots (up from 19) including tab bar icons
- Each slot shows expected dimensions + usage hint in Arabic
- Upload accepts SVG, PNG, JPG, WebP, GIF
- Preview renders at exact expected size (not pixelated, not huge)
- Old files for same slot are cleaned up on re-upload

CSS connected to theme variables:
- --btn-press-scale, --btn-min-height, --btn-weight, --btn-shadow
- --r-full (button radius), --r-btn-secondary, --r-input
- --card-border-width
- All existing color variables mapped from theme.json
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c64ac838
...@@ -32,12 +32,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_theme'])) { ...@@ -32,12 +32,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_theme'])) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
$slot = $_POST['slot'] ?? ''; $slot = $_POST['slot'] ?? '';
$expectedW = intval($_POST['expected_w'] ?? 64);
$expectedH = intval($_POST['expected_h'] ?? 64);
$file = $_FILES['asset']; $file = $_FILES['asset'];
if ($file['error'] === 0 && $slot) { if ($file['error'] === 0 && $slot) {
$ext = pathinfo($file['name'], PATHINFO_EXTENSION); $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
foreach (glob($BRAND_DIR . $slot . '.*') as $old) { unlink($old); }
$dest = $BRAND_DIR . $slot . '.' . $ext; $dest = $BRAND_DIR . $slot . '.' . $ext;
move_uploaded_file($file['tmp_name'], $dest); move_uploaded_file($file['tmp_name'], $dest);
$theme['assets'][$slot] = '/public/assets/brand/' . $slot . '.' . $ext; $theme['assets'][$slot] = '/public/assets/brand/' . $slot . '.' . $ext;
$theme['asset_sizes'][$slot] = ['w' => $expectedW, 'h' => $expectedH];
file_put_contents($THEME_FILE, json_encode($theme, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); file_put_contents($THEME_FILE, json_encode($theme, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
} }
} }
...@@ -202,6 +206,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { ...@@ -202,6 +206,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
</div> </div>
</div> </div>
<!-- BUTTON SHAPES -->
<h2>🔘 أشكال الأزرار</h2>
<div class="section">
<div class="grid">
<?php
$shapes = [
['key' => 'btn_radius', 'label' => 'انحناء الأزرار الأساسية (px)', 'default' => '9999', 'hint' => '9999 = حبة (pill), 12 = مستطيل ناعم, 0 = حاد', 'type' => 'number'],
['key' => 'btn_secondary_radius', 'label' => 'انحناء الأزرار الثانوية (px)', 'default' => '9999', 'hint' => 'أزرار الإلغاء والخيارات', 'type' => 'number'],
['key' => 'btn_height', 'label' => 'ارتفاع الزر (px)', 'default' => '44', 'hint' => 'الحد الأدنى لارتفاع الزر — 44 للمس', 'type' => 'number'],
['key' => 'btn_font_weight', 'label' => 'ثقل خط الزر', 'default' => '700', 'hint' => '400=عادي, 600=سميك, 700=عريض, 800=أعرض', 'type' => 'number'],
['key' => 'btn_shadow', 'label' => 'ظل الزر الأساسي', 'default' => '0 4px 16px rgba(228,172,56,0.3)', 'hint' => 'CSS box-shadow كامل', 'type' => 'text'],
['key' => 'card_border_width', 'label' => 'سمك حدود البطاقة (px)', 'default' => '1', 'hint' => '0 = بلا حدود, 1 = ناعم, 2 = واضح', 'type' => 'number'],
['key' => 'input_radius', 'label' => 'انحناء حقول الإدخال (px)', 'default' => '12', 'hint' => 'حقول النص وكلمة المرور', 'type' => 'number'],
];
foreach ($shapes as $s):
$val = $theme[$s['key']] ?? $s['default'];
?>
<div class="field">
<label><?= $s['label'] ?></label>
<input type="<?= $s['type'] ?>" name="theme[<?= $s['key'] ?>]" value="<?= $val ?>">
<div class="hint"><?= $s['hint'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<button type="submit">💾 حفظ كل التغييرات</button> <button type="submit">💾 حفظ كل التغييرات</button>
</form> </form>
...@@ -213,41 +243,52 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { ...@@ -213,41 +243,52 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
<div class="grid"> <div class="grid">
<?php <?php
$assetSlots = [ $assetSlots = [
['slot' => 'logo', 'label' => 'شعار المنصة', 'size' => 'SVG أو PNG — 200×60px', 'accept' => '.svg,.png'], ['slot' => 'logo', 'label' => 'شعار المنصة', 'w' => 200, 'h' => 60, 'hint' => 'يظهر في الهيدر — يُفضل SVG شفاف'],
['slot' => 'logo_icon', 'label' => 'أيقونة الشعار', 'size' => 'SVG أو PNG — 64×64px', 'accept' => '.svg,.png'], ['slot' => 'logo_icon', 'label' => 'أيقونة الشعار (مربعة)', 'w' => 64, 'h' => 64, 'hint' => 'الأيقونة المختصرة للشعار'],
['slot' => 'splash_bg', 'label' => 'خلفية البداية', 'size' => 'PNG/JPG — 1080×1920px', 'accept' => '.png,.jpg,.webp'], ['slot' => 'splash_bg', 'label' => 'خلفية شاشة البداية', 'w' => 1080, 'h' => 1920, 'hint' => 'خلفية كاملة عند فتح التطبيق'],
['slot' => 'favicon', 'label' => 'Favicon', 'size' => 'PNG — 32×32px', 'accept' => '.png,.ico'], ['slot' => 'favicon', 'label' => 'Favicon', 'w' => 32, 'h' => 32, 'hint' => 'أيقونة تبويب المتصفح'],
['slot' => 'coin_icon', 'label' => 'أيقونة العملات', 'size' => 'SVG أو PNG — 24×24px', 'accept' => '.svg,.png'], ['slot' => 'coin_icon', 'label' => 'أيقونة العملات (HUD)', 'w' => 24, 'h' => 24, 'hint' => 'تظهر بجانب رقم العملات في الأعلى'],
['slot' => 'gem_icon', 'label' => 'أيقونة الجواهر', 'size' => 'SVG أو PNG — 24×24px', 'accept' => '.svg,.png'], ['slot' => 'gem_icon', 'label' => 'أيقونة الجواهر (HUD)', 'w' => 24, 'h' => 24, 'hint' => 'تظهر بجانب رقم الجواهر في الأعلى'],
['slot' => 'xp_icon', 'label' => 'أيقونة الخبرة', 'size' => 'SVG أو PNG — 24×24px', 'accept' => '.svg,.png'], ['slot' => 'xp_icon', 'label' => 'أيقونة المستوى (HUD)', 'w' => 24, 'h' => 24, 'hint' => 'تظهر بجانب رقم المستوى'],
['slot' => 'chess_icon', 'label' => 'أيقونة الشطرنج', 'size' => 'SVG أو PNG — 64×64px', 'accept' => '.svg,.png'], ['slot' => 'chess_icon', 'label' => 'أيقونة الشطرنج', 'w' => 64, 'h' => 64, 'hint' => 'في مربع الشطرنج بالصفحة الرئيسية'],
['slot' => 'domino_icon', 'label' => 'أيقونة الدومينو', 'size' => 'SVG أو PNG — 64×64px', 'accept' => '.svg,.png'], ['slot' => 'domino_icon', 'label' => 'أيقونة الدومينو', 'w' => 64, 'h' => 64, 'hint' => 'في مربع الدومينو بالصفحة الرئيسية'],
['slot' => 'ludo_icon', 'label' => 'أيقونة اللودو', 'size' => 'SVG أو PNG — 64×64px', 'accept' => '.svg,.png'], ['slot' => 'ludo_icon', 'label' => 'أيقونة اللودو', 'w' => 64, 'h' => 64, 'hint' => 'في مربع اللودو بالصفحة الرئيسية'],
['slot' => 'backgammon_icon', 'label' => 'أيقونة الطاولة', 'size' => 'SVG أو PNG — 64×64px', 'accept' => '.svg,.png'], ['slot' => 'backgammon_icon', 'label' => 'أيقونة الطاولة', 'w' => 64, 'h' => 64, 'hint' => 'في مربع الطاولة بالصفحة الرئيسية'],
['slot' => 'win_trophy', 'label' => 'كأس الفوز', 'size' => 'SVG أو PNG — 128×128px', 'accept' => '.svg,.png'], ['slot' => 'win_trophy', 'label' => 'كأس الفوز', 'w' => 128, 'h' => 128, 'hint' => 'شاشة نتيجة المباراة — فوز'],
['slot' => 'loss_icon', 'label' => 'أيقونة الخسارة', 'size' => 'SVG أو PNG — 128×128px', 'accept' => '.svg,.png'], ['slot' => 'loss_icon', 'label' => 'أيقونة الخسارة', 'w' => 128, 'h' => 128, 'hint' => 'شاشة نتيجة المباراة — خسارة'],
['slot' => 'draw_icon', 'label' => 'أيقونة التعادل', 'size' => 'SVG أو PNG — 128×128px', 'accept' => '.svg,.png'], ['slot' => 'draw_icon', 'label' => 'أيقونة التعادل', 'w' => 128, 'h' => 128, 'hint' => 'شاشة نتيجة المباراة — تعادل'],
['slot' => 'daily_reward', 'label' => 'صندوق المكافأة اليومية', 'size' => 'SVG أو PNG — 128×128px', 'accept' => '.svg,.png'], ['slot' => 'daily_reward', 'label' => 'صندوق المكافأة اليومية', 'w' => 128, 'h' => 128, 'hint' => 'شاشة المكافأة اليومية'],
['slot' => 'rank_gold', 'label' => 'ميدالية ذهبية', 'size' => 'SVG أو PNG — 32×32px', 'accept' => '.svg,.png'], ['slot' => 'rank_gold', 'label' => 'ميدالية ذهبية (#1)', 'w' => 32, 'h' => 32, 'hint' => 'بجانب المركز الأول في الترتيب'],
['slot' => 'rank_silver', 'label' => 'ميدالية فضية', 'size' => 'SVG أو PNG — 32×32px', 'accept' => '.svg,.png'], ['slot' => 'rank_silver', 'label' => 'ميدالية فضية (#2)', 'w' => 32, 'h' => 32, 'hint' => 'بجانب المركز الثاني'],
['slot' => 'rank_bronze', 'label' => 'ميدالية برونزية', 'size' => 'SVG أو PNG — 32×32px', 'accept' => '.svg,.png'], ['slot' => 'rank_bronze', 'label' => 'ميدالية برونزية (#3)', 'w' => 32, 'h' => 32, 'hint' => 'بجانب المركز الثالث'],
['slot' => 'default_avatar', 'label' => 'صورة افتراضية للاعب', 'size' => 'PNG — 128×128px', 'accept' => '.png,.jpg,.webp'], ['slot' => 'default_avatar', 'label' => 'صورة افتراضية للاعب', 'w' => 128, 'h' => 128, 'hint' => 'تظهر إذا اللاعب ما رفع صورة'],
['slot' => 'notification_bell', 'label' => 'أيقونة الإشعارات', 'size' => 'SVG — 24×24px', 'accept' => '.svg,.png'], ['slot' => 'notification_bell', 'label' => 'أيقونة الإشعارات', 'w' => 24, 'h' => 24, 'hint' => 'جرس الإشعارات في الهيدر'],
['slot' => 'tab_play', 'label' => 'أيقونة تبويب "العب"', 'w' => 24, 'h' => 24, 'hint' => 'شريط التنقل السفلي'],
['slot' => 'tab_rank', 'label' => 'أيقونة تبويب "الترتيب"', 'w' => 24, 'h' => 24, 'hint' => 'شريط التنقل السفلي'],
['slot' => 'tab_social', 'label' => 'أيقونة تبويب "الأصدقاء"', 'w' => 24, 'h' => 24, 'hint' => 'شريط التنقل السفلي'],
['slot' => 'tab_shop', 'label' => 'أيقونة تبويب "المتجر"', 'w' => 24, 'h' => 24, 'hint' => 'شريط التنقل السفلي'],
['slot' => 'tab_profile', 'label' => 'أيقونة تبويب "حسابي"', 'w' => 24, 'h' => 24, 'hint' => 'شريط التنقل السفلي'],
]; ];
foreach ($assetSlots as $a): foreach ($assetSlots as $a):
$current = $theme['assets'][$a['slot']] ?? null; $current = $theme['assets'][$a['slot']] ?? null;
$displayW = min($a['w'], 80);
$displayH = min($a['h'], 80);
?> ?>
<div class="field"> <div class="field">
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="slot" value="<?= $a['slot'] ?>"> <input type="hidden" name="slot" value="<?= $a['slot'] ?>">
<input type="hidden" name="expected_w" value="<?= $a['w'] ?>">
<input type="hidden" name="expected_h" value="<?= $a['h'] ?>">
<label><?= $a['label'] ?></label> <label><?= $a['label'] ?></label>
<div class="upload-box" onclick="this.querySelector('input[type=file]').click()"> <div class="upload-box" onclick="this.querySelector('input[type=file]').click()">
<input type="file" name="asset" accept="<?= $a['accept'] ?>" style="display:none" onchange="this.form.submit()"> <input type="file" name="asset" accept=".svg,.png,.jpg,.jpeg,.webp,.gif,.ico" style="display:none" onchange="this.form.submit()">
📤 اضغط للرفع
<div class="size-hint">⚠️ <?= $a['size'] ?></div>
<?php if ($current): ?> <?php if ($current): ?>
<div class="current"><img src="<?= $current ?>"></div> <div class="current"><img src="<?= $current ?>" style="width:<?= $displayW ?>px;height:<?= $displayH ?>px;object-fit:contain;image-rendering:auto;"></div>
<div style="font-size:10px;color:#64748b;margin-top:4px;">✓ مرفوع</div>
<?php else: ?>
📤 اضغط للرفع
<?php endif; ?> <?php endif; ?>
<div class="size-hint"><?= $a['w'] ?>×<?= $a['h'] ?>px — <?= $a['hint'] ?></div>
</div> </div>
</form> </form>
</div> </div>
......
...@@ -153,7 +153,7 @@ html, body { ...@@ -153,7 +153,7 @@ html, body {
/* Cards */ /* Cards */
.card { .card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: var(--card-border-width, 1px) solid var(--border);
border-radius: var(--r-xl); border-radius: var(--r-xl);
padding: var(--s-4); padding: var(--s-4);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
...@@ -170,27 +170,28 @@ html, body { ...@@ -170,27 +170,28 @@ html, body {
gap: var(--s-2); gap: var(--s-2);
padding: var(--s-3) var(--s-5); padding: var(--s-3) var(--s-5);
border: none; border: none;
border-radius: var(--r-full); border-radius: var(--r-full, 9999px);
font-family: var(--font-ar); font-family: var(--font-ar);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: var(--btn-weight, 700);
cursor: pointer; cursor: pointer;
transition: transform var(--dur-fast) var(--ease-spring), box-shadow var(--dur-fast); transition: transform var(--dur-fast) var(--ease-spring), box-shadow var(--dur-fast);
min-height: 44px; min-height: var(--btn-min-height, 44px);
} }
.btn:active { transform: scale(0.92); transition-duration: 50ms; } .btn:active { transform: scale(var(--btn-press-scale, 0.92)); transition-duration: 50ms; }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--gold), var(--gold-soft)); background: linear-gradient(135deg, var(--gold), var(--gold-soft));
color: #1a1a1a; color: #1a1a1a;
box-shadow: 0 4px 16px rgba(228,172,56,0.3); box-shadow: var(--btn-shadow, 0 4px 16px rgba(228,172,56,0.3));
} }
.btn-secondary { .btn-secondary {
background: var(--bg-elevated); background: var(--bg-elevated);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-hover); border: var(--card-border-width, 1px) solid var(--border-hover);
border-radius: var(--r-btn-secondary, var(--r-full, 9999px));
} }
.btn-game { .btn-game {
...@@ -206,14 +207,14 @@ html, body { ...@@ -206,14 +207,14 @@ html, body {
width: 100%; width: 100%;
padding: var(--s-3) var(--s-4); padding: var(--s-3) var(--s-4);
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: var(--card-border-width, 1px) solid var(--border);
border-radius: var(--r-md); border-radius: var(--r-input, var(--r-md));
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-ar); font-family: var(--font-ar);
font-size: 15px; font-size: 15px;
outline: none; outline: none;
transition: border-color var(--dur-fast); transition: border-color var(--dur-fast);
min-height: 44px; min-height: var(--btn-min-height, 44px);
} }
.input:focus { border-color: var(--gold); } .input:focus { border-color: var(--gold); }
......
const THEME_URL = '/public/assets/brand/theme.json';
let themeData = null;
export async function load() {
try {
const res = await fetch(THEME_URL + '?t=' + Date.now());
if (!res.ok) return;
themeData = await res.json();
applyColors();
applyAnimations();
} catch (e) {}
}
export function getAsset(slot, fallback) {
if (themeData?.assets?.[slot]) return themeData.assets[slot];
return fallback || null;
}
export function getColor(key, fallback) {
return themeData?.[key] || fallback;
}
function applyColors() {
if (!themeData) return;
const root = document.documentElement.style;
const map = {
bg_deep: '--bg-deep',
bg_base: '--bg-base',
bg_card: '--bg-card',
bg_elevated: '--bg-elevated',
gold: '--gold',
gold_soft: '--gold-soft',
blue: '--blue',
cyan: '--cyan',
purple: '--purple',
success: '--success',
error: '--error',
text_primary: '--text-primary',
text_secondary: '--text-secondary',
chess_primary: '--chess-primary',
chess_secondary: '--chess-secondary',
domino_primary: '--domino-primary',
domino_secondary: '--domino-secondary',
ludo_primary: '--ludo-primary',
ludo_secondary: '--ludo-secondary',
backgammon_primary: '--backgammon-primary',
backgammon_secondary: '--backgammon-secondary'
};
for (const [key, cssVar] of Object.entries(map)) {
if (themeData[key]) {
root.setProperty(cssVar, themeData[key]);
}
}
}
function applyAnimations() {
if (!themeData) return;
const root = document.documentElement.style;
if (themeData.anim_speed) root.setProperty('--dur-normal', themeData.anim_speed + 'ms');
if (themeData.anim_fast) root.setProperty('--dur-fast', themeData.anim_fast + 'ms');
if (themeData.anim_slow) root.setProperty('--dur-slow', themeData.anim_slow + 'ms');
if (themeData.border_radius) root.setProperty('--r-md', themeData.border_radius + 'px');
if (themeData.card_radius) root.setProperty('--r-xl', themeData.card_radius + 'px');
if (themeData.btn_scale) root.setProperty('--btn-press-scale', themeData.btn_scale);
if (themeData.btn_radius) root.setProperty('--r-full', themeData.btn_radius + 'px');
if (themeData.btn_secondary_radius) root.setProperty('--r-btn-secondary', themeData.btn_secondary_radius + 'px');
if (themeData.btn_height) root.setProperty('--btn-min-height', themeData.btn_height + 'px');
if (themeData.btn_font_weight) root.setProperty('--btn-weight', themeData.btn_font_weight);
if (themeData.btn_shadow) root.setProperty('--btn-shadow', themeData.btn_shadow);
if (themeData.card_border_width) root.setProperty('--card-border-width', themeData.card_border_width + 'px');
if (themeData.input_radius) root.setProperty('--r-input', themeData.input_radius + 'px');
}
export function assetImg(slot, fallbackEmoji, width, height) {
const url = getAsset(slot);
if (url) {
return `<img src="${url}" alt="" style="width:${width}px;height:${height}px;object-fit:contain;image-rendering:auto;" draggable="false">`;
}
return `<span style="font-size:${Math.min(width, height) * 0.7}px;line-height:1;">${fallbackEmoji}</span>`;
}
...@@ -4,10 +4,12 @@ import * as scene from './core/scene.js'; ...@@ -4,10 +4,12 @@ import * as scene from './core/scene.js';
import * as audio from './core/audio.js'; import * as audio from './core/audio.js';
import * as input from './core/input.js'; import * as input from './core/input.js';
import * as hud from './core/hud.js'; import * as hud from './core/hud.js';
import * as theme from './core/theme.js';
import { setLang } from './core/i18n.js'; import { setLang } from './core/i18n.js';
async function boot() { async function boot() {
setLang(store.get('language') || 'ar'); setLang(store.get('language') || 'ar');
await theme.load();
audio.init(); audio.init();
input.init(); input.init();
scene.init(); scene.init();
......
...@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js'; ...@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { assetImg } from '../../../core/theme.js';
const games = [ const games = [
{ key: 'chess', name: 'شطرنج', nameEn: 'Chess', color: '#2563EB', secondary: '#F5B731', icon: '♟', gradient: 'linear-gradient(135deg, #1e40af, #3b82f6)' }, { key: 'chess', name: 'شطرنج', nameEn: 'Chess', color: '#2563EB', secondary: '#F5B731', icon: '♟', gradient: 'linear-gradient(135deg, #1e40af, #3b82f6)' },
...@@ -19,7 +20,7 @@ export function mountTable(el) { ...@@ -19,7 +20,7 @@ export function mountTable(el) {
<div class="game-tile" data-game="${g.key}" style="--game-color:${g.color};--game-gradient:${g.gradient};"> <div class="game-tile" data-game="${g.key}" style="--game-color:${g.color};--game-gradient:${g.gradient};">
<div class="game-tile-bg" style="background:${g.gradient};"></div> <div class="game-tile-bg" style="background:${g.gradient};"></div>
<div class="game-tile-content"> <div class="game-tile-content">
<div class="game-tile-icon">${g.icon}</div> <div class="game-tile-icon">${assetImg(g.key + '_icon', g.icon, 48, 48)}</div>
<div class="game-tile-name">${g.name}</div> <div class="game-tile-name">${g.name}</div>
</div> </div>
</div> </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