สร้าง Post-It Notes GAS + G Sheets

 


Demo


สิ่งที่ต้องเตรียมก่อน

  1. สร้าง Google Sheet ใหม่ 1 ไฟล์
  2. เปลี่ยนชื่อ Sheet (Tab ด้านล่าง) เป็น Data
  3. ที่แถวแรก (Header) ให้ใส่หัวข้อตามนี้: ID, Text, Color, Rotation, Timestamp, AuthorName, AuthorEmail, UID
  4. ไปที่ Extensions (ส่วนขยาย) > Apps Script
  5. เปิดไฟล์  Code.gs และวางโค้ด script ลงไป
  6. ตั้งค่า ID ของ Google Sheet ที่คุณสร้าง ใน const SPREADSHEET_ID
    const SPREADSHEET_ID = 'วาง_SHEET_ID_ของคุณที่นี่'; // ดูจาก URL ของ Google Sheet
  7. สร้างไฟล์ 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 ไปเปิดใช้งานได้เลยครับ

แสดงความคิดเห็น (0)
ใหม่กว่า เก่ากว่า