Files
2026-04-26 11:41:15 +00:00

1893 lines
73 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
var API = '/Admin/api/';
/** ผ่าน PHP Admin เพื่อให้ PUT ถึง Node แม้ nginx ไม่ proxy /Game/api/ ไปยัง Node */
var GAME_QUIZ_API = '/Admin/api/game-quiz-settings.php';
var GAME_TIMING_API = '/Game/api/game-timing';
var GAME_GAUNTLET_ASSETS_API = '/Game/api/gauntlet-assets';
var stackGameSaveInFlight = false;
/** โหมดแทนที่รูป: ชื่อไฟล์เป้าหมายก่อนเปิด file picker */
var gauntletReplaceTarget = null;
var cred = { credentials: 'include' };
function el(id) { return document.getElementById(id); }
function api(path, opts) {
opts = opts || {};
opts.credentials = 'include';
if (!opts.headers) opts.headers = {};
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
return fetch(API + path, opts).then(function (r) {
return r.text().then(function (text) {
var j = {};
try {
j = text ? JSON.parse(text) : {};
} catch (e) {
if (!r.ok) throw new Error(text || r.statusText);
throw e;
}
if (!r.ok) throw new Error(j.error || r.statusText || 'Request failed');
return j;
});
});
}
function setMsg(id, text, cls) {
var n = el(id);
if (!n) return;
n.textContent = text || '';
n.className = 'msg' + (cls ? ' ' + cls : '');
}
function gameQuizFetch(method, body) {
var m = method || 'GET';
var url = GAME_QUIZ_API;
if (m === 'GET' && body == null) {
url += (GAME_QUIZ_API.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now();
}
var opts = { method: m, credentials: 'include', cache: 'no-store' };
if (body != null) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
return fetch(url, opts).then(function (r) {
return r.text().then(function (text) {
var j = {};
try {
j = text ? JSON.parse(text) : {};
} catch (e) {
if (!r.ok) {
throw new Error((text || '').slice(0, 280) || r.statusText || 'Request failed');
}
throw new Error('คำตอบไม่ใช่ JSON (HTTP ' + r.status + ') — ตรวจ nginx / PHP proxy');
}
if (!r.ok) {
throw new Error(j.error || ('HTTP ' + r.status + ' ' + r.statusText));
}
return j;
});
});
}
function gameTimingFetch(method, body) {
var m = method || 'GET';
var url = GAME_TIMING_API;
if (m === 'GET' && body == null) {
url += (GAME_TIMING_API.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now();
}
var opts = { method: m, credentials: 'include', cache: 'no-store' };
if (body != null) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
return fetch(url, opts).then(function (r) {
return r.text().then(function (text) {
var j = {};
try {
j = text ? JSON.parse(text) : {};
} catch (e) {
if (!r.ok) {
var plain = (text || r.statusText || 'Request failed').trim();
if (r.status === 404) {
plain += ' — รีสตาร์ท Node ที่รัน Game/server.js (pm2 / systemd) หลัง deploy · Restart Game Node after deploy';
}
throw new Error(plain);
}
throw e;
}
if (!r.ok) {
var base = j.error || r.statusText || 'Request failed';
if (r.status === 404) {
base += ' — รีสตาร์ท Node ที่รัน Game/server.js (pm2 / systemd) หลัง deploy · Restart Game Node after deploy';
}
throw new Error(base);
}
return j;
});
});
}
function addQuizAdminRow(text, answerTrue) {
var list = el('quiz-admin-questions-list');
if (!list) return;
var row = document.createElement('div');
row.className = 'quiz-admin-q-row';
var lab1 = document.createElement('label');
lab1.className = 'quiz-admin-q-label';
lab1.appendChild(document.createTextNode('คำถาม '));
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'quiz-admin-q-text';
inp.placeholder = 'พิมพ์คำถาม...';
inp.maxLength = 500;
inp.value = text || '';
lab1.appendChild(inp);
var lab2 = document.createElement('label');
lab2.className = 'quiz-admin-ans-label';
lab2.appendChild(document.createTextNode('คำตอบถูก '));
var sel = document.createElement('select');
sel.className = 'quiz-admin-q-ans';
[['true', 'จริง (ถูก)'], ['false', 'เท็จ (ผิด)']].forEach(function (pair) {
var o = document.createElement('option');
o.value = pair[0];
o.textContent = pair[1];
var isTrue = answerTrue !== false;
if (pair[0] === 'true' ? isTrue : !isTrue) o.selected = true;
sel.appendChild(o);
});
lab2.appendChild(sel);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-sm quiz-admin-q-remove';
rm.setAttribute('aria-label', 'ลบข้อนี้');
rm.textContent = 'ลบ';
rm.addEventListener('click', function () {
row.remove();
if (!list.querySelector('.quiz-admin-q-row')) addQuizAdminRow('', true);
});
row.appendChild(lab1);
row.appendChild(lab2);
row.appendChild(rm);
list.appendChild(row);
}
function renderQuizAdminQuestions(arr) {
var list = el('quiz-admin-questions-list');
if (!list) return;
list.innerHTML = '';
var rows = (arr && arr.length) ? arr : [{ text: '', answerTrue: true }];
rows.forEach(function (q) {
addQuizAdminRow(q.text || '', q.answerTrue !== false);
});
}
function loadQuizSettingsPanel() {
gameQuizFetch('GET').then(function (data) {
el('quiz-read-sec').value = String(Math.round((data.readMs || 10000) / 1000));
el('quiz-answer-sec').value = String(Math.round((data.answerMs || 5000) / 1000));
var bMs = data.betweenMs != null ? data.betweenMs : 3500;
el('quiz-between-sec').value = String(Math.max(0, Math.round(bMs / 1000)));
renderQuizAdminQuestions(data.questions);
setMsg('quiz-settings-msg', '', '');
}).catch(function (e) {
setMsg('quiz-settings-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function saveQuizSettingsPanel() {
var readS = parseInt(el('quiz-read-sec').value, 10);
var ansS = parseInt(el('quiz-answer-sec').value, 10);
var betS = parseInt(el('quiz-between-sec').value, 10);
if (Number.isNaN(readS) || readS < 1) readS = 10;
if (Number.isNaN(ansS) || ansS < 1) ansS = 5;
if (Number.isNaN(betS) || betS < 0) betS = 3;
readS = Math.min(300, readS);
ansS = Math.min(300, ansS);
betS = Math.min(120, betS);
var questions = [];
document.querySelectorAll('#quiz-admin-questions-list .quiz-admin-q-row').forEach(function (row) {
var inp = row.querySelector('.quiz-admin-q-text');
var sel = row.querySelector('.quiz-admin-q-ans');
var t = inp && inp.value ? String(inp.value).trim() : '';
if (!t) return;
questions.push({ text: t, answerTrue: !!(sel && sel.value === 'true') });
});
gameQuizFetch('PUT', {
readMs: readS * 1000,
answerMs: ansS * 1000,
betweenMs: betS * 1000,
questions: questions,
}).then(function () {
setMsg('quiz-settings-msg', 'บันทึกแล้ว', 'ok');
}).catch(function (e) {
setMsg('quiz-settings-msg', e.message || 'บันทึกไม่ได้', 'error');
});
}
var QUIZ_CARRY_MAX_SLOTS = 16;
function rebuildQuizCarryCorrectSelect(row) {
var sel = row.querySelector('.quiz-carry-correct-sel');
if (!sel) return;
var inputs = row.querySelectorAll('.quiz-carry-slot-inp');
var prev = sel.value;
sel.innerHTML = '';
var has = false;
for (var i = 0; i < inputs.length; i++) {
var t = inputs[i].value ? String(inputs[i].value).trim() : '';
if (!t) continue;
has = true;
var o = document.createElement('option');
o.value = String(i);
var preview = t.length > 48 ? t.slice(0, 45) + '…' : t;
o.textContent = String(i + 1) + '. ' + preview;
sel.appendChild(o);
}
if (!has) {
var ox = document.createElement('option');
ox.value = '';
ox.textContent = '— กรอกตัวเลือกอย่างน้อย 2 ช่อง —';
sel.appendChild(ox);
sel.disabled = true;
return;
}
sel.disabled = false;
var ok = false;
for (var j = 0; j < sel.options.length; j++) {
if (sel.options[j].value === prev) {
sel.selectedIndex = j;
ok = true;
break;
}
}
if (!ok) sel.selectedIndex = 0;
}
function bindQuizCarryRowInputs(row) {
row.querySelectorAll('.quiz-carry-slot-inp').forEach(function (inp) {
inp.addEventListener('input', function () {
rebuildQuizCarryCorrectSelect(row);
});
});
}
function addQuizCarryAdminRow(q) {
var list = el('quiz-carry-admin-list');
if (!list) return;
q = q || {};
var choices = Array.isArray(q.choices) ? q.choices : [];
var block = document.createElement('div');
block.className = 'quiz-carry-admin-block';
var head = document.createElement('div');
head.className = 'quiz-carry-admin-block-head';
var labQ = document.createElement('label');
labQ.className = 'quiz-carry-q-label';
labQ.appendChild(document.createTextNode('คำถาม'));
var inpQ = document.createElement('input');
inpQ.type = 'text';
inpQ.className = 'quiz-carry-q-text';
inpQ.placeholder = 'พิมพ์คำถาม…';
inpQ.maxLength = 500;
inpQ.value = q.text ? String(q.text) : '';
labQ.appendChild(inpQ);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-sm quiz-carry-block-remove';
rm.setAttribute('aria-label', 'ลบข้อนี้');
rm.textContent = 'ลบข้อ';
rm.addEventListener('click', function () {
block.remove();
if (!list.querySelector('.quiz-carry-admin-block')) addQuizCarryAdminRow(null);
});
head.appendChild(labQ);
head.appendChild(rm);
block.appendChild(head);
var grid = document.createElement('div');
grid.className = 'quiz-carry-choice-grid';
for (var s = 0; s < QUIZ_CARRY_MAX_SLOTS; s++) {
var lab = document.createElement('label');
lab.className = 'quiz-carry-slot-label';
lab.appendChild(document.createTextNode('ตัวเลือก ' + (s + 1)));
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'quiz-carry-slot-inp';
inp.placeholder = s < 2 ? 'จำเป็น' : 'ไม่บังคับ';
inp.maxLength = 160;
inp.value = choices[s] != null ? String(choices[s]) : '';
lab.appendChild(inp);
grid.appendChild(lab);
}
block.appendChild(grid);
var cw = document.createElement('div');
cw.className = 'quiz-carry-correct-wrap';
var labC = document.createElement('label');
labC.className = 'quiz-carry-correct-label';
labC.appendChild(document.createTextNode('ข้อที่ถูกต้อง'));
var sel = document.createElement('select');
sel.className = 'quiz-carry-correct-sel';
sel.setAttribute('aria-label', 'เลือกข้อที่ถูก');
labC.appendChild(sel);
cw.appendChild(labC);
block.appendChild(cw);
list.appendChild(block);
bindQuizCarryRowInputs(block);
rebuildQuizCarryCorrectSelect(block);
var ci = Number(q.correctIndex);
if (Number.isFinite(ci) && ci >= 0) {
var targetSlot = ci;
var opts = sel.querySelectorAll('option');
for (var k = 0; k < opts.length; k++) {
if (opts[k].value === String(targetSlot)) {
sel.selectedIndex = k;
break;
}
}
}
}
function renderQuizCarryAdminList(arr) {
var list = el('quiz-carry-admin-list');
if (!list) return;
list.innerHTML = '';
var rows = arr && arr.length ? arr : [null];
rows.forEach(function (q) {
addQuizCarryAdminRow(q);
});
}
function loadQuizCarryPanel() {
gameQuizFetch('GET').then(function (data) {
renderQuizCarryAdminList(data.carryQuestions || []);
var readSec = el('quiz-carry-read-sec');
var ansSec = el('quiz-carry-answer-sec');
if (readSec) {
var rms = Number(data.carryReadMs);
readSec.value = String(Number.isFinite(rms) ? Math.round(rms / 1000) : 3);
}
if (ansSec) {
var ams = Number(data.carryAnswerMs);
ansSec.value = String(Number.isFinite(ams) ? Math.round(ams / 1000) : 5);
}
var sessLen = el('quiz-carry-session-len');
if (sessLen) {
var sl = parseInt(String(data.carrySessionLength), 10);
sessLen.value = String(Number.isFinite(sl) && sl >= 0 ? sl : 0);
}
setMsg('quiz-carry-settings-msg', '', '');
}).catch(function (e) {
setMsg('quiz-carry-settings-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function saveQuizCarryPanel() {
var list = el('quiz-carry-admin-list');
if (!list) return;
var carryQuestions = [];
list.querySelectorAll('.quiz-carry-admin-block').forEach(function (block) {
var inpQ = block.querySelector('.quiz-carry-q-text');
var text = inpQ && inpQ.value ? String(inpQ.value).trim() : '';
if (!text) return;
var slots = [];
block.querySelectorAll('.quiz-carry-slot-inp').forEach(function (inp, idx) {
var v = inp.value ? String(inp.value).trim() : '';
if (v) slots.push({ idx: idx, text: v });
});
if (slots.length < 2) return;
var sel = block.querySelector('.quiz-carry-correct-sel');
var slotStr = sel && !sel.disabled && sel.value !== '' ? sel.value : null;
if (slotStr == null) return;
var slotNum = parseInt(slotStr, 10);
var correctIndex = -1;
for (var i = 0; i < slots.length; i++) {
if (slots[i].idx === slotNum) {
correctIndex = i;
break;
}
}
if (correctIndex < 0) return;
carryQuestions.push({
text: text,
choices: slots.map(function (s) {
return s.text;
}),
correctIndex: correctIndex,
});
});
var readSecEl = el('quiz-carry-read-sec');
var ansSecEl = el('quiz-carry-answer-sec');
var readSec = readSecEl ? parseInt(String(readSecEl.value || '3'), 10) : 3;
var ansSec = ansSecEl ? parseInt(String(ansSecEl.value || '5'), 10) : 5;
if (!Number.isFinite(readSec) || readSec < 0) readSec = 0;
if (readSec > 120) readSec = 120;
if (!Number.isFinite(ansSec) || ansSec < 1) ansSec = 1;
if (ansSec > 300) ansSec = 300;
var carryReadMs = readSec * 1000;
var carryAnswerMs = ansSec * 1000;
var sessEl = el('quiz-carry-session-len');
var carrySessionLength = sessEl ? parseInt(String(sessEl.value || '0'), 10) : 0;
if (!Number.isFinite(carrySessionLength) || carrySessionLength < 0) carrySessionLength = 0;
if (carrySessionLength > 500) carrySessionLength = 500;
gameQuizFetch('PUT', {
carryQuestions: carryQuestions,
carryReadMs: carryReadMs,
carryAnswerMs: carryAnswerMs,
carrySessionLength: carrySessionLength,
}).then(function () {
setMsg('quiz-carry-settings-msg', 'บันทึกแล้ว', 'ok');
}).catch(function (e) {
setMsg('quiz-carry-settings-msg', e.message || 'บันทึกไม่ได้', 'error');
});
}
/** หมวด Quiz Battle — ชื่อไทยให้สอดคล้องธีมการศึกษาไซเบอร์ / หน้า Quiz-Battle */
var QB_LABELS_ABC = ['A', 'B', 'C'];
var QUIZ_BATTLE_CATEGORIES = [
{ id: 'cybercrime', labelTh: 'อาชญากรรมออนไลน์', labelEn: 'Cybercrime' },
{ id: 'cyber_law', labelTh: 'กฎหมายไซเบอร์', labelEn: 'Cyber law' },
{ id: 'privacy', labelTh: 'ความเป็นส่วนตัวและข้อมูลส่วนบุคคล', labelEn: 'Privacy & PDPA' },
{ id: 'digital_security', labelTh: 'ความปลอดภัยดิจิทัล', labelEn: 'Digital security' },
{ id: 'social_media', labelTh: 'สื่อสังคมออนไลน์', labelEn: 'Social media' },
{ id: 'ethics', labelTh: 'จริยธรรมดิจิทัล', labelEn: 'Digital ethics' },
{ id: 'ai_data', labelTh: 'AI และข้อมูล', labelEn: 'AI & data' },
];
function qbBattleCategoryLabel(catId) {
for (var i = 0; i < QUIZ_BATTLE_CATEGORIES.length; i++) {
if (QUIZ_BATTLE_CATEGORIES[i].id === catId) return QUIZ_BATTLE_CATEGORIES[i].labelTh;
}
return 'Quiz Battle';
}
function fillQbBattleFilterOptions() {
var sel = el('qb-battle-filter-cat');
if (!sel) return;
var keep = sel.value;
while (sel.options.length > 1) sel.remove(1);
QUIZ_BATTLE_CATEGORIES.forEach(function (c) {
var o = document.createElement('option');
o.value = c.id;
o.textContent = c.labelTh;
sel.appendChild(o);
});
if (keep) {
for (var oi = 0; oi < sel.options.length; oi++) {
if (sel.options[oi].value === keep) {
sel.value = keep;
break;
}
}
}
}
function applyQbBattleFilter() {
var list = el('qb-battle-admin-list');
var f = el('qb-battle-filter-cat');
if (!list || !f) return;
var fv = f.value || '';
list.querySelectorAll('.qb-battle-block').forEach(function (row) {
var catInp = row.querySelector('.qb-battle-cat');
var cat = catInp && catInp.value ? catInp.value : '';
row.hidden = !!(fv && cat !== fv);
});
refreshQbBattlePreview();
}
function getFirstVisibleQbBattleBlock() {
var list = el('qb-battle-admin-list');
if (!list) return null;
var rows = list.querySelectorAll('.qb-battle-block');
for (var i = 0; i < rows.length; i++) {
if (rows[i].hidden) continue;
var q = rows[i].querySelector('.qb-battle-q-text');
var t = q && q.value ? String(q.value).trim() : '';
if (t) return rows[i];
}
for (var j = 0; j < rows.length; j++) {
if (!rows[j].hidden) return rows[j];
}
return null;
}
function readQbBattleBlockData(row) {
if (!row) return null;
var catSel = row.querySelector('.qb-battle-cat');
var qInp = row.querySelector('.qb-battle-q-text');
var a0 = row.querySelector('.qb-battle-a');
var a1 = row.querySelector('.qb-battle-b');
var a2 = row.querySelector('.qb-battle-c');
var radios = row.querySelectorAll('input[type="radio"][name^="qb-correct-"]');
var ci = 0;
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) {
ci = parseInt(radios[i].value, 10);
if (!Number.isFinite(ci)) ci = 0;
break;
}
}
return {
categoryId: catSel && catSel.value ? catSel.value : 'cybercrime',
text: qInp && qInp.value ? String(qInp.value).trim() : '',
choices: [
a0 && a0.value ? String(a0.value).trim() : '',
a1 && a1.value ? String(a1.value).trim() : '',
a2 && a2.value ? String(a2.value).trim() : '',
],
correctIndex: Math.max(0, Math.min(2, ci)),
};
}
function refreshQbBattlePreview() {
var hudCat = el('qb-preview-hud-cat');
var qtext = el('qb-preview-modal-q');
var c0 = el('qb-preview-c0');
var c1 = el('qb-preview-c1');
var c2 = el('qb-preview-c2');
var qModal = el('qb-cyber-modal-question');
var sModal = el('qb-cyber-modal-summary');
var layer = el('qb-modal-layer');
if (!hudCat || !qtext || !c0 || !c1 || !c2 || !qModal || !sModal) return;
var modeBtn = document.querySelector('.qb-preview-mode-btn.is-active');
var mode = modeBtn && modeBtn.getAttribute('data-qb-mode') ? modeBtn.getAttribute('data-qb-mode') : 'question';
var row = getFirstVisibleQbBattleBlock();
var d = readQbBattleBlockData(row);
var defQ = 'การกระทำใดผิดกฎหมายไซเบอร์?';
var defC = [
'การเจาะระบบเข้าสู่ข้อมูลส่วนบุคคล',
'การใช้ WI-FI สาธารณะเพื่อดูข่าว',
'การอัปเดตซอฟต์แวร์เพื่อความปลอดภัย',
];
hudCat.textContent = d ? qbBattleCategoryLabel(d.categoryId) : qbBattleCategoryLabel('cybercrime');
qtext.textContent = d && d.text ? d.text : defQ;
var ch = d ? d.choices : defC;
for (var s = 0; s < 3; s++) {
var eln = s === 0 ? c0 : s === 1 ? c1 : c2;
var txt = ch[s] ? ch[s] : defC[s];
eln.textContent = QB_LABELS_ABC[s] + ' : ' + txt;
}
var choiceEls = document.querySelectorAll('#qb-preview-choices .qb-cyber-choice');
choiceEls.forEach(function (node) {
node.classList.remove('qb-cyber-choice--correct', 'qb-cyber-choice--wrong', 'qb-cyber-choice--neutral');
var mark = node.querySelector('.qb-cyber-choice-mark');
if (mark) mark.textContent = '';
});
if (mode === 'summary') {
qModal.hidden = true;
sModal.hidden = false;
if (layer) layer.classList.add('qb-modal-layer--summary');
} else {
qModal.hidden = false;
sModal.hidden = true;
if (layer) layer.classList.remove('qb-modal-layer--summary');
var ci = d ? d.correctIndex : 0;
if (mode === 'revealed') {
var wrongPick = (ci + 1) % 3;
choiceEls.forEach(function (node, idx) {
var mark = node.querySelector('.qb-cyber-choice-mark');
if (idx === ci) {
node.classList.add('qb-cyber-choice--correct');
if (mark) mark.textContent = '✓';
} else if (idx === wrongPick) {
node.classList.add('qb-cyber-choice--wrong');
if (mark) mark.textContent = '✕';
} else {
node.classList.add('qb-cyber-choice--neutral');
}
});
}
}
}
function addQbBattleRow(q) {
var list = el('qb-battle-admin-list');
if (!list) return;
q = q || {};
var uid = 'r' + Math.random().toString(36).slice(2, 10);
var block = document.createElement('div');
block.className = 'qb-battle-block';
block.setAttribute('data-category', q.categoryId || 'cybercrime');
var head = document.createElement('div');
head.className = 'qb-battle-block-head';
var labCat = document.createElement('label');
labCat.className = 'qb-battle-cat-label';
labCat.appendChild(document.createTextNode('หมวด'));
var selCat = document.createElement('select');
selCat.className = 'qb-battle-cat';
QUIZ_BATTLE_CATEGORIES.forEach(function (c) {
var o = document.createElement('option');
o.value = c.id;
o.textContent = c.labelTh;
if ((q.categoryId || 'cybercrime') === c.id) o.selected = true;
selCat.appendChild(o);
});
selCat.addEventListener('change', function () {
block.setAttribute('data-category', selCat.value);
applyQbBattleFilter();
});
labCat.appendChild(selCat);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-sm qb-battle-remove';
rm.textContent = 'ลบข้อ';
rm.addEventListener('click', function () {
block.remove();
if (!list.querySelector('.qb-battle-block')) addQbBattleRow(null);
applyQbBattleFilter();
});
head.appendChild(labCat);
head.appendChild(rm);
block.appendChild(head);
var labQ = document.createElement('label');
labQ.className = 'qb-battle-q-label';
labQ.appendChild(document.createTextNode('คำถาม'));
var inpQ = document.createElement('input');
inpQ.type = 'text';
inpQ.className = 'qb-battle-q-text';
inpQ.maxLength = 500;
inpQ.placeholder = 'พิมพ์คำถาม…';
inpQ.value = q.text ? String(q.text) : '';
labQ.appendChild(inpQ);
block.appendChild(labQ);
var choices = Array.isArray(q.choices) ? q.choices : [];
var grid = document.createElement('div');
grid.className = 'qb-battle-abc-grid';
[['A', 'a', 0], ['B', 'b', 1], ['C', 'c', 2]].forEach(function (tri) {
var lab = document.createElement('label');
lab.className = 'qb-battle-opt-label';
lab.appendChild(document.createTextNode('ตัวเลือก ' + tri[0]));
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'qb-battle-' + tri[1];
inp.maxLength = 160;
inp.placeholder = tri[0];
inp.value = choices[tri[2]] != null ? String(choices[tri[2]]) : '';
lab.appendChild(inp);
grid.appendChild(lab);
});
block.appendChild(grid);
var ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(2, Math.floor(ci)));
var fs = document.createElement('fieldset');
fs.className = 'qb-battle-correct-fs';
var leg = document.createElement('legend');
leg.textContent = 'ข้อที่ถูกต้อง';
fs.appendChild(leg);
var rg = document.createElement('div');
rg.className = 'qb-battle-correct-rg';
for (var r = 0; r < 3; r++) {
var labR = document.createElement('label');
labR.className = 'qb-battle-radio-label';
var radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'qb-correct-' + uid;
radio.value = String(r);
if (r === ci) radio.checked = true;
labR.appendChild(radio);
labR.appendChild(document.createTextNode(' ' + QB_LABELS_ABC[r]));
rg.appendChild(labR);
}
fs.appendChild(rg);
block.appendChild(fs);
function onFieldInput() {
refreshQbBattlePreview();
}
inpQ.addEventListener('input', onFieldInput);
grid.querySelectorAll('input').forEach(function (inp) {
inp.addEventListener('input', onFieldInput);
});
rg.querySelectorAll('input[type="radio"]').forEach(function (rdo) {
rdo.addEventListener('change', onFieldInput);
});
list.appendChild(block);
applyQbBattleFilter();
}
function renderQbBattleList(arr, clearMsg) {
var list = el('qb-battle-admin-list');
if (!list) return;
list.innerHTML = '';
fillQbBattleFilterOptions();
var rows = arr && arr.length ? arr : [null];
rows.forEach(function (q) {
addQbBattleRow(q);
});
if (clearMsg !== false) setMsg('qb-battle-settings-msg', '', '');
}
function loadQbBattlePanel(keepSavedMsg) {
gameQuizFetch('GET').then(function (data) {
renderQbBattleList(data.battleQuizMcq || [], !keepSavedMsg);
refreshQbBattlePreview();
}).catch(function (e) {
setMsg('qb-battle-settings-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function classifyQbBattleRow(block) {
var d = readQbBattleBlockData(block);
if (!d) return { kind: 'empty', d: null };
var hasQ = !!d.text;
var filled = 0;
for (var i = 0; i < d.choices.length; i++) {
if (d.choices[i]) filled++;
}
if (!hasQ && filled === 0) return { kind: 'empty', d: d };
if (hasQ && filled === 3) return { kind: 'ok', d: d };
return { kind: 'partial', d: d };
}
function saveQbBattlePanel() {
var list = el('qb-battle-admin-list');
if (!list) return;
var battleQuizMcq = [];
var partialLines = [];
var blocks = list.querySelectorAll('.qb-battle-block');
for (var bi = 0; bi < blocks.length; bi++) {
var block = blocks[bi];
var st = classifyQbBattleRow(block);
if (st.kind === 'empty') continue;
if (st.kind === 'partial') {
partialLines.push('แถวที่ ' + (bi + 1) + ': ต้องกรอกคำถามและตัวเลือก A, B, C ให้ครบทุกช่อง');
continue;
}
battleQuizMcq.push({
categoryId: st.d.categoryId,
text: st.d.text,
choices: st.d.choices,
correctIndex: st.d.correctIndex,
});
}
if (partialLines.length) {
setMsg(
'qb-battle-settings-msg',
'ยังบันทึกไม่ได้ — ' + partialLines.join(' · ') + ' · (Empty question/options are skipped by the server; fill all fields.)',
'error'
);
return;
}
gameQuizFetch('PUT', { battleQuizMcq: battleQuizMcq }).then(function (res) {
var n = battleQuizMcq.length;
var savedN = res && typeof res.battleQuizMcqSaved === 'number' ? res.battleQuizMcqSaved : n;
if (n > 0 && savedN !== n) {
setMsg(
'qb-battle-settings-msg',
'เซิร์ฟเวอร์รับเพียง ' + savedN + ' จาก ' + n + ' ข้อ (ข้อที่ไม่ผ่านกฎถูกตัด) · Server kept ' + savedN + '/' + n + ' — ตรวจหมวด/คำถาม/A B C ให้ครบ · Restart Node (Game/server.js) ถ้ายังผิดปกติ',
'error'
);
} else {
setMsg(
'qb-battle-settings-msg',
n ? ('บันทึกแล้ว ' + n + ' ข้อ (ดิสก์ + Node) · Saved ' + n + ' question(s)') : 'บันทึกแล้ว (0 ข้อ) · Saved empty set',
'ok'
);
}
return loadQbBattlePanel(true);
}).catch(function (e) {
setMsg('qb-battle-settings-msg', e.message || 'บันทึกไม่ได้', 'error');
});
}
function laneUrlsToText(arr) {
if (!arr || !arr.length) return '';
return arr.map(function (u) { return String(u || '').trim(); }).filter(Boolean).join('\n');
}
function parseLaneUrlsTextarea() {
var raw = el('game-timing-lane-urls') && el('game-timing-lane-urls').value ? String(el('game-timing-lane-urls').value) : '';
var lines = raw.split(/[\n\r]+/);
var out = [];
for (var i = 0; i < lines.length && out.length < 24; i++) {
var t = lines[i].trim();
if (t) out.push(t);
}
return out;
}
function moveLaneUrl(index, dir) {
var urls = parseLaneUrlsTextarea();
var j = index + dir;
if (j < 0 || j >= urls.length) return;
var t = urls[index];
urls[index] = urls[j];
urls[j] = t;
var ta = el('game-timing-lane-urls');
if (ta) ta.value = urls.join('\n');
renderGameTimingLaneVisuals();
}
function renderGameTimingLaneVisuals() {
var wrap = el('game-timing-lane-visual-list');
if (!wrap) return;
wrap.innerHTML = '';
var urls = parseLaneUrlsTextarea();
if (!urls.length) {
var ph = document.createElement('p');
ph.className = 'muted';
ph.style.margin = '0';
ph.textContent = 'ยังไม่มีรูป — กด «Lane» ที่การ์ดในคลัง · No lane images yet';
wrap.appendChild(ph);
return;
}
urls.forEach(function (u, i) {
var card = document.createElement('div');
card.className = 'game-timing-visual-card';
card.setAttribute('role', 'listitem');
var img = document.createElement('img');
img.className = 'game-timing-visual-card-img';
img.src = u;
img.alt = '';
img.referrerPolicy = 'no-referrer';
img.onerror = function () { img.classList.add('is-broken'); };
var actions = document.createElement('div');
actions.className = 'game-timing-visual-card-actions';
function mkBtn(cls, text, fn) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm ' + cls;
b.textContent = text;
b.addEventListener('click', fn);
return b;
}
actions.appendChild(mkBtn('btn-ghost', 'ดู', function () {
window.open(u, '_blank', 'noopener,noreferrer');
}));
actions.appendChild(mkBtn('btn-ghost', '←', function () {
moveLaneUrl(i, -1);
}));
actions.appendChild(mkBtn('btn-ghost', '→', function () {
moveLaneUrl(i, 1);
}));
actions.appendChild(mkBtn('btn-danger', 'ลบ', function () {
var next = urls.filter(function (_, j) { return j !== i; });
var ta = el('game-timing-lane-urls');
if (ta) ta.value = next.join('\n');
renderGameTimingLaneVisuals();
setGauntletUploadMsg('เอาออกจาก Lane แล้ว (ไฟล์ในคลังยังอยู่) · Removed from lane', 'ok');
}));
card.appendChild(img);
card.appendChild(actions);
wrap.appendChild(card);
});
}
function renderGameTimingLaserVisuals() {
var slots = [
{ inpId: 'game-timing-laser-top-url', prevId: 'game-timing-laser-top-preview' },
{ inpId: 'game-timing-laser-bottom-url', prevId: 'game-timing-laser-bottom-preview' },
{ inpId: 'game-timing-laser-line-url', prevId: 'game-timing-laser-line-preview' },
];
slots.forEach(function (s) {
var inp = el(s.inpId);
var prev = el(s.prevId);
if (!prev) return;
prev.innerHTML = '';
var url = inp && inp.value ? String(inp.value).trim() : '';
if (!url) {
var ph = document.createElement('div');
ph.className = 'game-timing-laser-placeholder';
ph.textContent = 'ยังไม่ตั้ง · Not set';
prev.appendChild(ph);
return;
}
var img = document.createElement('img');
img.className = 'game-timing-laser-preview-img';
img.src = url;
img.alt = '';
img.referrerPolicy = 'no-referrer';
img.onerror = function () { img.classList.add('is-broken'); };
prev.appendChild(img);
});
}
function bindGameTimingAssignedVisuals() {
function wire(which, viewId, clearId) {
var inpId = 'game-timing-laser-' + which + '-url';
var vBtn = el(viewId);
var cBtn = el(clearId);
if (vBtn) vBtn.addEventListener('click', function () {
var inp = el(inpId);
var u = inp && inp.value ? String(inp.value).trim() : '';
if (!u) return;
window.open(u, '_blank', 'noopener,noreferrer');
});
if (cBtn) cBtn.addEventListener('click', function () {
var inp = el(inpId);
if (inp) inp.value = '';
renderGameTimingLaserVisuals();
setGauntletUploadMsg('ล้างช่อง Laser แล้ว · Laser slot cleared', 'ok');
});
}
wire('top', 'btn-game-timing-laser-top-view', 'btn-game-timing-laser-top-clear');
wire('bottom', 'btn-game-timing-laser-bottom-view', 'btn-game-timing-laser-bottom-clear');
wire('line', 'btn-game-timing-laser-line-view', 'btn-game-timing-laser-line-clear');
}
function gauntletAssetsJsonFetch(url, opts) {
opts = opts || {};
opts.credentials = 'include';
return fetch(url, opts).then(function (r) {
return r.text().then(function (text) {
var j = {};
try {
j = text ? JSON.parse(text) : {};
} catch (e) {
if (!r.ok) throw new Error(text || r.statusText);
throw e;
}
if (!r.ok) {
var plain = j.error || r.statusText || 'Request failed';
if (r.status === 404) {
plain += ' — รีสตาร์ท Node ที่รัน Game/server.js หลัง deploy · Restart Game Node';
}
throw new Error(plain);
}
return j;
});
});
}
function setGauntletUploadMsg(text, cls) {
setMsg('gauntlet-asset-upload-msg', text, cls);
}
function readFileAsDataURL(file) {
return new Promise(function (resolve, reject) {
var fr = new FileReader();
fr.onload = function () { resolve(fr.result); };
fr.onerror = function () { reject(new Error('อ่านไฟล์ไม่ได้ · Cannot read file')); };
fr.readAsDataURL(file);
});
}
function appendLaneUrlUnique(url) {
var ta = el('game-timing-lane-urls');
if (!ta || !url) return;
var lines = parseLaneUrlsTextarea();
if (lines.indexOf(url) >= 0) {
setGauntletUploadMsg('มี URL นี้ใน Lane แล้ว · Already in lane list', 'ok');
return;
}
lines.push(url);
ta.value = lines.join('\n');
renderGameTimingLaneVisuals();
setGauntletUploadMsg('เพิ่มใน Lane แล้ว · Added to lane URLs', 'ok');
}
function setLaserUrlField(which, url) {
var ids = { top: 'game-timing-laser-top-url', bottom: 'game-timing-laser-bottom-url', line: 'game-timing-laser-line-url' };
var inp = el(ids[which]);
if (!inp) return;
inp.value = url;
renderGameTimingLaserVisuals();
setGauntletUploadMsg('ตั้งค่า Laser แล้ว · Laser field updated', 'ok');
}
function uploadGauntletDataUrls(fileArr) {
var files = [].slice.call(fileArr || []);
if (!files.length) return Promise.resolve();
var rf = gauntletReplaceTarget;
gauntletReplaceTarget = null;
if (rf) files = files.slice(0, 1);
setGauntletUploadMsg('กำลังอัปโหลด… · Uploading…', '');
var idx = 0;
function step() {
if (idx >= files.length) {
setGauntletUploadMsg(rf ? 'แทนที่รูปแล้ว · Image replaced' : ('อัปโหลดแล้ว ' + files.length + ' ไฟล์ · Uploaded'), 'ok');
return refreshGauntletAssetLibrary();
}
var file = files[idx++];
return readFileAsDataURL(file).then(function (dataUrl) {
var body = { imageDataUrl: dataUrl };
if (rf) body.replaceFilename = rf;
else if (file.name) body.label = String(file.name).replace(/\.[^.]+$/, '').slice(0, 120);
return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}).then(step).catch(function (e) {
setGauntletUploadMsg(e.message || 'อัปโหลดไม่สำเร็จ · Upload failed', 'error');
return refreshGauntletAssetLibrary();
});
}
return step();
}
function refreshGauntletAssetLibrary() {
var listEl = el('gauntlet-asset-list');
if (!listEl) return Promise.resolve();
return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API, { method: 'GET' })
.then(function (data) {
listEl.innerHTML = '';
var items = data.items || [];
if (!items.length) {
var empty = document.createElement('p');
empty.className = 'muted';
empty.style.margin = '0';
empty.textContent = 'ยังไม่มีรูปในคลัง — อัปโหลดด้านบน · No assets yet — upload above';
listEl.appendChild(empty);
return;
}
items.forEach(function (it, ix) {
var card = document.createElement('article');
card.className = 'gauntlet-asset-card';
var img = document.createElement('img');
img.className = 'gauntlet-asset-card-preview';
img.src = it.url + '?t=' + (it.mtime || 0);
img.alt = it.label || '';
var labelWrap = document.createElement('div');
labelWrap.className = 'gauntlet-asset-card-label-wrap';
var labelInp = document.createElement('input');
labelInp.type = 'text';
labelInp.maxLength = 120;
labelInp.value = it.label || '';
var lid = 'gauntlet-asset-label-' + ix;
labelInp.id = lid;
var lab = document.createElement('label');
lab.htmlFor = lid;
lab.textContent = 'ชื่อเรียก · Label';
labelWrap.appendChild(lab);
labelWrap.appendChild(labelInp);
var urlP = document.createElement('p');
urlP.className = 'gauntlet-asset-card-url';
urlP.textContent = it.url;
var actions = document.createElement('div');
actions.className = 'gauntlet-asset-card-actions';
function mkBtn(cls, text, handler) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn ' + cls;
b.textContent = text;
b.addEventListener('click', handler);
return b;
}
actions.appendChild(mkBtn('btn-ghost', 'ดูรูป', function () {
window.open(it.url, '_blank', 'noopener,noreferrer');
}));
actions.appendChild(mkBtn('btn-ghost', 'บันทึกชื่อ', function () {
gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: it.filename, label: labelInp.value }),
})
.then(function () {
setGauntletUploadMsg('บันทึกชื่อแล้ว · Label saved', 'ok');
return refreshGauntletAssetLibrary();
})
.catch(function (e) {
setGauntletUploadMsg(e.message, 'error');
});
}));
actions.appendChild(mkBtn('btn-ghost', 'แทนที่รูป', function () {
gauntletReplaceTarget = it.filename;
var finp = el('gauntlet-asset-file-input');
if (finp) finp.click();
}));
actions.appendChild(mkBtn('btn-ghost', 'Lane', function () {
appendLaneUrlUnique(it.url);
}));
actions.appendChild(mkBtn('btn-ghost', 'Laser บน', function () {
setLaserUrlField('top', it.url);
}));
actions.appendChild(mkBtn('btn-ghost', 'Laser ล่าง', function () {
setLaserUrlField('bottom', it.url);
}));
actions.appendChild(mkBtn('btn-ghost', 'Laser เส้น', function () {
setLaserUrlField('line', it.url);
}));
actions.appendChild(mkBtn('btn-danger', 'ลบ', function () {
if (!confirm('ลบไฟล์ ' + it.filename + ' จากเซิร์ฟเวอร์? · Delete from server?')) return;
gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '?file=' + encodeURIComponent(it.filename), { method: 'DELETE' })
.then(function () {
setGauntletUploadMsg('ลบแล้ว · Deleted', 'ok');
return refreshGauntletAssetLibrary();
})
.catch(function (e) {
setGauntletUploadMsg(e.message, 'error');
});
}));
card.appendChild(img);
card.appendChild(labelWrap);
card.appendChild(urlP);
card.appendChild(actions);
listEl.appendChild(card);
});
})
.catch(function (e) {
listEl.innerHTML = '';
var p = document.createElement('p');
p.className = 'msg error';
p.textContent = e.message || 'โหลดคลังไม่ได้';
listEl.appendChild(p);
});
}
function bindGauntletAssetLibrary() {
var drop = el('gauntlet-asset-drop');
var finp = el('gauntlet-asset-file-input');
if (!drop || !finp) return;
drop.addEventListener('click', function () {
finp.click();
});
drop.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
finp.click();
}
});
['dragenter', 'dragover'].forEach(function (ev) {
drop.addEventListener(ev, function (e) {
e.preventDefault();
e.stopPropagation();
drop.classList.add('is-dragover');
});
});
['dragleave', 'drop'].forEach(function (ev) {
drop.addEventListener(ev, function (e) {
e.preventDefault();
e.stopPropagation();
if (ev !== 'drop') drop.classList.remove('is-dragover');
});
});
drop.addEventListener('drop', function (e) {
drop.classList.remove('is-dragover');
var files = e.dataTransfer && e.dataTransfer.files;
if (!files || !files.length) return;
var arr = [];
for (var i = 0; i < files.length; i++) arr.push(files[i]);
uploadGauntletDataUrls(arr);
});
finp.addEventListener('change', function () {
if (!finp.files || !finp.files.length) return;
var arr = [];
for (var i = 0; i < finp.files.length; i++) arr.push(finp.files[i]);
finp.value = '';
uploadGauntletDataUrls(arr);
});
}
/** ว่าง / อัตโนมัติ / auto → null · ตัวเลข 0.853.2 tile */
function parseStackBlockWidthForSave(raw) {
var t = String(raw == null ? '' : raw).trim();
if (!t) return { ok: true, value: null };
var low = t.toLowerCase();
if (t === 'อัตโนมัติ' || low === 'auto' || low === 'automatic') return { ok: true, value: null };
var w = parseFloat(t.replace(',', '.'));
if (!Number.isFinite(w)) {
return { ok: false, error: 'กว้างบล็อก: ใส่ตัวเลข 0.85–3.2 หรือปล่อยว่างเพื่ออัตโนมัติ' };
}
if (w < 0.85 || w > 3.2) {
return { ok: false, error: 'กว้างบล็อกต้องอยู่ระหว่าง 0.85–3.2 tile หรือปล่อยว่าง' };
}
return { ok: true, value: Math.round(Math.max(0.85, Math.min(3.2, w)) * 100) / 100 };
}
/** ช่อง Hz ใช้ type=text — ปล่อยว่าง = 0.55 */
function parseStackSwingHzForSave(raw) {
var t = String(raw == null ? '' : raw).trim().replace(',', '.');
if (!t) return { ok: true, value: 0.55 };
var h = parseFloat(t);
if (!Number.isFinite(h)) {
return { ok: false, error: 'สวิง Hz: ใส่ตัวเลข (เช่น 0.58) หรือปล่อยว่างให้เป็น 0.55' };
}
h = Math.max(0.08, Math.min(2.8, h));
return { ok: true, value: Math.round(h * 100) / 100 };
}
/** ซิงก์ช่อง Stack จากข้อมูลเซิร์ฟเวอร์ (ช่องอยู่ใน DOM แม้แท็บอื่นเปิดอยู่ — ต้องไม่ปล่อยให้ค้างค่า default จาก HTML) */
function applyGameTimingDataToStackInputs(data) {
if (!data || typeof data !== 'object') return;
if (el('stack-swing-hz')) {
var hz = data.stackSwingHz != null && data.stackSwingHz !== '' ? Number(data.stackSwingHz) : NaN;
if (!Number.isFinite(hz)) hz = 0.55;
hz = Math.max(0.08, Math.min(2.8, hz));
el('stack-swing-hz').value = String(Math.round(hz * 100) / 100);
}
if (el('stack-block-width-tiles')) {
var rawW = data.stackBlockWidthTiles;
var bw = rawW != null && rawW !== '' ? Number(rawW) : NaN;
el('stack-block-width-tiles').value = Number.isFinite(bw) && bw >= 0.85 ? String(Math.round(bw * 100) / 100) : '';
}
}
function loadStackGamePanel() {
gameTimingFetch('GET')
.then(function (data) {
applyGameTimingDataToStackInputs(data);
setMsg('stack-game-msg', '', '');
})
.catch(function (e) {
setMsg('stack-game-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function saveStackGamePanel() {
if (stackGameSaveInFlight) return;
var hzParsed = parseStackSwingHzForSave(el('stack-swing-hz') ? el('stack-swing-hz').value : '');
if (!hzParsed.ok) {
setMsg('stack-game-msg', hzParsed.error, 'error');
return;
}
var hzIn = hzParsed.value;
var bwParsed = parseStackBlockWidthForSave(el('stack-block-width-tiles') ? el('stack-block-width-tiles').value : '');
if (!bwParsed.ok) {
setMsg('stack-game-msg', bwParsed.error, 'error');
return;
}
var blockW = bwParsed.value;
var btn = el('btn-stack-game-save');
stackGameSaveInFlight = true;
if (btn) {
btn.disabled = true;
if (!btn.dataset.prevLabel) btn.dataset.prevLabel = btn.textContent;
btn.textContent = 'กำลังบันทึก…';
}
gameTimingFetch('GET')
.then(function (data) {
data.stackSwingHz = hzIn;
data.stackBlockWidthTiles = blockW;
return gameTimingFetch('PUT', data);
})
.then(function () {
return gameTimingFetch('GET');
})
.then(function (fresh) {
applyGameTimingDataToStackInputs(fresh);
var hz = fresh && fresh.stackSwingHz != null ? Number(fresh.stackSwingHz) : hzIn;
if (!Number.isFinite(hz)) hz = hzIn;
hz = Math.round(hz * 100) / 100;
var wLabel = fresh && fresh.stackBlockWidthTiles != null && Number.isFinite(Number(fresh.stackBlockWidthTiles))
? String(Math.round(Number(fresh.stackBlockWidthTiles) * 100) / 100) + ' tile'
: 'อัตโนมัติ (จากแผนที่)';
setMsg('stack-game-msg', 'บันทึกสำเร็จ — สวิง ' + hz + ' Hz · กว้าง ' + wLabel + ' · โหลดยืนยันจากเซิร์ฟเวอร์แล้ว · ผู้เล่นต้องรีเฟรชหน้าเล่นหรือเข้าห้องใหม่', 'ok');
})
.catch(function (e) {
setMsg('stack-game-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็คว่า Node รัน Game/server.js และ URL /Game/api/game-timing ไม่ 404', 'error');
})
.then(function () {
stackGameSaveInFlight = false;
if (btn) {
btn.disabled = false;
btn.textContent = btn.dataset.prevLabel || 'บันทึก';
}
});
}
function loadGameTimingPanel() {
gameTimingFetch('GET')
.then(function (data) {
el('game-timing-tick-ms').value = String(data.gauntletTickMs != null ? data.gauntletTickMs : 220);
el('game-timing-jump-ticks').value = String(data.gauntletJumpTicks != null ? data.gauntletJumpTicks : 16);
el('game-timing-limit-sec').value = String(data.gauntletTimeLimitSec != null ? data.gauntletTimeLimitSec : 0);
if (el('game-timing-lane-urls')) el('game-timing-lane-urls').value = laneUrlsToText(data.gauntletLaneImageUrls);
if (el('game-timing-laser-top-url')) el('game-timing-laser-top-url').value = data.gauntletLaserTopUrl != null ? String(data.gauntletLaserTopUrl) : '';
if (el('game-timing-laser-bottom-url')) el('game-timing-laser-bottom-url').value = data.gauntletLaserBottomUrl != null ? String(data.gauntletLaserBottomUrl) : '';
if (el('game-timing-laser-line-url')) el('game-timing-laser-line-url').value = data.gauntletLaserLineUrl != null ? String(data.gauntletLaserLineUrl) : '';
if (el('game-timing-laser-fill')) el('game-timing-laser-fill').value = data.gauntletLaserFillColor != null ? String(data.gauntletLaserFillColor) : '';
if (el('game-timing-laser-stroke')) el('game-timing-laser-stroke').value = data.gauntletLaserStrokeColor != null ? String(data.gauntletLaserStrokeColor) : '';
var lw = data.gauntletLaserLineWidthPx != null ? Number(data.gauntletLaserLineWidthPx) : 2;
if (el('game-timing-laser-line-width')) el('game-timing-laser-line-width').value = String(Number.isFinite(lw) ? lw : 2);
renderGameTimingLaneVisuals();
renderGameTimingLaserVisuals();
applyGameTimingDataToStackInputs(data);
setMsg('game-timing-msg', '', '');
return refreshGauntletAssetLibrary();
})
.catch(function (e) {
setMsg('game-timing-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function loadJumpSurviveTimingPanel() {
gameTimingFetch('GET')
.then(function (data) {
var inp = el('jump-survive-height-mult');
if (inp) {
var jsm = Number(data.jumpSurviveJumpHeightMult);
inp.value = String(Number.isFinite(jsm) ? Math.round(Math.max(0.5, Math.min(4, jsm)) * 100) / 100 : 1.5);
}
setMsg('jump-survive-timing-msg', '', '');
})
.catch(function (e) {
setMsg('jump-survive-timing-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function saveJumpSurviveTimingPanel() {
var inp = el('jump-survive-height-mult');
var jumpSurvMult = inp ? parseFloat(String(inp.value)) : 1.5;
if (Number.isNaN(jumpSurvMult)) jumpSurvMult = 1.5;
jumpSurvMult = Math.round(Math.max(0.5, Math.min(4, jumpSurvMult)) * 100) / 100;
gameTimingFetch('GET')
.then(function (data) {
data.jumpSurviveJumpHeightMult = jumpSurvMult;
return gameTimingFetch('PUT', data);
})
.then(function () {
setMsg('jump-survive-timing-msg', 'บันทึกแล้ว — ผู้เล่นกระโดดให้รอดได้ค่าใหม่เมื่อรีเฟรชหน้าเล่นหรือ sync จากเซิร์ฟเวอร์', 'ok');
})
.catch(function (e) {
setMsg('jump-survive-timing-msg', e.message || 'บันทึกไม่ได้', 'error');
});
}
function saveGameTimingPanel() {
var tickMs = parseInt(el('game-timing-tick-ms').value, 10);
var jumpT = parseInt(el('game-timing-jump-ticks').value, 10);
var limS = parseInt(el('game-timing-limit-sec').value, 10);
if (Number.isNaN(tickMs)) tickMs = 220;
if (Number.isNaN(jumpT)) jumpT = 16;
if (Number.isNaN(limS) || limS < 0) limS = 0;
tickMs = Math.max(80, Math.min(800, tickMs));
jumpT = Math.max(4, Math.min(40, jumpT));
limS = limS <= 0 ? 0 : Math.max(10, Math.min(7200, limS));
var lwPx = el('game-timing-laser-line-width') ? parseInt(el('game-timing-laser-line-width').value, 10) : 2;
if (Number.isNaN(lwPx)) lwPx = 2;
lwPx = Math.max(0, Math.min(24, lwPx));
/** แท็บเวลาเกม = เฉพาะ Gauntlet — อย่าส่ง stackSwingHz/stackBlockWidthTiles จากฟอร์ม (หลังรีเฟรชช่อง Stack ค้าง 0.55 จาก HTML จะทับค่าที่บันทึกไว้) */
gameTimingFetch('GET')
.then(function (data) {
data.gauntletTickMs = tickMs;
data.gauntletJumpTicks = jumpT;
data.gauntletTimeLimitSec = limS;
data.gauntletLaneImageUrls = parseLaneUrlsTextarea();
data.gauntletLaserTopUrl = el('game-timing-laser-top-url') ? String(el('game-timing-laser-top-url').value).trim() : '';
data.gauntletLaserBottomUrl = el('game-timing-laser-bottom-url') ? String(el('game-timing-laser-bottom-url').value).trim() : '';
data.gauntletLaserLineUrl = el('game-timing-laser-line-url') ? String(el('game-timing-laser-line-url').value).trim() : '';
data.gauntletLaserFillColor = el('game-timing-laser-fill') ? String(el('game-timing-laser-fill').value).trim() : '';
data.gauntletLaserStrokeColor = el('game-timing-laser-stroke') ? String(el('game-timing-laser-stroke').value).trim() : '';
data.gauntletLaserLineWidthPx = lwPx;
return gameTimingFetch('PUT', data);
})
.then(function () {
setMsg('game-timing-msg', 'บันทึกแล้ว — พรมแดง tick·กระโดด / รูปอุปสรรค์ · กระโดดให้รอดตั้งที่แท็บกระโดดให้รอด · ค่า Stack แก้ที่แท็บ Stack เท่านั้น · จำกัดเวลาใช้กับรอบถัดไปที่โฮสต์เริ่มเกม', 'ok');
})
.catch(function (e) {
setMsg('game-timing-msg', e.message || 'บันทึกไม่ได้', 'error');
});
}
function showPanel(name) {
['panel-setup', 'panel-login', 'panel-app'].forEach(function (id) {
var p = el(id);
if (p) p.hidden = id !== name;
});
el('top-actions').hidden = name !== 'panel-app';
}
function setTab(name) {
document.querySelectorAll('.tab').forEach(function (t) {
var on = t.getAttribute('data-tab') === name;
t.classList.toggle('is-active', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('.tab-panel').forEach(function (p) {
p.hidden = !p.id || p.id !== 'tab-panel-' + name;
});
var embedPanels = [
{ tab: 'map-editor', panel: 'tab-panel-map-editor', fsBtn: 'btn-map-editor-fullscreen' },
{ tab: 'characters', panel: 'tab-panel-characters', fsBtn: 'btn-character-fullscreen' },
];
embedPanels.forEach(function (ep) {
if (name === ep.tab) return;
var pnl = el(ep.panel);
var fsBtn = el(ep.fsBtn);
if (pnl && pnl.classList.contains('is-fullscreen')) {
pnl.classList.remove('is-fullscreen');
if (fsBtn) {
fsBtn.textContent = 'เต็มจอ';
fsBtn.setAttribute('aria-pressed', 'false');
}
}
});
if (name === 'quiz') loadQuizSettingsPanel();
if (name === 'quiz-carry') loadQuizCarryPanel();
if (name === 'quiz-battle') loadQbBattlePanel();
if (name === 'jump-survive') loadJumpSurviveTimingPanel();
if (name === 'game-timing') loadGameTimingPanel();
if (name === 'stack-game') loadStackGamePanel();
}
function boot() {
return api('session.php').then(function (s) {
if (s.needsSetup) {
showPanel('panel-setup');
return null;
}
if (!s.loggedIn) {
showPanel('panel-login');
return null;
}
showPanel('panel-app');
el('admin-user-label').textContent = (s.admin && s.admin.username) || '';
return s.admin;
});
}
el('form-setup').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
var p1 = fd.get('password');
var p2 = fd.get('password2');
if (p1 !== p2) {
setMsg('setup-msg', 'รหัสผ่านไม่ตรงกัน', 'error');
return;
}
api('setup.php', { method: 'POST', body: { password: p1 } })
.then(function () {
setMsg('setup-msg', 'สร้างสำเร็จ — กรุณาล็อกอิน', 'ok');
showPanel('panel-login');
})
.catch(function (err) {
setMsg('setup-msg', err.message || 'ผิดพลาด', 'error');
});
});
(function bindLoginPasswordToggle() {
var inp = el('login-password');
var btn = el('login-password-toggle');
if (!inp || !btn) return;
btn.addEventListener('click', function () {
var show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
btn.setAttribute('aria-pressed', show ? 'true' : 'false');
btn.setAttribute('aria-label', show ? 'ซ่อนรหัสผ่าน' : 'แสดงรหัสผ่าน');
btn.textContent = show ? 'ซ่อน' : 'แสดง';
});
})();
el('form-login').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
api('login.php', {
method: 'POST',
body: {
username: fd.get('username'),
password: fd.get('password'),
},
})
.then(function () {
setMsg('login-msg', '', '');
return boot();
})
.then(function (admin) {
if (admin) loadAll(admin);
})
.catch(function (err) {
setMsg('login-msg', err.message || 'เข้าไม่ได้', 'error');
});
});
el('btn-logout').addEventListener('click', function () {
api('logout.php', { method: 'POST' })
.then(function () {
location.reload();
})
.catch(function () {
location.reload();
});
});
document.querySelectorAll('.tab').forEach(function (tab) {
tab.addEventListener('click', function () {
var name = tab.getAttribute('data-tab');
if (name === 'change-password' && tab.classList.contains('is-active')) {
setTab('oauth');
return;
}
setTab(name);
});
});
function bindEmbedPanelFullscreen(btnId, panelId) {
var btn = el(btnId);
var panel = el(panelId);
if (!btn || !panel) return;
function setFs(on) {
panel.classList.toggle('is-fullscreen', on);
btn.textContent = on ? 'ออกจากเต็มจอ' : 'เต็มจอ';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
btn.addEventListener('click', function () {
setFs(!panel.classList.contains('is-fullscreen'));
});
document.addEventListener('keydown', function (e) {
if (e.code !== 'Escape' || !panel.classList.contains('is-fullscreen')) return;
setFs(false);
});
}
bindEmbedPanelFullscreen('btn-map-editor-fullscreen', 'tab-panel-map-editor');
bindEmbedPanelFullscreen('btn-character-fullscreen', 'tab-panel-characters');
var btnGameTimingSave = el('btn-game-timing-save');
if (btnGameTimingSave) {
btnGameTimingSave.addEventListener('click', function () {
saveGameTimingPanel();
});
}
var btnJumpSurviveSave = el('btn-jump-survive-save');
if (btnJumpSurviveSave) {
btnJumpSurviveSave.addEventListener('click', saveJumpSurviveTimingPanel);
}
var btnStackGameSave = el('btn-stack-game-save');
if (btnStackGameSave) {
btnStackGameSave.addEventListener('click', saveStackGamePanel);
}
bindGauntletAssetLibrary();
bindGameTimingAssignedVisuals();
var btnQuizAdd = el('btn-quiz-admin-add');
var btnQuizSave = el('btn-quiz-admin-save');
if (btnQuizAdd) {
btnQuizAdd.addEventListener('click', function () {
addQuizAdminRow('', true);
});
}
if (btnQuizSave) btnQuizSave.addEventListener('click', saveQuizSettingsPanel);
var btnQuizCarryAdd = el('btn-quiz-carry-add');
var btnQuizCarrySave = el('btn-quiz-carry-save');
if (btnQuizCarryAdd) {
btnQuizCarryAdd.addEventListener('click', function () {
addQuizCarryAdminRow(null);
});
}
if (btnQuizCarrySave) btnQuizCarrySave.addEventListener('click', saveQuizCarryPanel);
var btnQbBattleAdd = el('btn-qb-battle-add');
var btnQbBattleSave = el('btn-qb-battle-save');
if (btnQbBattleAdd) {
btnQbBattleAdd.addEventListener('click', function () {
addQbBattleRow(null);
refreshQbBattlePreview();
});
}
if (btnQbBattleSave) btnQbBattleSave.addEventListener('click', saveQbBattlePanel);
var qbFilterCat = el('qb-battle-filter-cat');
if (qbFilterCat) qbFilterCat.addEventListener('change', applyQbBattleFilter);
document.querySelectorAll('.qb-preview-mode-btn').forEach(function (b) {
b.addEventListener('click', function () {
document.querySelectorAll('.qb-preview-mode-btn').forEach(function (x) {
x.classList.toggle('is-active', x === b);
});
refreshQbBattlePreview();
});
});
function loadOAuth() {
return api('oauth.php').then(function (r) {
var o = r.oauth || {};
var f = el('form-oauth');
['facebookAppId', 'facebookAppSecret', 'facebookRedirectUri', 'googleClientId', 'googleClientSecret', 'googleRedirectUri'].forEach(function (k) {
var inp = f.querySelector('[name="' + k + '"]');
if (inp) inp.value = o[k] || '';
});
});
}
el('form-oauth').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
var body = {};
fd.forEach(function (v, k) {
body[k] = v;
});
api('oauth.php', { method: 'PUT', body: body })
.then(function () {
setMsg('oauth-msg', 'บันทึกแล้ว', 'ok');
})
.catch(function (err) {
setMsg('oauth-msg', err.message || 'บันทึกไม่สำเร็จ', 'error');
});
});
function renderAccounts(list) {
var tb = el('table-accounts').querySelector('tbody');
tb.innerHTML = '';
var sumCoins = 0;
(list || []).forEach(function (a) {
var c = Math.max(0, parseInt(a.coins, 10) || 0);
sumCoins += c;
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + escapeHtml(a.displayName || '—') + '</td>' +
'<td>' + escapeHtml(a.email || '—') + '</td>' +
'<td>' + escapeHtml(a.loginType || '') + '</td>' +
'<td><small>' + escapeHtml(a.providerUserId || '—') + '</small></td>' +
'<td class="td-coins">' +
'<input type="number" class="input-coins" min="0" step="1" value="' + c + '" data-account-id="' + escapeAttr(a.id) + '" aria-label="COINS">' +
'<button type="button" class="btn btn-primary btn-coins-save" data-id="' + escapeAttr(a.id) + '">บันทึก COINS</button>' +
'</td>' +
'<td>' + (a.blocked ? '<span class="msg error">บล็อก</span>' : 'ปกติ') + '</td>' +
'<td><button type="button" class="btn btn-danger btn-sm-del" data-id="' + escapeAttr(a.id) + '">ลบ</button> ' +
'<button type="button" class="btn btn-ghost btn-sm-toggle" data-id="' + escapeAttr(a.id) + '" data-blocked="' + (a.blocked ? '1' : '0') + '">' + (a.blocked ? 'ปลดบล็อก' : 'บล็อก') + '</button></td>';
tb.appendChild(tr);
});
var sumEl = el('accounts-coins-summary');
if (sumEl) {
sumEl.textContent = (list && list.length) ? ('ผู้ใช้ ' + list.length + ' บัญชี · รวม COINS ' + sumCoins) : 'ยังไม่มีบัญชี';
}
tb.querySelectorAll('.btn-sm-del').forEach(function (b) {
b.addEventListener('click', function () {
if (!confirm('ลบบัญชีนี้?')) return;
api('accounts.php?id=' + encodeURIComponent(b.getAttribute('data-id')), { method: 'DELETE' })
.then(loadAccounts)
.catch(function (err) {
setMsg('accounts-msg', err.message, 'error');
});
});
});
tb.querySelectorAll('.btn-sm-toggle').forEach(function (b) {
b.addEventListener('click', function () {
var id = b.getAttribute('data-id');
var blocked = b.getAttribute('data-blocked') === '1';
api('accounts.php', {
method: 'PATCH',
body: { id: id, blocked: !blocked },
})
.then(loadAccounts)
.catch(function (err) {
setMsg('accounts-msg', err.message, 'error');
});
});
});
tb.querySelectorAll('.btn-coins-save').forEach(function (b) {
b.addEventListener('click', function () {
var id = b.getAttribute('data-id');
var row = b.closest('tr');
var inp = row ? row.querySelector('input.input-coins') : null;
var coins = Math.max(0, parseInt(inp && inp.value, 10) || 0);
api('accounts.php', { method: 'PATCH', body: { id: id, coins: coins } })
.then(function () {
setMsg('accounts-msg', 'อัปเดต COINS แล้ว', 'ok');
return loadAccounts();
})
.catch(function (err) {
setMsg('accounts-msg', err.message, 'error');
});
});
});
}
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function escapeAttr(s) {
return String(s || '').replace(/"/g, '&quot;');
}
function loadAccounts() {
return api('accounts.php').then(function (r) {
renderAccounts(r.accounts);
setMsg('accounts-msg', '', '');
});
}
el('form-account-add').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
var body = {
email: fd.get('email'),
displayName: fd.get('displayName'),
loginType: fd.get('loginType'),
providerUserId: fd.get('providerUserId'),
notes: fd.get('notes'),
blocked: fd.get('blocked') === 'on',
coins: Math.max(0, parseInt(fd.get('coins'), 10) || 0),
};
api('accounts.php', { method: 'POST', body: body })
.then(function () {
e.target.reset();
return loadAccounts();
})
.catch(function (err) {
setMsg('accounts-msg', err.message, 'error');
});
});
function renderAdmins(list, myId) {
var tb = el('table-admins').querySelector('tbody');
tb.innerHTML = '';
(list || []).forEach(function (a) {
var tr = document.createElement('tr');
var delBtn = a.id === myId
? '—'
: '<button type="button" class="btn btn-danger btn-adm-del" data-id="' + escapeAttr(a.id) + '">ลบ</button>';
tr.innerHTML =
'<td>' + escapeHtml(a.username) + '</td>' +
'<td>' + escapeHtml(a.role || 'admin') + '</td>' +
'<td><small>' + escapeHtml((a.createdAt || '').slice(0, 10)) + '</small></td>' +
'<td>' + delBtn + '</td>';
tb.appendChild(tr);
});
tb.querySelectorAll('.btn-adm-del').forEach(function (b) {
b.addEventListener('click', function () {
if (!confirm('ลบแอดมินนี้?')) return;
api('admins.php?id=' + encodeURIComponent(b.getAttribute('data-id')), { method: 'DELETE' })
.then(loadAdmins)
.catch(function (err) {
setMsg('admins-msg', err.message, 'error');
});
});
});
}
function fillAdminResetSelect(admins, myId) {
var sel = el('select-admin-reset-target');
if (!sel) return;
sel.innerHTML = '<option value="">— เลือก —</option>';
(admins || []).forEach(function (a) {
if (!a.id || a.id === myId) return;
var opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.username + ' (' + (a.role || 'admin') + ')';
sel.appendChild(opt);
});
}
function loadAdmins() {
return api('admins.php').then(function (r) {
return api('session.php').then(function (s) {
var myId = s.admin && s.admin.id;
renderAdmins(r.admins, myId);
fillAdminResetSelect(r.admins, myId);
setMsg('admins-msg', '', '');
});
});
}
el('form-change-password').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
var cur = fd.get('currentPassword');
var n1 = fd.get('newPassword');
var n2 = fd.get('newPassword2');
if (n1 !== n2) {
setMsg('password-self-msg', 'รหัสใหม่ไม่ตรงกัน', 'error');
return;
}
api('password.php', {
method: 'POST',
body: { currentPassword: cur, newPassword: n1 },
})
.then(function () {
e.target.reset();
setMsg('password-self-msg', 'เปลี่ยนรหัสผ่านแล้ว', 'ok');
})
.catch(function (err) {
setMsg('password-self-msg', err.message, 'error');
});
});
el('form-admin-reset-other').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
var tid = fd.get('targetId');
var n1 = fd.get('newPassword');
var n2 = fd.get('newPassword2');
if (!tid) {
setMsg('admins-msg', 'เลือกแอดมิน', 'error');
return;
}
if (n1 !== n2) {
setMsg('admins-msg', 'รหัสใหม่ไม่ตรงกัน', 'error');
return;
}
api('admins.php', {
method: 'PATCH',
body: { id: tid, newPassword: n1 },
})
.then(function () {
e.target.reset();
setMsg('admins-msg', 'ตั้งรหัสใหม่ให้แอดมินที่เลือกแล้ว', 'ok');
})
.catch(function (err) {
setMsg('admins-msg', err.message, 'error');
});
});
el('form-admin-add').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
api('admins.php', {
method: 'POST',
body: {
username: fd.get('username'),
password: fd.get('password'),
role: fd.get('role'),
},
})
.then(function () {
e.target.reset();
return loadAdmins();
})
.catch(function (err) {
setMsg('admins-msg', err.message, 'error');
});
});
function getDesiredAdminTab(admin) {
var want = null;
try {
want = new URL(window.location.href).searchParams.get('tab');
} catch (e) { /* ignore */ }
var allowed = { 'change-password': true, oauth: true, 'map-editor': true, quiz: true, 'quiz-carry': true, 'quiz-battle': true, 'jump-survive': true, 'game-timing': true, 'stack-game': true, characters: true, accounts: true, admins: true };
if (!want || !allowed[want]) return 'oauth';
if (want === 'admins' && (!admin || admin.role !== 'super')) return 'oauth';
return want;
}
function loadAll(admin) {
loadOAuth().catch(function (e) {
setMsg('oauth-msg', e.message, 'error');
});
loadAccounts().catch(function (e) {
setMsg('accounts-msg', e.message, 'error');
});
var isSuper = admin && admin.role === 'super';
el('tab-admins').hidden = !isSuper;
el('form-admin-add').hidden = !isSuper;
if (isSuper) {
loadAdmins().catch(function (e) {
setMsg('admins-msg', e.message, 'error');
});
}
setTab(getDesiredAdminTab(admin));
/** อย่า prefetch ซิงก์ Stack ที่นี่ — GET ที่เริ่มก่อน PUT บันทึกอาจกลับช้ากว่าและทับช่องกว้าง/HZ ด้วยข้อมูลเก่า · โหลดจริงที่แท็บ Stack / เวลาเกมแทน */
}
boot().then(function (admin) {
if (admin) loadAll(admin);
});
})();