เท็มเพลตการ์ดเมนู + ค้นหาด้วยเสียงภาษาไทย

 


Demo

💎 สรุปสิ่งที่ได้

✅ Bootstrap 5 Grid สวยหรู
✅ การ์ด Hover มีแอนิเมชัน
✅ Pagination แบบ ... (1,2,3,…,7,8)
✅ ช่องค้นหาแบบคิวรี่
✅ ค้นหาด้วยเสียงภาษาไทย
✅ ปุ่ม “ล้าง” รีเซ็ตการค้นหาได้
✅ ดึงข้อมูลจาก Google Sheet


สร้างตาราง




code.gs

     
// === Web App Template & Rendering Functions ===
function doGet() {
  const htmlTemplate = HtmlService.createTemplateFromFile('index').evaluate();
  const template =  htmlTemplate .setTitle("Project GuruChian")
    .setFaviconUrl("https://semicon.github.io/img/web/logoxxx.png")
    .addMetaTag('viewport', 'width=device-width , initial-scale=1')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .setSandboxMode(HtmlService.SandboxMode.IFRAME);
return  template; 
}


// get data 
function getdata(){
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("data");
  const data = sheet.getDataRange().getDisplayValues().slice(1);
  const rows = data.map(r => ({
    title: r[1],
    text: r[2],
    teacher: r[3],
    img: r[4],
    link: r[5]
  }))
  return rows; 
}

    


index.html

     
<!doctype html>
<html lang="th">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Bootstrap 5 Cards with Search + Voice + Pagination</title>

  <!-- Bootstrap 5 -->
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">

  <!-- Google Fonts -->
  <link href="https://fonts.googleapis.com/css2?family=K2D&family=Sriracha&display=swap" rel="stylesheet">

  <!-- Bootstrap Icons -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">

  <style>
    h1 {
      font-family: "Sriracha", cursive;
    }

    .card {
      transition: all 0.35s ease;
      border: none;
      border-radius: 1rem;
      overflow: hidden;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
      font-family: "K2D", cursive;
    }

    .card:hover {
      transform: translateY(-8px);
      box-shadow: 0 8px 20px rgba(0,0,0,0.2);
      cursor: pointer;
    }

    .card img {
      transition: transform 0.5s ease;
    }

    .card:hover img {
      transform: scale(1.08);
    }

    .card-title {
      color: #007bff;
      transition: color 0.3s ease;
    }

    .card:hover .card-title {
      color: #0d6efd;
    }

    .card {
      opacity: 0;
      animation: fadeInUp 0.6s ease forwards;
    }

    @keyframes fadeInUp {
      0% { opacity: 0; transform: translateY(15px); }
      100% { opacity: 1; transform: translateY(0); }
    }

    .pagination {
      justify-content: center;
      margin-top: 2rem;
    }

    .page-item.active .page-link {
      background-color: #0d6efd;
      border-color: #0d6efd;
      color: #fff;
    }

    #searchBox {
      max-width: 480px;
      margin: 0 auto 2rem;
      position: relative;
    }

    #searchInput{
      padding-right: 38px;
    }

    /* 🎤 ปุ่มไมค์ */
    #micBtn {
      position: absolute;
      right: 40px;
      background: none;
      border: none;
      border-radius: 50px;
      font-size: 1.5rem;
      color: #0d6efd;
      cursor: pointer;
      transition: color 0.3s ease;
      padding: 2px;
      width: 36px;
      height: 36px;
    }

    #micBtn:hover {
      color: #ff3b3b;
    }

    #micBtn.mic-on {
      background: red;
      color: #fff;
      animation: blink 1s infinite;
    }

    #clearBtn{
      padding: 0px;
      background-color: #fff0;
      width: 38px;
      height: 38px;
      color: #000;
      border: 1px solid #9995;
    }

    @keyframes blink {
      50% { opacity: 0.4; }
    }
  </style>
</head>

