Commit d6be81de authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: redesign top bar with profile avatar + add photo upload

Replace cluttered logo/level/coins/gems/bell HUD with clean layout:
avatar+level badge (left), coins+gems (center), bell (right). Avatar
taps navigate to profile. Add profile photo upload with camera badge
overlay, client+server validation, Supabase Storage upload endpoint.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 8f6dd147
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
$token = requireAuth();
$userId = getUserId($token);
if (!$userId) jsonError('Invalid user', 401);
if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
jsonError('No file uploaded');
}
$file = $_FILES['avatar'];
$maxSize = 5 * 1024 * 1024;
if ($file['size'] > $maxSize) {
jsonError('File too large (max 5MB)');
}
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowed)) {
jsonError('Invalid file type. Allowed: JPEG, PNG, WebP, GIF');
}
$ext = match($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
default => 'jpg'
};
$filename = $userId . '_' . time() . '.' . $ext;
$storagePath = 'avatars/' . $filename;
$bucket = 'profile-images';
$storageUrl = SUPABASE_STORAGE . '/object/' . $bucket . '/' . $storagePath;
$ch = curl_init($storageUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $mime,
'x-upsert: true'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents($file['tmp_name']));
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
$decoded = json_decode($response, true);
jsonError($decoded['message'] ?? 'Upload failed', 500);
}
$publicUrl = SUPABASE_STORAGE . '/object/public/' . $bucket . '/' . $storagePath;
$db = supabaseService();
$result = $db->update('profiles', ['avatar_url' => $publicUrl], ['id' => 'eq.' . $userId]);
if (isset($result['error'])) {
jsonError('Failed to update profile');
}
jsonResponse(['avatar_url' => $publicUrl]);
...@@ -53,9 +53,11 @@ html, body { ...@@ -53,9 +53,11 @@ html, body {
.hud-profile { .hud-profile {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--s-2); justify-content: center;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
min-width: 44px;
min-height: 44px;
} }
.hud-avatar { .hud-avatar {
...@@ -376,71 +378,6 @@ html, body { ...@@ -376,71 +378,6 @@ html, body {
.p-4 { padding: var(--s-4); } .p-4 { padding: var(--s-4); }
.mt-4 { margin-top: var(--s-4); } .mt-4 { margin-top: var(--s-4); }
/* HUD elements */
.hud-brand {
font-size: 20px;
font-weight: 800;
color: var(--gold);
letter-spacing: -0.5px;
}
.hud-stats {
display: flex;
align-items: center;
gap: var(--s-4);
}
.hud-stat {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: 13px;
font-weight: 600;
font-family: var(--font-lat);
}
.hud-stat .icon { width: 16px; height: 16px; }
.hud-coins { color: var(--gold); }
.hud-gems { color: var(--purple); }
.hud-level { color: var(--cyan); }
.hud-actions {
display: flex;
align-items: center;
gap: var(--s-3);
}
.hud-btn {
position: relative;
width: 36px; height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--r-full);
background: var(--bg-elevated);
border: 1px solid var(--border);
cursor: pointer;
color: var(--text-secondary);
transition: transform var(--dur-fast);
}
.hud-btn:active { transform: scale(0.9); }
.hud-badge {
position: absolute;
top: -2px; right: -2px;
width: 16px; height: 16px;
background: var(--orange);
border-radius: var(--r-full);
font-size: 9px;
font-weight: 700;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.hud-badge:empty { display: none; }
/* Loading / Skeleton */ /* Loading / Skeleton */
.skeleton { .skeleton {
......
...@@ -28,13 +28,17 @@ export function init() { ...@@ -28,13 +28,17 @@ export function init() {
function renderHud() { function renderHud() {
const player = store.get('player'); const player = store.get('player');
const avatarContent = player?.avatar_url
? `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`
: `<span style="font-size:16px;color:var(--text-secondary);">${emoji('person', '👤', 16)}</span>`;
const level = player?.level || 1;
hudEl.innerHTML = ` hudEl.innerHTML = `
<div class="hud-brand">${assetImg('logo', 'EL3AB', 80, 28)}</div> <div class="hud-profile" id="hud-profile">
<div class="hud-avatar">${avatarContent}</div>
<div class="hud-level-badge">${level}</div>
</div>
<div class="hud-stats"> <div class="hud-stats">
<div class="hud-stat hud-level">
<span class="icon">${emoji('hud_level', '⬡', 16)}</span>
<span id="hud-level">${player?.level || 1}</span>
</div>
<div class="hud-stat hud-coins"> <div class="hud-stat hud-coins">
<span class="icon">${emoji('hud_coin', '●', 16)}</span> <span class="icon">${emoji('hud_coin', '●', 16)}</span>
<span id="hud-coins">${formatNum(player?.coins || 0)}</span> <span id="hud-coins">${formatNum(player?.coins || 0)}</span>
...@@ -52,6 +56,9 @@ function renderHud() { ...@@ -52,6 +56,9 @@ function renderHud() {
</div> </div>
`; `;
document.getElementById('hud-profile')?.addEventListener('click', () => {
bus.emit('navigate', { world: 'profile' });
});
document.getElementById('hud-notif')?.addEventListener('click', () => { document.getElementById('hud-notif')?.addEventListener('click', () => {
bus.emit('navigate', { world: 'social', scene: 'notifications' }); bus.emit('navigate', { world: 'social', scene: 'notifications' });
}); });
...@@ -104,12 +111,18 @@ function updateTabs({ to }) { ...@@ -104,12 +111,18 @@ function updateTabs({ to }) {
function updateHud() { function updateHud() {
const player = store.get('player'); const player = store.get('player');
if (!player) return; if (!player) return;
const lvl = document.getElementById('hud-level');
const coins = document.getElementById('hud-coins'); const coins = document.getElementById('hud-coins');
const gems = document.getElementById('hud-gems'); const gems = document.getElementById('hud-gems');
if (lvl) lvl.textContent = player.level || 1;
if (coins) coins.textContent = formatNum(player.coins || 0); if (coins) coins.textContent = formatNum(player.coins || 0);
if (gems) gems.textContent = formatNum(player.gems || 0); if (gems) gems.textContent = formatNum(player.gems || 0);
const levelBadge = hudEl.querySelector('.hud-level-badge');
if (levelBadge) levelBadge.textContent = player.level || 1;
const avatarEl = hudEl.querySelector('.hud-avatar');
if (avatarEl && player.avatar_url) {
avatarEl.innerHTML = `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`;
}
} }
function updateBell(count) { function updateBell(count) {
......
...@@ -7,7 +7,6 @@ import { t } from '../../../core/i18n.js'; ...@@ -7,7 +7,6 @@ import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
export async function mountView(el) { export async function mountView(el) {
// Always fetch fresh profile data
try { try {
const fresh = await net.get('profile.php'); const fresh = await net.get('profile.php');
if (fresh && !fresh.error) store.set('player', fresh); if (fresh && !fresh.error) store.set('player', fresh);
...@@ -18,10 +17,15 @@ export async function mountView(el) { ...@@ -18,10 +17,15 @@ export async function mountView(el) {
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);"> <div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);">
<!-- Player Card --> <!-- Player Card -->
<div class="card" style="text-align:center;padding:var(--s-6);"> <div class="card" style="text-align:center;padding:var(--s-6);">
<div style="width:80px;height:80px;border-radius:50%;background:var(--bg-elevated);margin:0 auto var(--s-3);display:flex;align-items:center;justify-content:center;font-size:36px;border:3px solid var(--gold);"> <div class="profile-avatar-wrap" id="avatar-wrap">
${player.avatar_url ? `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">` : emoji('person', '👤', 36)} <div class="profile-avatar">
${player.avatar_url ? `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">` : emoji('person', '👤', 40)}
</div>
<div class="profile-avatar-edit">${emoji('camera', '📷', 14)}</div>
</div> </div>
<div style="font-size:18px;font-weight:700;">${player.display_name || player.username || 'Player'}</div> <input type="file" id="avatar-input" accept="image/*" style="display:none;">
<div id="avatar-uploading" style="display:none;margin:var(--s-2) auto 0;font-size:12px;color:var(--text-secondary);">جاري الرفع...</div>
<div style="font-size:18px;font-weight:700;margin-top:var(--s-3);">${player.display_name || player.username || 'Player'}</div>
<div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">Level ${player.level || 1}</div> <div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">Level ${player.level || 1}</div>
</div> </div>
...@@ -62,6 +66,53 @@ export async function mountView(el) { ...@@ -62,6 +66,53 @@ export async function mountView(el) {
</div> </div>
`; `;
const avatarInput = el.querySelector('#avatar-input');
const avatarWrap = el.querySelector('#avatar-wrap');
let isUploading = false;
avatarWrap.addEventListener('click', () => {
if (isUploading) return;
audio.play('click');
avatarInput.click();
});
avatarInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file || isUploading) return;
if (file.size > 5 * 1024 * 1024) {
bus.emit('toast', { text: 'الصورة كبيرة جداً (الحد 5MB)', type: 'error' });
return;
}
isUploading = true;
avatarWrap.style.opacity = '0.5';
const uploading = el.querySelector('#avatar-uploading');
uploading.style.display = 'block';
try {
const formData = new FormData();
formData.append('avatar', file);
const token = store.get('auth.token');
const res = await fetch('/api/avatar.php', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (data.avatar_url) {
store.set('player', { ...store.get('player'), avatar_url: data.avatar_url });
bus.emit('store:player');
mountView(el);
return;
} else {
bus.emit('toast', { text: data.error || 'فشل رفع الصورة', type: 'error' });
}
} catch (err) {
bus.emit('toast', { text: 'فشل رفع الصورة', type: 'error' });
}
isUploading = false;
avatarWrap.style.opacity = '1';
uploading.style.display = 'none';
});
el.querySelector('#btn-settings').addEventListener('click', () => { el.querySelector('#btn-settings').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.push('profile-settings'); scene.push('profile-settings');
......
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