diff --git a/README.md b/README.md
index c09290d..63f1b03 100644
--- a/README.md
+++ b/README.md
@@ -26,12 +26,14 @@ sudo /root/repos/justice/scripts/deploy-nginx.sh
## เว็บ (`/var/www/html`)
-หลังแก้ใน repo แล้วไป production:
+หลังแก้ใน repo แล้วไป production (**ทุกครั้ง** ที่ต้องการให้ `/var/www/html` ตรงกับ `www/html/` ใน repo — path คนละตัว ไม่ sync เอง):
```bash
sudo /root/repos/justice/scripts/deploy-www.sh
```
+กฎ Cursor: `.cursor/rules/sync-var-www-html.mdc` (และที่ workspace `/root`: `~/.cursor/rules/justice-sync-var-www-html.mdc`) ให้ช่วยเตือน/รันหลังแก้ `www/html`.
+
ดึงจากเซิร์ฟเวอร์เข้า repo + commit + push:
```bash
diff --git a/nginx/srv1361159.hstgr.cloud b/nginx/srv1361159.hstgr.cloud
index e7a76b2..50a7e7d 100644
--- a/nginx/srv1361159.hstgr.cloud
+++ b/nginx/srv1361159.hstgr.cloud
@@ -6,11 +6,22 @@ server {
server_name srv1361159.hstgr.cloud;
root /var/www/html;
index index.php index.html index.htm;
+ # กันค่าเริ่มต้น 1M ตัด POST (รูป base64 ผ่าน Admin API) — คู่กับบล็อก /Admin/api/ และ /Game/api/ ด้านล่าง
+ client_max_body_size 20M;
location / {
try_files $uri $uri/ =404;
}
+ # Admin API — JSON body ใหญ่ (รูป base64 อัปโหลดป้าย quiz_carry ฯลฯ) · ค่าเริ่มต้น nginx 1M ทำให้ POST ถูกตัดเงียบ ๆ
+ location ~ ^/Admin/api/.+\.php$ {
+ client_max_body_size 20M;
+ include snippets/fastcgi-php.conf;
+ fastcgi_pass unix:/run/php/php8.3-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ include fastcgi_params;
+ }
+
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
@@ -44,6 +55,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # รูปป้าย quiz_carry อัปโหลดจาก Admin — เก็บใต้ Game/public/img/quiz-carry-plaque-assets/ (เสิร์ฟโดย location /Game/ ด้านล่าง)
# Game (ZEP-like) — รูปตัวละครอยู่ที่ Node (data/characters)
location /Game/img/characters/ {
proxy_pass http://127.0.0.1:3001;
diff --git a/scripts/deploy-nginx.sh b/scripts/deploy-nginx.sh
index 4bad852..83a8907 100755
--- a/scripts/deploy-nginx.sh
+++ b/scripts/deploy-nginx.sh
@@ -6,3 +6,4 @@ install -o root -g root -m 644 "$SRC" "$DST"
nginx -t
systemctl reload nginx
echo "OK: deployed $SRC -> $DST and nginx reloaded."
+echo "Tip: ถ้า POST อัปโหลดรูปผ่าน Admin API ยังว่าง body — ตั้ง PHP-FPM post_max_size ≥20M (เช่น pool www.conf) · If uploads still fail, raise PHP post_max_size to match nginx 20M."
diff --git a/scripts/deploy-www.sh b/scripts/deploy-www.sh
index 868528a..d524d1f 100755
--- a/scripts/deploy-www.sh
+++ b/scripts/deploy-www.sh
@@ -1,5 +1,6 @@
#!/bin/bash
# Deploy tracked web files from repo -> /var/www/html (ไม่ลบ node_modules บนเซิร์ฟเวอร์)
+# ใช้ทุกครั้งหลังแก้ www/html ใน repo แล้วต้องการให้เว็บจริงตรงกับ repo
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$REPO/www/html/"
@@ -9,7 +10,13 @@ if [[ "$(id -u)" -ne 0 ]]; then
exit 1
fi
rsync -a \
+ --checksum \
--exclude 'node_modules' \
"$SRC" "$DST"
chown -R www-data:www-data "$DST"
+# รูปป้าย quiz_carry — ให้เขียน/ลบได้ชัดเจน (FTP/Node); setgid ให้ไฟล์ใหม่ได้กลุ่ม www-data
+PLAQUE="$DST/Game/public/img/quiz-carry-plaque-assets"
+mkdir -p "$PLAQUE"
+chown www-data:www-data "$PLAQUE"
+chmod 2775 "$PLAQUE"
echo "OK: deployed $SRC -> $DST (node_modules on server untouched)"
diff --git a/scripts/fix-quiz-carry-plaque-assets-permissions.sh b/scripts/fix-quiz-carry-plaque-assets-permissions.sh
new file mode 100755
index 0000000..76bb915
--- /dev/null
+++ b/scripts/fix-quiz-carry-plaque-assets-permissions.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# ตั้งค่าโฟลเดอร์อัปโหลดรูปป้าย quiz_carry ให้ Node (www-data) อ่าน/เขียน/ลบได้
+# และ (ถ้าตั้งค่า) ให้บัญชี FTP เขียนได้ด้วย ACL
+#
+# Usage:
+# sudo bash scripts/fix-quiz-carry-plaque-assets-permissions.sh
+# sudo WEBROOT=/var/www/html OWNER=www-data GROUP=www-data bash scripts/fix-quiz-carry-plaque-assets-permissions.sh
+# sudo FTP_USER=yourftpuser bash scripts/fix-quiz-carry-plaque-assets-permissions.sh # ต้องมี package acl
+#
+# English: Ensures Game/public/img/quiz-carry-plaque-assets is writable by the game (www-data).
+# Optional FTP_USER: setfacl so that Linux user can STOR/delete via FTP without 553.
+set -euo pipefail
+
+WEBROOT="${WEBROOT:-/var/www/html}"
+OWNER="${OWNER:-www-data}"
+GROUP="${GROUP:-www-data}"
+DIR="$WEBROOT/Game/public/img/quiz-carry-plaque-assets"
+
+if [[ "$(id -u)" -ne 0 ]]; then
+ echo "Run as root: sudo $0" >&2
+ exit 1
+fi
+
+mkdir -p "$DIR"
+chown "$OWNER:$GROUP" "$DIR"
+# rwxrwxr-x + setgid: ไฟล์ใหม่ได้กลุ่มเดียวกับโฟลเดอร์ (เหมาะถ้า FTP user อยู่กลุ่ม www-data)
+chmod 2775 "$DIR"
+
+if [[ -n "${FTP_USER:-}" ]]; then
+ if command -v setfacl >/dev/null 2>&1; then
+ setfacl -m "u:${FTP_USER}:rwx" "$DIR"
+ setfacl -d -m "u:${FTP_USER}:rwx" "$DIR"
+ echo "ACL: user ${FTP_USER} has rwx on $DIR (default ACL for new files)."
+ else
+ echo "WARN: FTP_USER set but setfacl not installed. Install acl package or run: usermod -aG ${GROUP} ${FTP_USER}" >&2
+ fi
+fi
+
+echo "OK: $DIR"
+ls -ldn "$DIR"
diff --git a/scripts/test-plaque-upload-node.sh b/scripts/test-plaque-upload-node.sh
new file mode 100755
index 0000000..9466fbf
--- /dev/null
+++ b/scripts/test-plaque-upload-node.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# ทดสอบ POST /Game/api/quiz-carry-plaque-upload ที่ Node (ไม่ผ่าน PHP) — หลัง deploy รันบนเซิร์ฟ: bash scripts/test-plaque-upload-node.sh
+set -euo pipefail
+PORT="${GAME_NODE_PORT:-3001}"
+PNG_B64='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
+BODY=$(printf '%s' "{\"imageDataUrl\":\"data:image/png;base64,${PNG_B64}\"}")
+code=$(curl -sS -o /tmp/plaque-up.json -w '%{http_code}' -X POST "http://127.0.0.1:${PORT}/Game/api/quiz-carry-plaque-upload" \
+ -H 'Content-Type: application/json' --data-binary "$BODY" || true)
+echo "HTTP $code"
+cat /tmp/plaque-up.json
+echo
+if [[ "$code" != "200" ]]; then
+ echo "FAIL: expected 200" >&2
+ exit 1
+fi
+if ! grep -q '"ok":true' /tmp/plaque-up.json; then
+ echo "FAIL: expected ok:true in JSON" >&2
+ exit 1
+fi
+echo "OK: Node plaque upload accepts minimal PNG"
diff --git a/www/html/Admin/README.txt b/www/html/Admin/README.txt
index 5e529dd..581c465 100644
--- a/www/html/Admin/README.txt
+++ b/www/html/Admin/README.txt
@@ -25,3 +25,7 @@ TH / EN
สิทธิ์โฟลเดอร์ (ถ้าเขียนไม่ได้):
sudo chown -R www-data:www-data /var/www/html/Admin/private
sudo chmod 750 /var/www/html/Admin/private
+
+รูปป้าย quiz_carry (FTP 553 / Node อัปโหลดไม่ได้):
+sudo bash /path/to/repo/scripts/fix-quiz-carry-plaque-assets-permissions.sh
+# FTP user เขียนได้: sudo FTP_USER=ftpusername bash .../fix-quiz-carry-plaque-assets-permissions.sh
diff --git a/www/html/Admin/admin.css b/www/html/Admin/admin.css
index c8b55b1..abf79b3 100644
--- a/www/html/Admin/admin.css
+++ b/www/html/Admin/admin.css
@@ -825,6 +825,19 @@ code {
min-width: 0;
}
+.quiz-carry-slot-img {
+ font: inherit;
+ font-size: 0.78rem;
+ padding: 0.32rem 0.45rem;
+ margin-top: 0.25rem;
+ border-radius: 8px;
+ border: 1px dashed var(--border);
+ background: rgba(0, 0, 0, 0.15);
+ color: var(--text);
+ width: 100%;
+ min-width: 0;
+}
+
.quiz-carry-correct-wrap {
display: flex;
flex-wrap: wrap;
@@ -2185,3 +2198,76 @@ code {
background: rgba(0, 35, 50, 0.5);
box-shadow: 0 0 12px rgba(0, 242, 255, 0.12);
}
+
+/* quiz_carry — ป้ายต่อช่อง: โหมด neon ไม่ใช้สีขอบ fixed */
+.quiz-carry-plaque-slots-root .quiz-carry-theme-grid.quiz-carry-plaque-grid--neon [data-quiz-carry-plaque-color-row='fixed'] {
+ opacity: 0.48;
+}
+
+.quiz-carry-plaque-slots-root .quiz-carry-plaque-neon-hint {
+ color: var(--muted);
+}
+
+.quiz-carry-plaque-slots-root .quiz-carry-plaque-image-row {
+ grid-template-columns: 1fr;
+ max-width: none;
+}
+
+.quiz-carry-plaque-slots-root .quiz-carry-plaque-image-row .admin-inp-text {
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ font-family: ui-monospace, monospace;
+ font-size: 0.78rem;
+}
+
+.quiz-carry-plaque-slots-root .quiz-carry-plaque-preview-err {
+ color: var(--danger);
+}
+
+.quiz-carry-plaque-slots-root .quiz-carry-plaque-preview-img {
+ vertical-align: top;
+ box-sizing: border-box;
+}
+
+/* quiz_carry — ปรับขนาดป้ายบนแมป (ทั่วทั้งแมป) */
+.quiz-carry-plaque-map-scale-panel {
+ margin: 0 0 1rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid rgba(122, 200, 255, 0.45);
+ border-radius: var(--radius);
+ background: rgba(12, 22, 40, 0.55);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
+}
+.quiz-carry-plaque-map-scale-panel__title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: #c8e6ff;
+ letter-spacing: 0.02em;
+}
+.quiz-carry-plaque-map-scale-panel__row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 0.75rem 1.25rem;
+}
+.quiz-carry-plaque-map-scale-range-wrap {
+ flex: 1 1 220px;
+ min-width: 180px;
+}
+.quiz-carry-plaque-map-scale-range-wrap input[type='range'] {
+ width: 100%;
+ margin-top: 0.35rem;
+}
+.quiz-carry-plaque-map-scale-label {
+ display: block;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+.quiz-carry-plaque-map-scale-out {
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: #9fe8a8;
+ font-variant-numeric: tabular-nums;
+ padding-bottom: 0.2rem;
+}
diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js
index 29c5038..cad2af2 100644
--- a/www/html/Admin/admin.js
+++ b/www/html/Admin/admin.js
@@ -6,6 +6,18 @@
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';
+ /** ลำดับอัปโหลดจริงอยู่ที่ quizCarryPlaqueUploadWithFallback — Node ก่อน แล้วค่อย PHP */
+ var GAME_QUIZ_CARRY_PLAQUE_UPLOAD_NODE = '/Game/api/quiz-carry-plaque-upload';
+ var GAME_QUIZ_CARRY_PLAQUE_UPLOAD_PHP = '/Admin/api/game-quiz-carry-plaque-upload.php';
+ /** >0 ระหว่างอัปโหลดรูปป้าย — ห้ามบันทึกก่อนจบไม่งั้น plaqueImageUrl ว่างถูกเขียนลง JSON */
+ var quizCarryPlaqueUploadPending = 0;
+ /** epoch ms เมื่อเริ่มอัปโหลดล่าสุด — กันสถานะค้างบล็อกบันทึกตลอด */
+ var quizCarryPlaqueUploadStartMs = 0;
+ var quizCarrySaveInFlight = false;
+ /** ซิงก์ range ↔ ตัวเลข carryChoicePlaqueMapScale (wire ครั้งเดียว) */
+ var quizCarryPlaqueScaleControlsWired = false;
+ /** สำรอง URL หลังอัปโหลดสำเร็จต่อช่อง — กัน race ที่ช่อง text ว่างตอน buildForSave แต่รูปอัปโหลดแล้วจริง */
+ var quizCarryPlaquePendingUrlBySlot = {};
var stackGameSaveInFlight = false;
/** โหมดแทนที่รูป: ชื่อไฟล์เป้าหมายก่อนเปิด file picker */
var gauntletReplaceTarget = null;
@@ -266,6 +278,7 @@
if (!list) return;
q = q || {};
var choices = Array.isArray(q.choices) ? q.choices : [];
+ var choiceImages = Array.isArray(q.choiceImageUrls) ? q.choiceImageUrls : [];
var block = document.createElement('div');
block.className = 'quiz-carry-admin-block';
@@ -310,6 +323,15 @@
inp.maxLength = 160;
inp.value = choices[s] != null ? String(choices[s]) : '';
lab.appendChild(inp);
+ var imgInp = document.createElement('input');
+ imgInp.type = 'url';
+ imgInp.className = 'quiz-carry-slot-img';
+ imgInp.placeholder = 'รูป (URL — ไม่บังคับ)';
+ imgInp.maxLength = 512;
+ imgInp.autocomplete = 'off';
+ imgInp.value = choiceImages[s] != null ? String(choiceImages[s]) : '';
+ imgInp.setAttribute('aria-label', 'URL รูปตัวเลือก ' + (s + 1));
+ lab.appendChild(imgInp);
grid.appendChild(lab);
}
block.appendChild(grid);
@@ -526,13 +548,703 @@
quizCarryBindThemePickersOnce._done = true;
}
+ /** ธีมนับ 3-2-1 embed — สีม่าน / กล่อง / ขนาด (carryEmbedCountdownTheme) */
+ var QUIZ_CARRY_ECD_COLS = [
+ { sw: 'quiz-carry-ecd-overlay-swatch', ra: 'quiz-carry-ecd-overlay-alpha', out: 'quiz-carry-ecd-overlay-alpha-out', hi: 'quiz-carry-ecd-overlay-val', pr: 'quiz-carry-ecd-overlay-preview' },
+ { sw: 'quiz-carry-ecd-inner-bg-swatch', ra: 'quiz-carry-ecd-inner-bg-alpha', out: 'quiz-carry-ecd-inner-bg-alpha-out', hi: 'quiz-carry-ecd-inner-bg-val', pr: 'quiz-carry-ecd-inner-bg-preview' },
+ { sw: 'quiz-carry-ecd-inner-border-swatch', ra: 'quiz-carry-ecd-inner-border-alpha', out: 'quiz-carry-ecd-inner-border-alpha-out', hi: 'quiz-carry-ecd-inner-border-val', pr: 'quiz-carry-ecd-inner-border-preview' },
+ ];
+ var QUIZ_CARRY_ECD_DEFAULTS_RGBA = {
+ overlay: { r: 8, g: 10, b: 20, a: 0.42 },
+ innerBg: { r: 12, g: 14, b: 28, a: 0.82 },
+ innerBorder: { r: 122, g: 162, b: 247, a: 0.45 },
+ };
+
+ function quizCarryEcdColKey(idx) {
+ if (idx === 0) return 'overlay';
+ if (idx === 1) return 'innerBg';
+ return 'innerBorder';
+ }
+
+ function quizCarryEcdSyncRow(idx) {
+ var row = QUIZ_CARRY_ECD_COLS[idx];
+ var sw = el(row.sw);
+ var ra = el(row.ra);
+ var hi = el(row.hi);
+ var pr = el(row.pr);
+ var out = el(row.out);
+ if (!sw || !ra || !hi) return;
+ var prRgb = quizCarryParseHexToRgb(sw.value);
+ if (!prRgb) return;
+ var pct = parseInt(String(ra.value), 10);
+ if (!Number.isFinite(pct)) pct = 100;
+ pct = Math.max(0, Math.min(100, pct));
+ var s = quizCarryRgbToRgbaString({ r: prRgb.r, g: prRgb.g, b: prRgb.b, a: pct / 100 });
+ hi.value = s;
+ if (out) out.textContent = pct + '%';
+ if (pr) pr.textContent = s;
+ }
+
+ function quizCarryEcdSetRowFromRgba(idx, cssStr) {
+ var row = QUIZ_CARRY_ECD_COLS[idx];
+ var key = quizCarryEcdColKey(idx);
+ var fb = QUIZ_CARRY_ECD_DEFAULTS_RGBA[key];
+ var o = quizCarryParseCssColor(cssStr, fb);
+ var sw = el(row.sw);
+ var ra = el(row.ra);
+ var out = el(row.out);
+ var hi = el(row.hi);
+ var pr = el(row.pr);
+ if (!sw || !ra || !hi) return;
+ sw.value = quizCarryHexFromRgb(o.r, o.g, o.b);
+ var pct = Math.max(0, Math.min(100, Math.round(Number(o.a) * 100)));
+ ra.value = String(pct);
+ if (out) out.textContent = pct + '%';
+ hi.value = quizCarryRgbToRgbaString(o);
+ if (pr) pr.textContent = hi.value;
+ }
+
+ function quizCarryEcdBindPickersOnce() {
+ if (quizCarryEcdBindPickersOnce._done) return;
+ for (var i = 0; i < QUIZ_CARRY_ECD_COLS.length; i++) {
+ (function (idx) {
+ var row = QUIZ_CARRY_ECD_COLS[idx];
+ var sw = el(row.sw);
+ var ra = el(row.ra);
+ if (!sw || !ra) return;
+ if (ra.getAttribute('data-ecd-bound') === '1') return;
+ ra.setAttribute('data-ecd-bound', '1');
+ function sync() {
+ quizCarryEcdSyncRow(idx);
+ }
+ sw.addEventListener('input', sync);
+ sw.addEventListener('change', sync);
+ ra.addEventListener('input', sync);
+ ra.addEventListener('change', sync);
+ })(i);
+ }
+ quizCarryEcdBindPickersOnce._done = true;
+ }
+
+ function quizCarryEcdBuildForSave() {
+ quizCarryEcdBindPickersOnce();
+ for (var i = 0; i < QUIZ_CARRY_ECD_COLS.length; i++) quizCarryEcdSyncRow(i);
+ var o0 = el('quiz-carry-ecd-overlay-val');
+ var o1 = el('quiz-carry-ecd-inner-bg-val');
+ var o2 = el('quiz-carry-ecd-inner-border-val');
+ var bwEl = el('quiz-carry-ecd-inner-border-w');
+ var radEl = el('quiz-carry-ecd-inner-radius');
+ var dig = el('quiz-carry-ecd-digit-swatch');
+ var m1 = el('quiz-carry-ecd-map-cqmin');
+ var m2 = el('quiz-carry-ecd-map-cqh');
+ var m3 = el('quiz-carry-ecd-map-max-px');
+ var sv = el('quiz-carry-ecd-screen-vw');
+ var sm = el('quiz-carry-ecd-screen-max-px');
+ var bw = bwEl ? parseInt(String(bwEl.value || '1'), 10) : 1;
+ if (!Number.isFinite(bw)) bw = 1;
+ bw = Math.max(0, Math.min(12, Math.round(bw)));
+ var rad = radEl ? parseInt(String(radEl.value || '12'), 10) : 12;
+ if (!Number.isFinite(rad)) rad = 12;
+ rad = Math.max(0, Math.min(32, Math.round(rad)));
+ var cqmin = m1 ? parseInt(String(m1.value || '78'), 10) : 78;
+ var cqh = m2 ? parseInt(String(m2.value || '82'), 10) : 82;
+ var mpx = m3 ? parseInt(String(m3.value || '200'), 10) : 200;
+ cqmin = Math.max(35, Math.min(100, cqmin));
+ cqh = Math.max(35, Math.min(100, cqh));
+ mpx = Math.max(48, Math.min(400, mpx));
+ var vw = sv ? parseInt(String(sv.value || '28'), 10) : 28;
+ var smx = sm ? parseInt(String(sm.value || '132'), 10) : 132;
+ vw = Math.max(6, Math.min(44, vw));
+ smx = Math.max(48, Math.min(220, smx));
+ var digitHex = dig && /^#[0-9a-fA-F]{6}$/.test(String(dig.value || '').trim()) ? String(dig.value).trim() : '#ffe066';
+ return {
+ overlayBackdrop: (o0 && o0.value.trim()) ? o0.value.trim().slice(0, 120) : quizCarryRgbToRgbaString(QUIZ_CARRY_ECD_DEFAULTS_RGBA.overlay),
+ innerBg: (o1 && o1.value.trim()) ? o1.value.trim().slice(0, 120) : quizCarryRgbToRgbaString(QUIZ_CARRY_ECD_DEFAULTS_RGBA.innerBg),
+ innerBorder: (o2 && o2.value.trim()) ? o2.value.trim().slice(0, 120) : quizCarryRgbToRgbaString(QUIZ_CARRY_ECD_DEFAULTS_RGBA.innerBorder),
+ innerBorderWpx: bw,
+ innerRadiusPx: rad,
+ digitColor: digitHex.slice(0, 32),
+ mapDigitCqmin: cqmin,
+ mapDigitCqh: cqh,
+ mapDigitMaxPx: mpx,
+ screenDigitVw: vw,
+ screenDigitMaxPx: smx,
+ };
+ }
+
+ function quizCarryEcdFillForm(th) {
+ quizCarryEcdBindPickersOnce();
+ var t = th && typeof th === 'object' ? th : {};
+ quizCarryEcdSetRowFromRgba(0, t.overlayBackdrop);
+ quizCarryEcdSetRowFromRgba(1, t.innerBg);
+ quizCarryEcdSetRowFromRgba(2, t.innerBorder);
+ var bwEl = el('quiz-carry-ecd-inner-border-w');
+ var radEl = el('quiz-carry-ecd-inner-radius');
+ var dig = el('quiz-carry-ecd-digit-swatch');
+ var m1 = el('quiz-carry-ecd-map-cqmin');
+ var m2 = el('quiz-carry-ecd-map-cqh');
+ var m3 = el('quiz-carry-ecd-map-max-px');
+ var sv = el('quiz-carry-ecd-screen-vw');
+ var sm = el('quiz-carry-ecd-screen-max-px');
+ var bw = parseInt(String(t.innerBorderWpx), 10);
+ if (bwEl) bwEl.value = String(Number.isFinite(bw) && bw >= 0 ? Math.min(12, bw) : 1);
+ var rad = parseInt(String(t.innerRadiusPx), 10);
+ if (radEl) radEl.value = String(Number.isFinite(rad) && rad >= 0 ? Math.min(32, rad) : 12);
+ if (dig && typeof t.digitColor === 'string' && /^#[0-9a-fA-F]{6}$/.test(t.digitColor.trim())) dig.value = t.digitColor.trim();
+ var cqmin = parseInt(String(t.mapDigitCqmin), 10);
+ var cqh = parseInt(String(t.mapDigitCqh), 10);
+ var mpx = parseInt(String(t.mapDigitMaxPx), 10);
+ if (m1) m1.value = String(Number.isFinite(cqmin) ? Math.max(35, Math.min(100, cqmin)) : 78);
+ if (m2) m2.value = String(Number.isFinite(cqh) ? Math.max(35, Math.min(100, cqh)) : 82);
+ if (m3) m3.value = String(Number.isFinite(mpx) ? Math.max(48, Math.min(400, mpx)) : 200);
+ var vw = parseInt(String(t.screenDigitVw), 10);
+ var smx = parseInt(String(t.screenDigitMaxPx), 10);
+ if (sv) sv.value = String(Number.isFinite(vw) ? Math.max(6, Math.min(44, vw)) : 28);
+ if (sm) sm.value = String(Number.isFinite(smx) ? Math.max(48, Math.min(220, smx)) : 132);
+ }
+
+ var QUIZ_CARRY_PLAQUE_SLOT_COUNT = 16;
+ var QUIZ_CARRY_PLAQUE_KINDS = ['fixed', 'fill', 'text'];
+ var QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA = {
+ fixed: { r: 122, g: 200, b: 255, a: 0.9 },
+ fill: { r: 12, g: 10, b: 20, a: 0.88 },
+ text: { r: 248, g: 249, b: 255, a: 1 },
+ };
+
+ function quizCarryPlaqueSlotPrefix(si) {
+ return 'quiz-carry-plaque-s' + si + '-';
+ }
+
+ /**
+ * อัปโหลดป้าย: ลอง POST ตรง Node (/Game/api/ — nginx proxy 20M) ก่อน แล้วค่อย PHP ถ้าล้ม
+ * เดิมลอง PHP ก่อนทำให้พังง่ายเมื่อ php-fpm ต่อ 127.0.0.1:3001 ไม่ได้หรือ session ผิด แม้เกมจะรันอยู่
+ */
+ function quizCarryPlaqueUploadWithFallback(dataUrl) {
+ var body = JSON.stringify({ imageDataUrl: dataUrl });
+ var opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body };
+ return gauntletAssetsJsonFetch(GAME_QUIZ_CARRY_PLAQUE_UPLOAD_NODE, opts).catch(function (errFirst) {
+ var msg = String((errFirst && errFirst.message) || '');
+ if (/\b401\b|Unauthorized|หมดเซสชัน/i.test(msg)) {
+ return Promise.reject(errFirst);
+ }
+ var tryPhp =
+ /\b404\b|\b405\b|Not Found|ไม่พบ|502|503|504|Bad Gateway|Failed to fetch|NetworkError|Load failed|ECONNREFUSED/i.test(
+ msg,
+ ) ||
+ /\b413\b|Request Entity Too Large|Payload too large|รับ body ไม่ได้|too large/i.test(msg);
+ if (!tryPhp) {
+ return Promise.reject(errFirst);
+ }
+ return gauntletAssetsJsonFetch(GAME_QUIZ_CARRY_PLAQUE_UPLOAD_PHP, opts).then(function (res) {
+ try {
+ setMsg(
+ 'quiz-carry-settings-msg',
+ 'อัปโหลดสำเร็จผ่าน PHP (รองจาก /Game/api/) · Upload OK via PHP fallback',
+ 'ok',
+ );
+ } catch (e1) { /* ignore */ }
+ return res;
+ });
+ });
+ }
+
+ /** URL สำหรับ — path ที่ขึ้นต้นด้วย / ให้ต่อ origin (รองรับ Admin ใต้โดเมนเดียวกับ /Game/) */
+ function quizCarryPlaqueAbsMediaUrl(path) {
+ var s = String(path || '').trim();
+ if (!s) return '';
+ if (/^https?:\/\//i.test(s)) return s;
+ if (s.charAt(0) === '/') return String(window.location.origin || '') + s;
+ return '';
+ }
+
+ function quizCarryPlaqueUpdateImagePreview(si) {
+ var pfx = quizCarryPlaqueSlotPrefix(si);
+ var inp = el(pfx + 'plaque-image-url');
+ var img = el(pfx + 'plaque-preview-img');
+ var errEl = el(pfx + 'plaque-preview-err');
+ if (!img) return;
+ var raw = inp && inp.value ? String(inp.value).trim() : '';
+ if (errEl) errEl.textContent = '';
+ if (!raw) {
+ img.style.display = 'none';
+ img.removeAttribute('src');
+ return;
+ }
+ var abs = quizCarryPlaqueAbsMediaUrl(raw);
+ if (!abs) {
+ img.style.display = 'none';
+ if (errEl) errEl.textContent = 'รูปแบบ URL ไม่รองรับ';
+ return;
+ }
+ img.onerror = function () {
+ img.style.display = 'none';
+ if (errEl) errEl.textContent = 'โหลดตัวอย่างไม่ได้ — ตรวจ path / สิทธิ์ไฟล์ / รีสตาร์ท Node';
+ };
+ img.onload = function () {
+ try {
+ img.removeAttribute('width');
+ img.removeAttribute('height');
+ } catch (e) { /* ignore */ }
+ img.style.display = 'block';
+ if (errEl) errEl.textContent = '';
+ };
+ var sep = abs.indexOf('?') >= 0 ? '&' : '?';
+ img.src = abs + sep + '_pv=' + Date.now();
+ }
+
+ function quizCarryPlaqueUpdateNeonUi(si) {
+ var modeEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-mode');
+ var grid = modeEl && modeEl.closest ? modeEl.closest('.quiz-carry-theme-grid') : null;
+ if (!grid) return;
+ if (modeEl && modeEl.value === 'fixed') grid.classList.remove('quiz-carry-plaque-grid--neon');
+ else grid.classList.add('quiz-carry-plaque-grid--neon');
+ var hint = grid.querySelector('.quiz-carry-plaque-neon-hint');
+ if (hint) hint.style.display = modeEl && modeEl.value === 'fixed' ? 'none' : '';
+ }
+
+ function quizCarryPlaqueRowIds(si, kind) {
+ var seg = kind === 'fixed' ? 'fixed' : kind === 'fill' ? 'fill' : 'text';
+ var p = quizCarryPlaqueSlotPrefix(si);
+ return {
+ sw: p + seg + '-swatch',
+ ra: p + seg + '-alpha',
+ out: p + seg + '-alpha-out',
+ hi: p + seg + '-val',
+ };
+ }
+
+ function quizCarryPlaqueEnsureMounted() {
+ var root = el('quiz-carry-plaque-slots-root');
+ if (!root || root.getAttribute('data-mounted') === '1') return;
+ for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) {
+ var det = document.createElement('details');
+ det.className = 'quiz-carry-plaque-slot-details';
+ if (si === 0) det.open = true;
+ var summ = document.createElement('summary');
+ summ.textContent = 'ตัวเลือก ' + (si + 1);
+ det.appendChild(summ);
+ var grid = document.createElement('div');
+ grid.className = 'quiz-carry-theme-grid quiz-carry-plaque-grid';
+ var rowMode = document.createElement('div');
+ rowMode.className = 'quiz-carry-theme-row quiz-carry-theme-row--narrow';
+ rowMode.style.marginBottom = '0.45rem';
+ var labMode = document.createElement('label');
+ labMode.className = 'admin-field quiz-carry-theme-label';
+ labMode.appendChild(document.createTextNode('โหมดขอบป้าย'));
+ var selMode = document.createElement('select');
+ selMode.id = quizCarryPlaqueSlotPrefix(si) + 'border-mode';
+ selMode.setAttribute('aria-label', 'โหมดขอบป้ายช่อง ' + (si + 1));
+ [['neon', 'เรืองตามช่อง (neon)'], ['fixed', 'สีขอบคงที่']].forEach(function (opt) {
+ var o = document.createElement('option');
+ o.value = opt[0];
+ o.textContent = opt[1];
+ selMode.appendChild(o);
+ });
+ labMode.appendChild(selMode);
+ rowMode.appendChild(labMode);
+ grid.appendChild(rowMode);
+ selMode.addEventListener('change', function () {
+ quizCarryPlaqueUpdateNeonUi(si);
+ });
+
+ var hintNeon = document.createElement('p');
+ hintNeon.className = 'muted quiz-carry-plaque-neon-hint';
+ hintNeon.style.cssText = 'margin:0 0 0.5rem;font-size:0.78rem;line-height:1.45';
+ hintNeon.innerHTML =
+ 'Neon / เรืองตามช่อง: ขอบบนแมปใช้สีตามเลขช่อง ไม่ใช้ «สีขอบ (fixed)» ด้านล่าง — ต้องการขอบตาม picker ให้เลือก สีขอบคงที่ · ' +
+ 'English: In Neon mode the map uses per-slot hue; Fixed border uses the fixed-border pickers.';
+ grid.appendChild(hintNeon);
+
+ function addColorRow(labelText, kind, defHex, defAlpha) {
+ var ids = quizCarryPlaqueRowIds(si, kind);
+ var row = document.createElement('div');
+ row.className = 'quiz-carry-theme-row';
+ row.setAttribute('data-quiz-carry-plaque-color-row', kind);
+ var lbl = document.createElement('span');
+ lbl.className = 'quiz-carry-theme-label';
+ lbl.textContent = labelText;
+ row.appendChild(lbl);
+ var controls = document.createElement('div');
+ controls.className = 'quiz-carry-color-controls';
+ var sw = document.createElement('input');
+ sw.type = 'color';
+ sw.id = ids.sw;
+ sw.value = defHex;
+ sw.title = labelText;
+ controls.appendChild(sw);
+ var labA = document.createElement('label');
+ labA.className = 'quiz-carry-alpha-label';
+ var spanA = document.createElement('span');
+ spanA.textContent = 'โปร่ง A';
+ labA.appendChild(spanA);
+ var ra = document.createElement('input');
+ ra.type = 'range';
+ ra.id = ids.ra;
+ ra.min = '0';
+ ra.max = '100';
+ ra.step = '1';
+ ra.value = String(defAlpha);
+ labA.appendChild(ra);
+ var out = document.createElement('output');
+ out.id = ids.out;
+ out.className = 'quiz-carry-alpha-out';
+ out.setAttribute('for', ids.ra);
+ out.textContent = defAlpha + '%';
+ labA.appendChild(out);
+ controls.appendChild(labA);
+ var hi = document.createElement('input');
+ hi.type = 'hidden';
+ hi.id = ids.hi;
+ controls.appendChild(hi);
+ row.appendChild(controls);
+ grid.appendChild(row);
+ }
+ addColorRow('สีขอบ (fixed)', 'fixed', '#7ac8ff', 90);
+ addColorRow('พื้นหลังกล่อง', 'fill', '#0c0a14', 88);
+ addColorRow('สีตัวอักษร', 'text', '#f8f9ff', 100);
+ var rowW = document.createElement('div');
+ rowW.className = 'quiz-carry-theme-row quiz-carry-theme-row--narrow';
+ var labW = document.createElement('label');
+ labW.className = 'admin-field quiz-carry-theme-label';
+ labW.appendChild(document.createTextNode('ความหนาขอบ (px)'));
+ var inpW = document.createElement('input');
+ inpW.type = 'number';
+ inpW.className = 'admin-inp-num';
+ inpW.id = quizCarryPlaqueSlotPrefix(si) + 'border-w';
+ inpW.min = '0';
+ inpW.max = '8';
+ inpW.step = '0.5';
+ inpW.value = '2.5';
+ labW.appendChild(inpW);
+ rowW.appendChild(labW);
+ grid.appendChild(rowW);
+
+ var rowImg = document.createElement('div');
+ /* ห้ามใช้ quiz-carry-theme-row--narrow — มี max-width:12rem ทำช่อง URL แคบมาก ดูเหมือนอัปโหลดไม่ทำงาน */
+ rowImg.className = 'quiz-carry-theme-row quiz-carry-plaque-image-row';
+ rowImg.style.cssText = 'flex-wrap:wrap;gap:0.65rem;align-items:flex-end';
+ var labUrl = document.createElement('label');
+ labUrl.className = 'admin-field';
+ labUrl.style.cssText = 'flex:1;min-width:200px;margin:0';
+ labUrl.appendChild(document.createTextNode('รูปบนป้าย (ช่องนี้)'));
+ var inpImgUrl = document.createElement('input');
+ inpImgUrl.type = 'text';
+ inpImgUrl.className = 'admin-inp-text';
+ inpImgUrl.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-image-url';
+ inpImgUrl.placeholder = '/Game/img/quiz-carry-plaque-assets/... หรือ https://...';
+ inpImgUrl.setAttribute('aria-label', 'URL รูปป้ายช่อง ' + (si + 1));
+ labUrl.appendChild(document.createElement('br'));
+ labUrl.appendChild(inpImgUrl);
+ var prevErr = document.createElement('span');
+ prevErr.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-preview-err';
+ prevErr.className = 'muted quiz-carry-plaque-preview-err';
+ prevErr.style.cssText = 'display:block;font-size:0.72rem;margin:0.25rem 0 0.15rem;min-height:1em';
+ labUrl.appendChild(prevErr);
+ var prevImg = document.createElement('img');
+ prevImg.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-preview-img';
+ prevImg.alt = 'พรีวิวรูปป้ายช่อง ' + (si + 1);
+ prevImg.className = 'quiz-carry-plaque-preview-img';
+ /* ห้าม width/height = 0 — บางเบราว์เซอร์แสดงกล่อง 0×0 แม้ onload แล้ว · Don't use 0×0 attrs or preview stays invisible */
+ prevImg.style.cssText = 'display:none;max-height:120px;max-width:min(100%,320px);width:auto;height:auto;border-radius:8px;border:1px solid var(--border);object-fit:contain;background:var(--card)';
+ labUrl.appendChild(prevImg);
+ inpImgUrl.addEventListener('input', function () {
+ var v = String(inpImgUrl.value || '').trim();
+ if (!v) delete quizCarryPlaquePendingUrlBySlot[si];
+ quizCarryPlaqueUpdateImagePreview(si);
+ });
+ inpImgUrl.addEventListener('change', function () {
+ var v2 = String(inpImgUrl.value || '').trim();
+ if (!v2) delete quizCarryPlaquePendingUrlBySlot[si];
+ quizCarryPlaqueUpdateImagePreview(si);
+ });
+ rowImg.appendChild(labUrl);
+ var upWrap = document.createElement('div');
+ upWrap.className = 'admin-field';
+ upWrap.style.margin = '0';
+ var fileInp = document.createElement('input');
+ fileInp.type = 'file';
+ fileInp.accept = 'image/png,image/jpeg,image/webp,image/gif';
+ fileInp.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-image-file';
+ fileInp.style.fontSize = '0.82rem';
+ var upMsg = document.createElement('span');
+ upMsg.className = 'muted';
+ upMsg.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-image-msg';
+ upMsg.style.cssText = 'display:block;font-size:0.75rem;margin-top:0.25rem';
+ fileInp.addEventListener('change', function () {
+ while (upMsg.firstChild) upMsg.removeChild(upMsg.firstChild);
+ upMsg.textContent = '';
+ if (!fileInp.files || !fileInp.files[0]) return;
+ upMsg.textContent = 'กำลังอ่านไฟล์และอัปโหลด… · Reading file & uploading…';
+ try {
+ upMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ } catch (scrollErr) { /* ignore */ }
+ quizCarryPlaqueUploadPending++;
+ quizCarryPlaqueUploadStartMs = Date.now();
+ readFileAsDataURL(fileInp.files[0])
+ .then(function (dataUrl) {
+ return quizCarryPlaqueUploadWithFallback(dataUrl);
+ })
+ .then(function (res) {
+ if (res && res.url) {
+ var uOk = String(res.url).trim();
+ inpImgUrl.value = uOk;
+ quizCarryPlaquePendingUrlBySlot[si] = uOk;
+ quizCarryPlaqueUpdateImagePreview(si);
+ upMsg.textContent = '';
+ var ok1 = document.createElement('span');
+ ok1.textContent = 'อัปโหลดแล้ว · path ที่ได้: ';
+ upMsg.appendChild(ok1);
+ var codeU = document.createElement('code');
+ codeU.style.cssText = 'font-size:0.78rem;word-break:break-all';
+ codeU.textContent = res.url;
+ upMsg.appendChild(codeU);
+ var br = document.createElement('br');
+ upMsg.appendChild(br);
+ var ok2 = document.createElement('span');
+ ok2.textContent = 'กด «บันทึก» ด้านล่างเพื่อเขียนลง quiz-settings.json · Then click Save below';
+ upMsg.appendChild(ok2);
+ } else {
+ upMsg.textContent = (res && res.error) || 'ตอบกลับไม่มี url — ตรวจ Node /Game/api/quiz-carry-plaque-upload';
+ }
+ })
+ .catch(function (e) {
+ upMsg.textContent = e.message || 'อัปโหลดไม่สำเร็จ';
+ setMsg('quiz-carry-settings-msg', e.message || 'อัปโหลดรูปป้ายไม่สำเร็จ · Plaque upload failed', 'error');
+ try {
+ upMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ var smUp = el('quiz-carry-settings-msg');
+ if (smUp) smUp.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ } catch (scrollE) { /* ignore */ }
+ })
+ .finally(function () {
+ fileInp.value = '';
+ quizCarryPlaqueUploadPending = Math.max(0, quizCarryPlaqueUploadPending - 1);
+ if (quizCarryPlaqueUploadPending === 0) quizCarryPlaqueUploadStartMs = 0;
+ });
+ });
+ upWrap.appendChild(document.createTextNode('อัปโหลด'));
+ upWrap.appendChild(document.createElement('br'));
+ upWrap.appendChild(fileInp);
+ upWrap.appendChild(upMsg);
+ rowImg.appendChild(upWrap);
+ grid.appendChild(rowImg);
+
+ det.appendChild(grid);
+ root.appendChild(det);
+ quizCarryPlaqueUpdateNeonUi(si);
+ }
+ root.setAttribute('data-mounted', '1');
+ }
+
+ function quizCarryPlaqueComposeFromPickers(si, kind) {
+ var ids = quizCarryPlaqueRowIds(si, kind);
+ var sw = el(ids.sw);
+ var ra = el(ids.ra);
+ var fb = QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA[kind] || QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA.fill;
+ if (!sw || !ra) return quizCarryRgbToRgbaString(fb);
+ var pr = quizCarryParseHexToRgb(sw.value);
+ if (!pr) return quizCarryRgbToRgbaString(fb);
+ var pct = parseInt(String(ra.value), 10);
+ if (!Number.isFinite(pct)) pct = Math.round((fb.a != null ? fb.a : 1) * 100);
+ pct = Math.max(0, Math.min(100, pct));
+ return quizCarryRgbToRgbaString({ r: pr.r, g: pr.g, b: pr.b, a: pct / 100 });
+ }
+
+ function quizCarryPlaqueSetPickersFromString(si, kind, cssStr) {
+ var ids = quizCarryPlaqueRowIds(si, kind);
+ var fb = QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA[kind] || QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA.fill;
+ var o = quizCarryParseCssColor(cssStr, fb);
+ var sw = el(ids.sw);
+ var ra = el(ids.ra);
+ var hi = el(ids.hi);
+ var out = el(ids.out);
+ if (sw) sw.value = quizCarryHexFromRgb(o.r, o.g, o.b);
+ if (ra) ra.value = String(Math.round((o.a != null ? o.a : 1) * 100));
+ if (out) out.textContent = ra ? ra.value + '%' : '';
+ if (hi) hi.value = quizCarryRgbToRgbaString(o);
+ }
+
+ function quizCarryPlaqueSyncRow(si, kind) {
+ var s = quizCarryPlaqueComposeFromPickers(si, kind);
+ var ids = quizCarryPlaqueRowIds(si, kind);
+ var hi = el(ids.hi);
+ var out = el(ids.out);
+ var ra = el(ids.ra);
+ if (hi) hi.value = s;
+ if (out && ra) out.textContent = ra.value + '%';
+ }
+
+ var quizCarryPlaqueBindPickersDone = false;
+ function quizCarryPlaqueBindPickersOnce() {
+ if (quizCarryPlaqueBindPickersDone) return;
+ quizCarryPlaqueEnsureMounted();
+ for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) {
+ QUIZ_CARRY_PLAQUE_KINDS.forEach(function (kind) {
+ var ids = quizCarryPlaqueRowIds(si, kind);
+ var sw = el(ids.sw);
+ var ra = el(ids.ra);
+ if (sw) {
+ sw.addEventListener('input', function () {
+ quizCarryPlaqueSyncRow(si, kind);
+ });
+ }
+ if (ra) {
+ ra.addEventListener('input', function () {
+ quizCarryPlaqueSyncRow(si, kind);
+ });
+ }
+ });
+ }
+ quizCarryPlaqueBindPickersDone = true;
+ }
+
+ function quizCarryPlaqueBuildForSave() {
+ quizCarryPlaqueEnsureMounted();
+ quizCarryPlaqueBindPickersOnce();
+ var arr = [];
+ for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) {
+ for (var ki = 0; ki < QUIZ_CARRY_PLAQUE_KINDS.length; ki++) {
+ quizCarryPlaqueSyncRow(si, QUIZ_CARRY_PLAQUE_KINDS[ki]);
+ }
+ var modeEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-mode');
+ var mode = modeEl && modeEl.value === 'fixed' ? 'fixed' : 'neon';
+ var bwEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-w');
+ var bw = bwEl ? parseFloat(String(bwEl.value)) : 2.5;
+ if (!Number.isFinite(bw)) bw = 2.5;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ var imgUrlEl = el(quizCarryPlaqueSlotPrefix(si) + 'plaque-image-url');
+ var plaqueImageUrl = imgUrlEl ? String(imgUrlEl.value || '').trim() : '';
+ if (!plaqueImageUrl && quizCarryPlaquePendingUrlBySlot[si]) {
+ plaqueImageUrl = String(quizCarryPlaquePendingUrlBySlot[si] || '').trim();
+ if (imgUrlEl && plaqueImageUrl) imgUrlEl.value = plaqueImageUrl;
+ }
+ plaqueImageUrl = plaqueImageUrl.slice(0, 512);
+ arr.push({
+ borderMode: mode,
+ fixedBorder: quizCarryPlaqueComposeFromPickers(si, 'fixed'),
+ fillBg: quizCarryPlaqueComposeFromPickers(si, 'fill'),
+ textColor: quizCarryPlaqueComposeFromPickers(si, 'text'),
+ borderWidthPx: bw,
+ plaqueImageUrl: plaqueImageUrl,
+ });
+ }
+ return arr;
+ }
+
+ function quizCarryPlaqueFillSlot(si, t) {
+ var obj = t && typeof t === 'object' ? t : {};
+ var modeEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-mode');
+ if (modeEl) modeEl.value = String(obj.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ quizCarryPlaqueSetPickersFromString(si, 'fixed', obj.fixedBorder);
+ quizCarryPlaqueSetPickersFromString(si, 'fill', obj.fillBg);
+ quizCarryPlaqueSetPickersFromString(si, 'text', obj.textColor);
+ var bwEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-w');
+ var bw = parseFloat(String(obj.borderWidthPx));
+ if (bwEl) {
+ bwEl.value = String(Number.isFinite(bw) ? Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10 : 2.5);
+ }
+ var imgEl = el(quizCarryPlaqueSlotPrefix(si) + 'plaque-image-url');
+ if (imgEl) {
+ imgEl.value = obj.plaqueImageUrl != null ? String(obj.plaqueImageUrl) : '';
+ }
+ var srvU = obj.plaqueImageUrl != null ? String(obj.plaqueImageUrl).trim() : '';
+ if (srvU) quizCarryPlaquePendingUrlBySlot[si] = srvU.slice(0, 512);
+ var msgEl = el(quizCarryPlaqueSlotPrefix(si) + 'plaque-image-msg');
+ if (msgEl) msgEl.textContent = '';
+ quizCarryPlaqueUpdateNeonUi(si);
+ quizCarryPlaqueUpdateImagePreview(si);
+ }
+
+ function quizCarryPlaqueFillAllFromApi(data) {
+ quizCarryPlaqueBindPickersOnce();
+ var arr = null;
+ if (data && Array.isArray(data.carryChoicePlaqueThemes) && data.carryChoicePlaqueThemes.length) {
+ arr = data.carryChoicePlaqueThemes;
+ } else if (data && data.carryChoicePlaqueTheme && typeof data.carryChoicePlaqueTheme === 'object') {
+ arr = [];
+ for (var u = 0; u < QUIZ_CARRY_PLAQUE_SLOT_COUNT; u++) arr.push(data.carryChoicePlaqueTheme);
+ }
+ for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) {
+ var th = (arr && arr[si]) ? arr[si] : {};
+ quizCarryPlaqueFillSlot(si, th);
+ quizCarryPlaqueUpdateImagePreview(si);
+ }
+ }
+
+ function quizCarrySetPlaqueMapScaleInputs(scale) {
+ var pms = Number(scale);
+ if (!Number.isFinite(pms)) pms = 1.25;
+ pms = Math.max(0.85, Math.min(2.5, pms));
+ var cent = Math.round(pms * 100);
+ cent = Math.max(85, Math.min(250, cent));
+ var num = el('quiz-carry-plaque-map-scale');
+ var rng = el('quiz-carry-plaque-map-scale-range');
+ var out = el('quiz-carry-plaque-map-scale-out');
+ if (num) num.value = String(Math.round((cent / 100) * 100) / 100);
+ if (rng) rng.value = String(cent);
+ if (out) out.textContent = '× ' + (num ? num.value : String(cent / 100));
+ }
+
+ function wireQuizCarryPlaqueMapScaleControls() {
+ if (quizCarryPlaqueScaleControlsWired) return;
+ var num = el('quiz-carry-plaque-map-scale');
+ var rng = el('quiz-carry-plaque-map-scale-range');
+ if (!num || !rng) return;
+ quizCarryPlaqueScaleControlsWired = true;
+ function fromRange() {
+ var c = parseInt(String(rng.value), 10);
+ if (!Number.isFinite(c)) c = 125;
+ c = Math.max(85, Math.min(250, c));
+ rng.value = String(c);
+ var sc = c / 100;
+ num.value = String(Math.round(sc * 100) / 100);
+ var out = el('quiz-carry-plaque-map-scale-out');
+ if (out) out.textContent = '× ' + num.value;
+ }
+ function fromNum() {
+ var p = parseFloat(String(num.value || '').replace(',', '.'));
+ if (!Number.isFinite(p)) p = 1.25;
+ p = Math.max(0.85, Math.min(2.5, p));
+ rng.value = String(Math.round(p * 100));
+ num.value = String(Math.round(p * 100) / 100);
+ var out = el('quiz-carry-plaque-map-scale-out');
+ if (out) out.textContent = '× ' + num.value;
+ }
+ rng.addEventListener('input', fromRange);
+ rng.addEventListener('change', fromRange);
+ num.addEventListener('input', fromNum);
+ num.addEventListener('change', fromNum);
+ }
+
function loadQuizCarryPanel(opts) {
opts = opts || {};
var delay = Number(opts.fetchDelayMs) || 0;
var runLoad = function () {
gameQuizFetch('GET').then(function (data) {
if (opts.themeOverride && typeof opts.themeOverride === 'object') {
- data.carryMapPanelTheme = opts.themeOverride;
+ var ov = opts.themeOverride;
+ if (ov.panelBg != null || ov.borderWidthPx != null || ov.panelBorder != null || ov.textColor != null) {
+ data.carryMapPanelTheme = ov;
+ } else if (ov.carryMapPanelTheme && typeof ov.carryMapPanelTheme === 'object') {
+ data.carryMapPanelTheme = ov.carryMapPanelTheme;
+ }
+ if (ov.carryEmbedCountdownTheme && typeof ov.carryEmbedCountdownTheme === 'object') {
+ data.carryEmbedCountdownTheme = ov.carryEmbedCountdownTheme;
+ }
+ if (ov.carryChoicePlaqueThemes && Array.isArray(ov.carryChoicePlaqueThemes)) {
+ data.carryChoicePlaqueThemes = ov.carryChoicePlaqueThemes;
+ if (ov.carryChoicePlaqueThemes[0] && typeof ov.carryChoicePlaqueThemes[0] === 'object') {
+ data.carryChoicePlaqueTheme = ov.carryChoicePlaqueThemes[0];
+ }
+ } else if (ov.carryChoicePlaqueTheme && typeof ov.carryChoicePlaqueTheme === 'object') {
+ /* legacy อ็อบเจ็กต์เดียว — ต้องตั้งทั้งอาร์เรย์ ไม่งั้น Fill ยังใช้ carryChoicePlaqueThemes จาก GET เก่า (URL ป้ายหายหลังบันทึก) */
+ data.carryChoicePlaqueTheme = ov.carryChoicePlaqueTheme;
+ var expPlaque = [];
+ for (var exi = 0; exi < QUIZ_CARRY_PLAQUE_SLOT_COUNT; exi++) {
+ expPlaque.push(ov.carryChoicePlaqueTheme);
+ }
+ data.carryChoicePlaqueThemes = expPlaque;
+ }
}
if (opts.timingOverride && typeof opts.timingOverride === 'object') {
var t = opts.timingOverride;
@@ -556,6 +1268,7 @@
var sl = parseInt(String(data.carrySessionLength), 10);
sessLen.value = String(Number.isFinite(sl) && sl >= 0 ? sl : 0);
}
+ quizCarrySetPlaqueMapScaleInputs(data.carryChoicePlaqueMapScale);
quizCarryBindThemePickersOnce();
var th = data.carryMapPanelTheme && typeof data.carryMapPanelTheme === 'object' ? data.carryMapPanelTheme : {};
quizCarrySetPickersFromColorString('bg', th.panelBg);
@@ -566,6 +1279,8 @@
var bw = parseInt(String(th.borderWidthPx), 10);
bwInp.value = String(Number.isFinite(bw) && bw >= 0 ? Math.min(12, bw) : 2);
}
+ quizCarryEcdFillForm(data.carryEmbedCountdownTheme || {});
+ quizCarryPlaqueFillAllFromApi(data);
if (opts.clearMsg !== false) setMsg('quiz-carry-settings-msg', '', '');
}).catch(function (e) {
setMsg('quiz-carry-settings-msg', e.message || 'โหลดไม่ได้', 'error');
@@ -575,18 +1290,56 @@
else runLoad();
}
+ /** รอจน plaque upload จบ (pending=0) — กันกดบันทึกทันทีหลังเลือกไฟล์แล้ว build ได้ URL ว่าง · returns false if timeout */
+ function waitUntilQuizCarryUploadsDone(maxMs) {
+ maxMs = maxMs || 120000;
+ return new Promise(function (resolve) {
+ if (quizCarryPlaqueUploadPending <= 0) return resolve(true);
+ var started = Date.now();
+ var iv = setInterval(function () {
+ if (quizCarryPlaqueUploadPending <= 0) {
+ clearInterval(iv);
+ resolve(true);
+ } else if (Date.now() - started >= maxMs) {
+ clearInterval(iv);
+ resolve(false);
+ }
+ }, 40);
+ });
+ }
+
function saveQuizCarryPanel() {
var list = el('quiz-carry-admin-list');
- if (!list) return;
+ var saveBtn = el('btn-quiz-carry-save');
+ if (!list) {
+ setMsg('quiz-carry-settings-msg', 'ไม่พบรายการคำถามในแท็บนี้ — รีเฟรชหน้า · quiz-carry-admin-list missing', 'error');
+ return;
+ }
+ if (quizCarryPlaqueUploadPending > 0 && quizCarryPlaqueUploadStartMs > 0 && Date.now() - quizCarryPlaqueUploadStartMs > 120000) {
+ quizCarryPlaqueUploadPending = 0;
+ quizCarryPlaqueUploadStartMs = 0;
+ setMsg(
+ 'quiz-carry-settings-msg',
+ 'ยกเลิกสถานะอัปโหลดค้าง (>2 นาที) แล้วดำเนินการบันทึก — ถ้ายังต้องการรูปให้อัปโหลดใหม่ · Stuck upload cleared; re-upload if needed',
+ 'error',
+ );
+ }
+ if (quizCarrySaveInFlight) {
+ setMsg('quiz-carry-settings-msg', 'กำลังบันทึกอยู่แล้ว — รอสักครู่ · Save already in progress', 'error');
+ 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 });
+ block.querySelectorAll('.quiz-carry-slot-label').forEach(function (lab, idx) {
+ var ti = lab.querySelector('.quiz-carry-slot-inp');
+ var ii = lab.querySelector('.quiz-carry-slot-img');
+ var tv = ti && ti.value ? String(ti.value).trim() : '';
+ var iv = ii && ii.value ? String(ii.value).trim().slice(0, 512) : '';
+ if (tv) slots.push({ idx: idx, text: tv, imageUrl: iv });
});
if (slots.length < 2) return;
var sel = block.querySelector('.quiz-carry-correct-sel');
@@ -601,13 +1354,20 @@
}
}
if (correctIndex < 0) return;
- carryQuestions.push({
+ var rowOut = {
text: text,
choices: slots.map(function (s) {
return s.text;
}),
correctIndex: correctIndex,
+ };
+ var urls = slots.map(function (s) {
+ return s.imageUrl || '';
});
+ if (urls.some(function (u) {
+ return u;
+ })) rowOut.choiceImageUrls = urls;
+ carryQuestions.push(rowOut);
});
var readSecEl = el('quiz-carry-read-sec');
@@ -645,25 +1405,85 @@
return;
}
- var putBody = {
- carryReadMs: carryReadMs,
- carryAnswerMs: carryAnswerMs,
- carrySessionLength: carrySessionLength,
- carryMapPanelTheme: carryMapPanelTheme,
- };
- if (carryQuestions.length > 0) {
- putBody.carryQuestions = carryQuestions;
+ quizCarrySaveInFlight = true;
+ if (saveBtn) {
+ saveBtn.disabled = true;
+ saveBtn.setAttribute('aria-busy', 'true');
}
+ var waitLine =
+ quizCarryPlaqueUploadPending > 0
+ ? 'กำลังรอให้อัปโหลดรูปป้ายจบ แล้วจะบันทึกอัตโนมัติ (ไม่ต้องกดซ้ำ)… · Waiting for plaque upload, then save…'
+ : 'กำลังบันทึก… · Saving…';
+ setMsg('quiz-carry-settings-msg', waitLine, '');
+ try {
+ var smEl = el('quiz-carry-settings-msg');
+ if (smEl) smEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ } catch (scrollSave) { /* ignore */ }
- gameQuizFetch('PUT', putBody).then(function (res) {
+ waitUntilQuizCarryUploadsDone(120000).then(function (uploadsOk) {
+ if (!uploadsOk) {
+ quizCarrySaveInFlight = false;
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ saveBtn.removeAttribute('aria-busy');
+ }
+ setMsg(
+ 'quiz-carry-settings-msg',
+ 'รออัปโหลดนานเกิน 2 นาที — ตรวจเครือข่าย/เซิร์ฟเกมแล้วลองใหม่ · Upload wait timeout',
+ 'error',
+ );
+ return;
+ }
+ var plaqueScaleEl = el('quiz-carry-plaque-map-scale');
+ var plaqueMapScale = 1.25;
+ if (plaqueScaleEl) {
+ var psn = parseFloat(String(plaqueScaleEl.value || '').replace(',', '.'));
+ if (Number.isFinite(psn)) plaqueMapScale = Math.max(0.85, Math.min(2.5, psn));
+ }
+ var putBody = {
+ carryReadMs: carryReadMs,
+ carryAnswerMs: carryAnswerMs,
+ carrySessionLength: carrySessionLength,
+ carryMapPanelTheme: carryMapPanelTheme,
+ carryEmbedCountdownTheme: quizCarryEcdBuildForSave(),
+ carryChoicePlaqueThemes: quizCarryPlaqueBuildForSave(),
+ carryChoicePlaqueMapScale: plaqueMapScale,
+ };
+ if (carryQuestions.length > 0) {
+ putBody.carryQuestions = carryQuestions;
+ }
+
+ gameQuizFetch('PUT', putBody).then(function (res) {
var tail = carryQuestions.length ? ' · คำถาม ' + carryQuestions.length + ' ข้อ' : ' · คงชุดคำถามเดิม (อัปเดตเวลา/ธีมอย่างเดียว)';
var themeFromRes = res && res.carryMapPanelTheme && typeof res.carryMapPanelTheme === 'object' ? res.carryMapPanelTheme : null;
- var themeOk = !!themeFromRes;
- var themeTail = themeOk ? ' · ธีมแผงบันทึกแล้ว' : '';
+ var ecdFromRes = res && res.carryEmbedCountdownTheme && typeof res.carryEmbedCountdownTheme === 'object' ? res.carryEmbedCountdownTheme : null;
+ var plaqueFromRes = null;
+ if (res && Array.isArray(res.carryChoicePlaqueThemes) && res.carryChoicePlaqueThemes.length) {
+ plaqueFromRes = res.carryChoicePlaqueThemes;
+ } else if (res && res.carryChoicePlaqueTheme && typeof res.carryChoicePlaqueTheme === 'object') {
+ plaqueFromRes = [];
+ for (var pxi = 0; pxi < QUIZ_CARRY_PLAQUE_SLOT_COUNT; pxi++) {
+ plaqueFromRes.push(res.carryChoicePlaqueTheme);
+ }
+ }
+ if (!plaqueFromRes) {
+ plaqueFromRes = quizCarryPlaqueBuildForSave();
+ }
+ var themeOk = !!themeFromRes || !!ecdFromRes || !!plaqueFromRes;
+ var themeTail = themeOk ? ' · ธีมแผง/ป้าย/นับถอยหลังบันทึกแล้ว' : '';
setMsg('quiz-carry-settings-msg', 'บันทึกแล้ว' + tail + themeTail, 'ok');
+ quizCarryPlaquePendingUrlBySlot = {};
loadQuizCarryPanel({
clearMsg: false,
- themeOverride: themeFromRes || carryMapPanelTheme,
+ themeOverride: {
+ carryMapPanelTheme: themeFromRes || carryMapPanelTheme,
+ carryEmbedCountdownTheme: ecdFromRes || quizCarryEcdBuildForSave(),
+ carryChoicePlaqueThemes: Array.isArray(plaqueFromRes) ? plaqueFromRes : null,
+ carryChoicePlaqueTheme:
+ Array.isArray(plaqueFromRes) && plaqueFromRes[0] && typeof plaqueFromRes[0] === 'object'
+ ? plaqueFromRes[0]
+ : null,
+ },
timingOverride: {
carryReadMs: carryReadMs,
carryAnswerMs: carryAnswerMs,
@@ -671,8 +1491,15 @@
},
fetchDelayMs: 120,
});
- }).catch(function (e) {
- setMsg('quiz-carry-settings-msg', e.message || 'บันทึกไม่ได้', 'error');
+ }).catch(function (e) {
+ setMsg('quiz-carry-settings-msg', e.message || 'บันทึกไม่ได้', 'error');
+ }).finally(function () {
+ quizCarrySaveInFlight = false;
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ saveBtn.removeAttribute('aria-busy');
+ }
+ });
});
}
@@ -1181,17 +2008,51 @@
return fetch(url, opts).then(function (r) {
return r.text().then(function (text) {
var j = {};
+ var parsed = false;
try {
j = text ? JSON.parse(text) : {};
+ parsed = true;
} catch (e) {
- if (!r.ok) throw new Error(text || r.statusText);
+ if (!r.ok) {
+ var snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 220);
+ var tail = '';
+ if (r.status === 413) {
+ tail =
+ ' · คำขอใหญ่เกิน (nginx client_max_body_size / PHP post_max_size) — รัน deploy-nginx และตั้ง post_max_size ≥20M · Payload too large';
+ } else if (r.status === 401) {
+ tail = ' · ล็อกอิน Admin ใหม่ · Re-login to Admin';
+ } else if (r.status === 502 || r.status === 503 || r.status === 504) {
+ tail = ' · เซิร์ฟเกม/PHP upstream ล่ม — ตรวจ Node + php-fpm · Upstream error';
+ } else if (r.status === 404) {
+ tail = ' · ตรวจ path ไฟล์ / รีสตาร์ทบริการหลัง deploy · Not found';
+ }
+ throw new Error((snippet || r.statusText || 'Request failed') + tail + ' (HTTP ' + r.status + ')');
+ }
throw e;
}
if (!r.ok) {
- var plain = j.error || r.statusText || 'Request failed';
+ var plain = (j && j.error) || r.statusText || 'Request failed';
+ if (r.status === 413) {
+ plain =
+ (j && j.error) ||
+ 'ไฟล์/คำขอใหญ่เกินขีดจำกัด (nginx หรือ PHP) — ตั้ง client_max_body_size (server หรือ /Admin/api/) และ post_max_size ≥20M · Payload too large';
+ }
+ if (r.status === 401) {
+ plain =
+ (j && j.error) ||
+ 'หมดเซสชันแอดมิน — ล็อกอินใหม่แล้วลองอัปโหลดอีกครั้ง · Re-login to Admin and retry upload';
+ }
if (r.status === 404) {
plain += ' — รีสตาร์ท Node ที่รัน Game/server.js หลัง deploy · Restart Game Node';
}
+ if (r.status === 502 || r.status === 503 || r.status === 504) {
+ plain +=
+ ' — PHP/Node upstream ล่ม — ตรวจ game-zep (Node) และ php-fpm · Check Node + PHP-FPM';
+ }
+ if (parsed && (!j || Object.keys(j).length === 0)) {
+ plain = (text || '').replace(/\s+/g, ' ').trim().slice(0, 200) || plain;
+ if (r.status) plain += ' (HTTP ' + r.status + ')';
+ }
throw new Error(plain);
}
return j;
@@ -1816,6 +2677,7 @@
});
}
if (btnQuizCarrySave) btnQuizCarrySave.addEventListener('click', saveQuizCarryPanel);
+ wireQuizCarryPlaqueMapScaleControls();
quizCarryBindThemePickersOnce();
var btnQbBattleAdd = el('btn-qb-battle-add');
diff --git a/www/html/Admin/api/game-quiz-carry-plaque-upload.php b/www/html/Admin/api/game-quiz-carry-plaque-upload.php
new file mode 100644
index 0000000..1d862d4
--- /dev/null
+++ b/www/html/Admin/api/game-quiz-carry-plaque-upload.php
@@ -0,0 +1,69 @@
+ false, 'error' => 'ต้องเปิด PHP extension curl · Enable php-curl'], 500);
+}
+
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+if ($method !== 'POST') {
+ json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+}
+
+$raw = file_get_contents('php://input');
+if ($raw === false) {
+ $raw = '';
+}
+$contentLen = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
+if ($raw === '' && $contentLen > 0) {
+ json_response([
+ 'ok' => false,
+ 'error' => 'รับ body ไม่ได้ (มักเกิดจาก nginx client_max_body_size หรือ PHP post_max_size เล็กเกินไป) — ต้องตั้งอย่างน้อย ~20M สำหรับ /Admin/api/*.php · Request body was discarded (raise nginx body limit + PHP post_max_size)',
+ ], 413);
+}
+
+$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001';
+$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
+$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-carry-plaque-upload';
+
+$ch = curl_init($target);
+if ($ch === false) {
+ json_response(['ok' => false, 'error' => 'curl init failed'], 500);
+}
+
+curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $raw,
+ CURLOPT_HTTPHEADER => [
+ 'Accept: application/json',
+ 'Content-Type: application/json',
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 120,
+]);
+
+$out = curl_exec($ch);
+$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+$err = curl_error($ch);
+curl_close($ch);
+
+if ($out === false || $out === '') {
+ json_response([
+ 'ok' => false,
+ 'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจ systemd/pm2 และพอร์ต ' . $port . ' · ' . $err,
+ ], 502);
+}
+
+http_response_code($code > 0 ? $code : 500);
+header('Content-Type: application/json; charset=utf-8');
+header('X-Content-Type-Options: nosniff');
+header('Cache-Control: no-store');
+echo $out;
diff --git a/www/html/Admin/api/game-quiz-settings.php b/www/html/Admin/api/game-quiz-settings.php
index 0e2ff72..da3cbda 100644
--- a/www/html/Admin/api/game-quiz-settings.php
+++ b/www/html/Admin/api/game-quiz-settings.php
@@ -52,7 +52,7 @@ function merge_quiz_settings_disk_from_put(string $rawBody): bool
if ($hadFileButInvalidJson) {
return false;
}
- $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'questions', 'carryQuestions', 'battleQuizMcq'];
+ $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'carryEmbedCountdownTheme', 'carryChoicePlaqueTheme', 'carryChoicePlaqueThemes', 'carryChoicePlaqueMapScale', 'questions', 'carryQuestions', 'battleQuizMcq'];
foreach ($mergeKeys as $k) {
if (array_key_exists($k, $patch)) {
$base[$k] = $patch[$k];
@@ -99,7 +99,11 @@ function overlay_quiz_settings_from_disk(string $nodeBody): string
if (array_key_exists('carryMapPanelTheme', $disk) && is_array($disk['carryMapPanelTheme'])) {
$j['carryMapPanelTheme'] = $disk['carryMapPanelTheme'];
}
- foreach (['carryReadMs', 'carryAnswerMs', 'carrySessionLength'] as $ck) {
+ if (array_key_exists('carryEmbedCountdownTheme', $disk) && is_array($disk['carryEmbedCountdownTheme'])) {
+ $j['carryEmbedCountdownTheme'] = $disk['carryEmbedCountdownTheme'];
+ }
+ /* ไม่ทับ carryChoicePlaqueThemes / carryChoicePlaqueTheme จากดิสก์ — ใช้ค่าจาก Node (อ่านไฟล์เดียวกันกับ disk merge) เพื่อกันทับด้วย JSON เก่าที่ไม่มี plaqueImageUrl ทำให้ช่อง URL ใน Admin ว่างหลังรีเฟรช */
+ foreach (['carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryChoicePlaqueMapScale'] as $ck) {
if (array_key_exists($ck, $disk)) {
$j[$ck] = $disk[$ck];
}
@@ -169,6 +173,15 @@ function proxy_quiz_curl(string $method, ?string $body = null): void
if (isset($dj['carryMapPanelTheme']) && is_array($dj['carryMapPanelTheme'])) {
$payload['carryMapPanelTheme'] = $dj['carryMapPanelTheme'];
}
+ if (isset($dj['carryEmbedCountdownTheme']) && is_array($dj['carryEmbedCountdownTheme'])) {
+ $payload['carryEmbedCountdownTheme'] = $dj['carryEmbedCountdownTheme'];
+ }
+ if (isset($dj['carryChoicePlaqueThemes']) && is_array($dj['carryChoicePlaqueThemes'])) {
+ $payload['carryChoicePlaqueThemes'] = $dj['carryChoicePlaqueThemes'];
+ }
+ if (isset($dj['carryChoicePlaqueTheme']) && is_array($dj['carryChoicePlaqueTheme'])) {
+ $payload['carryChoicePlaqueTheme'] = $dj['carryChoicePlaqueTheme'];
+ }
if (array_key_exists('carryReadMs', $dj)) {
$payload['carryReadMs'] = $dj['carryReadMs'];
}
diff --git a/www/html/Admin/game-quiz-settings.php b/www/html/Admin/game-quiz-settings.php
index 0d9f6f1..12a832c 100644
--- a/www/html/Admin/game-quiz-settings.php
+++ b/www/html/Admin/game-quiz-settings.php
@@ -46,7 +46,7 @@ function merge_quiz_settings_disk_from_put(string $rawBody): bool
}
}
}
- $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'questions', 'carryQuestions', 'battleQuizMcq'];
+ $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'carryEmbedCountdownTheme', 'questions', 'carryQuestions', 'battleQuizMcq'];
foreach ($mergeKeys as $k) {
if (array_key_exists($k, $patch)) {
$base[$k] = $patch[$k];
@@ -149,6 +149,9 @@ function proxy_quiz_curl(string $method, ?string $body = null): void
if (isset($dj['carryMapPanelTheme']) && is_array($dj['carryMapPanelTheme'])) {
$payload['carryMapPanelTheme'] = $dj['carryMapPanelTheme'];
}
+ if (isset($dj['carryEmbedCountdownTheme']) && is_array($dj['carryEmbedCountdownTheme'])) {
+ $payload['carryEmbedCountdownTheme'] = $dj['carryEmbedCountdownTheme'];
+ }
if (array_key_exists('carryReadMs', $dj)) {
$payload['carryReadMs'] = $dj['carryReadMs'];
}
diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html
index 2ae322c..9ac2165 100644
--- a/www/html/Admin/index.html
+++ b/www/html/Admin/index.html
@@ -9,7 +9,7 @@
-
+
รูปป้ายแต่ละช่อง: เลือกไฟล์แล้วกด «บันทึก» ได้เลย — ระบบจะรอให้อัปโหลดจบแล้วค่อยบันทึก URL ลงเซิร์ฟ (หรือรอเห็น «อัปโหลดแล้ว» ก่อนก็ได้) · ปุ่มบันทึกไม่ส่งไฟล์แทนการอัปโหลด · English: Pick plaque file then Save — we wait for upload to finish before writing URLs (Save does not replace the upload step).