<body>
  <div class="container py-5">
    <div class="text-center py-4">
      <img src='https://semicon.github.io/img/web/logoxxx.png' alt="logo" width="80">
      <h1 class="mt-3 text-center fw-bold text-primary">ระบบส่งงานออนไลน์</h1>
    </div>

    <div id="searchBox" class="input-group   flex-nowrap mb-4">
      <input type="text" id="searchInput" class="form-control pe-5" placeholder="🔍 พิมพ์หรือพูดคำค้นหา เช่น ชื่อเรื่อง หรือชื่อผู้สอน..." autocomplete="off" />
      <button type="button" class="btn btn-outline-secondary p-0" id="micBtn">🎙️</button>
      <button type="button"  class="btn btn-outline-secondary" id="clearBtn">❌</button>
    </div>

    <!-- พื้นที่การ์ด -->
    <div id="cardContainer" class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"></div>

    <!-- Pagination -->
    <nav>
      <ul id="pagination" class="pagination"></ul>
    </nav>
  </div>

  <script>
    let currentPage = 1;
    const cardsPerPage = 8;
    let allCardsData = [];
    let filteredCards = [];

    // ฟังก์ชันแสดงการ์ด
    function renderCards(cardsData) {
      if (!cardsData || !Array.isArray(cardsData)) return;

      const start = (currentPage - 1) * cardsPerPage;
      const end = start + cardsPerPage;
      const currentCards = cardsData.slice(start, end);
      
      const container = document.getElementById('cardContainer');

      // สร้าง html ของการ์ด (ใช้ default image ถ้าไม่มี)
      const cardsHTML = currentCards.map((card, idx) => {
        const img = card.img && card.img.trim() ? card.img : `https:\/\/picsum.photos\/seed\/${start + idx + 10}\/600\/300`;
        const link = card.link && card.link.trim() ? card.link : '#';
        const safeLink = link.replace(/'/g, "\\'");
        return `
          <div class="col">
            <div class="card h-100" onclick="openPage('${safeLink}')">
              <img src="${img}" class="card-img-top" alt="${escapeHtml(card.title || '')}">
              <div class="card-body">
                <h5 class="card-title">${escapeHtml(card.title || '')}</h5>
                <p class="card-text">${escapeHtml(card.text || '')}</p>
              </div>
              <div class="card-footer bg-white">
                <small class="text-muted"><b>ผู้สอน:</b> ${escapeHtml(card.teacher || '')}</small>
              </div>
            </div>
          </div>
        `;
      }).join('');

      container.innerHTML = cardsHTML || `<div style="width:100%"><p class="text-center text-muted fs-5">❌ ไม่พบข้อมูลที่ค้นหา</p></div>`;
      renderPagination(cardsData);
    }
    
    // การแสดงผลการแบ่งหน้า
    function renderPagination(cardsData) {
      const pagination = document.getElementById('pagination');
      const totalPages = Math.ceil(cardsData.length / cardsPerPage) || 1;

      // ซ่อน pagination ถ้าการ์ดน้อยกว่าหรือเท่ากับจำนวนต่อหน้า
      if (cardsData.length <= cardsPerPage) {
        pagination.style.display = 'none';
        return;
      } else {
        pagination.style.display = 'flex';
      }

      pagination.innerHTML = '';
      
      // ปุ่มก่อนหน้า
      const prevLi = document.createElement('li');
      prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
      prevLi.innerHTML = `<button class="page-link"><i class="bi bi-chevron-left"></i></button>`;
      prevLi.onclick = () => {
        if (currentPage > 1) {
          currentPage--;
          renderCards(filteredCards);
        }
      };
      pagination.appendChild(prevLi);

      // แสดงเลขหน้า (ใช้ ... ถ้ามีเยอะ)
      const maxVisible = 7;
      let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
      let endPage = Math.min(totalPages, startPage + maxVisible - 1);
      if (endPage - startPage < maxVisible - 1)
        startPage = Math.max(1, endPage - maxVisible + 1);

      if (startPage > 1) {
        addPageButton(1);
        if (startPage > 2) addEllipsis();
      }

      for (let i = startPage; i <= endPage; i++) addPageButton(i);

      if (endPage < totalPages) {
        if (endPage < totalPages - 1) addEllipsis();
        addPageButton(totalPages);
      }
      
      // ปุ่มถัดไป
      const nextLi = document.createElement('li');
      nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
      nextLi.innerHTML = `<button class="page-link"><i class="bi bi-chevron-right"></i></button>`;
      nextLi.onclick = () => {
        if (currentPage < totalPages) {
          currentPage++;
          renderCards(filteredCards);
        }
      };
      pagination.appendChild(nextLi);
      
      // เพิ่มปุ่มหน้า
      function addPageButton(page) {
        const li = document.createElement('li');
        li.className = `page-item ${page === currentPage ? 'active' : ''}`;
        li.innerHTML = `<button class="page-link">${page}</button>`;
        li.onclick = () => {
          currentPage = page;
          renderCards(filteredCards);
        };
        pagination.appendChild(li);
      }
      
      // แสดงเลขหน้าแบบมี ... เมื่อจำนวนหน้ามากเกินไป
      function addEllipsis() {
        const li = document.createElement('li');
        li.className = 'page-item disabled';
        li.innerHTML = `<span class="page-link">...</span>`;
        pagination.appendChild(li);
      }
    }


    // ค้นหาแบบคิวรี่
    const searchInput = document.getElementById("searchInput");
    document.getElementById("searchInput").addEventListener("input", e => {
      const q = e.target.value.toLowerCase().trim();
      filteredCards = allCardsData.filter(c =>
        (c.title && c.title.toLowerCase().includes(q)) ||
        (c.text && c.text.toLowerCase().includes(q)) ||
        (c.teacher && c.teacher.toLowerCase().includes(q))
      );
      currentPage = 1;
      renderCards(filteredCards);
    });

    document.getElementById("clearBtn").addEventListener("click", () => {
      searchInput.value = "";
      filteredCards = [...allCardsData];
      currentPage = 1;
      renderCards(filteredCards);
    });

    // 🎤 ค้นหาด้วยเสียง
    const micBtn = document.getElementById('micBtn');
    let recognition;

    if ('webkitSpeechRecognition' in window) {
      recognition = new webkitSpeechRecognition();
      recognition.lang = 'th-TH';
      recognition.continuous = false;
      recognition.interimResults = false;

      micBtn.addEventListener('click', () => {
        recognition.start();
        micBtn.classList.add('mic-on');
      });

      recognition.onresult = (event) => {
        const transcript = event.results[0][0].transcript;
        searchInput.value = transcript;
        micBtn.classList.remove('mic-on');
        searchInput.dispatchEvent(new Event('input'));
      };

      recognition.onend = () => micBtn.classList.remove('mic-on');
    } else {
      micBtn.style.display = 'none'; // ไม่รองรับในเบราว์เซอร์
    }

    // เปิดลิงก์หน้าเว็บ
    function openPage(url) {
      try {
        const safeURL = new URL(url, window.location.origin);
        if (safeURL.protocol === 'http:' || safeURL.protocol === 'https:') {
          window.open(safeURL.href, '_blank');
        }
      } catch {
        console.warn('Invalid URL:', url);
      }
    }
    
    // ป้องกัน XSS พื้นฐานสำหรับข้อความที่แสดงใน HTML 
    function escapeHtml(text) {
      return String(text)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
    }

    // เริ่มต้น: ดึงข้อมูลจาก GAS แล้วเก็บไว้ในตัวแปร global แล้วเรียก render
    google && google.script
      ? google.script.run.withSuccessHandler(data => {
          allCardsData = Array.isArray(data) ? data : [];
          filteredCards = [...allCardsData];
          renderCards(filteredCards);
        }).getdata()
      : (() => {
          // โหมดทดสอบ (ถ้าไม่ใช่ใน Apps Script) — ลบส่วนนี้เมื่อใช้งานจริง
          allCardsData = Array.from({length:36}, (_,i) => ({
            title: `การ์ดที่ ${i+1}`,
            text: `เนื้อหาทดลองที่ ${i+1}`,
            teacher: `ครู ${i+1}`,
            img: '',
            link: '#'
          }));
          filteredCards = [...allCardsData];
          renderCards(filteredCards);
        })();
  </script>
</body>
</html>



    



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