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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// เริ่มต้น: ดึงข้อมูลจาก 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>