แจกฟรี พร้อมวิธีการติดตั้ง! KruBoard — กระดานส่งการบ้าน
KruBoard — กระดานส่งการบ้าน
ที่ครูสร้างเองได้ ฟรี 100%
นักเรียนถ่ายรูปงานส่งผ่านมือถือ ครูตรวจให้คะแนนเห็นทั้งห้องในจอเดียว — ทำงานบน Google ทั้งหมด ข้อมูลอยู่ในบัญชีครูเอง ไม่มีค่าใช้จ่าย ไม่ต้องมีเซิร์ฟเวอร์ บทความนี้แจกโค้ดเต็ม พร้อมวิธีติดตั้งทีละขั้น
📌 KruBoard คืออะไร?
KruBoard เป็นระบบส่งการบ้านแบบกระดาน ที่ออกแบบมาเพื่อครูไทยโดยเฉพาะ ครูสร้าง "กระดาน" สำหรับการบ้านแต่ละชิ้น แล้วแจก QR code หรือลิงก์ให้นักเรียน นักเรียนถ่ายรูปงานพร้อมใส่เลขที่ส่งเข้ามา ครูก็ตรวจให้คะแนน เครื่องหมาย ✓ / ✗ / ★ และเขียนคอมเมนต์ได้ในจอเดียว เห็นงานทั้งห้องพร้อมกัน
จุดเด่นคือ ทำงานบน Google ทั้งหมด — ใช้ Google Sheets เป็นฐานข้อมูล, Google Drive เก็บรูป, และ Google Apps Script รันระบบ ทุกอย่างอยู่ในบัญชี Google ของครูเอง ไม่ต้องสมัครบริการอะไรเพิ่ม ไม่มีใครเห็นข้อมูลนักเรียนของเรา และไม่มีค่าใช้จ่ายรายเดือน
✨ ความสามารถของระบบ
- สร้างกระดานได้ไม่จำกัด — การบ้านแต่ละชิ้นคือกระดานหนึ่งใบ แยกตามวิชา/ห้อง/เรื่องได้
- นักเรียนส่งง่ายมาก — สแกน QR ใส่เลขที่ ถ่ายรูป กดส่ง ระบบย่อรูปอัตโนมัติไม่กินเน็ต
- ตรวจเห็นทั้งห้อง — รูปงานเรียงตามเลขที่ แตะดูได้ ซูม/เลื่อนภาพได้ ให้คะแนน + คอมเมนต์
- ดูงานเพื่อนได้ (เปิด/ปิดได้) — ครูเลือกได้ว่าจะให้นักเรียนเห็นงานของเพื่อนในห้องไหม
- สรุปผล + ดาวน์โหลด CSV — ดูคะแนนเฉลี่ย สูงสุด-ต่ำสุด และโหลดเข้าโปรแกรมคะแนนได้
- มี PIN ป้องกัน — หน้าตรวจงานของครูล็อกด้วย PIN คนอื่นเปิดดูไม่ได้
🛠️ วิธีติดตั้ง (ทำตามทีละขั้น)
ใช้เวลาประมาณ 10 นาที ทำครั้งเดียวจบ แนะนำให้ทำบนคอมพิวเตอร์
- เปิดเว็บ sheets.google.com
- กดปุ่ม
ว่าง (Blank)เพื่อสร้างชีตใหม่ - ตั้งชื่อไฟล์มุมซ้ายบนว่า
KruBoard(หรือชื่ออะไรก็ได้)
- ที่เมนูด้านบน กด
ส่วนขยาย (Extensions)แล้วเลือกApps Script - จะมีแท็บใหม่เปิดขึ้นมาชื่อ "Apps Script" — หน้านี้คือที่วางโค้ด
📄 ไฟล์แรก: Code.gs
- ในหน้า Apps Script จะเห็นไฟล์
Code.gsพร้อมโค้ดตัวอย่าง - ลบโค้ดเดิมทิ้งให้หมด แล้ววางโค้ดด้านล่างนี้แทน
- กดปุ่มรูปแผ่นดิสก์ 💾 (บันทึก)
/* ============================================================
* KruBoard · Google Apps Script edition (Code.gs)
* --------------------------------------------------------------
* โมเดล: ครู 1 คน = 1 สำเนา (Sheet + Script ฝังในตัว)
* - ฐานข้อมูล : Google Sheets (ชีต Boards, Subs)
* - เก็บรูป : Google Drive (โฟลเดอร์ KruBoard ในไดรฟ์ครู)
* - กันหน้าครู : PIN (เก็บใน Script Properties แบบ hash)
*
* วิธีติดตั้ง (สรุป — มีคู่มือภาพแยก):
* 1) เปิด Google Sheet เปล่า → เมนู ส่วนขยาย > Apps Script
* 2) วางไฟล์นี้เป็น Code.gs และวาง Index.html เป็นไฟล์ HTML ชื่อ "Index"
* 3) Deploy > New deployment > Web app
* - Execute as: Me (เจ้าของ)
* - Who has access: Anyone
* 4) เปิดลิงก์ที่ได้ → ตั้ง PIN ครั้งแรก → ใช้งานได้เลย
* ============================================================ */
/* ---------- ค่าคงที่ ---------- */
var SHEET_BOARDS = 'Boards';
var SHEET_SUBS = 'Subs';
var DRIVE_FOLDER = 'KruBoard (รูปงานนักเรียน)';
var PROP_PIN = 'KB_PIN_HASH';
var PROP_SALT = 'KB_PIN_SALT';
var PROP_FOLDER = 'KB_FOLDER_ID';
var PROP_SSID = 'KB_SSID'; // เก็บ ID ของ Sheet ไว้ใช้ตอนเปิดผ่าน web app
/* หัวคอลัมน์ — ลำดับนี้ใช้ทั้งอ่านและเขียน ห้ามสลับ */
var BOARD_COLS = ['id', 'title', 'room', 'roster', 'peer', 'created'];
var SUB_COLS = ['id', 'board', 'no', 'name', 'file_id', 'status', 'score', 'comment', 'created', 'reviewed'];
/* ============================================================
* ENTRY POINTS
* ============================================================ */
/**
* รันอัตโนมัติเมื่อเปิดไฟล์ Sheet — บันทึก ID ของ Sheet ไว้
* เพื่อให้ web app เปิดฐานข้อมูลได้ (ตอนเปิดผ่าน web app จะไม่มี active spreadsheet)
*/
function onOpen(e) {
captureSsId_();
try {
SpreadsheetApp.getUi()
.createMenu('KruBoard')
.addItem('เชื่อมต่อฐานข้อมูล (รันครั้งแรก)', 'captureSsId_')
.addItem('ล้าง PIN (ถ้าลืม)', 'resetPinManually')
.addToUi();
} catch (err) { /* ไม่มี UI ก็ข้าม */ }
}
/** บันทึก ID ของ Sheet ปัจจุบันลง Properties (เรียกจากบริบทที่มี active sheet) */
function captureSsId_() {
try {
var ss = SpreadsheetApp.getActiveSpreadsheet();
if (ss) {
props_().setProperty(PROP_SSID, ss.getId());
try { ss.toast('เชื่อมต่อฐานข้อมูลเรียบร้อย พร้อมใช้งาน', 'KruBoard', 4); } catch (e) {}
return ss.getId();
}
} catch (err) {}
return null;
}
/** เสิร์ฟหน้าเว็บ (ครู + นักเรียน ใช้ไฟล์เดียวกัน แยกด้วย ?board=) */
function doGet(e) {
// พยายามเชื่อมต่อฐานข้อมูลก่อน (เก็บ ID ถ้ายังไม่มี)
if (!props_().getProperty(PROP_SSID)) captureSsId_();
try { ensureSheets_(); } catch (err) { /* ถ้ายังเชื่อมไม่ได้ ให้หน้าเว็บโหลดได้ แล้วค่อยแจ้ง error ตอนเรียก API */ }
var t = HtmlService.createTemplateFromFile('Index');
t.boardParam = (e && e.parameter && e.parameter.board) ? String(e.parameter.board) : '';
t.webAppUrl = ScriptApp.getService().getUrl();
return t.evaluate()
.setTitle('กระดานครู · KruBoard')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, viewport-fit=cover')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
/**
* API กลาง — frontend เรียกผ่าน google.script.run.apiCall(payload)
* payload = { action, ...args, pin? }
* คืน object เสมอ (มี .ok หรือ .error)
*/
function apiCall(payload) {
// หุ้มผลลัพธ์เป็น JSON string เสมอ — เลี่ยงปัญหา google.script.run คืน null
// เมื่อ object ซับซ้อน (timestamp, nested) serialize ไม่ผ่าน
var result;
try {
result = apiDispatch_(payload);
} catch (err) {
result = { error: 'เกิดข้อผิดพลาด: ' + (err && err.message ? err.message : err) };
}
if (result === null || result === undefined) result = { error: 'ระบบไม่ตอบกลับ' };
return JSON.stringify(result);
}
function apiDispatch_(payload) {
try {
payload = payload || {};
var action = payload.action || '';
/* ---- การกระทำสาธารณะ (นักเรียน — ไม่ต้อง PIN) ---- */
switch (action) {
case 'getBoardPublic': return getBoardPublic_(payload);
case 'submitWork': return submitWork_(payload);
case 'getResult': return getResult_(payload);
case 'getPeer': return getPeer_(payload);
case 'pinStatus': return { ok: true, hasPin: hasPin_() };
case 'setupPin': return setupPin_(payload);
case 'verifyPin': return { ok: true, valid: checkPin_(payload.pin) };
}
/* ---- ต่อจากนี้ต้องเป็นครู: ผ่าน PIN หรือเป็นเจ้าของ Sheet ---- */
if (!isTeacher_(payload.pin)) {
return { error: 'PIN ไม่ถูกต้อง หรือยังไม่ได้เข้าสู่ระบบครู', authFail: true };
}
switch (action) {
case 'listBoards': return listBoards_();
case 'createBoard': return createBoard_(payload);
case 'getSubs': return getSubs_(payload);
case 'gradeSub': return gradeSub_(payload);
case 'deleteSub': return deleteSub_(payload);
case 'deleteBoard': return deleteBoard_(payload);
}
return { error: 'ไม่รู้จักคำสั่ง: ' + action };
} catch (err) {
return { error: 'เกิดข้อผิดพลาด: ' + (err && err.message ? err.message : err) };
}
}
/** ให้ Index.html ดึง partial (เผื่ออยากแยกไฟล์ในอนาคต) */
function include(name) {
return HtmlService.createHtmlOutputFromFile(name).getContent();
}
/* ============================================================
* PIN / AUTH
* ============================================================ */
function props_() { return PropertiesService.getScriptProperties(); }
function hasPin_() { return !!props_().getProperty(PROP_PIN); }
/** เจ้าของ Sheet ถือว่าเป็นครูเสมอ (เผื่อ login Google อยู่) */
function isOwner_() {
try {
var me = Session.getEffectiveUser().getEmail();
if (!me) return false;
var owner = ss_().getOwner();
return !!(owner && me === owner.getEmail());
} catch (e) { return false; }
}
function isTeacher_(pin) {
if (isOwner_()) return true;
return checkPin_(pin);
}
function pinHash_(pin, salt) {
var raw = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(salt) + '::' + String(pin),
Utilities.Charset.UTF_8
);
return raw.map(function (b) {
var v = (b < 0 ? b + 256 : b).toString(16);
return v.length === 1 ? '0' + v : v;
}).join('');
}
function setupPin_(payload) {
var pin = (payload.pin || '').toString().trim();
if (hasPin_() && !isOwner_()) {
return { error: 'ตั้ง PIN ไว้แล้ว ถ้าลืมให้เจ้าของไฟล์รีเซ็ตในเมนู Apps Script' };
}
if (!/^\d{4,6}$/.test(pin)) return { error: 'PIN ต้องเป็นตัวเลข 4-6 หลัก' };
var salt = Utilities.getUuid();
props_().setProperty(PROP_SALT, salt);
props_().setProperty(PROP_PIN, pinHash_(pin, salt));
return { ok: true };
}
function checkPin_(pin) {
if (!hasPin_()) return false;
pin = (pin || '').toString().trim();
if (!pin) return false;
var salt = props_().getProperty(PROP_SALT);
var want = props_().getProperty(PROP_PIN);
return pinHash_(pin, salt) === want;
}
/** รันมือจากเมนู Apps Script ถ้าครูลืม PIN */
function resetPinManually() {
props_().deleteProperty(PROP_PIN);
props_().deleteProperty(PROP_SALT);
try {
SpreadsheetApp.getActive().toast('ล้าง PIN แล้ว เปิดเว็บแล้วตั้งใหม่ได้เลย', 'KruBoard', 5);
} catch (e) { Logger.log('ล้าง PIN แล้ว'); }
}
/**
* ⭐ รันฟังก์ชันนี้ครั้งเดียวก่อน Deploy (กดเลือก setupKruBoard แล้วกด ▶️ Run)
* จะเชื่อมต่อฐานข้อมูล + สร้างชีต + สร้างโฟลเดอร์รูป ให้พร้อมใช้ทันที
* ป้องกันอาการ "หมุนค้าง" ตอนเปิด web app ครั้งแรก
*/
function setupKruBoard() {
var id = captureSsId_();
ensureSheets_();
getFolder_();
Logger.log('✅ ติดตั้งเรียบร้อย! SSID = ' + id + ' — Deploy เป็น Web app ได้เลย');
try {
SpreadsheetApp.getActive().toast('✅ พร้อมใช้งาน! Deploy เป็น Web app ได้เลย', 'KruBoard', 5);
} catch (e) {}
return id;
}
/* ============================================================
* SHEETS HELPERS (DB layer)
* ============================================================ */
function ss_() {
// ลองใช้ active ก่อน (กรณีรันจากใน Sheet) — เร็วสุด
try {
var active = SpreadsheetApp.getActiveSpreadsheet();
if (active) {
// เก็บ ID ไว้เผื่อ web app เรียกครั้งหน้า
var sid = props_().getProperty(PROP_SSID);
if (!sid) props_().setProperty(PROP_SSID, active.getId());
return active;
}
} catch (e) { /* web app context — ไม่มี active, ไปใช้ ID แทน */ }
// เปิดด้วย ID ที่เก็บไว้ (กรณีเปิดผ่าน web app)
var id = props_().getProperty(PROP_SSID);
if (id) {
try { return SpreadsheetApp.openById(id); } catch (e2) {}
}
throw new Error('ยังไม่ได้เชื่อมต่อฐานข้อมูล — เปิดไฟล์ Google Sheet หนึ่งครั้ง (เมนู KruBoard > เชื่อมต่อฐานข้อมูล) แล้วลองใหม่');
}
function ensureSheets_() {
var ss = ss_();
ensureSheetWithHeader_(ss, SHEET_BOARDS, BOARD_COLS);
ensureSheetWithHeader_(ss, SHEET_SUBS, SUB_COLS);
}
function ensureSheetWithHeader_(ss, name, cols) {
var sh = ss.getSheetByName(name);
if (!sh) sh = ss.insertSheet(name);
var first = sh.getRange(1, 1, 1, cols.length).getValues()[0];
var empty = first.every(function (c) { return c === '' || c === null; });
if (empty) {
sh.getRange(1, 1, 1, cols.length).setValues([cols]);
sh.setFrozenRows(1);
}
return sh;
}
function sheet_(name) { return ss_().getSheetByName(name); }
/** อ่านทุกแถวเป็น array ของ object ตามหัวคอลัมน์ */
function readRows_(name, cols) {
var sh = sheet_(name);
var last = sh.getLastRow();
if (last < 2) return [];
var values = sh.getRange(2, 1, last - 1, cols.length).getValues();
return values.map(function (row, i) {
var o = { _row: i + 2 };
cols.forEach(function (c, j) { o[c] = row[j]; });
return o;
});
}
function appendRow_(name, cols, obj) {
var sh = sheet_(name);
var row = cols.map(function (c) { return obj[c] != null ? obj[c] : ''; });
sh.appendRow(row);
}
function updateRow_(name, cols, rowIndex, patch) {
var sh = sheet_(name);
var current = sh.getRange(rowIndex, 1, 1, cols.length).getValues()[0];
cols.forEach(function (c, j) {
if (patch.hasOwnProperty(c)) current[j] = patch[c] != null ? patch[c] : '';
});
sh.getRange(rowIndex, 1, 1, cols.length).setValues([current]);
}
function deleteRow_(name, rowIndex) {
sheet_(name).deleteRow(rowIndex);
}
function uid_() { return Utilities.getUuid().replace(/-/g, '').slice(0, 8); }
function now_() { return Date.now(); }
/* ใช้ LockService กันเขียนชนกัน (นักเรียนส่งพร้อมกันหลายคน) */
function withLock_(fn) {
var lock = LockService.getScriptLock();
lock.waitLock(20000);
try { return fn(); }
finally { lock.releaseLock(); }
}
/* ============================================================
* DRIVE HELPERS (image storage)
* ============================================================ */
function getFolder_() {
var id = props_().getProperty(PROP_FOLDER);
if (id) {
try { return DriveApp.getFolderById(id); } catch (e) { /* หาย → สร้างใหม่ */ }
}
var folder = DriveApp.createFolder(DRIVE_FOLDER);
props_().setProperty(PROP_FOLDER, folder.getId());
return folder;
}
/** บันทึกรูปจาก base64 dataURL → คืน fileId */
function saveImage_(dataUrl, filename) {
var m = /^data:([^;]+);base64,(.*)$/.exec(dataUrl || '');
if (!m) throw new Error('รูปไม่ถูกต้อง');
var contentType = m[1];
var bytes = Utilities.base64Decode(m[2]);
var blob = Utilities.newBlob(bytes, contentType, filename || 'work.jpg');
var file = getFolder_().createFile(blob);
// ให้ "ใครมีลิงก์ก็เปิดได้" เพื่อให้ <img> แสดงผลฝั่งนักเรียน
try {
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
} catch (e) { /* domain บางที่จำกัด — รูปจะเสิร์ฟผ่าน proxy แทน */ }
return file.getId();
}
function deleteImage_(fileId) {
if (!fileId) return;
try { DriveApp.getFileById(fileId).setTrashed(true); } catch (e) {}
}
/** URL รูปสำหรับแสดงผล — ใช้ thumbnail ของ Drive (เบา + เร็ว) */
function imageUrl_(fileId) {
if (!fileId) return '';
return 'https://drive.google.com/thumbnail?id=' + fileId + '&sz=w1000';
}
/* ============================================================
* PUBLIC ACTIONS (นักเรียน)
* ============================================================ */
function getBoardPublic_(payload) {
var id = (payload.board || '').toString();
var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
var b = findBy_(boards, 'id', id);
if (!b) return { error: 'ไม่พบกระดานนี้' };
return {
ok: true,
board: { id: b.id, title: b.title, room: b.room, peer: Number(b.peer) ? 1 : 0 }
};
}
function submitWork_(payload) {
return withLock_(function () {
var board = (payload.board || '').toString();
var no = parseInt(payload.no, 10);
var name = (payload.name || '').toString().slice(0, 60);
if (!board || !no || !payload.image) return { error: 'ข้อมูลไม่ครบ' };
var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
if (!findBy_(boards, 'id', board)) return { error: 'ไม่พบกระดาน' };
var fileId = saveImage_(payload.image, board + '-no' + no + '.jpg');
// ถ้าเลขที่นี้เคยส่งแล้ว → ลบรูปเก่า แล้วเขียนทับ (รีเซ็ตสถานะเป็น wait)
var subs = readRows_(SHEET_SUBS, SUB_COLS);
var existing = subs.filter(function (s) {
return String(s.board) === board && parseInt(s.no, 10) === no;
})[0];
if (existing) {
deleteImage_(existing.file_id);
updateRow_(SHEET_SUBS, SUB_COLS, existing._row, {
name: name, file_id: fileId, status: 'wait',
score: '', comment: '', created: now_(), reviewed: ''
});
} else {
appendRow_(SHEET_SUBS, SUB_COLS, {
id: uid_(), board: board, no: no, name: name, file_id: fileId,
status: 'wait', score: '', comment: '', created: now_(), reviewed: ''
});
}
return { ok: true };
});
}
function getResult_(payload) {
var board = (payload.board || '').toString();
var no = parseInt(payload.no, 10);
if (!board || !no) return { error: 'ข้อมูลไม่ครบ' };
var subs = readRows_(SHEET_SUBS, SUB_COLS);
var s = subs.filter(function (x) {
return String(x.board) === board && parseInt(x.no, 10) === no;
})[0];
if (!s) return { error: 'ยังไม่พบงานของเลขที่นี้' };
return {
ok: true,
sub: {
no: s.no, name: s.name, img: imageUrl_(s.file_id),
status: s.status || 'wait', score: s.score, comment: s.comment
}
};
}
/**
* งานเพื่อนในห้อง (สาธารณะ) — เปิดให้ดูเฉพาะเมื่อบอร์ดตั้ง peer=1
* คืนแค่ เลขที่ + รูป เท่านั้น ไม่คืนคะแนน/คอมเมนต์ (กันนักเรียนเห็นคะแนนกัน)
*/
function getPeer_(payload) {
var id = (payload.board || '').toString();
var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
var b = findBy_(boards, 'id', id);
if (!b) return { error: 'ไม่พบกระดาน' };
if (!Number(b.peer)) return { ok: true, peer: false, subs: [] };
var subs = readRows_(SHEET_SUBS, SUB_COLS)
.filter(function (s) { return String(s.board) === id; })
.sort(function (a, b2) { return parseInt(a.no, 10) - parseInt(b2.no, 10); })
.map(function (s) { return { no: s.no, name: s.name, img: imageUrl_(s.file_id) }; });
return { ok: true, peer: true, subs: subs };
}
/* ============================================================
* TEACHER ACTIONS (ต้องผ่าน PIN)
* ============================================================ */
function listBoards_() {
var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
var subs = readRows_(SHEET_SUBS, SUB_COLS);
var byBoard = {};
subs.forEach(function (s) {
var k = String(s.board);
if (!byBoard[k]) byBoard[k] = { submitted: 0, reviewed: 0 };
byBoard[k].submitted++;
if (s.status && s.status !== 'wait') byBoard[k].reviewed++;
});
var out = boards.map(function (b) {
var c = byBoard[String(b.id)] || { submitted: 0, reviewed: 0 };
return {
id: b.id, title: b.title, room: b.room,
roster: Number(b.roster) || 0, peer: Number(b.peer) ? 1 : 0,
created: b.created, submitted: c.submitted, reviewed: c.reviewed
};
});
out.sort(function (a, b) { return (b.created || 0) - (a.created || 0); });
return { ok: true, boards: out };
}
function createBoard_(payload) {
var title = (payload.title || '').toString().slice(0, 120).trim();
if (!title) return { error: 'ต้องมีชื่อการบ้าน' };
var id = uid_();
appendRow_(SHEET_BOARDS, BOARD_COLS, {
id: id, title: title,
room: (payload.room || '').toString().slice(0, 40),
roster: parseInt(payload.roster, 10) || 0,
peer: payload.peer ? 1 : 0,
created: now_()
});
return { ok: true, id: id };
}
function getSubs_(payload) {
var board = (payload.board || '').toString();
var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
if (!findBy_(boards, 'id', board)) return { error: 'ไม่พบกระดาน' };
var subs = readRows_(SHEET_SUBS, SUB_COLS)
.filter(function (s) { return String(s.board) === board; })
.sort(function (a, b) { return parseInt(a.no, 10) - parseInt(b.no, 10); });
var out = subs.map(function (s) {
return {
id: s.id, no: s.no, name: s.name, img: imageUrl_(s.file_id),
status: s.status || 'wait', score: s.score, comment: s.comment
};
});
return { ok: true, subs: out };
}
function gradeSub_(payload) {
return withLock_(function () {
var sid = (payload.id || '').toString();
var subs = readRows_(SHEET_SUBS, SUB_COLS);
var s = findBy_(subs, 'id', sid);
if (!s) return { error: 'ไม่พบงานนี้' };
updateRow_(SHEET_SUBS, SUB_COLS, s._row, {
status: payload.status || 'wait',
score: payload.score != null ? payload.score : '',
comment: payload.comment != null ? payload.comment : '',
reviewed: now_()
});
return { ok: true };
});
}
function deleteSub_(payload) {
return withLock_(function () {
var sid = (payload.id || '').toString();
var subs = readRows_(SHEET_SUBS, SUB_COLS);
var s = findBy_(subs, 'id', sid);
if (!s) return { error: 'ไม่พบงานนี้' };
deleteImage_(s.file_id);
deleteRow_(SHEET_SUBS, s._row);
return { ok: true };
});
}
function deleteBoard_(payload) {
return withLock_(function () {
var board = (payload.board || '').toString();
var subs = readRows_(SHEET_SUBS, SUB_COLS)
.filter(function (s) { return String(s.board) === board; });
// ลบรูปทั้งหมดก่อน
subs.forEach(function (s) { deleteImage_(s.file_id); });
// ลบแถว subs จากล่างขึ้นบน (กัน index เลื่อน)
subs.map(function (s) { return s._row; })
.sort(function (a, b) { return b - a; })
.forEach(function (r) { deleteRow_(SHEET_SUBS, r); });
// ลบตัวบอร์ด
var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
var b = findBy_(boards, 'id', board);
if (b) deleteRow_(SHEET_BOARDS, b._row);
return { ok: true };
});
}
/* ---------- util ---------- */
function findBy_(arr, key, val) {
val = String(val);
for (var i = 0; i < arr.length; i++) {
if (String(arr[i][key]) === val) return arr[i];
}
return null;
}
📄 ไฟล์ที่สอง: Index.html (สร้างใหม่)
- กดเครื่องหมาย
+ข้างคำว่า "ไฟล์ (Files)" แล้วเลือกHTML - ตั้งชื่อไฟล์ว่า
Indexเป๊ะๆ (ตัว I ใหญ่ ไม่ต้องพิมพ์ .html) - ลบโค้ดเดิมในไฟล์ทิ้ง แล้ววางโค้ดด้านล่างนี้แทน
- กดบันทึก 💾
Index เท่านั้น ถ้าตั้งชื่ออื่นระบบจะหาหน้าเว็บไม่เจอ<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<base target="_top">
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Thai:wght@400;500;600;700&family=Sarabun:wght@400;600;700&display=swap');
:root{
--paper:#F6F4EF;--card:#FFFFFF;--ink:#23201B;--ink-soft:#6B655C;--line:#E5E0D6;
--chalk:#1F6F5C;--chalk-deep:#155244;--marker:#E8643C;--marker-soft:#FBE7DF;
--star:#E5A516;--ok:#2E9E6B;
--shadow:0 1px 2px rgba(35,32,27,.05),0 8px 24px rgba(35,32,27,.06);
--shadow-lg:0 12px 40px rgba(35,32,27,.14);--r:18px;--r-sm:12px;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;padding:0}
body{font-family:'IBM Plex Sans Thai','Sarabun',system-ui,sans-serif;background:var(--paper);color:var(--ink);line-height:1.55;-webkit-font-smoothing:antialiased;overscroll-behavior-y:none}
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
input,select,textarea{font-family:inherit;font-size:16px}
.hidden{display:none!important}
.wrap{max-width:1080px;margin:0 auto;padding:0 16px}
.topbar{position:sticky;top:0;z-index:40;background:rgba(246,244,239,.85);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--line)}
.topbar .wrap{display:flex;align-items:center;gap:12px;height:60px}
.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px;letter-spacing:-.01em}
.brand .logo{width:30px;height:30px;border-radius:9px;background:var(--chalk);display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px;box-shadow:inset 0 0 0 2px rgba(255,255,255,.18);transform:rotate(-4deg)}
.brand small{color:var(--ink-soft);font-weight:500;font-size:12.5px;margin-left:2px}
.who{font-size:13px;color:var(--ink-soft);display:flex;align-items:center;gap:8px;margin-left:auto}
.who .dot{width:8px;height:8px;border-radius:50%;background:var(--ok)}
.who button{font-size:12px;color:var(--marker);font-weight:600}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 18px;border-radius:12px;font-weight:600;font-size:15px;background:var(--card);border:1px solid var(--line);color:var(--ink);transition:transform .08s,box-shadow .15s,background .15s}
.btn:active{transform:scale(.97)}
.btn.primary{background:var(--chalk);color:#fff;border-color:transparent;box-shadow:0 6px 16px rgba(31,111,92,.28)}
.btn.primary:active{background:var(--chalk-deep)}
.btn.ghost{background:transparent}.btn.sm{padding:8px 13px;font-size:13.5px;border-radius:10px}.btn.block{width:100%}
.btn.danger{color:var(--marker);border-color:#f3d4c9}
.btn:disabled{opacity:.5;pointer-events:none}
.hero{padding:54px 0 30px;text-align:center}
.hero h1{font-size:clamp(30px,7vw,46px);line-height:1.08;letter-spacing:-.02em;margin:0 0 14px;font-weight:700}
.hero h1 .hl{color:var(--chalk);position:relative;white-space:nowrap}
.hero h1 .hl::after{content:"";position:absolute;left:-2px;right:-2px;bottom:4px;height:10px;background:rgba(31,111,92,.16);z-index:-1;border-radius:3px}
.hero p{max-width:520px;margin:0 auto;color:var(--ink-soft);font-size:17px}
.roles{display:grid;grid-template-columns:1fr 1fr;gap:14px;max-width:560px;margin:30px auto 0}
.role{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:20px 18px;box-shadow:var(--shadow);transition:transform .12s,box-shadow .15s}
.role:active{transform:translateY(1px)}
.role .ic{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-size:21px;margin-bottom:12px}
.role.t .ic{background:var(--chalk);color:#fff}.role.s .ic{background:var(--marker-soft);color:var(--marker)}
.role h3{margin:0 0 4px;font-size:18px}.role span{font-size:13.5px;color:var(--ink-soft)}
@media(max-width:520px){.roles{grid-template-columns:1fr}}
.page{padding:24px 0 100px;animation:fade .25s ease}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.phead{display:flex;align-items:center;gap:12px;margin-bottom:20px}
.phead h2{margin:0;font-size:23px;letter-spacing:-.01em}.phead .sub{color:var(--ink-soft);font-size:14px}
.back{font-size:14px;color:var(--ink-soft);display:inline-flex;gap:5px;align-items:center;padding:6px 0;margin-bottom:4px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
.bcard{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:18px;box-shadow:var(--shadow);text-align:left;position:relative;overflow:hidden;transition:transform .12s,box-shadow .15s}
.bcard:active{transform:translateY(1px)}
.bcard .tag{display:inline-block;font-size:12px;font-weight:600;color:var(--chalk-deep);background:rgba(31,111,92,.1);padding:3px 9px;border-radius:20px;margin-bottom:10px}
.bcard h3{margin:0 0 4px;font-size:17px;line-height:1.3}.bcard .meta{font-size:13px;color:var(--ink-soft)}
.bcard .prog{margin-top:14px;height:7px;border-radius:10px;background:var(--line);overflow:hidden}
.bcard .prog>i{display:block;height:100%;background:var(--chalk);border-radius:10px;transition:width .4s}
.bcard .pnum{font-size:12.5px;color:var(--ink-soft);margin-top:7px;display:flex;justify-content:space-between}
.bcard .cardmenu{position:absolute;top:12px;right:12px;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--ink-soft);font-size:18px;background:var(--paper)}
.newcard{border:1.5px dashed var(--line);background:transparent;border-radius:var(--r);display:grid;place-items:center;min-height:150px;color:var(--ink-soft);font-weight:600;gap:8px;transition:border-color .15s,color .15s}
.newcard:hover{border-color:var(--chalk);color:var(--chalk)}
.newcard .plus{width:38px;height:38px;border-radius:50%;background:var(--card);border:1px solid var(--line);display:grid;place-items:center;font-size:22px}
.empty{text-align:center;padding:50px 20px;color:var(--ink-soft)}.empty .big{font-size:40px;margin-bottom:8px}
.field{margin-bottom:16px}.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:6px}
.field input,.field select,.field textarea{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--ink);transition:border-color .15s,box-shadow .15s}
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--chalk);box-shadow:0 0 0 3px rgba(31,111,92,.12)}
.toggle{display:flex;align-items:center;justify-content:space-between;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.toggle .lab{font-size:14px;font-weight:600}.toggle .lab small{display:block;font-weight:400;color:var(--ink-soft);font-size:12.5px;margin-top:2px}
.sw{width:48px;height:28px;border-radius:20px;background:var(--line);position:relative;transition:background .2s;flex:none}
.sw::after{content:"";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);transition:left .2s}
.sw.on{background:var(--chalk)}.sw.on::after{left:23px}
.share{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:22px;text-align:center;box-shadow:var(--shadow)}
.qrbox{width:188px;height:188px;margin:6px auto 16px;background:#fff;border-radius:14px;border:1px solid var(--line);display:grid;place-items:center;padding:12px}
.qrbox img,.qrbox canvas{width:100%;height:100%}
.linkrow{display:flex;gap:8px;margin-top:8px}
.linkrow input{flex:1;font-size:13px;padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--ink-soft)}
.scard{max-width:460px;margin:0 auto}
.stask{background:var(--chalk);color:#fff;border-radius:var(--r);padding:20px;margin-bottom:18px;box-shadow:0 8px 24px rgba(31,111,92,.25)}
.stask .tag{font-size:12px;opacity:.85;font-weight:600}.stask h2{margin:4px 0 2px;font-size:22px}.stask .rm{font-size:13.5px;opacity:.9}
.drop{border:1.5px dashed var(--line);border-radius:var(--r);background:var(--card);padding:26px 18px;text-align:center;color:var(--ink-soft);transition:border-color .15s,background .15s;display:block}
.drop.has{border-style:solid;border-color:var(--chalk);padding:12px}
.drop .cam{width:54px;height:54px;border-radius:50%;background:var(--marker-soft);color:var(--marker);display:grid;place-items:center;font-size:26px;margin:0 auto 10px}
.preview{width:100%;border-radius:12px;display:block}
.shrink-note{font-size:12px;color:var(--ok);margin-top:8px;display:flex;align-items:center;justify-content:center;gap:5px}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
.seg{display:inline-flex;background:var(--card);border:1px solid var(--line);border-radius:11px;padding:3px;gap:2px}
.seg button{padding:7px 13px;border-radius:8px;font-size:13.5px;font-weight:600;color:var(--ink-soft)}
.seg button.on{background:var(--chalk);color:#fff}
.mgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.work{background:var(--card);border:1px solid var(--line);border-radius:var(--r-sm);overflow:hidden;box-shadow:var(--shadow);position:relative;transition:transform .12s,box-shadow .15s}
.work:active{transform:scale(.985)}
.work .thumb{width:100%;aspect-ratio:3/4;object-fit:cover;display:block;background:var(--line)}
.work .wfoot{padding:8px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.work .no{font-weight:700;font-size:14px}.work .nm{font-size:12px;color:var(--ink-soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:50%;display:grid;place-items:center;font-size:14px;color:#fff;box-shadow:0 2px 6px rgba(0,0,0,.2)}
.badge.ok{background:var(--ok)}.badge.no{background:var(--marker)}.badge.star{background:var(--star)}
.work .scorechip{font-size:12px;font-weight:700;color:var(--chalk-deep);background:rgba(31,111,92,.1);padding:2px 7px;border-radius:8px}
.pending-ring{position:absolute;top:8px;left:8px;width:10px;height:10px;border-radius:50%;background:var(--marker);box-shadow:0 0 0 3px rgba(232,100,60,.2)}
.viewer{position:fixed;inset:0;z-index:60;background:rgba(20,18,15,.96);display:flex;flex-direction:column;animation:fade .2s}
.vtop{display:flex;align-items:center;gap:12px;padding:14px 16px;color:#fff;padding-top:max(14px,env(safe-area-inset-top))}
.vtop .vname{font-weight:700;font-size:16px}.vtop .vsub{font-size:12.5px;opacity:.7}
.vtools{margin-left:auto;display:flex;gap:8px}
.vbtn{width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;display:grid;place-items:center;font-size:18px}
.vbtn.danger{background:rgba(232,100,60,.22)}
.vstage{flex:1;position:relative;display:grid;place-items:center;overflow:hidden;touch-action:none}
.vstage img{max-width:100%;max-height:100%;object-fit:contain;border-radius:8px;will-change:transform;transition:transform .05s linear;user-select:none;-webkit-user-drag:none}
.varrow{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border-radius:50%;background:rgba(255,255,255,.14);color:#fff;display:grid;place-items:center;font-size:22px;z-index:2}
.varrow.l{left:10px}.varrow.r{right:10px}.varrow:disabled{opacity:.25}
.vhint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);font-size:11.5px;color:rgba(255,255,255,.55);z-index:2;pointer-events:none}
.vmark{background:#fff;padding:14px 16px;padding-bottom:max(14px,env(safe-area-inset-bottom));border-radius:20px 20px 0 0}
.stamps{display:flex;gap:10px;margin-bottom:12px}
.stamp{flex:1;padding:12px;border-radius:13px;border:1.5px solid var(--line);font-weight:700;font-size:15px;display:flex;align-items:center;justify-content:center;gap:7px;background:var(--card);transition:.12s}
.stamp.ok.on{background:var(--ok);color:#fff;border-color:transparent}
.stamp.no.on{background:var(--marker);color:#fff;border-color:transparent}
.stamp.star.on{background:var(--star);color:#fff;border-color:transparent}
.markrow{display:flex;gap:10px;align-items:stretch}
.markrow .sc{width:118px;flex:none}.markrow .sc input{text-align:center;font-weight:700;font-size:18px}
.markrow textarea{flex:1;resize:none;height:50px;padding:9px 12px}
.vsavebar{display:flex;gap:10px;margin-top:12px}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:20px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px;text-align:center}
.stat .v{font-size:26px;font-weight:700;letter-spacing:-.02em}.stat .k{font-size:12px;color:var(--ink-soft);margin-top:2px}
.stat.accent .v{color:var(--chalk)}
@media(max-width:520px){.stats{grid-template-columns:repeat(2,1fr)}}
table{width:100%;border-collapse:collapse;background:var(--card);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
th,td{text-align:left;padding:11px 14px;font-size:14px;border-bottom:1px solid var(--line)}
th{background:var(--paper);font-size:12.5px;color:var(--ink-soft);font-weight:600}
tr:last-child td{border-bottom:none}
.st-chip{font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:20px}
.st-chip.done{background:rgba(46,158,107,.12);color:var(--ok)}.st-chip.wait{background:rgba(232,100,60,.12);color:var(--marker)}
.result{max-width:460px;margin:0 auto;text-align:center}
.rsheet{background:var(--card);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow);margin-top:14px}
.rsheet img{width:100%;display:block}
.rstamp{font-size:46px;padding:14px 0 4px}.rscore{font-size:34px;font-weight:700;color:var(--chalk)}
.rcomment{background:var(--marker-soft);color:#8a3a22;border-radius:13px;padding:13px 15px;margin:14px;font-size:14.5px;text-align:left;position:relative}
.rcomment::before{content:"ครูเขียนถึงเธอ";display:block;font-size:11px;font-weight:700;color:var(--marker);margin-bottom:4px}
.peerwrap{margin-top:26px}
.peerwrap h3{font-size:15px;margin:0 0 10px;color:var(--ink-soft);font-weight:600;display:flex;align-items:center;gap:7px}
.peergrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}
.peercell{border-radius:10px;overflow:hidden;border:1px solid var(--line);background:var(--card);position:relative}
.peercell img{width:100%;aspect-ratio:3/4;object-fit:cover;display:block}
.peercell .pno{position:absolute;bottom:0;left:0;right:0;background:rgba(35,32,27,.7);color:#fff;font-size:11px;padding:2px 5px;font-weight:600}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(20px);background:var(--ink);color:#fff;padding:12px 20px;border-radius:30px;font-size:14px;font-weight:600;opacity:0;transition:.25s;z-index:90;box-shadow:var(--shadow-lg);max-width:90vw;text-align:center}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.modal-bg{position:fixed;inset:0;background:rgba(20,18,15,.4);z-index:70;display:grid;place-items:center;padding:18px;animation:fade .2s}
.modal{background:var(--card);border-radius:20px;padding:22px;max-width:420px;width:100%;box-shadow:var(--shadow-lg);max-height:90vh;overflow:auto}
.modal h3{margin:0 0 16px;font-size:20px}
.row2{display:flex;gap:10px}.row2>*{flex:1}
.spin{width:34px;height:34px;border:3px solid var(--line);border-top-color:var(--chalk);border-radius:50%;animation:sp 1s linear infinite;margin:30px auto}
@keyframes sp{to{transform:rotate(360deg)}}
.pingate{max-width:380px;margin:50px auto;background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:28px;box-shadow:var(--shadow);text-align:center}
.pingate .ic{width:54px;height:54px;border-radius:14px;background:var(--chalk);color:#fff;display:grid;place-items:center;font-size:26px;margin:0 auto 14px}
.pingate h2{margin:0 0 6px;font-size:21px}.pingate p{margin:0 0 18px;color:var(--ink-soft);font-size:14px}
.pininput{font-size:26px!important;text-align:center;letter-spacing:10px;font-weight:700}
</style>
</head>
<body>
<div class="topbar"><div class="wrap">
<div class="brand"><span class="logo">ค</span><span>กระดานครู<small>KruBoard</small></span></div>
<div id="whoami" class="who hidden"><span class="dot"></span><span id="whoTxt"></span><button onclick="teacherSignOut()">ออก</button></div>
</div></div>
<div id="app"></div>
<div class="toast" id="toast"></div>
<script>
/* ============================================================
KruBoard · frontend (Google Apps Script edition)
transport: google.script.run (ไม่ใช่ fetch)
============================================================ */
// ค่าจาก server (ฝังตอน render)
const BOARD_PARAM = '<?= boardParam ?>';
const WEBAPP_URL = '<?= webAppUrl ?>';
// PIN ครู — เก็บใน sessionStorage (ปิดแท็บแล้วถามใหม่ ปลอดภัยกว่า)
let _pin = sessionStorage.getItem('kb_pin') || null;
/* ---- เรียก server แบบ Promise (หุ้ม google.script.run) ---- */
function call(action, args) {
return new Promise((resolve, reject) => {
const payload = Object.assign({ action: action, pin: _pin }, args || {});
google.script.run
.withSuccessHandler(raw => {
let res;
try { res = (typeof raw === 'string') ? JSON.parse(raw) : raw; }
catch (e) { reject(new Error('ข้อมูลที่ได้รับไม่ถูกต้อง')); return; }
if (!res) { reject(new Error('ระบบไม่ตอบกลับ')); return; }
if (res.authFail) { teacherSignOut(); reject(new Error(res.error)); return; }
if (res.error) { reject(new Error(res.error)); return; }
resolve(res);
})
.withFailureHandler(err => reject(new Error((err && err.message) || 'การเชื่อมต่อมีปัญหา')))
.apiCall(payload);
});
}
/* ---- image compression (เหมือนเดิม แต่คืน dataURL สำหรับ GAS) ---- */
function compress(file, maxW = 1200, quality = .72) {
return new Promise((res, rej) => {
const img = new Image();
img.onload = () => {
let { width: w, height: h } = img;
if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; }
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(img, 0, 0, w, h);
const dataUrl = c.toDataURL('image/jpeg', quality);
res({ dataUrl, origKB: Math.round(file.size/1024), newKB: Math.round((dataUrl.length*0.75)/1024) });
};
img.onerror = () => rej(new Error('เปิดรูปไม่ได้'));
img.src = URL.createObjectURL(file);
});
}
/* ---- helpers ---- */
const $ = s => document.querySelector(s);
const app = $('#app');
const go = h => { location.hash = h; };
function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(t._);t._=setTimeout(()=>t.classList.remove('show'),2600)}
function esc(s){return(s||'').toString().replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
function setWho(t){const w=$('#whoami');if(t){w.classList.remove('hidden');$('#whoTxt').textContent=t}else w.classList.add('hidden')}
function loading(){app.innerHTML='<div class="wrap"><div class="spin"></div></div>'}
function qrURL(text){return 'https://api.qrserver.com/v1/create-qr-code/?size=320x320&margin=0&data='+encodeURIComponent(text)}
function studentLink(boardId){return WEBAPP_URL+'?board='+encodeURIComponent(boardId)}
/* ---- teacher session ---- */
function teacherSignOut(){_pin=null;sessionStorage.removeItem('kb_pin');go('')}
/* ============================================================ ROUTER */
async function route(){
const parts = location.hash.slice(1).split('/');
const view = parts[0], arg = parts[1];
// ถ้าเปิดด้วย ?board= → นักเรียนเข้าหน้าส่งงานทันที (ไม่ต้องเห็นหน้าครูเลย)
if (BOARD_PARAM && !view) return studentSubmit(BOARD_PARAM);
if (view==='t') return teacherGate(teacherHome);
if (view==='board') return teacherGate(()=>teacherBoard(arg));
if (view==='new') return teacherGate(teacherNew);
if (view==='share') return teacherGate(()=>shareView(arg));
if (view==='sum') return teacherGate(()=>summaryView(arg));
if (view==='s') return studentSubmit(arg);
if (view==='r') return studentResult(arg);
return landing();
}
window.addEventListener('hashchange', route);
/* ============================================================ LANDING */
function landing(){
setWho(_pin?'โหมดครู':'');
app.innerHTML=`<div class="wrap">
<div class="hero">
<h1>ส่งการบ้าน<br>ตรวจง่าย เห็น<span class="hl">ทั้งห้อง</span>ในจอเดียว</h1>
<p>นักเรียนถ่ายงานส่งในไม่กี่วิ ครูกวาดตาดูทั้งห้อง ให้คะแนนและคอมเมนต์ได้ทันที</p>
</div>
<div class="roles">
<button class="role t" onclick="go('t')"><div class="ic">ค</div><h3>ฉันเป็นครู</h3><span>ใส่ PIN เพื่อสร้างและตรวจกระดาน</span></button>
<button class="role s" onclick="openStudentEntry()"><div class="ic">นร</div><h3>ฉันเป็นนักเรียน</h3><span>ส่งงาน หรือดูผลการตรวจของตัวเอง</span></button>
</div>
</div>`;
}
function openStudentEntry(){
modal(`<h3>เข้ากระดานของห้อง</h3>
<div class="field"><label>รหัสกระดาน (จากครู)</label><input id="bc" placeholder="เช่น ab12cd34" autocomplete="off"></div>
<div class="row2"><button class="btn ghost" onclick="closeModal()">ยกเลิก</button><button class="btn primary" onclick="enterBoard()">ไปส่งงาน</button></div>`);
setTimeout(()=>$('#bc')?.focus(),100);
}
function enterBoard(){const c=$('#bc').value.trim();closeModal();if(c)go('s/'+c)}
/* ============================================================ PIN GATE */
async function teacherGate(next){
// ถ้ามี PIN ใน session แล้ว → ตรวจสอบความถูกต้องเงียบๆ ครั้งเดียว
if(_pin){
try{const r=await call('verifyPin',{});if(r.valid){return next();}}catch(e){}
_pin=null;sessionStorage.removeItem('kb_pin');
}
setWho('');
let st;try{st=await call('pinStatus',{})}catch(e){return toast(e.message)}
if(!st.hasPin) return pinSetup(next);
pinEnter(next);
}
function pinSetup(next){
app.innerHTML=`<div class="wrap"><div class="pingate">
<div class="ic">🔐</div><h2>ตั้ง PIN ครูครั้งแรก</h2>
<p>ตั้งรหัส 4-6 หลักไว้ป้องกันหน้าตรวจงาน จำให้ดีนะ — ใช้ทุกครั้งที่เข้าโหมดครู</p>
<div class="field"><input id="p1" class="pininput" type="password" inputmode="numeric" maxlength="6" placeholder="••••" onkeydown="if(event.key==='Enter')$('#p2').focus()"></div>
<div class="field"><input id="p2" class="pininput" type="password" inputmode="numeric" maxlength="6" placeholder="ยืนยันอีกครั้ง" onkeydown="if(event.key==='Enter')doPinSetup()"></div>
<button class="btn primary block" id="pBtn" onclick="doPinSetup()">ตั้ง PIN</button>
<button class="btn ghost block" style="margin-top:8px" onclick="go('')">กลับ</button>
</div></div>`;
window._afterPin=next;
setTimeout(()=>$('#p1')?.focus(),100);
}
async function doPinSetup(){
const a=$('#p1').value.trim(),b=$('#p2').value.trim();
if(!/^\d{4,6}$/.test(a))return toast('PIN ต้องเป็นตัวเลข 4-6 หลัก');
if(a!==b)return toast('PIN สองช่องไม่ตรงกัน');
$('#pBtn').disabled=true;
try{await call('setupPin',{pin:a});_pin=a;sessionStorage.setItem('kb_pin',a);(window._afterPin||teacherHome)()}
catch(e){$('#pBtn').disabled=false;toast(e.message)}
}
function pinEnter(next){
app.innerHTML=`<div class="wrap"><div class="pingate">
<div class="ic">🔐</div><h2>ใส่ PIN ครู</h2><p>กรอกรหัสเพื่อเข้าโหมดครู</p>
<div class="field"><input id="pin" class="pininput" type="password" inputmode="numeric" maxlength="6" placeholder="••••" onkeydown="if(event.key==='Enter')doPinEnter()"></div>
<button class="btn primary block" id="pBtn" onclick="doPinEnter()">เข้าสู่โหมดครู</button>
<button class="btn ghost block" style="margin-top:8px" onclick="go('')">กลับ</button>
<p style="font-size:12px;color:var(--ink-soft);margin:14px 0 0">ลืม PIN? เปิด Apps Script แล้วรันฟังก์ชัน resetPinManually</p>
</div></div>`;
window._afterPin=next;
setTimeout(()=>$('#pin')?.focus(),100);
}
async function doPinEnter(){
const p=$('#pin').value.trim();if(!p)return toast('ใส่ PIN ก่อนนะ');
$('#pBtn').disabled=true;
try{
const r=await call('verifyPin',{pin:p});
if(!r.valid){$('#pBtn').disabled=false;return toast('PIN ไม่ถูกต้อง')}
_pin=p;sessionStorage.setItem('kb_pin',p);(window._afterPin||teacherHome)();
}catch(e){$('#pBtn').disabled=false;toast(e.message)}
}
/* ============================================================ TEACHER HOME */
async function teacherHome(){
setWho('โหมดครู');loading();
let data;try{data=await call('listBoards',{})}catch(e){return toast(e.message)}
const boards=(data&&data.boards)?data.boards:[];
let cards='';
for(const b of boards){
const total=b.roster||b.submitted||0;
const pct=total?Math.min(100,Math.round(b.reviewed/total*100)):0;
cards+=`<div class="bcard">
<button class="cardmenu" onclick="event.stopPropagation();boardMenu('${b.id}','${esc(b.title).replace(/'/g,"\\'")}')">⋯</button>
<button style="all:unset;cursor:pointer;display:block;width:100%" onclick="go('board/${b.id}')">
<span class="tag">${esc(b.room||'ไม่ระบุห้อง')}</span><h3>${esc(b.title)}</h3>
<div class="meta">ส่งแล้ว ${b.submitted} ชิ้น · ตรวจแล้ว ${b.reviewed}</div>
<div class="prog"><i style="width:${pct}%"></i></div>
<div class="pnum"><span>ความคืบหน้าการตรวจ</span><span>${pct}%</span></div>
</button></div>`;
}
app.innerHTML=`<div class="wrap"><div class="page">
<div class="phead"><div><h2>กระดานของฉัน</h2><div class="sub">การบ้านแต่ละชิ้นคือกระดานหนึ่งใบ</div></div></div>
<div class="grid"><button class="newcard" onclick="go('new')"><span class="plus">+</span>สร้างการบ้านใหม่</button>${cards}</div>
${boards.length?'':'<div class="empty"><div class="big">📋</div>ยังไม่มีกระดาน เริ่มสร้างการบ้านแรกได้เลย</div>'}
</div></div>`;
}
function boardMenu(id,title){
modal(`<h3>${esc(title)}</h3>
<button class="btn block" style="margin-bottom:8px" onclick="closeModal();go('board/${id}')">✏️ เปิดตรวจงาน</button>
<button class="btn block" style="margin-bottom:8px" onclick="closeModal();go('share/${id}')">📤 แจกลิงก์ให้นักเรียน</button>
<button class="btn block" style="margin-bottom:8px" onclick="closeModal();go('sum/${id}')">📊 ดูสรุปผล</button>
<button class="btn danger block" onclick="confirmDeleteBoard('${id}','${esc(title).replace(/'/g,"\\'")}')">🗑 ลบกระดานนี้</button>`);
}
function confirmDeleteBoard(id,title){
modal(`<h3>ลบ "${esc(title)}"?</h3>
<p style="font-size:14px;color:var(--ink-soft);margin-top:-6px">จะลบงานนักเรียนและรูปทั้งหมดในกระดานนี้ — กู้คืนไม่ได้</p>
<div class="row2"><button class="btn" onclick="closeModal()">ยกเลิก</button><button class="btn danger" id="delBtn" onclick="doDeleteBoard('${id}')">ลบถาวร</button></div>`);
}
async function doDeleteBoard(id){
$('#delBtn').disabled=true;
try{await call('deleteBoard',{board:id});closeModal();toast('ลบกระดานแล้ว');teacherHome()}
catch(e){$('#delBtn').disabled=false;toast(e.message)}
}
/* ============================================================ TEACHER NEW */
function teacherNew(){
setWho('โหมดครู');
app.innerHTML=`<div class="wrap"><div class="page">
<button class="back" onclick="go('t')">← กลับ</button><div class="phead"><h2>สร้างการบ้านใหม่</h2></div>
<div style="max-width:460px">
<div class="field"><label>ชื่อการบ้าน</label><input id="f_title" placeholder="เช่น ใบงานเศษส่วน บทที่ 3"></div>
<div class="field"><label>ห้อง / ระดับชั้น</label><input id="f_room" placeholder="เช่น ม.1/2"></div>
<div class="field"><label>จำนวนนักเรียนในห้อง</label><input id="f_roster" type="number" inputmode="numeric" value="35"></div>
<div class="field"><div class="toggle" onclick="this.querySelector('.sw').classList.toggle('on')">
<div class="lab">ให้นักเรียนเห็นงานของกัน<small>เปิด = เห็นงานเพื่อนทั้งห้อง · ปิด = เห็นเฉพาะงานตัวเอง</small></div><div class="sw" id="f_peer"></div></div></div>
<button class="btn primary block" id="cbtn" onclick="createBoard()">สร้างกระดาน + รับลิงก์</button>
</div></div></div>`;
setTimeout(()=>$('#f_title')?.focus(),100);
}
async function createBoard(){
const title=$('#f_title').value.trim();if(!title)return toast('ใส่ชื่อการบ้านก่อนนะ');
$('#cbtn').disabled=true;
try{
const r=await call('createBoard',{title,room:$('#f_room').value.trim(),roster:parseInt($('#f_roster').value)||0,peer:$('#f_peer').classList.contains('on')});
go('share/'+r.id);
}catch(e){toast(e.message);$('#cbtn').disabled=false}
}
/* ============================================================ SHARE */
async function shareView(id){
setWho('โหมดครู');
const link=studentLink(id);
app.innerHTML=`<div class="wrap"><div class="page">
<button class="back" onclick="go('board/${id}')">← ไปกระดาน</button><div class="phead"><h2>แจกให้นักเรียน</h2></div>
<div class="share" style="max-width:420px;margin:0 auto">
<div class="qrbox"><img src="${qrURL(link)}" alt="QR code"></div>
<div style="font-weight:600">ฉายโค้ดนี้หน้าห้อง ให้นักเรียนสแกน</div>
<div style="font-size:13px;color:var(--ink-soft);margin-top:4px">หรือส่งลิงก์ในไลน์กลุ่มห้อง</div>
<div class="linkrow"><input id="lk" value="${link}" readonly><button class="btn sm" onclick="copyLink()">คัดลอก</button></div>
<div style="font-size:12.5px;color:var(--ink-soft);margin-top:10px">รหัสกระดาน: <b style="color:var(--chalk)">${id}</b></div>
<button class="btn primary block" style="margin-top:18px" onclick="go('board/${id}')">ไปหน้าตรวจงาน</button>
</div></div></div>`;
}
function copyLink(){const i=$('#lk');i.select();navigator.clipboard?.writeText(i.value);toast('คัดลอกลิงก์แล้ว')}
/* ============================================================ MARKING BOARD */
let _filter='all',_subsCache=[],_curBoard=null;
async function teacherBoard(id){
setWho('โหมดครู');loading();_curBoard=id;
let data;try{data=await call('getSubs',{board:id})}catch(e){toast(e.message);return go('t')}
_subsCache=data.subs;
const subs=data.subs;
const counts={all:subs.length,wait:subs.filter(s=>s.status==='wait').length,done:subs.filter(s=>s.status!=='wait').length};
let show=subs;
if(_filter==='wait')show=subs.filter(s=>s.status==='wait');
if(_filter==='done')show=subs.filter(s=>s.status!=='wait');
const seg=(k,l)=>`<button class="${_filter===k?'on':''}" onclick="setFilter('${k}','${id}')">${l}</button>`;
let cells='';
show.forEach(s=>{
const idx=subs.indexOf(s);
const badge=s.status==='ok'?'<div class="badge ok">✓</div>':s.status==='no'?'<div class="badge no">✕</div>':s.status==='star'?'<div class="badge star">★</div>':'';
const ring=s.status==='wait'?'<div class="pending-ring"></div>':'';
cells+=`<button class="work" onclick="openViewer(${idx})">${ring}${badge}
<img class="thumb" src="${s.img}" loading="lazy" referrerpolicy="no-referrer" alt="งานเลขที่ ${s.no}">
<div class="wfoot"><span class="no">เลขที่ ${s.no}</span>
${s.score!=null&&s.score!==''?`<span class="scorechip">${esc(String(s.score))}</span>`:`<span class="nm">${esc(s.name||'')}</span>`}</div></button>`;
});
app.innerHTML=`<div class="wrap"><div class="page">
<button class="back" onclick="go('t')">← กระดานทั้งหมด</button><div class="phead"><h2>ตรวจงาน</h2></div>
<div class="toolbar"><div class="seg">${seg('all','ทั้งหมด '+counts.all)}${seg('wait','ยังไม่ตรวจ '+counts.wait)}${seg('done','ตรวจแล้ว '+counts.done)}</div>
<div style="flex:1"></div><button class="btn sm" onclick="go('share/${id}')">📤 แจกลิงก์</button><button class="btn sm" onclick="go('sum/${id}')">📊 สรุปผล</button></div>
${show.length?`<div class="mgrid">${cells}</div>`:`<div class="empty"><div class="big">🪧</div>${subs.length?'ไม่มีงานในหมวดนี้':'ยังไม่มีนักเรียนส่งงาน — แจกลิงก์ให้ห้องได้เลย'}</div>`}
</div></div>`;
}
function setFilter(k,id){_filter=k;teacherBoard(id)}
/* ============================================================ VIEWER (zoom/pan/pinch) */
let _vIdx=0,_vSubs=[];
let _zoom=1,_panX=0,_panY=0;
function openViewer(idx){_vSubs=_subsCache;_vIdx=idx;renderViewer()}
function renderViewer(){
const s=_vSubs[_vIdx];
resetZoom();
const stamp=k=>`<button class="stamp ${k} ${s.status===k?'on':''}" onclick="setStamp('${k}')">${k==='ok'?'✓ ถูก':k==='no'?'✕ ผิด':'★ ดีเด่น'}</button>`;
let el=document.getElementById('viewer');
if(!el){el=document.createElement('div');el.id='viewer';el.className='viewer';document.body.appendChild(el)}
el.innerHTML=`<div class="vtop">
<div><div class="vname">เลขที่ ${s.no}${s.name?' · '+esc(s.name):''}</div><div class="vsub">${_vIdx+1} / ${_vSubs.length}</div></div>
<div class="vtools">
<button class="vbtn" onclick="resetZoom();applyZoom()" title="รีเซ็ตซูม">⊙</button>
<button class="vbtn danger" onclick="confirmDeleteSub('${s.id}')" title="ลบงานนี้">🗑</button>
<button class="vbtn" onclick="closeViewer()" title="ปิด">✕</button>
</div></div>
<div class="vstage" id="vstage">
<button class="varrow l" ${_vIdx===0?'disabled':''} onclick="vNav(-1)">‹</button>
<img id="vimg" src="${s.img}" referrerpolicy="no-referrer" alt="งานเลขที่ ${s.no}" draggable="false">
<button class="varrow r" ${_vIdx===_vSubs.length-1?'disabled':''} onclick="vNav(1)">›</button>
<div class="vhint">แตะสองครั้งเพื่อซูม · ลากเพื่อเลื่อน · บีบนิ้วซูมได้</div>
</div>
<div class="vmark"><div class="stamps">${stamp('ok')}${stamp('no')}${stamp('star')}</div>
<div class="markrow"><div class="sc field" style="margin:0"><input id="v_score" inputmode="decimal" placeholder="คะแนน" value="${s.score!=null?esc(String(s.score)):''}"></div>
<textarea id="v_comment" placeholder="คอมเมนต์ถึงนักเรียน (ไม่บังคับ)">${esc(s.comment||'')}</textarea></div>
<div class="vsavebar"><button class="btn ghost" onclick="closeViewer()">ปิด</button><button class="btn primary block" id="saveBtn" onclick="saveMark()">บันทึก & ถัดไป →</button></div></div>`;
document.body.style.overflow='hidden';
attachZoom();
}
function setStamp(k){const s=_vSubs[_vIdx];collectInputs();s.status=s.status===k?'wait':k;renderViewer()}
function collectInputs(){const sc=document.getElementById('v_score'),cm=document.getElementById('v_comment'),s=_vSubs[_vIdx];if(sc)s.score=sc.value;if(cm)s.comment=cm.value}
function vNav(d){collectInputs();const n=_vIdx+d;if(n<0||n>=_vSubs.length)return;_vIdx=n;renderViewer()}
async function saveMark(){
collectInputs();const s=_vSubs[_vIdx];
if(s.status==='wait'&&s.score!==''&&s.score!=null)s.status='ok';
$('#saveBtn').disabled=true;
try{await call('gradeSub',{id:s.id,status:s.status,score:s.score,comment:s.comment});toast('บันทึกแล้ว')}
catch(e){$('#saveBtn').disabled=false;return toast(e.message)}
if(_vIdx<_vSubs.length-1){_vIdx++;renderViewer()}else closeViewer();
}
function closeViewer(){const el=document.getElementById('viewer');if(el)el.remove();document.body.style.overflow='';if(_curBoard)teacherBoard(_curBoard)}
function confirmDeleteSub(sid){
modal(`<h3>ลบงานชิ้นนี้?</h3><p style="font-size:14px;color:var(--ink-soft);margin-top:-6px">นักเรียนจะต้องส่งใหม่ — กู้คืนรูปไม่ได้</p>
<div class="row2"><button class="btn" onclick="closeModal()">ยกเลิก</button><button class="btn danger" id="dsBtn" onclick="doDeleteSub('${sid}')">ลบ</button></div>`);
}
async function doDeleteSub(sid){
$('#dsBtn').disabled=true;
try{await call('deleteSub',{id:sid});closeModal();
const i=_vSubs.findIndex(x=>x.id===sid);if(i>=0)_vSubs.splice(i,1);
if(_vSubs.length===0){closeViewer();return}
if(_vIdx>=_vSubs.length)_vIdx=_vSubs.length-1;
_subsCache=_vSubs;renderViewer();toast('ลบแล้ว');
}catch(e){$('#dsBtn').disabled=false;toast(e.message)}
}
/* --- zoom/pan/pinch engine --- */
function resetZoom(){_zoom=1;_panX=0;_panY=0}
function applyZoom(){const img=document.getElementById('vimg');if(img)img.style.transform=`translate(${_panX}px,${_panY}px) scale(${_zoom})`}
function attachZoom(){
const stage=document.getElementById('vstage'),img=document.getElementById('vimg');
if(!stage||!img)return;
let lastTap=0,dragging=false,sx=0,sy=0,startPanX=0,startPanY=0;
let pinchStart=0,zoomStart=1;
const pts=new Map();
img.addEventListener('dblclick',e=>{e.preventDefault();_zoom=_zoom>1?1:2.2;if(_zoom===1)resetZoom();applyZoom()});
stage.addEventListener('pointerdown',e=>{
pts.set(e.pointerId,{x:e.clientX,y:e.clientY});
if(pts.size===1){dragging=true;sx=e.clientX;sy=e.clientY;startPanX=_panX;startPanY=_panY;
const now=Date.now();if(now-lastTap<300){_zoom=_zoom>1?1:2.2;if(_zoom===1)resetZoom();applyZoom();dragging=false}lastTap=now;
}else if(pts.size===2){dragging=false;const a=[...pts.values()];pinchStart=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);zoomStart=_zoom}
});
stage.addEventListener('pointermove',e=>{
if(!pts.has(e.pointerId))return;
pts.set(e.pointerId,{x:e.clientX,y:e.clientY});
if(pts.size===2){const a=[...pts.values()];const d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);
_zoom=Math.min(5,Math.max(1,zoomStart*(d/pinchStart)));if(_zoom===1)resetZoom();applyZoom();
}else if(dragging&&_zoom>1){_panX=startPanX+(e.clientX-sx);_panY=startPanY+(e.clientY-sy);applyZoom()}
});
const up=e=>{pts.delete(e.pointerId);if(pts.size<2)pinchStart=0;if(pts.size===0)dragging=false};
stage.addEventListener('pointerup',up);stage.addEventListener('pointercancel',up);
}
/* ============================================================ SUMMARY */
async function summaryView(id){
setWho('โหมดครู');loading();
let data;try{data=await call('getSubs',{board:id})}catch(e){return toast(e.message)}
const subs=data.subs;
const scored=subs.map(s=>parseFloat(s.score)).filter(n=>!isNaN(n));
const avg=scored.length?(scored.reduce((a,b)=>a+b,0)/scored.length).toFixed(1):'–';
const mx=scored.length?Math.max(...scored):'–',mn=scored.length?Math.min(...scored):'–';
const done=subs.filter(s=>s.status!=='wait').length;
let rows='';subs.forEach(s=>{rows+=`<tr><td>${s.no}</td><td>${esc(s.name||'–')}</td><td>${s.score!=null&&s.score!==''?esc(String(s.score)):'–'}</td><td>${s.status!=='wait'?'<span class="st-chip done">ตรวจแล้ว</span>':'<span class="st-chip wait">รอตรวจ</span>'}</td></tr>`});
app.innerHTML=`<div class="wrap"><div class="page"><button class="back" onclick="go('board/${id}')">← กลับกระดาน</button><div class="phead"><h2>สรุปผล</h2></div>
<div class="stats"><div class="stat accent"><div class="v">${subs.length}</div><div class="k">ส่งแล้ว</div></div>
<div class="stat"><div class="v">${done}</div><div class="k">ตรวจแล้ว</div></div>
<div class="stat accent"><div class="v">${avg}</div><div class="k">คะแนนเฉลี่ย</div></div>
<div class="stat"><div class="v">${mx}/${mn}</div><div class="k">สูงสุด/ต่ำสุด</div></div></div>
<div class="toolbar"><div style="flex:1"></div><button class="btn sm" onclick="exportCSV()">⬇ ดาวน์โหลด CSV</button></div>
<table><thead><tr><th>เลขที่</th><th>ชื่อ</th><th>คะแนน</th><th>สถานะ</th></tr></thead><tbody>${rows||'<tr><td colspan=4 style="text-align:center;color:var(--ink-soft)">ยังไม่มีข้อมูล</td></tr>'}</tbody></table>
</div></div>`;
window._sumSubs=subs;
}
function exportCSV(){
const subs=window._sumSubs||[];
let csv='\uFEFFเลขที่,ชื่อ,คะแนน,สถานะ,คอมเมนต์\n';
subs.forEach(s=>{csv+=`${s.no},"${(s.name||'').replace(/"/g,'""')}",${s.score!=null?s.score:''},${s.status!=='wait'?'ตรวจแล้ว':'รอตรวจ'},"${(s.comment||'').replace(/"/g,'""')}"\n`});
const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download='สรุปผล.csv';a.click();toast('ดาวน์โหลดแล้ว');
}
/* ============================================================ STUDENT SUBMIT */
let _pendingImg=null,_curStudentBoard=null;
async function studentSubmit(id){
setWho('');loading();_curStudentBoard=id;
let r;try{r=await call('getBoardPublic',{board:id})}catch(e){
app.innerHTML=`<div class="wrap"><div class="empty" style="padding-top:80px"><div class="big">🔍</div>ไม่พบกระดานนี้<br><span style="font-size:13px">ลองเช็ครหัสกระดานกับครูอีกครั้ง</span><br><button class="btn sm" style="margin-top:14px" onclick="go('')">กลับหน้าแรก</button></div></div>`;return}
const b=r.board;_pendingImg=null;
app.innerHTML=`<div class="wrap"><div class="page"><div class="scard">
<div class="stask"><div class="tag">ส่งการบ้าน</div><h2>${esc(b.title)}</h2><div class="rm">${esc(b.room||'')}</div></div>
<div class="field"><label>เลขที่ของเธอ</label><input id="s_no" type="number" inputmode="numeric" placeholder="เช่น 12"></div>
<div class="field"><label>ชื่อ (ไม่บังคับ)</label><input id="s_name" placeholder="ชื่อเล่นก็ได้"></div>
<div class="field"><label>รูปงานของเธอ</label>
<label class="drop" id="drop"><input type="file" accept="image/*" capture="environment" style="display:none" onchange="pickImg(this)">
<div id="dropInner"><div class="cam">📷</div>แตะเพื่อถ่ายรูป หรือเลือกจากเครื่อง</div></label></div>
<button class="btn primary block" id="subBtn" onclick="submitWork('${id}')">ส่งงาน</button>
<button class="btn ghost block" style="margin-top:8px" onclick="go('r/${id}')">ดูผลการตรวจของฉัน</button>
</div>${b.peer?'<div id="peerZone"></div>':''}</div></div>`;
if(b.peer)loadPeer(id);
}
async function pickImg(input){
const f=input.files[0];if(!f)return;
$('#dropInner').innerHTML='<div style="color:var(--ink-soft)">กำลังย่อรูป…</div>';
try{
const{dataUrl,origKB,newKB}=await compress(f);_pendingImg=dataUrl;
$('#drop').classList.add('has');
$('#dropInner').innerHTML=`<img class="preview" src="${dataUrl}"><div class="shrink-note">✓ ย่อรูปแล้ว ${origKB}KB → ${newKB}KB · แตะเพื่อเปลี่ยนรูป</div>`;
}catch(e){$('#dropInner').innerHTML='<div class="cam">📷</div>เปิดรูปไม่ได้ ลองใหม่อีกครั้ง';toast(e.message)}
}
async function submitWork(id){
const no=$('#s_no').value.trim();if(!no)return toast('ใส่เลขที่ก่อนนะ');
if(parseInt(no)<1)return toast('เลขที่ต้องมากกว่า 0');
if(!_pendingImg)return toast('ยังไม่ได้แนบรูปงาน');
$('#subBtn').disabled=true;$('#subBtn').textContent='กำลังส่ง…';
try{await call('submitWork',{board:id,no:parseInt(no),name:$('#s_name').value.trim(),image:_pendingImg})}
catch(e){$('#subBtn').disabled=false;$('#subBtn').textContent='ส่งงาน';return toast(e.message)}
app.innerHTML=`<div class="wrap"><div class="page"><div class="result">
<div style="font-size:60px;margin-top:30px">🎉</div><h2 style="margin:6px 0">ส่งงานเรียบร้อย!</h2>
<p style="color:var(--ink-soft)">เลขที่ ${esc(no)}</p>
<button class="btn primary block" style="margin-top:20px;max-width:300px;margin-inline:auto" onclick="go('r/${id}')">ดูผลการตรวจ (เมื่อครูตรวจแล้ว)</button>
<button class="btn ghost block" style="margin-top:8px;max-width:300px;margin-inline:auto" onclick="go('s/${id}')">ส่งใหม่ / ส่งคนอื่น</button>
</div></div></div>`;
}
async function loadPeer(id){
const zone=$('#peerZone');if(!zone)return;
zone.innerHTML='<div class="peerwrap"><h3>👀 งานของเพื่อนในห้อง</h3><div class="spin" style="margin:10px auto"></div></div>';
let data;try{data=await call('getPeer',{board:id})}catch(e){zone.innerHTML='';return}
if(!data.peer||!data.subs.length){
zone.innerHTML=`<div class="peerwrap"><h3>👀 งานของเพื่อนในห้อง</h3><div class="empty" style="padding:24px"><div class="big">🪧</div>ยังไม่มีเพื่อนส่งงาน เป็นคนแรกเลย!</div></div>`;return;
}
let cells='';
data.subs.forEach(s=>{
cells+=`<div class="peercell"><img src="${s.img}" loading="lazy" referrerpolicy="no-referrer" alt="งานเลขที่ ${s.no}"><div class="pno">เลขที่ ${s.no}</div></div>`;
});
zone.innerHTML=`<div class="peerwrap"><h3>👀 งานของเพื่อนในห้อง (${data.subs.length})</h3><div class="peergrid">${cells}</div></div>`;
}
/* ============================================================ STUDENT RESULT */
async function studentResult(id){
setWho('');
app.innerHTML=`<div class="wrap"><div class="page"><div class="result">
<button class="back" onclick="go('s/${id}')">← กลับไปส่งงาน</button>
<div class="phead" style="justify-content:center"><h2>ดูผลการตรวจ</h2></div>
<div class="field" style="max-width:240px;margin:0 auto"><label>ใส่เลขที่ของเธอ</label><input id="r_no" type="number" inputmode="numeric" placeholder="เลขที่" onkeydown="if(event.key==='Enter')showResult('${id}')"></div>
<button class="btn primary" style="margin-top:6px" onclick="showResult('${id}')">ดูผล</button><div id="rOut"></div></div></div></div>`;
setTimeout(()=>$('#r_no')?.focus(),100);
}
async function showResult(id){
const no=$('#r_no').value.trim();if(!no)return toast('ใส่เลขที่ก่อนนะ');
const out=$('#rOut');out.innerHTML='<div class="spin"></div>';
let r;try{r=await call('getResult',{board:id,no:parseInt(no)})}catch(e){out.innerHTML=`<div class="empty" style="padding:30px"><div class="big">🤔</div>${esc(e.message)}</div>`;return}
const s=r.sub;
if(s.status==='wait'){out.innerHTML=`<div class="rsheet"><img src="${s.img}" referrerpolicy="no-referrer"><div style="padding:18px"><div style="font-size:34px">⏳</div><div style="font-weight:600">ครูยังไม่ได้ตรวจงานนี้</div><div style="font-size:13px;color:var(--ink-soft)">กลับมาดูใหม่ทีหลังนะ</div></div></div>`;return}
const stamp=s.status==='ok'?'✅':s.status==='no'?'❌':'⭐';
out.innerHTML=`<div class="rsheet"><img src="${s.img}" referrerpolicy="no-referrer"><div class="rstamp">${stamp}</div>
${s.score!=null&&s.score!==''?`<div class="rscore">${esc(String(s.score))} คะแนน</div>`:''}
${s.comment?`<div class="rcomment">${esc(s.comment)}</div>`:'<div style="height:14px"></div>'}</div>`;
}
/* ============================================================ MODAL */
function modal(html){let m=document.createElement('div');m.className='modal-bg';m.id='mbg';m.onclick=e=>{if(e.target===m)closeModal()};m.innerHTML=`<div class="modal">${html}</div>`;document.body.appendChild(m)}
function closeModal(){const m=$('#mbg');if(m)m.remove()}
/* ---- boot ---- */
route();
</script>
</body>
</html>
- ด้านบนของหน้า Apps Script มีแถบเลือกฟังก์ชัน (ข้างปุ่ม ▶️ Run) — กดเลือก
setupKruBoard - กดปุ่ม ▶️ Run
- ครั้งแรกจะขออนุญาตสิทธิ์ → กด ตรวจสอบสิทธิ์ → เลือกบัญชี → ถ้าเจอ "Google ยังไม่ได้ยืนยันแอปนี้" กด ขั้นสูง (Advanced) → ไปที่ KruBoard (ไม่ปลอดภัย) → อนุญาต
- รอจนข้างล่างขึ้นข้อความว่า "✅ ติดตั้งเรียบร้อย" (ดูใน Execution log) — ถือว่าผ่าน
- มุมขวาบน กดปุ่มสีน้ำเงิน
ทำให้ใช้งานได้ (Deploy)แล้วเลือกการทำให้ใช้งานได้ใหม่ - กดรูปเฟือง ⚙️ ข้างคำว่า "เลือกประเภท" แล้วเลือก
เว็บแอป (Web app) - ตั้งค่าตามตารางนี้:
| ช่อง | ตั้งเป็น |
|---|---|
| คำอธิบาย (Description) | KruBoard (พิมพ์อะไรก็ได้) |
| ดำเนินการในชื่อ (Execute as) | ฉัน (Me) ← ชื่อบัญชีครู |
| ผู้มีสิทธิ์เข้าถึง (Who has access) | ทุกคน (Anyone) ← ต้องเป็นอันนี้! |
- กด
ทำให้ใช้งานได้ (Deploy)
ครั้งแรก Google จะถามขออนุญาตให้โปรแกรมเข้าถึง Sheet และ Drive ของครู
- กด
ตรวจสอบสิทธิ์ (Authorize access) - เลือกบัญชี Google ของครู
- ถ้าเจอหน้าจอเตือน "Google ยังไม่ได้ยืนยันแอปนี้" ให้กด
ขั้นสูง (Advanced)แล้วกดไปที่ KruBoard (ไม่ปลอดภัย) - กด
อนุญาต (Allow)
- หลัง Deploy เสร็จ จะมีกล่องขึ้นมาพร้อม URL ของเว็บแอป (ลิงก์ยาวๆ ขึ้นต้นด้วย
https://script.google.com/...) - คัดลอกลิงก์นี้เก็บไว้ — นี่คือลิงก์เว็บ KruBoard ของครู (บุ๊กมาร์กไว้เลย)
- เปิดลิงก์ → ครั้งแรกจะให้ ตั้ง PIN ครู 4-6 หลัก (ไว้ป้องกันหน้าตรวจงาน จำให้ดี!)
- เสร็จแล้ว! เริ่มสร้างการบ้านได้เลย
🎓 วิธีใช้งานประจำวัน
👩🏫 ฝั่งครู
- เปิดลิงก์ → กด "ฉันเป็นครู" → ใส่ PIN
- กด "สร้างการบ้านใหม่" ตั้งชื่อ เลือกห้อง
- ได้ QR + ลิงก์ → ฉายหน้าห้อง หรือส่งไลน์กลุ่ม
- แตะรูปเพื่อตรวจ ให้ ✓ / ✗ / ★ ใส่คะแนน + คอมเมนต์
- ดูสรุปผล โหลด CSV เข้าโปรแกรมคะแนน
🧑🎓 ฝั่งนักเรียน
- สแกน QR หรือเปิดลิงก์จากครู
- ใส่เลขที่ → ถ่ายรูปงาน → กดส่ง
- ระบบย่อรูปให้อัตโนมัติ ไม่กินเน็ต
- กลับมาดูผลการตรวจได้ทีหลังด้วยเลขที่
❓ ปัญหาที่พบบ่อย
setupKruBoard แล้วกด ▶️ Run หนึ่งครั้ง จากนั้น Deploy ใหม่ (เลือก New version) แล้วเปิดเว็บอีกทีIndex เป๊ะ (I ตัวใหญ่ ไม่มี .html ต่อท้าย) — กดจุดสามจุดข้างชื่อไฟล์ → เปลี่ยนชื่อ (Rename) ให้ถูก แล้ว Deploy ใหม่resetPinManually จากเมนูบนสุด → กด ▶️ รัน → กลับไปเปิดเว็บตั้ง PIN ใหม่ได้เลยDeploy → จัดการการทำให้ใช้งานได้ → กดดินสอ ✏️ → ช่องเวอร์ชันเลือก "ใหม่ (New version)" → Deploy (ใช้ลิงก์เดิม ไม่ต้องแจกใหม่)ลองเอาไปใช้ในห้องเรียนได้เลย
KruBoard เป็นเครื่องมือฟรีจากครูติ ที่อยากให้ครูไทยทุกคนมีระบบเก็บงานนักเรียนที่ดี ถ้าชอบ ฝากแชร์บทความนี้ให้เพื่อนครูด้วยนะครับ 💛
KruBoard · Google Apps Script edition · แจกฟรีโดย ครูติ (kru-ti.com)