|
|
|
|
@@ -73,29 +73,37 @@ function handle(msg){
|
|
|
|
|
break;
|
|
|
|
|
case 'room_update':
|
|
|
|
|
room=msg.room;
|
|
|
|
|
if(role==='mod')renderMod();else renderPlayer();
|
|
|
|
|
if(role==='mod'){renderMod();renderRoundButtons();}else renderPlayer();
|
|
|
|
|
break;
|
|
|
|
|
case 'settings_updated':
|
|
|
|
|
room=msg.room;
|
|
|
|
|
if(role==='mod'){renderMod();renderModSettings();}else renderPlayer();
|
|
|
|
|
if(role==='mod'){renderMod();renderRoundButtons();renderModSettings();}else renderPlayer();
|
|
|
|
|
break;
|
|
|
|
|
case 'new_round':
|
|
|
|
|
toast('NEW ROUND STARTED','ok');
|
|
|
|
|
break;
|
|
|
|
|
case 'round_open':
|
|
|
|
|
room=msg.room;
|
|
|
|
|
if(role==='mod')renderMod();
|
|
|
|
|
else{renderPlayerBuzzer();startPlayerTimer();toast('ROUND OPEN','ok');}
|
|
|
|
|
if(role==='mod'){renderMod();renderRoundButtons();}
|
|
|
|
|
else{renderPlayerBuzzer();startPlayerTimer();}
|
|
|
|
|
// If timer is enabled and this is a fresh open, load the timer
|
|
|
|
|
if(room.settings.timerSeconds>0 && modTimerRemaining===0 && !modTimerRunning){
|
|
|
|
|
modTimerRemaining=room.settings.timerSeconds;
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 'round_closed':
|
|
|
|
|
room=msg.room;
|
|
|
|
|
if(role==='mod')renderMod();
|
|
|
|
|
else{renderPlayerBuzzer();stopPlayerTimer();toast('ROUND CLOSED','warn');}
|
|
|
|
|
if(role==='mod'){renderMod();renderRoundButtons();toast('ROUND CLOSED','warn');}
|
|
|
|
|
else{renderPlayerBuzzer();stopPlayerTimer();}
|
|
|
|
|
break;
|
|
|
|
|
case 'buzzer_reset':
|
|
|
|
|
room=msg.room;
|
|
|
|
|
if(role==='mod')renderMod();else renderPlayerBuzzer();
|
|
|
|
|
if(role==='mod'){renderMod();renderRoundButtons();}else renderPlayerBuzzer();
|
|
|
|
|
break;
|
|
|
|
|
case 'buzz_event':
|
|
|
|
|
room=msg.room;
|
|
|
|
|
if(role==='mod')renderModBuzz(msg);else{renderPlayerBuzzer();addFeed(msg);}
|
|
|
|
|
if(role==='mod'){renderMod();renderRoundButtons();}else{renderPlayerBuzzer();addFeed(msg);}
|
|
|
|
|
break;
|
|
|
|
|
case 'buzz_rejected':
|
|
|
|
|
toast(msg.reason,'warn');break;
|
|
|
|
|
@@ -272,7 +280,6 @@ function createRoom(){
|
|
|
|
|
// MOD TIMER
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
function fmtTime(s){return Math.floor(s/60)+':'+(s%60<10?'0':'')+(s%60);}
|
|
|
|
|
|
|
|
|
|
function modTimerLoad(){
|
|
|
|
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
|
|
|
|
modTimerRunning=false;
|
|
|
|
|
@@ -315,6 +322,41 @@ function modTimerToggle(){
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleTimer(){
|
|
|
|
|
modTimerToggle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modTimerToggle(){
|
|
|
|
|
if(modTimerRunning){
|
|
|
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
|
|
|
|
} else {
|
|
|
|
|
if(modTimerRemaining<=0)modTimerLoad();
|
|
|
|
|
modTimerRunning=true;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
|
|
|
modTimerInterval=setInterval(()=>{
|
|
|
|
|
modTimerRemaining--;
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
|
|
|
if(modTimerRemaining<=0){
|
|
|
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
|
|
|
ws_send({type:'close_round'});
|
|
|
|
|
toast('TIME UP — round closed','warn');
|
|
|
|
|
if(typeof gsap!=='undefined'){
|
|
|
|
|
gsap.to('#mod-timer-disp',{scale:1.08,duration:0.1,yoyo:true,repeat:3,ease:'power2.inOut'});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},1000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleTimerFromSwitch(){
|
|
|
|
|
modTimerToggle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderModTimerDisplay(){
|
|
|
|
|
const el=document.getElementById('mod-timer-disp');
|
|
|
|
|
const s=modTimerRemaining;
|
|
|
|
|
@@ -322,6 +364,23 @@ function renderModTimerDisplay(){
|
|
|
|
|
el.className='timer-digits'+(s<=5?' danger':s<=10?' warn':'');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modTimerLoad(){
|
|
|
|
|
const sec=parseInt(document.getElementById('mod-timer-set').value)||30;
|
|
|
|
|
modTimerRemaining=sec;
|
|
|
|
|
modTimerRunning=false;
|
|
|
|
|
clearInterval(modTimerInterval);
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modTimerReset(){
|
|
|
|
|
clearInterval(modTimerInterval);
|
|
|
|
|
modTimerRunning=false;
|
|
|
|
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function broadcastTimerToPlayers(sec,running){
|
|
|
|
|
try{
|
|
|
|
|
const bc=new BroadcastChannel('buzzer_timer');
|
|
|
|
|
@@ -330,6 +389,78 @@ function broadcastTimerToPlayers(sec,running){
|
|
|
|
|
}catch{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
// MOD ROUND CONTROL
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
function toggleRound(){
|
|
|
|
|
if(room.buzzerState.roundOpen){
|
|
|
|
|
ws_send({type:'close_round'});
|
|
|
|
|
// If linked (toggle ON) and timer running, pause it
|
|
|
|
|
if(document.getElementById('timer-tog').checked && modTimerRunning){
|
|
|
|
|
modTimerToggle();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
ws_send({type:'open_round'});
|
|
|
|
|
// If linked (toggle ON) and timer stopped, reset and start it
|
|
|
|
|
if(document.getElementById('timer-tog').checked && !modTimerRunning){
|
|
|
|
|
modTimerLoad();
|
|
|
|
|
modTimerRunning=true;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
|
|
|
modTimerInterval=setInterval(()=>{
|
|
|
|
|
modTimerRemaining--;
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
|
|
|
if(modTimerRemaining<=0){
|
|
|
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
|
|
|
ws_send({type:'close_round'});
|
|
|
|
|
toast('TIME UP — round closed','warn');
|
|
|
|
|
}
|
|
|
|
|
},1000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resumeRound(){
|
|
|
|
|
ws_send({type:'resume_round'});
|
|
|
|
|
// If linked (toggle ON) and timer stopped, resume it (preserve current value)
|
|
|
|
|
if(document.getElementById('timer-tog').checked && !modTimerRunning){
|
|
|
|
|
modTimerRunning=true;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
|
|
|
modTimerInterval=setInterval(()=>{
|
|
|
|
|
modTimerRemaining--;
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
|
|
|
if(modTimerRemaining<=0){
|
|
|
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
|
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
|
|
|
ws_send({type:'close_round'});
|
|
|
|
|
toast('TIME UP — round closed','warn');
|
|
|
|
|
}
|
|
|
|
|
},1000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
// TIMER
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
function renderRoundButtons(){
|
|
|
|
|
const btn=document.getElementById('mod-round-btn');
|
|
|
|
|
const resumeBtn=document.getElementById('mod-resume-btn');
|
|
|
|
|
|
|
|
|
|
if(room.buzzerState.roundOpen){
|
|
|
|
|
btn.innerHTML='■ PAUSE ROUND';
|
|
|
|
|
btn.className='btn btn-red btn-full';
|
|
|
|
|
resumeBtn.style.display='none';
|
|
|
|
|
} else {
|
|
|
|
|
btn.innerHTML='▶ OPEN ROUND';
|
|
|
|
|
btn.className='btn btn-g btn-full';
|
|
|
|
|
resumeBtn.style.display='block';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
// MOD RENDER
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
@@ -351,6 +482,7 @@ function renderMod(){
|
|
|
|
|
renderModBuzz(null);
|
|
|
|
|
renderModPlayerList();
|
|
|
|
|
renderModTeams();
|
|
|
|
|
renderRoundButtons();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderModBuzz(evt){
|
|
|
|
|
@@ -372,7 +504,7 @@ function renderModBuzz(evt){
|
|
|
|
|
div.innerHTML=`
|
|
|
|
|
<div class="bz-rank ${idx===0?'first':''}">${idx+1}</div>
|
|
|
|
|
<div class="bz-info">
|
|
|
|
|
<div class="bz-name">${esc(p.name)}</div>
|
|
|
|
|
<div class="bz-name">${pid}</div>
|
|
|
|
|
${teamName?`<div class="bz-team" style="color:${color}">${esc(teamName)}</div>`:''}
|
|
|
|
|
${ms?`<div class="bz-ms">${ms} after first</div>`:''}
|
|
|
|
|
</div>
|
|
|
|
|
@@ -409,7 +541,7 @@ function renderModPlayerList(){
|
|
|
|
|
row.className='pl-row'+(p.isConnected?'':' offline');
|
|
|
|
|
row.innerHTML=`
|
|
|
|
|
<div class="pl-info" style="flex:1;min-width:0;">
|
|
|
|
|
<div class="pl-name">${esc(p.name)} ${p.isConnected?'':`<span class="tag tag-red" style="font-size:9px;padding:2px 6px;">OFFLINE</span>`}</div>
|
|
|
|
|
<div class="pl-name">${p.id} ${p.isConnected?'':`<span class="tag tag-red" style="font-size:9px;padding:2px 6px;">OFFLINE</span>`}</div>
|
|
|
|
|
${teamName?`<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>`:'<div class="pl-meta">No team</div>'}
|
|
|
|
|
${teamSel}
|
|
|
|
|
</div>
|
|
|
|
|
@@ -435,7 +567,7 @@ function renderModTeams(){
|
|
|
|
|
card.innerHTML=`
|
|
|
|
|
<div class="tc-n" style="color:${color}">${esc(name)}</div>
|
|
|
|
|
<div class="tc-c" style="color:${color}">${members.length}</div>
|
|
|
|
|
<div class="tc-m">${members.map(p=>esc(p.name)).join('<br>')||'—'}</div>
|
|
|
|
|
<div class="tc-m">${members.map(p=>p.id).join('<br>')||'—'}</div>
|
|
|
|
|
`;
|
|
|
|
|
grid.appendChild(card);
|
|
|
|
|
if(typeof gsap!=='undefined'){
|
|
|
|
|
@@ -453,6 +585,8 @@ function renderModSettings(){
|
|
|
|
|
document.getElementById('ls-numteams').value=s.numTeams;
|
|
|
|
|
segActivate('ls-seg-mode',s.mode);
|
|
|
|
|
renderLiveTeamNames();
|
|
|
|
|
renderRoundButtons();
|
|
|
|
|
renderModTimerDisplay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function segActivate(groupId,val){
|
|
|
|
|
@@ -525,7 +659,7 @@ function renderPlayer(){
|
|
|
|
|
if(!room)return;
|
|
|
|
|
document.getElementById('p-code').textContent=room.id;
|
|
|
|
|
const me=room.players.find(p=>p.id===myId);
|
|
|
|
|
document.getElementById('p-namelbl').textContent=me?.name??'';
|
|
|
|
|
document.getElementById('p-namelbl').textContent=me?.id||'';
|
|
|
|
|
renderTeamPicker();
|
|
|
|
|
renderPlayerBuzzer();
|
|
|
|
|
renderRoster();
|
|
|
|
|
@@ -548,21 +682,20 @@ function renderTeamPicker(){
|
|
|
|
|
btn.className='team-btn'+(isMine?' mine':'');
|
|
|
|
|
btn.style.borderColor=isMine?color:'var(--border2)';
|
|
|
|
|
btn.style.color=isMine?color:'var(--text)';
|
|
|
|
|
btn.innerHTML=`<div>${esc(s.teamNames[i]??greekName(i))}</div><div class="tb-count">${members.length} player${members.length!==1?'s':''}</div>`;
|
|
|
|
|
btn.style.padding='16px';
|
|
|
|
|
btn.style.fontSize='20px';
|
|
|
|
|
btn.style.fontWeight='600';
|
|
|
|
|
btn.style.cursor='pointer';
|
|
|
|
|
btn.innerHTML=`<div style="font-size:24px;">${esc(s.teamNames[i]??greekName(i))}</div><div style="font-size:14px;color:var(--dim);margin-top:4px;">${members.length} player${members.length!==1?'s':''}</div>`;
|
|
|
|
|
btn.onclick=()=>ws_send({type:'pick_team',teamIndex:i});
|
|
|
|
|
grid.appendChild(btn);
|
|
|
|
|
if(typeof gsap!=='undefined'){
|
|
|
|
|
gsap.fromTo(btn,{opacity:0,y:10},{opacity:1,y:0,duration:0.3,ease:'power2.out',delay:i*0.04});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _lastBuzzState='';
|
|
|
|
|
function renderPlayerBuzzer(){
|
|
|
|
|
if(!room)return;
|
|
|
|
|
const btn=document.getElementById('buzz-btn');
|
|
|
|
|
const sts=document.getElementById('buzz-status');
|
|
|
|
|
const ripple=document.getElementById('buzz-ripple');
|
|
|
|
|
const bz=room.buzzerState;
|
|
|
|
|
const already=bz.buzzOrder.includes(myId);
|
|
|
|
|
const isFirst=bz.buzzOrder[0]===myId;
|
|
|
|
|
@@ -572,49 +705,33 @@ function renderPlayerBuzzer(){
|
|
|
|
|
if(!bz.roundOpen){
|
|
|
|
|
newState='closed';
|
|
|
|
|
btn.className='s-closed';btn.disabled=true;sts.textContent='WAITING FOR ROUND';
|
|
|
|
|
ripple.style.display='none';
|
|
|
|
|
if(hint)hint.classList.remove('visible');
|
|
|
|
|
} else if(isFirst){
|
|
|
|
|
newState='first';
|
|
|
|
|
btn.className='s-first';btn.disabled=false;sts.textContent='YOU BUZZED FIRST!';
|
|
|
|
|
ripple.style.display='flex';
|
|
|
|
|
if(hint)hint.classList.remove('visible');
|
|
|
|
|
} else if(already){
|
|
|
|
|
const pos=bz.buzzOrder.indexOf(myId)+1;
|
|
|
|
|
newState='buzzed';
|
|
|
|
|
btn.className='s-buzzed';btn.disabled=true;sts.textContent='BUZZED — #'+pos+' IN ORDER';
|
|
|
|
|
ripple.style.display='none';
|
|
|
|
|
if(hint)hint.classList.remove('visible');
|
|
|
|
|
} else if(room.settings.buzzerLockout&&bz.buzzOrder.length>0){
|
|
|
|
|
newState='locked';
|
|
|
|
|
btn.className='s-locked';btn.disabled=true;sts.textContent='BUZZER LOCKED OUT';
|
|
|
|
|
ripple.style.display='none';
|
|
|
|
|
if(hint)hint.classList.remove('visible');
|
|
|
|
|
} else {
|
|
|
|
|
newState='open';
|
|
|
|
|
btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!';
|
|
|
|
|
ripple.style.display='flex';
|
|
|
|
|
if(hint)hint.classList.add('visible');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Animate state transitions
|
|
|
|
|
if(newState!==_lastBuzzState && typeof gsap!=='undefined'){
|
|
|
|
|
if(newState==='first'){
|
|
|
|
|
gsap.fromTo(btn,{scale:0.88},{scale:1,duration:0.5,ease:'back.out(2.5)'});
|
|
|
|
|
gsap.fromTo(sts,{opacity:0,y:6},{opacity:1,y:0,duration:0.4,ease:'power2.out'});
|
|
|
|
|
} else if(newState==='open' && _lastBuzzState==='closed'){
|
|
|
|
|
gsap.fromTo(btn,{scale:0.95},{scale:1,duration:0.4,ease:'back.out(1.5)'});
|
|
|
|
|
} else if(newState==='closed'){
|
|
|
|
|
gsap.to(btn,{scale:0.97,duration:0.15,yoyo:true,repeat:1,ease:'power2.inOut'});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_lastBuzzState=newState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderRoster(){
|
|
|
|
|
if(!room)return;
|
|
|
|
|
const el=document.getElementById('p-roster');
|
|
|
|
|
if(room.players.length===0){el.innerHTML='<div style="font-size:13px;color:var(--dim);padding:12px;letter-spacing:1px;">No players yet.</div>';return;}
|
|
|
|
|
if(room.players.length===0){el.innerHTML='<div style="font-size:18px;color:var(--dim);padding:20px;">No players yet.</div>';return;}
|
|
|
|
|
el.innerHTML='';
|
|
|
|
|
room.players.forEach(p=>{
|
|
|
|
|
const isMe=p.id===myId;
|
|
|
|
|
@@ -623,9 +740,9 @@ function renderRoster(){
|
|
|
|
|
const row=document.createElement('div');
|
|
|
|
|
row.className='roster-row'+(isMe?' roster-me':'');
|
|
|
|
|
row.innerHTML=`
|
|
|
|
|
<div class="roster-dot" style="background:${p.isConnected?(isMe?'var(--g)':color):'var(--border2)'}"></div>
|
|
|
|
|
<div style="flex:1">${esc(p.name)}${isMe?' <span style="font-size:11px;color:var(--dim);letter-spacing:1px;">(YOU)</span>':''}</div>
|
|
|
|
|
${teamName?`<div style="font-size:12px;color:${color};letter-spacing:0.5px;">${esc(teamName)}</div>`:''}
|
|
|
|
|
<div class="roster-dot" style="width:16px;height:16px;border-radius:50%;background:${p.isConnected?(isMe?'var(--g)':color):'var(--border2)'}"></div>
|
|
|
|
|
<div style="font-size:20px;font-weight:700;">#${p.id}</div>
|
|
|
|
|
${teamName?`<div style="font-size:14px;color:${color};margin-top:2px;">${esc(teamName)}</div>`:''}
|
|
|
|
|
`;
|
|
|
|
|
el.appendChild(row);
|
|
|
|
|
});
|
|
|
|
|
@@ -639,26 +756,13 @@ function addFeed(evt){
|
|
|
|
|
const div=document.createElement('div');
|
|
|
|
|
div.className='feed-entry'+(isFirst?' first':'');
|
|
|
|
|
div.style.borderLeftColor=isFirst?'var(--yellow)':color;
|
|
|
|
|
div.innerHTML=`<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst?' <span style="color:var(--yellow);font-weight:700;"> — FIRST!</span>':''}`;
|
|
|
|
|
div.innerHTML=`<strong>#${evt.playerId}</strong>${teamStr} buzzed${isFirst?' <span style="color:var(--yellow);font-weight:700;"> — FIRST!</span>':''}`;
|
|
|
|
|
feed.prepend(div);
|
|
|
|
|
if(typeof gsap!=='undefined'){
|
|
|
|
|
gsap.fromTo(div,
|
|
|
|
|
{opacity:0,x:-20,borderLeftWidth:'3px'},
|
|
|
|
|
{opacity:1,x:0,duration:0.38,ease:'power3.out'}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
while(feed.children.length>30)feed.removeChild(feed.lastChild);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function doBuzz(){
|
|
|
|
|
ws_send({type:'buzz'});
|
|
|
|
|
// Immediate haptic-like feedback
|
|
|
|
|
if(typeof gsap!=='undefined'){
|
|
|
|
|
const btn=document.getElementById('buzz-btn');
|
|
|
|
|
gsap.to(btn,{scale:0.91,duration:0.08,ease:'power2.in',onComplete:()=>{
|
|
|
|
|
gsap.to(btn,{scale:1,duration:0.25,ease:'back.out(2.5)'});
|
|
|
|
|
}});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
|
|