345 lines
16 KiB
PHP
345 lines
16 KiB
PHP
@extends('siswa.layouts.app')
|
|
|
|
@section('title', 'Leaderboard')
|
|
|
|
@push('styles')
|
|
<style>
|
|
/* ── Ikon utilitas ─────────────────────────────────────── */
|
|
.icon-medal { width: 28px; height: 28px; object-fit: contain; vertical-align: middle; }
|
|
.icon-star { width: 16px; height: 16px; object-fit: contain; vertical-align: middle; margin-right: 2px; }
|
|
.icon-crown { width: 24px; height: 24px; object-fit: contain; display: block; }
|
|
.icon-rank { width: 24px; height: 24px; object-fit: contain; vertical-align: middle; }
|
|
.icon-book { width: 20px; height: 20px; object-fit: contain; vertical-align: middle; margin-right: 6px; }
|
|
.icon-target { width: 36px; height: 36px; object-fit: contain; vertical-align: middle; }
|
|
.icon-empty { width: 72px; height: 72px; object-fit: contain; margin-bottom: 12px; }
|
|
.icon-loader { width: 40px; height: 40px; object-fit: contain; margin-bottom: 8px; }
|
|
|
|
/* ── Page header ───────────────────────────────────────── */
|
|
.page-title {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
margin-bottom: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.page-subtitle { font-size: 14px; color: #64748b; margin-bottom: 24px; }
|
|
|
|
/* ── Podium ────────────────────────────────────────────── */
|
|
.podium-wrap { display: flex; align-items: flex-end; justify-content: center; gap: 12px; margin-bottom: 32px; }
|
|
.podium-item { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
|
|
|
.podium-avatar {
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 22px;
|
|
font-weight: 800;
|
|
color: white;
|
|
position: relative;
|
|
overflow: visible; /* crown perlu keluar area */
|
|
flex-shrink: 0;
|
|
width: 56px;
|
|
height: 56px;
|
|
}
|
|
.podium-avatar-inner {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.podium-avatar-inner img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
border-radius: 50%;
|
|
}
|
|
.podium-crown {
|
|
position: absolute;
|
|
top: -22px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 2;
|
|
pointer-events: none;
|
|
line-height: 1;
|
|
}
|
|
|
|
.rank-1 .podium-avatar { background: linear-gradient(135deg,#f59e0b,#d97706); width: 68px; height: 68px; font-size: 26px; }
|
|
.rank-2 .podium-avatar { background: linear-gradient(135deg,#94a3b8,#64748b); }
|
|
.rank-3 .podium-avatar { background: linear-gradient(135deg,#f97316,#ea580c); }
|
|
|
|
.podium-name { font-size: 13px; font-weight: 700; color: #1e293b; text-align: center; max-width: 90px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.podium-exp { font-size: 12px; color: #64748b; display: flex; align-items: center; gap: 3px; }
|
|
|
|
.podium-bar { border-radius: 12px 12px 0 0; width: 80px; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; color: white; }
|
|
.rank-1 .podium-bar { height: 80px; background: linear-gradient(135deg,#f59e0b,#d97706); }
|
|
.rank-2 .podium-bar { height: 60px; background: linear-gradient(135deg,#94a3b8,#64748b); }
|
|
.rank-3 .podium-bar { height: 44px; background: linear-gradient(135deg,#f97316,#ea580c); }
|
|
|
|
/* ── My rank banner ────────────────────────────────────── */
|
|
.my-rank-banner { background: linear-gradient(135deg,#667eea,#764ba2); border-radius: 16px; padding: 16px 20px; color: white; display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
|
|
.my-rank-avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(255,255,255,0.5); flex-shrink: 0; }
|
|
.my-rank-avatar-placeholder { width: 48px; height: 48px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; flex-shrink: 0; }
|
|
.my-rank-num { font-size: 36px; font-weight: 800; line-height: 1; }
|
|
.my-rank-info { flex: 1; }
|
|
.my-rank-label { font-size: 12px; opacity: 0.8; margin-bottom: 2px; }
|
|
.my-rank-nama { font-size: 16px; font-weight: 700; }
|
|
.my-rank-exp { font-size: 13px; opacity: 0.9; display: flex; align-items: center; gap: 4px; }
|
|
|
|
/* ── Card & list ───────────────────────────────────────── */
|
|
.custom-card { background: white; border-radius: 20px; border: 2px solid #e5e5e5; padding: 22px; }
|
|
.section-title { font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; }
|
|
|
|
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 6px #22c55e; animation: pulse 2s ease-in-out infinite; display: inline-block; margin-right: 6px; }
|
|
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(0.8)} }
|
|
|
|
.lb-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 12px; margin-bottom: 8px; transition: background 0.15s; }
|
|
.lb-row:hover { background: #f8fafc; }
|
|
.lb-row.highlight { background: #f0eeff; border: 2px solid #c4b5fd; }
|
|
|
|
.lb-rank { width: 32px; height: 32px; border-radius: 50%; background: #e2e8f0; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; color: #64748b; flex-shrink: 0; }
|
|
.lb-rank.gold { background: #fef3c7; color: #d97706; }
|
|
.lb-rank.silver { background: #f1f5f9; color: #64748b; }
|
|
.lb-rank.bronze { background: #ffedd5; color: #ea580c; }
|
|
/* rank badge gambar tidak perlu background bulat */
|
|
.lb-rank.has-img { background: transparent; }
|
|
|
|
.lb-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 2px solid #e2e8f0; }
|
|
.lb-avatar-placeholder { width: 36px; height: 36px; border-radius: 50%; background: #e6f0ff; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: #2b8ef3; flex-shrink: 0; }
|
|
|
|
.lb-nama { flex: 1; font-size: 14px; font-weight: 600; color: #1e293b; }
|
|
.lb-nisn { font-size: 12px; color: #94a3b8; }
|
|
.lb-exp { font-size: 14px; font-weight: 700; color: #667eea; display: flex; align-items: center; gap: 3px; }
|
|
|
|
.semester-badge { display: inline-block; background: #f0eeff; color: #667eea; font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 99px; margin-bottom: 20px; }
|
|
.empty-state { text-align: center; padding: 40px 20px; color: #94a3b8; }
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
|
|
@php $siswaLogin = Auth::guard('siswa')->user(); @endphp
|
|
|
|
<h3 class="page-title">
|
|
<img src="{{ asset('images/icon/siswal/medal-pita.png') }}" alt="Ikon medal leaderboard" class="icon-medal">
|
|
Leaderboard
|
|
</h3>
|
|
<p class="page-subtitle">Peringkat siswa berdasarkan total EXP yang dikumpulkan.</p>
|
|
|
|
<span class="semester-badge">Semester {{ $semester }} · {{ $tahunAjaran }}</span>
|
|
|
|
{{-- Container di-render real-time via JavaScript --}}
|
|
<div id="leaderboard-container">
|
|
<div style="text-align:center;padding:40px;color:#94a3b8;">
|
|
<div>
|
|
<img src="{{ asset('images/icon/siswal/jam-pasir.png') }}" alt="Memuat data" class="icon-loader">
|
|
</div>
|
|
<p style="font-size:13px">Memuat data...</p>
|
|
</div>
|
|
</div>
|
|
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
const currentSiswaId = {{ $siswaLogin->id_siswa }};
|
|
const jsonUrl = '{{ route("siswa.leaderboard.json") }}';
|
|
|
|
/* URL aset — dioper dari Blade agar JS tidak perlu tahu base URL */
|
|
const ASSETS = {
|
|
star: '{{ asset('images/icon/siswal/star.png') }}',
|
|
crown: '{{ asset('images/icon/siswal/crown.png') }}',
|
|
rank1: '{{ asset('images/icon/siswal/1.png') }}',
|
|
rank2: '{{ asset('images/icon/siswal/2.png') }}',
|
|
rank3: '{{ asset('images/icon/siswal/3.png') }}',
|
|
book: '{{ asset('images/icon/siswal/buku1.png') }}',
|
|
target: '{{ asset('images/icon/siswal/target.png') }}',
|
|
empty: '{{ asset('images/icon/siswal/lb.png') }}',
|
|
};
|
|
|
|
/* ── Helper: avatar untuk banner "posisimu" ── */
|
|
function avatarHtml(item, size = 'normal') {
|
|
const isLarge = size === 'large';
|
|
|
|
if (item.foto_url) {
|
|
const cls = isLarge ? 'my-rank-avatar' : 'lb-avatar';
|
|
return `<img src="${item.foto_url}?t=${Date.now()}" class="${cls}" alt="Foto profil ${escHtml(item.nama)}">`;
|
|
}
|
|
|
|
const initial = item.nama ? item.nama.charAt(0).toUpperCase() : '?';
|
|
if (isLarge) {
|
|
return `<div class="my-rank-avatar-placeholder" aria-label="Inisial ${escHtml(item.nama)}">${initial}</div>`;
|
|
}
|
|
return `<div class="lb-avatar-placeholder" aria-label="Inisial ${escHtml(item.nama)}">${initial}</div>`;
|
|
}
|
|
|
|
/* ── Helper: avatar di dalam podium ── */
|
|
function podiumAvatarHtml(item) {
|
|
if (item.foto_url) {
|
|
return `<img src="${item.foto_url}?t=${Date.now()}" alt="Foto profil ${escHtml(item.nama)}">`;
|
|
}
|
|
const initial = item.nama ? item.nama.charAt(0).toUpperCase() : '?';
|
|
return `<span aria-hidden="true">${initial}</span>`;
|
|
}
|
|
|
|
/* ── Helper: badge angka / gambar di kolom rank ── */
|
|
function rankBadgeHtml(ranking) {
|
|
if (ranking === 1) return `<img src="${ASSETS.rank1}" alt="Peringkat 1" class="icon-rank">`;
|
|
if (ranking === 2) return `<img src="${ASSETS.rank2}" alt="Peringkat 2" class="icon-rank">`;
|
|
if (ranking === 3) return `<img src="${ASSETS.rank3}" alt="Peringkat 3" class="icon-rank">`;
|
|
return ranking;
|
|
}
|
|
|
|
function rankCssClass(ranking) {
|
|
if (ranking === 1) return 'gold has-img';
|
|
if (ranking === 2) return 'silver has-img';
|
|
if (ranking === 3) return 'bronze has-img';
|
|
return '';
|
|
}
|
|
|
|
/* ── Escape HTML sederhana ── */
|
|
function escHtml(str) {
|
|
return String(str ?? '')
|
|
.replace(/&/g,'&').replace(/</g,'<')
|
|
.replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
/* ── Render bintang + angka EXP ── */
|
|
function expHtml(exp) {
|
|
return `<img src="${ASSETS.star}" alt="EXP" class="icon-star">${Number(exp).toLocaleString('id')}`;
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════
|
|
RENDER UTAMA
|
|
══════════════════════════════════════════════════════════ */
|
|
function renderLeaderboard(data) {
|
|
const { leaderboard, myRank } = data;
|
|
|
|
if (!leaderboard || leaderboard.length === 0) {
|
|
document.getElementById('leaderboard-container').innerHTML = `
|
|
<div class="empty-state">
|
|
<img src="${ASSETS.empty}" alt="Leaderboard kosong" class="icon-empty">
|
|
<p style="font-size:15px;font-weight:600;color:#475569">Belum ada data leaderboard.</p>
|
|
<p style="font-size:13px">Kerjakan challenge untuk masuk leaderboard!</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const first = leaderboard.find(x => x.ranking === 1);
|
|
const second = leaderboard.find(x => x.ranking === 2);
|
|
const third = leaderboard.find(x => x.ranking === 3);
|
|
|
|
/* ── Podium ── */
|
|
let podiumHtml = '';
|
|
if (first) {
|
|
podiumHtml = `<div class="podium-wrap">`;
|
|
|
|
if (second) podiumHtml += `
|
|
<div class="podium-item rank-2">
|
|
<div class="podium-avatar">
|
|
<div class="podium-avatar-inner">${podiumAvatarHtml(second)}</div>
|
|
</div>
|
|
<div class="podium-name">${escHtml(second.nama)}</div>
|
|
<div class="podium-exp">${expHtml(second.exp)}</div>
|
|
<div class="podium-bar">2</div>
|
|
</div>`;
|
|
|
|
podiumHtml += `
|
|
<div class="podium-item rank-1">
|
|
<div class="podium-avatar">
|
|
<span class="podium-crown">
|
|
<img src="${ASSETS.crown}" alt="Mahkota juara 1" class="icon-crown">
|
|
</span>
|
|
<div class="podium-avatar-inner">${podiumAvatarHtml(first)}</div>
|
|
</div>
|
|
<div class="podium-name">${escHtml(first.nama)}</div>
|
|
<div class="podium-exp">${expHtml(first.exp)}</div>
|
|
<div class="podium-bar">1</div>
|
|
</div>`;
|
|
|
|
if (third) podiumHtml += `
|
|
<div class="podium-item rank-3">
|
|
<div class="podium-avatar">
|
|
<div class="podium-avatar-inner">${podiumAvatarHtml(third)}</div>
|
|
</div>
|
|
<div class="podium-name">${escHtml(third.nama)}</div>
|
|
<div class="podium-exp">${expHtml(third.exp)}</div>
|
|
<div class="podium-bar">3</div>
|
|
</div>`;
|
|
|
|
podiumHtml += `</div>`;
|
|
}
|
|
|
|
/* ── Banner "Posisimu" ── */
|
|
let bannerHtml = '';
|
|
if (myRank) {
|
|
bannerHtml = `
|
|
<div class="my-rank-banner">
|
|
${avatarHtml(myRank, 'large')}
|
|
<div class="my-rank-num">#${myRank.ranking}</div>
|
|
<div class="my-rank-info">
|
|
<div class="my-rank-label">Posisimu saat ini</div>
|
|
<div class="my-rank-nama">${escHtml(myRank.nama)}</div>
|
|
<div class="my-rank-exp">${expHtml(myRank.exp)} EXP</div>
|
|
</div>
|
|
<img src="${ASSETS.target}" alt="Target peringkat" class="icon-target">
|
|
</div>`;
|
|
}
|
|
|
|
/* ── Baris tabel ── */
|
|
const rowsHtml = leaderboard.map(item => {
|
|
const isMe = item.id_siswa === currentSiswaId;
|
|
return `
|
|
<div class="lb-row ${isMe ? 'highlight' : ''}">
|
|
<div class="lb-rank ${rankCssClass(item.ranking)}">${rankBadgeHtml(item.ranking)}</div>
|
|
${avatarHtml(item)}
|
|
<div style="flex:1">
|
|
<div class="lb-nama">
|
|
${escHtml(item.nama)}
|
|
${isMe ? '<span style="background:#c4b5fd;color:#4c1d95;font-size:10px;padding:2px 7px;border-radius:99px;font-weight:700;margin-left:4px">Kamu</span>' : ''}
|
|
</div>
|
|
<div class="lb-nisn">${escHtml(item.nisn)}</div>
|
|
</div>
|
|
<div class="lb-exp">${expHtml(item.exp)}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
document.getElementById('leaderboard-container').innerHTML = `
|
|
${podiumHtml}
|
|
${bannerHtml}
|
|
<div class="custom-card">
|
|
<p class="section-title">
|
|
<span>
|
|
<img src="${ASSETS.book}" alt="Ikon daftar peringkat" class="icon-book">
|
|
Semua Peringkat
|
|
</span>
|
|
<span>
|
|
<span class="live-dot"></span>
|
|
<span style="font-size:11px;color:#22c55e;font-weight:600">Live</span>
|
|
</span>
|
|
</p>
|
|
${rowsHtml}
|
|
</div>`;
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════
|
|
POLLING
|
|
══════════════════════════════════════════════════════════ */
|
|
async function pollLeaderboard() {
|
|
try {
|
|
const res = await fetch(jsonUrl);
|
|
const data = await res.json();
|
|
renderLeaderboard(data);
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
setInterval(pollLeaderboard, 10000);
|
|
pollLeaderboard();
|
|
</script>
|
|
@endpush |