Commit 55a3ca73 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: full admin theme expansion — spacing sliders, RGBA pickers, per-icon uploads

Admin Theme New Sections:
- 📐 Spacing & Layout: 9 range sliders for border-radius, content-max,
  header-h, nav-bottom-h, card-padding, section-gap, touch-min
- 🎲 Domino: 5 color pickers for game theming
- 🎯 Backgammon: 8 color pickers for board/checker/felt colors
- 🎨 Icons Grid: per-icon upload (31 icons individually replaceable)

RGBA Picker:
- Color input + alpha slider (0-100%) for rgba-based CSS variables
- Live preview, outputs rgba() string
- Applied to: board highlights, overlays, borders

Per-Icon Override:
- Individual SVG/PNG upload per icon
- Runtime: replaces SVG <use> with <img> element
- Resolution guide: "24×24px SVG مفضل"

Upload Resolution Guides:
- Pieces: 128×128px
- Logo: 200×40px
- Favicon: 32×32px
- Icons: 24×24px
- Sprite: SVG 24px viewBox

All changes live-effective via CSS variable injection + runtime JS.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent d34a923d
...@@ -30,7 +30,21 @@ if (window.__themeAssets) { ...@@ -30,7 +30,21 @@ if (window.__themeAssets) {
el.setAttribute('href', el.getAttribute('href').replace('/public/icons/sprite.svg', a['sprite-svg'])); el.setAttribute('href', el.getAttribute('href').replace('/public/icons/sprite.svg', a['sprite-svg']));
}); });
} }
// Individual icon overrides
Object.keys(a).forEach(k => { Object.keys(a).forEach(k => {
if (k.startsWith('icon-')) {
const iconName = k;
document.querySelectorAll('use[href$="#' + iconName + '"]').forEach(useEl => {
const svg = useEl.closest('svg');
if (svg) {
const img = document.createElement('img');
img.src = a[k];
img.style.cssText = 'width:100%;height:100%;object-fit:contain;';
img.alt = iconName;
svg.replaceWith(img);
}
});
}
if (k.startsWith('piece-')) { if (k.startsWith('piece-')) {
const piece = k.replace('piece-', ''); const piece = k.replace('piece-', '');
document.documentElement.style.setProperty('--piece-' + piece, 'url(' + a[k] + ')'); document.documentElement.style.setProperty('--piece-' + piece, 'url(' + a[k] + ')');
......
...@@ -53,6 +53,28 @@ ...@@ -53,6 +53,28 @@
.upload-area:hover { border-color:rgba(21,215,255,0.4); } .upload-area:hover { border-color:rgba(21,215,255,0.4); }
.upload-area p { font-size:12px; color:#64748b; } .upload-area p { font-size:12px; color:#64748b; }
.rgba-picker { display:flex; gap:6px; align-items:center; }
.rgba-picker input[type="color"] { width:40px; height:32px; border:none; cursor:pointer; }
.rgba-picker .alpha-slider { width:80px; }
.rgba-picker .alpha-label { font-size:11px; min-width:30px; }
.rgba-picker .rgba-output { font-size:11px; width:160px; background:var(--bg-3); border:1px solid var(--border); border-radius:4px; padding:4px 6px; color:var(--text-2); }
.theme-field { margin-bottom:12px; }
.theme-field label { display:block; font-size:11px; color:#94a3b8; margin-bottom:6px; font-weight:500; }
.spacing-slider { flex:1; }
.slider-value { font-size:12px; color:#e2e8f0; min-width:48px; text-align:right; font-family:monospace; }
.icon-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(100px, 1fr)); gap:12px; }
.icon-item { display:flex; flex-direction:column; align-items:center; gap:6px; padding:12px 8px; background:#1a2332; border-radius:8px; border:1px solid rgba(255,255,255,0.08); }
.icon-item svg { width:24px; height:24px; color:#94a3b8; }
.icon-item img { width:24px; height:24px; object-fit:contain; }
.icon-item span { font-size:10px; color:#64748b; }
.icon-item input[type="file"] { display:none; }
.icon-item .icon-upload-btn { font-size:10px; padding:3px 8px; background:#15d7ff; color:#fff; border:none; border-radius:4px; cursor:pointer; }
.icon-item .icon-reset-btn { font-size:10px; padding:3px 6px; background:#0f1925; color:#64748b; border:1px solid rgba(255,255,255,0.1); border-radius:4px; cursor:pointer; }
.upload-hint { display:block; margin-top:4px; font-size:10px; color:#64748b; }
@media (max-width: 768px) { @media (max-width: 768px) {
.admin-layout { flex-direction:column; } .admin-layout { flex-direction:column; }
.admin-sidebar { width:100%; border-right:none; border-bottom:1px solid rgba(255,255,255,0.08); padding:12px 0; display:flex; overflow-x:auto; gap:0; } .admin-sidebar { width:100%; border-right:none; border-bottom:1px solid rgba(255,255,255,0.08); padding:12px 0; display:flex; overflow-x:auto; gap:0; }
...@@ -90,6 +112,10 @@ ...@@ -90,6 +112,10 @@
<div class="nav-item" data-section="pieces">Chess Pieces</div> <div class="nav-item" data-section="pieces">Chess Pieces</div>
<div class="nav-item" data-section="ludo">Ludo</div> <div class="nav-item" data-section="ludo">Ludo</div>
<div class="nav-item" data-section="icons">Icons</div> <div class="nav-item" data-section="icons">Icons</div>
<div class="nav-item" data-section="spacing">Spacing</div>
<div class="nav-item" data-section="domino">Domino</div>
<div class="nav-item" data-section="backgammon">Backgammon</div>
<div class="nav-item" data-section="icons-grid">Icons Grid</div>
</aside> </aside>
<div class="admin-main"> <div class="admin-main">
...@@ -150,6 +176,31 @@ ...@@ -150,6 +176,31 @@
<div class="field-grid" id="fields-icons"></div> <div class="field-grid" id="fields-icons"></div>
</div> </div>
<!-- Spacing & Layout -->
<div class="section" data-panel="spacing">
<div class="section-title">&#x1F4D0; Spacing & Layout</div>
<div id="fields-spacing"></div>
</div>
<!-- Domino -->
<div class="section" data-panel="domino">
<div class="section-title">&#x1F3B2; Domino</div>
<div class="field-grid" id="fields-domino"></div>
</div>
<!-- Backgammon -->
<div class="section" data-panel="backgammon">
<div class="section-title">&#x1F3AF; Backgammon</div>
<div class="field-grid" id="fields-backgammon"></div>
</div>
<!-- Icons Grid -->
<div class="section" data-panel="icons-grid" id="section-icons-grid">
<div class="section-title">&#x1F3A8; Icons (Individual)</div>
<p style="color:#64748b;font-size:12px;margin-bottom:12px;">Replace any icon with an SVG or PNG file — required size: 24x24px</p>
<div class="icon-grid"></div>
</div>
<div class="save-bar"> <div class="save-bar">
<button class="btn-save" onclick="saveAll()">Save All Changes</button> <button class="btn-save" onclick="saveAll()">Save All Changes</button>
<button class="btn-reset" onclick="clearCache()">Clear Cache</button> <button class="btn-reset" onclick="clearCache()">Clear Cache</button>
...@@ -272,6 +323,23 @@ const THEME_FIELDS = { ...@@ -272,6 +323,23 @@ const THEME_FIELDS = {
icons: [ icons: [
{ key:'sprite-svg', label:'Custom SVG Sprite (replaces default)', type:'file', category:'icons' }, { key:'sprite-svg', label:'Custom SVG Sprite (replaces default)', type:'file', category:'icons' },
], ],
domino: [
{ key:'domino-face', label:'Domino Face', type:'color', default:'#f5f0e8' },
{ key:'domino-pip', label:'Domino Pips', type:'color', default:'#1a1a1a' },
{ key:'domino-back', label:'Domino Back', type:'color', default:'#1a2a4a' },
{ key:'domino-board-bg', label:'Domino Board BG', type:'color', default:'#0a2a1a' },
{ key:'domino-playable-glow', label:'Playable Glow', type:'text', default:'rgba(255,200,50,0.4)' },
],
backgammon: [
{ key:'bg-felt', label:'Felt Color', type:'color', default:'#1a5c32' },
{ key:'bg-point-light', label:'Light Point', type:'color', default:'#d4a76a' },
{ key:'bg-point-dark', label:'Dark Point', type:'color', default:'#8b4513' },
{ key:'bg-bar', label:'Bar Color', type:'color', default:'#3a2418' },
{ key:'bg-checker-white', label:'White Checker', type:'color', default:'#f0ebe0' },
{ key:'bg-checker-black', label:'Black Checker', type:'color', default:'#1a1a1a' },
{ key:'bg-board-wood', label:'Board Wood', type:'color', default:'#5c3d2e' },
{ key:'bg-frame', label:'Frame Color', type:'color', default:'#3a2418' },
],
}; };
let adminUser = ''; let adminUser = '';
...@@ -332,9 +400,11 @@ function renderField(f, saved) { ...@@ -332,9 +400,11 @@ function renderField(f, saved) {
</div> </div>
</div>`; </div>`;
} else if (f.type === 'file') { } else if (f.type === 'file') {
const hint = getUploadHint(f.key, f.category);
return `<div class="field"> return `<div class="field">
<label>${f.label} <code style="font-size:10px;color:#64748b;">(${f.key})</code></label> <label>${f.label} <code style="font-size:10px;color:#64748b;">(${f.key})</code></label>
<input type="file" accept="image/*,.svg" data-key="${f.key}" data-category="${f.category || 'assets'}" onchange="uploadFile(this)"> <input type="file" accept="image/*,.svg" data-key="${f.key}" data-category="${f.category || 'assets'}" onchange="uploadFile(this)">
${hint ? '<small class="upload-hint">' + hint + '</small>' : ''}
${saved ? '<div class="preview"><img src="' + saved + '"><button class="remove-btn" onclick="removeAsset(this,\'' + f.key + '\')">Remove</button></div>' : ''} ${saved ? '<div class="preview"><img src="' + saved + '"><button class="remove-btn" onclick="removeAsset(this,\'' + f.key + '\')">Remove</button></div>' : ''}
</div>`; </div>`;
} }
...@@ -443,6 +513,194 @@ document.querySelectorAll('.admin-sidebar .nav-item').forEach(item => { ...@@ -443,6 +513,194 @@ document.querySelectorAll('.admin-sidebar .nav-item').forEach(item => {
// Show only first section on load // Show only first section on load
document.querySelectorAll('.section').forEach((s, i) => { s.style.display = i === 0 ? 'block' : 'none'; }); document.querySelectorAll('.section').forEach((s, i) => { s.style.display = i === 0 ? 'block' : 'none'; });
// Upload hint helper
function getUploadHint(key, category) {
if (key.startsWith('piece-')) return '128×128px PNG أو SVG';
if (key === 'logo-image') return '200×40px PNG/SVG (خلفية شفافة)';
if (key === 'favicon') return '32×32px PNG أو ICO';
if (key === 'sprite-svg') return 'ملف SVG — كل رمز 24px viewBox';
if (category === 'icons') return '24×24px SVG مفضل';
return '';
}
// Spacing & Layout sliders
const SPACING_FIELDS = [
{ key:'--radius-sm', label:'زوايا صغيرة', default:8, min:0, max:24, step:1, suffix:'px' },
{ key:'--radius-md', label:'زوايا متوسطة', default:12, min:0, max:32, step:1, suffix:'px' },
{ key:'--radius-lg', label:'زوايا كبيرة', default:16, min:0, max:40, step:1, suffix:'px' },
{ key:'--content-max', label:'عرض المحتوى', default:600, min:400, max:1200, step:10, suffix:'px' },
{ key:'--header-h', label:'ارتفاع الهيدر', default:52, min:40, max:80, step:1, suffix:'px' },
{ key:'--nav-bottom-h', label:'ارتفاع النافبار', default:56, min:40, max:72, step:1, suffix:'px' },
{ key:'--card-padding', label:'حشو الكرت', default:16, min:8, max:32, step:1, suffix:'px' },
{ key:'--section-gap', label:'مسافة الأقسام', default:16, min:8, max:40, step:1, suffix:'px' },
{ key:'--touch-min', label:'حجم اللمس', default:44, min:36, max:56, step:1, suffix:'px' },
];
function renderSpacingFields() {
const container = document.getElementById('fields-spacing');
if (!container) return;
container.innerHTML = SPACING_FIELDS.map(f => {
const saved = currentSettings[f.key] || '';
const val = saved ? parseInt(saved) : f.default;
return `<div class="theme-field">
<label>${f.label} <code style="font-size:10px;color:#64748b;">(${f.key})</code></label>
<div style="display:flex;gap:8px;align-items:center;">
<input type="range" min="${f.min}" max="${f.max}" value="${val}" step="${f.step}" data-key="${f.key}" class="spacing-slider">
<span class="slider-value">${val}${f.suffix || ''}</span>
</div>
</div>`;
}).join('');
}
function initSpacingSliders() {
document.querySelectorAll('.spacing-slider').forEach(slider => {
const valueSpan = slider.parentElement.querySelector('.slider-value');
slider.addEventListener('input', () => {
const val = slider.value + 'px';
valueSpan.textContent = val;
document.documentElement.style.setProperty(slider.dataset.key, val);
});
});
}
// RGBA Picker
function initRgbaPickers() {
document.querySelectorAll('.rgba-picker').forEach(picker => {
const colorInput = picker.querySelector('input[type="color"]');
const alphaSlider = picker.querySelector('.alpha-slider');
const alphaLabel = picker.querySelector('.alpha-label');
const output = picker.querySelector('.rgba-output');
const key = picker.dataset.key;
function update() {
const hex = colorInput.value;
const alpha = alphaSlider.value / 100;
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})`;
output.value = rgba;
alphaLabel.textContent = alphaSlider.value + '%';
document.documentElement.style.setProperty(key, rgba);
}
colorInput.addEventListener('input', update);
alphaSlider.addEventListener('input', update);
});
}
// RGBA fields - detect which fields need RGBA pickers
const RGBA_KEYS = ['board-selected','board-last-move','board-check','board-premove','board-legal',
'board-highlight-green','board-highlight-red','board-highlight-yellow',
'overlay-dark','overlay-result','overlay-error-bg','overlay-error-border',
'ludo-path-border','ludo-home-p1','ludo-home-p2','ludo-home-p3','ludo-home-p4',
'domino-playable-glow'];
function parseRgba(str) {
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([\d.]*)\)/);
if (m) {
const r = parseInt(m[1]), g = parseInt(m[2]), b = parseInt(m[3]);
const a = m[4] !== '' ? parseFloat(m[4]) : 1;
const hex = '#' + [r,g,b].map(x => x.toString(16).padStart(2,'0')).join('');
return { hex, alpha: Math.round(a * 100) };
}
return { hex:'#000000', alpha:100 };
}
// Override renderField for RGBA fields
const originalRenderField = renderField;
renderField = function(f, saved) {
if (f.type === 'text' && RGBA_KEYS.includes(f.key)) {
const val = saved || f.default;
const parsed = parseRgba(val);
return `<div class="field">
<label>${f.label} <code style="font-size:10px;color:#64748b;">(--${f.key})</code></label>
<div class="rgba-picker" data-key="--${f.key}">
<input type="color" value="${parsed.hex}">
<input type="range" class="alpha-slider" min="0" max="100" value="${parsed.alpha}">
<span class="alpha-label">${parsed.alpha}%</span>
<input type="text" class="rgba-output" value="${val}" data-key="${f.key}" data-category="${f.category || 'colors'}">
</div>
</div>`;
}
return originalRenderField(f, saved);
};
// Icon Grid
const ICONS = ['home','play','trophy','leaderboard','friends','shop','star','settings','profile','bell','coin','gem','dice','users','lock','games','domino','backgammon','cards','plus','check','x','arrow-left','arrow-right','flag','crown','clock','key','bot','search','lightning'];
function renderIconGrid() {
const container = document.querySelector('.icon-grid');
if (!container) return;
container.innerHTML = ICONS.map(icon => `
<div class="icon-item" data-icon="${icon}">
<svg style="width:24px;height:24px;"><use href="/public/icons/sprite.svg#icon-${icon}"></use></svg>
<span>icon-${icon}</span>
<label class="icon-upload-btn">تغيير<input type="file" accept=".svg,.png" onchange="uploadIcon('${icon}', this.files[0])"></label>
</div>
`).join('');
}
async function uploadIcon(iconName, file) {
if (!file) return;
const fd = new FormData();
fd.append('file', file);
fd.append('key', 'icon-' + iconName);
fd.append('category', 'icons');
fd.append('admin_user', adminUser);
fd.append('admin_pass', adminPass);
fd.append('label', 'icon-' + iconName);
const res = await fetch('/api/theme-upload', { method:'POST', body:fd });
const data = await res.json();
if (data.ok) {
showStatus('Uploaded icon: ' + iconName);
} else {
showStatus('Error: ' + (data.error || 'upload failed'), true);
}
}
// Extend saveAll to include spacing sliders
const originalSaveAll = saveAll;
saveAll = async function() {
// Collect spacing slider values into hidden inputs before saving
document.querySelectorAll('.spacing-slider').forEach(slider => {
const key = slider.dataset.key;
const val = slider.value + 'px';
// Create a temporary text input so the save loop picks it up
let existing = document.querySelector(`input[type="text"][data-key="${key}"]`);
if (!existing) {
const hidden = document.createElement('input');
hidden.type = 'text';
hidden.dataset.key = key;
hidden.dataset.category = 'spacing';
hidden.value = val;
hidden.style.display = 'none';
hidden.classList.add('spacing-hidden-input');
document.querySelector('.admin-main').appendChild(hidden);
} else {
existing.value = val;
}
});
// Also collect RGBA picker values
document.querySelectorAll('.rgba-picker .rgba-output').forEach(output => {
// These already have data-key and data-category, they'll be picked up by saveAll
});
await originalSaveAll();
// Remove temp hidden inputs
document.querySelectorAll('.spacing-hidden-input').forEach(el => el.remove());
};
// Extend loadTheme to also render spacing and icon grid
const originalLoadTheme = loadTheme;
loadTheme = async function() {
await originalLoadTheme();
renderSpacingFields();
initSpacingSliders();
initRgbaPickers();
renderIconGrid();
};
</script> </script>
</body> </body>
</html> </html>
...@@ -68,6 +68,10 @@ ...@@ -68,6 +68,10 @@
--text-section: clamp(12px, 0.5vw + 10px, 14px); --text-section: clamp(12px, 0.5vw + 10px, 14px);
--text-body: clamp(13px, 0.5vw + 11px, 15px); --text-body: clamp(13px, 0.5vw + 11px, 15px);
/* Spacing & Layout (admin-editable) */
--card-padding: 16px;
--section-gap: 16px;
/* Board Theme — Chess.com green */ /* Board Theme — Chess.com green */
--board-light: #EBECD0; --board-light: #EBECD0;
--board-dark: #779556; --board-dark: #779556;
......
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