Demo
สิ่งที่ต้องเตรียมก่อน
- สร้าง Google Sheet ใหม่ 1 ไฟล์
- เปลี่ยนชื่อ Sheet (Tab ด้านล่าง) เป็น
Data - ที่แถวแรก (Header) ให้ใส่หัวข้อตามนี้:
ID,Text,Color,Rotation,Timestamp,AuthorName,AuthorEmail,UID - ไปที่ Extensions (ส่วนขยาย) > Apps Script
- เปิดไฟล์
Code.gsและวางโค้ด script ลงไป - ตั้งค่า ID ของ Google Sheet ที่คุณสร้าง ใน const SPREADSHEET_ID
const SPREADSHEET_ID = 'วาง_SHEET_ID_ของคุณที่นี่'; // ดูจาก URL ของ Google Sheet - สร้างไฟล์ HTML ใหม่ใน Apps Script ชื่อ
Index.htmlและวางโค้ดส่วน HTML ลงไป
1. ไฟล์ Code.gs (ฝั่ง Server)
// === ตั้งค่า ID ของ Google Sheet ที่คุณสร้าง ===
const SPREADSHEET_ID = 'วาง_SHEET_ID_ของคุณที่นี่'; // ดูจาก URL ของ Google Sheet
const SHEET_NAME = 'Data';
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle('กำแพงระบายความในใจ (Google Sheets ver.)')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// === Helper Functions ===
function getSheet() {
return SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
}
function getUserInfo() {
// ดึงข้อมูลผู้ใช้ปัจจุบันที่รัน Script
const email = Session.getActiveUser().getEmail();
const key = Session.getTemporaryActiveUserKey(); // ใช้เป็น UID ชั่วคราว (ปลอดภัยกว่าส่ง Email ตรงๆ ในบางเคส)
return {
email: email,
uid: key || email // fallback
};
}
// === API Functions ===
// 1. ดึงข้อมูลทั้งหมด
function getConfessions() {
const sheet = getSheet();
const data = sheet.getDataRange().getDisplayValues();
const headers = data.shift(); // เอาหัวตารางออก
// แปลง Array เป็น Array of Objects
const confessions = data.map(row => ({
id: row[0],
text: row[1],
color: row[2],
rotation: row[3],
timestamp: row[4], // Google Sheet ส่งมาเป็น Date Object อยู่แล้ว
authorName: row[5],
authorEmail: row[6],
uid: row[7]
}));
// ส่งข้อมูล User ปัจจุบันไปด้วย เพื่อเช็คความเป็นเจ้าของ
return {
currentUser: getUserInfo(),
data: confessions
};
}
// 2. เพิ่มข้อมูลใหม่
function addConfession(payload) {
const sheet = getSheet();
const id = Utilities.getUuid();
const user = getUserInfo();
const timestamp = new Date();
// ตัดชื่อจาก Email (เนื่องจาก GAS ดึง DisplayName ตรงๆ ยากถ้าไม่ใช่ Workspace)
const displayName = user.email.split('@')[0];
sheet.appendRow([
id,
payload.text,
payload.color,
payload.rotation,
timestamp,
displayName, // AuthorName
user.email, // AuthorEmail
user.uid // UID
]);
return { success: true };
}
// 3. แก้ไขข้อมูล
function updateConfession(id, newText) {
const sheet = getSheet();
const data = sheet.getDataRange().getValues();
const user = getUserInfo();
// ค้นหาแถวที่ ID ตรงกัน และ UID ตรงกัน (Security Check)
for (let i = 1; i < data.length; i++) {
if (data[i][0] == id && data[i][7] == user.uid) {
// แก้ไข Column ที่ 2 (Index 1) คือ Text (แถวใน Sheet เริ่มนับ 1, array เริ่ม 0)
sheet.getRange(i + 1, 2).setValue(newText);
return { success: true };
}
}
throw new Error("ไม่สามารถแก้ไขได้ (ไม่พบ ID หรือคุณไม่ใช่เจ้าของ)");
}
// 4. ลบข้อมูล
function deleteConfession(id) {
const sheet = getSheet();
const data = sheet.getDataRange().getValues();
const user = getUserInfo();
for (let i = 1; i < data.length; i++) {
if (data[i][0] == id && data[i][7] == user.uid) {
sheet.deleteRow(i + 1);
return { success: true };
}
}
throw new Error("ไม่สามารถลบได้");
}
2. ไฟล์ Index.html (ฝั่ง Client)
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>กำแพงระบายความในใจ (Sheets Edition) 🤫</title>
<script src="https://cdn.tailwindcss.com"></script>
<link
href="https://fonts.googleapis.com/css2?family=Mali:wght@300;500;700&display=swap"
rel="stylesheet"
/>
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<style>
body { font-family: "Mali", cursive; }
@keyframes popIn {
0% { transform: scale(0.5) rotate(0deg); opacity: 0; }
80% { transform: scale(1.1) rotate(5deg); opacity: 1; }
100% { transform: scale(1) rotate(var(--rotation)); opacity: 1; }
}
.note-enter { animation: popIn 0.5s ease-out forwards; }
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.slide-up { animation: slideUp 0.3s ease-out forwards; }
</style>
</head>
<body class="bg-gray-800 min-h-screen text-gray-800 relative overflow-x-hidden selection:bg-pink-300 selection:text-pink-900">
<div class="fixed inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(#ffffff 2px, transparent 2px); background-size: 30px 30px;"></div>
<div id="networkStatus" class="fixed bottom-4 right-4 z-[60] hidden transition-all duration-300 transform translate-y-20">
<div class="bg-gray-900 text-white px-4 py-2 rounded-full shadow-lg flex items-center gap-2 border border-white/20">
<i id="statusIcon" class="ph-fill ph-wifi-slash text-red-400 animate-pulse"></i>
<span id="statusText" class="text-sm font-bold">Offline Mode</span>
</div>
</div>
<div id="loginScreen" class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-900/95 backdrop-blur-sm transition-opacity duration-500">
<h1 class="text-5xl font-bold text-white mb-8 drop-shadow-lg text-center leading-normal">
ยินดีต้อนรับ<br><span class="text-2xl text-yellow-300 font-normal">สู่กำแพงความลับ (Google Sheets)</span>
</h1>
<button onclick="enterApp()" class="bg-white hover:bg-gray-100 text-gray-800 font-bold py-3 px-8 rounded-full shadow-lg transform hover:scale-105 transition flex items-center gap-3 text-xl group">
<img src="https://www.svgrepo.com/show/475656/google-color.svg" class="w-8 h-8 group-hover:rotate-12 transition-transform" alt="Google Logo" />
เข้าสู่กำแพง (Login แล้ว)
</button>
<p class="text-gray-400 mt-6 text-sm bg-black/20 px-4 py-2 rounded-lg">
🔒 ข้อมูลจะถูกบันทึกลง Google Sheet ของเจ้าของเว็บ
</p>
</div>
<div id="appContent" class="hidden opacity-0 transition-opacity duration-500">
<header class="text-center py-8 relative z-10">
<div class="absolute top-4 right-4 flex items-center gap-3 bg-gray-900/50 p-2 pl-4 rounded-full backdrop-blur-sm border border-white/10 z-50 hover:bg-gray-900/80 transition-colors">
<div class="text-right hidden md:block">
<p id="userDisplayName" class="text-white font-bold text-sm leading-tight">Loading...</p>
<p id="userEmailDisplay" class="text-gray-300 text-[10px] leading-tight">...</p>
</div>
<img id="userAvatar" src="https://ui-avatars.com/api/?name=User&background=random" class="w-8 h-8 rounded-full border border-white shadow-sm bg-gray-600" />
</div>
<h1 class="text-4xl md:text-6xl font-bold text-white drop-shadow-lg transform -rotate-2 relative z-0">
📢 ระบายมา... Google Sheet รับฟัง
</h1>
<p class="text-gray-300 mt-2 text-lg relative z-0">(ข้อมูลปลอดภัย...มั้ง?) 🤭</p>
</header>
<div class="max-w-xl mx-auto px-4 mb-10 relative z-10">
<div class="bg-white/10 backdrop-blur-md p-6 rounded-2xl shadow-xl border border-white/20 transition-all hover:bg-white/15">
<textarea id="messageInput" rows="3" class="w-full p-4 rounded-xl bg-white/90 focus:outline-none focus:ring-4 focus:ring-yellow-300 text-lg placeholder-gray-400 shadow-inner resize-none text-gray-800" placeholder="พิมพ์อะไรก็ได้... บอกความในใจเราให้เขารู้?"></textarea>
<div class="flex justify-between items-center mt-4">
<div class="flex gap-2">
<button onclick="selectColor('yellow')" class="w-8 h-8 rounded-full bg-yellow-300 ring-2 ring-white ring-offset-2 ring-offset-gray-800 hover:scale-110 transition color-btn shadow-md" data-color="yellow"></button>
<button onclick="selectColor('pink')" class="w-8 h-8 rounded-full bg-pink-300 ring-transparent hover:ring-2 ring-white hover:scale-110 transition color-btn shadow-md" data-color="pink"></button>
<button onclick="selectColor('blue')" class="w-8 h-8 rounded-full bg-sky-300 ring-transparent hover:ring-2 ring-white hover:scale-110 transition color-btn shadow-md" data-color="blue"></button>
<button onclick="selectColor('green')" class="w-8 h-8 rounded-full bg-green-300 ring-transparent hover:ring-2 ring-white hover:scale-110 transition color-btn shadow-md" data-color="green"></button>
</div>
<button onclick="postConfession()" id="postBtn" class="bg-yellow-400 hover:bg-yellow-500 text-gray-900 font-bold py-2 px-6 rounded-full shadow-lg transform active:scale-95 transition flex items-center gap-2">
<span>แปะเลย!</span>
<i class="ph-bold ph-paper-plane-tilt"></i>
</button>
</div>
</div>
</div>
<main class="max-w-7xl mx-auto px-4 pb-20 relative z-0">
<div class="text-center mb-4">
<button onclick="loadData()" class="text-white/50 hover:text-white text-sm bg-white/10 px-3 py-1 rounded-full backdrop-blur-sm transition">
<i class="ph-bold ph-arrows-clockwise"></i> รีโหลดข้อมูล
</button>
</div>
<div id="wallContainer" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 auto-rows-max">
<div id="loading" class="col-span-full text-center text-white py-10 animate-pulse flex flex-col items-center gap-4">
<i class="ph-duotone ph-spinner-gap text-4xl animate-spin"></i>
<span>กำลังเรียกข้อมูลจาก Google Sheets...</span>
</div>
</div>
</main>
</div>
<script>
// === Global Variables ===
let currentUser = null;
let selectedColor = "yellow";
const colorMap = {
yellow: "bg-yellow-200 text-yellow-900 rotate-1 shadow-yellow-500/20",
pink: "bg-pink-200 text-pink-900 -rotate-1 shadow-pink-500/20",
blue: "bg-sky-200 text-sky-900 rotate-2 shadow-sky-500/20",
green: "bg-green-200 text-green-900 -rotate-2 shadow-green-500/20",
};
// === Entry Point ===
function enterApp() {
// ซ่อนหน้า Login
document.getElementById("loginScreen").classList.add("hidden");
const appContent = document.getElementById("appContent");
appContent.classList.remove("hidden");
// Animation
requestAnimationFrame(() => {
appContent.classList.remove("opacity-0");
});
// เริ่มโหลดข้อมูล
loadData();
}
// === Data Fetching (Google Script Run) ===
function loadData() {
const wallContainer = document.getElementById("wallContainer");
const loading = document.getElementById("loading");
// แสดง loading เล็กน้อยถ้าไม่ใช่ครั้งแรก
if(wallContainer.children.length > 1) {
// ไม่ต้องทำอะไร ถ้ามีข้อมูลอยู่แล้ว แค่อัพเดทเงียบๆ
} else {
loading.style.display = "flex";
}
google.script.run
.withSuccessHandler(renderConfessions)
.withFailureHandler(handleError)
.getConfessions();
}
function renderConfessions(response) {
const { currentUser: user, data } = response;
currentUser = user; // เก็บข้อมูล User ปัจจุบันไว้ใช้เช็คสิทธิ์
// อัพเดท Profile Header
const displayName = user.email.split('@')[0];
document.getElementById("userDisplayName").innerText = displayName;
document.getElementById("userEmailDisplay").innerText = user.email;
document.getElementById("userAvatar").src = "https://ui-avatars.com/api/?name=" + displayName;
const wallContainer = document.getElementById("wallContainer");
wallContainer.innerHTML = ""; // ล้างข้อมูลเก่า
if (data.length === 0) {
wallContainer.innerHTML = `
<div class="col-span-full text-center text-white/50 py-10 flex flex-col items-center">
<i class="ph-duotone ph-pencil-slash text-6xl mb-4 opacity-50"></i>
<span class="text-xl font-light">Sheet ยังว่างเปล่า... เริ่มคนแรกสิ!</span>
</div>`;
return;
}
// Sort: ใหม่สุดขึ้นก่อน (Timestamp อยู่ index 4)
data.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
data.forEach(item => {
createNoteElement(item);
});
}
function createNoteElement(data) {
const noteId = data.id;
const date = data.timestamp;
const div = document.createElement("div");
div.id = `note-${noteId}`;
const colorClass = colorMap[data.color] || colorMap["yellow"];
div.className = `group p-6 rounded-lg shadow-md hover:shadow-2xl transition-all duration-300 cursor-default note-enter relative min-h-[200px] flex flex-col justify-between ${colorClass}`;
const rot = data.rotation || Math.random() * 6 - 3;
div.style.setProperty("--rotation", `${rot}deg`);
// เช็คสิทธิ์การแก้ไข (ใช้ UID ที่ได้จาก Server)
const isOwner = currentUser && data.uid === currentUser.uid;
const buttonsHtml = isOwner ? `
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button onclick="editConfession('${noteId}')" class="bg-white/50 hover:bg-white p-1 rounded-full text-sm w-8 h-8 flex items-center justify-center shadow-sm text-gray-700" title="แก้ไข"><i class="ph-bold ph-pencil-simple"></i></button>
<button onclick="deleteConfession('${noteId}')" class="bg-white/50 hover:bg-red-500 hover:text-white p-1 rounded-full text-sm w-8 h-8 flex items-center justify-center shadow-sm text-gray-700 transition-colors" title="ลบ"><i class="ph-bold ph-trash"></i></button>
</div>
` : `<div></div>`;
div.innerHTML = `
<div class="absolute -top-3 left-1/2 transform -translate-x-1/2 w-4 h-4 rounded-full bg-red-500 shadow-sm z-10 border border-red-700"></div>
<div class="absolute -top-3 left-1/2 transform -translate-x-1/2 w-1 h-1 bg-white/50 rounded-full z-20"></div>
<div class="mb-4">
<p class="note-text text-xl font-medium leading-relaxed break-words whitespace-pre-wrap">${data.text}</p>
</div>
<div class="mt-auto pt-3 border-t border-black/10">
<div class="flex items-center gap-2 mb-2 opacity-80">
<div class="w-8 h-8 rounded-full bg-black/10 flex items-center justify-center text-sm overflow-hidden font-bold text-black/50 uppercase border border-white/30">
${data.authorName.charAt(0)}
</div>
<div class="flex flex-col leading-none overflow-hidden">
<span class="text-sm font-bold truncate">${data.authorName}</span>
<span class="text-[10px] opacity-70 truncate">${data.authorEmail}</span>
</div>
</div>
<div class="flex justify-between items-center h-8">
${buttonsHtml}
<div class="flex items-center gap-1 text-xs font-bold opacity-60">
<span>${timeAgo(date)}</span>
</div>
</div>
</div>
`;
document.getElementById("wallContainer").appendChild(div);
}
// === Actions ===
function postConfession() {
const input = document.getElementById("messageInput");
const text = input.value.trim();
if (!text) {
input.focus();
input.classList.add('ring-red-400');
setTimeout(() => input.classList.remove('ring-red-400'), 1000);
return;
}
const btn = document.getElementById("postBtn");
const originalBtnContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i class="ph-duotone ph-spinner animate-spin"></i> บันทึก...`;
const payload = {
text: text,
color: selectedColor,
rotation: Math.random() * 6 - 3
};
google.script.run
.withSuccessHandler(() => {
input.value = "";
btn.disabled = false;
btn.innerHTML = `<i class="ph-bold ph-check"></i> แปะแล้ว!`;
setTimeout(() => { btn.innerHTML = originalBtnContent; }, 1000);
loadData(); // รีโหลดข้อมูลใหม่
})
.withFailureHandler((err) => {
handleError(err);
btn.disabled = false;
btn.innerHTML = originalBtnContent;
})
.addConfession(payload);
}
function editConfession(id) {
const currentTextElement = document.querySelector(`#note-${id} .note-text`);
if(!currentTextElement) return;
const currentText = currentTextElement.innerText;
const newText = prompt("แก้ไขข้อความ:", currentText);
if (newText !== null && newText.trim() !== "") {
// Optimistic UI Update (แก้ที่หน้าจอไปก่อนเลย)
currentTextElement.innerText = newText;
google.script.run
.withFailureHandler((err) => {
alert("บันทึกไม่สำเร็จ: " + err.message);
currentTextElement.innerText = currentText; // คืนค่าเดิมถ้าพัง
})
.updateConfession(id, newText);
}
}
function deleteConfession(id) {
if (confirm("จะฉีกโน้ตนี้ทิ้งจริงๆ หรอ?")) {
const noteElement = document.getElementById(`note-${id}`);
noteElement.style.opacity = '0.5'; // ทำให้จางลงก่อน
google.script.run
.withSuccessHandler(() => {
noteElement.remove();
})
.withFailureHandler((err) => {
alert("ลบไม่สำเร็จ: " + err.message);
noteElement.style.opacity = '1';
})
.deleteConfession(id);
}
}
// === Utils ===
function handleError(error) {
console.error(error);
alert("เกิดข้อผิดพลาด: " + error.message);
}
function selectColor(color) {
selectedColor = color;
document.querySelectorAll(".color-btn").forEach((btn) => {
btn.classList.remove("ring-2", "ring-white", "ring-offset-2", "ring-offset-gray-800");
btn.classList.add("ring-transparent");
btn.style.transform = "scale(1)";
});
const target = event.currentTarget;
target.classList.remove("ring-transparent");
target.classList.add("ring-2", "ring-white", "ring-offset-2", "ring-offset-gray-800");
target.style.transform = "scale(1.1)";
}
function timeAgo(dateString) {
// แยกวันที่และเวลา
const [datePart, timePart] = dateString.split(', ');
const [day, month, year] = datePart.split('/');
const [hour, minute, second] = timePart.split(':');
const date = new Date(
year,
month - 1,
day,
hour,
minute,
second
);
const seconds = Math.floor((new Date() - date) / 1000);
let interval = seconds / 31536000;
if (interval >= 1) return Math.floor(interval) + " ปีที่แล้ว";
interval = seconds / 2592000;
if (interval >= 1) return Math.floor(interval) + " เดือนที่แล้ว";
interval = seconds / 86400;
if (interval >= 1) return Math.floor(interval) + " วันที่แล้ว";
interval = seconds / 3600;
if (interval >= 1) return Math.floor(interval) + " ชม.ที่แล้ว";
interval = seconds / 60;
if (interval >= 1) return Math.floor(interval) + " นาทีก่อน";
return "เมื่อกี้";
}
// === Network Status UI (Visual Only for GAS) ===
window.addEventListener('offline', () => {
const ns = document.getElementById("networkStatus");
ns.classList.remove("hidden", "translate-y-20");
ns.classList.add("slide-up");
});
window.addEventListener('online', () => {
const ns = document.getElementById("networkStatus");
document.getElementById("statusIcon").className = "ph-fill ph-wifi-high text-green-400";
document.getElementById("statusText").innerText = "Online";
setTimeout(() => { ns.classList.add("translate-y-20"); }, 3000);
});
</script>
</body>
</html>
วิธีการ Deploy (สำคัญมาก!)
เนื่องจากเราต้องการให้หน้าระบบรู้ว่า "ใครเป็นคนใช้งาน" เพื่อใช้แสดงชื่อและเช็คสิทธิ์การลบ/แก้ไข ต้องตั้งค่าตามนี้ครับ:
- กดปุ่ม Deploy (สีน้ำเงินมุมขวาบน) > New deployment.
- เลือกประเภทเป็น Web app.
ตั้งค่าตามนี้:
- Description: อะไรก็ได้ (เช่น V1)
- Execute as: เลือก ฉัน (me)
- Who has access: เลือก Anyone with Google account (ใครก็ได้ที่มีบัญชี Google)
- กด Deploy.
- จะได้ URL มา ให้ copy ไปเปิดใช้งานได้เลยครับ