Bài 19: Capstone Project – Trang “Course Hub / Portfolio” 1 trang (chuẩn Blogspot)
🎯 Mục tiêu
- Tạo 1 trang tổng hợp để giới thiệu khóa học + điều hướng bài học
- Ôn lại HTML semantic + CSS Flex/Grid + JS DOM/Event
- Có tìm kiếm bài học, tab lọc (HTML/CSS/JS/Project)
- Có Dark Mode
- Lưu “đã học” + theme vào
localStorage - Dễ thay link thật để dùng trên Blogspot
📌 Cách dùng trên Blogspot
- Tạo 1 Trang mới: “Course Hub”
- Dán toàn bộ code bên dưới
- Thay các link LINK_BAI_01… bằng URL bài thật
- Publish
✅ Trang này có thể thay cho Menu bài 16 (nâng cấp hơn).
💻 Code mẫu (Capstone 1 trang)
<div class="hub" id="hub">
<header class="top">
<div class="brand">
<div class="logo">⚡</div>
<div>
<h1>Course Hub – Web tĩnh</h1>
<p>HTML • CSS • JavaScript • Blogspot Ready</p>
</div>
</div>
<div class="actions">
<button class="btn ghost" id="btnTheme">🌙 Dark mode</button>
<a class="btn primary" href="LINK_MENU_KHOA_HOC" target="_blank">Mở Menu (cũ)</a>
</div>
</header>
<section class="panel">
<div class="stats">
<div class="stat"><b id="doneCount">0</b><br>Bài đã học</div>
<div class="stat"><b>19</b><br>Tổng bài</div>
<div class="stat"><b>5</b><br>Mini Project</div>
<div class="stat"><b id="now">--:--:--</b><br>Giờ hiện tại</div>
</div>
<div class="bar">
<input id="search" type="text" placeholder="Tìm bài... (vd: grid, fetch, quiz, seo)">
<button class="btn dark" id="btnReset">Reset</button>
</div>
<div class="tabs" id="tabs">
<button class="tab active" data-tab="all">Tất cả</button>
<button class="tab" data-tab="html">HTML</button>
<button class="tab" data-tab="css">CSS</button>
<button class="tab" data-tab="js">JavaScript</button>
<button class="tab" data-tab="project">Project</button>
<button class="tab" data-tab="publish">Xuất bản</button>
</div>
<div class="list" id="list"></div>
<div class="foot">
<span id="hint">Gợi ý: bấm “Đã học” để lưu tiến độ.</span>
</div>
</section>
</div>
<style>
:root{
--bg:#f7f8fa;
--card:#ffffff;
--text:#111;
--muted:#555;
--border:#e5e5e5;
--soft:#f3f4f6;
--primary:#2563eb;
--dark:#111;
}
.hub.dark{
--bg:#0b1220;
--card:#0f172a;
--text:#e5e7eb;
--muted:#cbd5e1;
--border:#1f2937;
--soft:#111827;
--primary:#60a5fa;
--dark:#e5e7eb;
}
.hub{
background:var(--bg);
color:var(--text);
font-family: Arial, Helvetica, sans-serif;
line-height:1.7;
max-width: 1060px;
margin: 16px auto;
padding: 0;
border-radius: 18px;
}
.top{
display:flex;
justify-content: space-between;
gap: 12px;
align-items:center;
padding: 16px;
background:var(--card);
border:1px solid var(--border);
border-radius: 18px;
}
.brand{
display:flex;
gap: 12px;
align-items:center;
}
.logo{
width: 44px;
height: 44px;
border-radius: 14px;
display:flex;
align-items:center;
justify-content:center;
background: var(--soft);
border:1px solid var(--border);
font-size: 22px;
}
.brand h1{ margin:0; font-size: 20px; }
.brand p{ margin:0; color:var(--muted); font-size: 13px; }
.actions{
display:flex;
gap: 10px;
flex-wrap:wrap;
justify-content:flex-end;
}
.panel{
margin-top: 12px;
padding: 16px;
background:var(--card);
border:1px solid var(--border);
border-radius: 18px;
}
.stats{
display:grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stat{
background:var(--soft);
border:1px solid var(--border);
border-radius: 16px;
padding: 10px 12px;
text-align:center;
}
.stat b{ font-size: 18px; }
.bar{
display:flex;
gap: 10px;
flex-wrap:wrap;
justify-content:center;
margin: 12px 0 10px;
}
input{
padding:10px 12px;
border:1px solid var(--border);
border-radius: 12px;
min-width: 280px;
width: min(520px, 100%);
background:var(--card);
color:var(--text);
outline:none;
}
.btn{
padding: 10px 12px;
border-radius: 12px;
border:0;
cursor:pointer;
font-weight: 800;
text-decoration:none;
display:inline-block;
text-align:center;
}
.btn.primary{ background:var(--primary); color:#fff; }
.btn.dark{ background:var(--dark); color: #fff; }
.hub.dark .btn.dark{ background: #e5e7eb; color:#0b1220; }
.btn.ghost{ background:var(--soft); border:1px solid var(--border); color:var(--text); }
.tabs{
display:flex;
gap: 8px;
flex-wrap:wrap;
justify-content:center;
margin-bottom: 12px;
}
.tab{
padding: 8px 10px;
border-radius: 999px;
border:1px solid var(--border);
background:var(--card);
color:var(--text);
cursor:pointer;
font-weight: 800;
font-size: 13px;
}
.tab.active{
background: var(--primary);
color:#fff;
border-color: transparent;
}
.list{
display:flex;
flex-direction:column;
gap: 10px;
}
.item{
display:flex;
justify-content: space-between;
gap: 12px;
flex-wrap:wrap;
align-items:center;
background:var(--card);
border:1px solid var(--border);
border-radius: 16px;
padding: 12px;
}
.left{
flex:1;
min-width: 240px;
}
.title{
font-weight: 900;
color:var(--text);
text-decoration:none;
}
.title:hover{ text-decoration: underline; }
.meta{
margin-top: 2px;
font-size: 13px;
color:var(--muted);
}
.pill{
display:inline-block;
margin-right: 6px;
font-size: 12px;
padding: 2px 10px;
border-radius: 999px;
background: var(--soft);
border:1px solid var(--border);
color:var(--text);
}
.right{
display:flex;
gap: 8px;
flex-wrap:wrap;
}
.done .title{ color: #16a34a; }
.hub.dark .done .title{ color: #86efac; }
.foot{
margin-top: 12px;
text-align:center;
color:var(--muted);
}
@media (max-width: 900px){
.top{ flex-direction:column; align-items:flex-start; }
.actions{ width:100%; justify-content:flex-start; }
.stats{ grid-template-columns: 1fr 1fr; }
}
</style>
<script>
// ===== DATA: danh sách bài (bạn thay link thật vào url) =====
const LESSONS = [
{ id:"bai-01", no:1, title:"Bài 1: Trình soạn thảo + cấu trúc HTML cơ bản", tag:"html", url:"LINK_BAI_01", key:"editor html co ban" },
{ id:"bai-02", no:2, title:"Bài 2: Các phần tử HTML cơ bản", tag:"html", url:"LINK_BAI_02", key:"heading p div span list" },
{ id:"bai-03", no:3, title:"Bài 3: Bảng trong HTML", tag:"html", url:"LINK_BAI_03", key:"table thead tbody tr td" },
{ id:"bai-04", no:4, title:"Bài 4: Link • Ảnh • Audio • Video", tag:"html", url:"LINK_BAI_04", key:"a img audio video iframe" },
{ id:"bai-05", no:5, title:"Bài 5: Form cơ bản", tag:"html", url:"LINK_BAI_05", key:"form input textarea select" },
{ id:"bai-06", no:6, title:"Bài 6: Semantic HTML + bố cục", tag:"html", url:"LINK_BAI_06", key:"semantic header nav main section footer" },
{ id:"bai-07", no:7, title:"Bài 7: CSS Selector + Box Model", tag:"css", url:"LINK_BAI_07", key:"css selector box model margin padding" },
{ id:"bai-08", no:8, title:"Bài 8: CSS Flexbox", tag:"css", url:"LINK_BAI_08", key:"flex justify align wrap gap" },
{ id:"bai-09", no:9, title:"Bài 9: CSS Grid + Responsive", tag:"css", url:"LINK_BAI_09", key:"grid repeat minmax responsive" },
{ id:"bai-10", no:10, title:"Bài 10: JavaScript cơ bản (DOM + Event)", tag:"js", url:"LINK_BAI_10", key:"js dom event let const" },
{ id:"bai-11", no:11, title:"Bài 11: Mini Project – To-do List", tag:"project", url:"LINK_BAI_11", key:"todo project dom" },
{ id:"bai-12", no:12, title:"Bài 12: Mini Project – Quiz trắc nghiệm", tag:"project", url:"LINK_BAI_12", key:"quiz radio cham diem" },
{ id:"bai-13", no:13, title:"Bài 13: Array + Object (CRUD mini)", tag:"js", url:"LINK_BAI_13", key:"array object crud filter map splice" },
{ id:"bai-14", no:14, title:"Bài 14: Fetch API", tag:"js", url:"LINK_BAI_14", key:"fetch api json async await" },
{ id:"bai-15", no:15, title:"Bài 15: Mini Project – Landing Page", tag:"project", url:"LINK_BAI_15", key:"landing one page scroll" },
{ id:"bai-16", no:16, title:"Bài 16: Menu khóa học + Template bài học", tag:"publish", url:"LINK_BAI_16", key:"menu template blogspot" },
{ id:"bai-17", no:17, title:"Bài 17: SEO + tối ưu tốc độ", tag:"publish", url:"LINK_BAI_17", key:"seo performance lazy alt title" },
{ id:"bai-18", no:18, title:"Bài 18: Xuất bản + Analytics + checklist", tag:"publish", url:"LINK_BAI_18", key:"analytics ga4 publish checklist" },
{ id:"bai-19", no:19, title:"Bài 19: Capstone – Course Hub 1 trang", tag:"project", url:"#", key:"capstone hub" }
];
const hub = document.getElementById("hub");
const listEl = document.getElementById("list");
const searchEl = document.getElementById("search");
const btnReset = document.getElementById("btnReset");
const btnTheme = document.getElementById("btnTheme");
const doneCountEl = document.getElementById("doneCount");
const nowEl = document.getElementById("now");
const hint = document.getElementById("hint");
const KEY_DONE = "hub_done_v1";
const KEY_THEME = "hub_theme_v1";
function getDone(){
try{ return JSON.parse(localStorage.getItem(KEY_DONE) || "[]"); }
catch(e){ return []; }
}
function setDone(arr){
localStorage.setItem(KEY_DONE, JSON.stringify(arr));
}
function setTheme(isDark){
hub.classList.toggle("dark", isDark);
localStorage.setItem(KEY_THEME, isDark ? "dark" : "light");
btnTheme.textContent = isDark ? "☀️ Light mode" : "🌙 Dark mode";
}
function getTheme(){
return (localStorage.getItem(KEY_THEME) || "light") === "dark";
}
function tagLabel(tag){
if(tag==="html") return "HTML";
if(tag==="css") return "CSS";
if(tag==="js") return "JS";
if(tag==="project") return "PROJECT";
if(tag==="publish") return "XUẤT BẢN";
return "ALL";
}
function render(items){
const done = getDone();
listEl.innerHTML = "";
if(items.length === 0){
listEl.innerHTML = "<div class='meta'>Không tìm thấy bài phù hợp.</div>";
return;
}
items.forEach(it => {
const row = document.createElement("div");
row.className = "item" + (done.includes(it.id) ? " done" : "");
row.innerHTML = `
<div class="left">
<span class="pill">${tagLabel(it.tag)}</span>
<a class="title" href="${it.url}" target="_blank">${it.title}</a>
<div class="meta">Từ khóa: ${it.key}</div>
</div>
<div class="right">
<button class="btn ghost mark" data-id="${it.id}">${done.includes(it.id) ? "Đã học ✓" : "Đã học"}</button>
<button class="btn primary copy" data-url="${it.url}">Copy link</button>
</div>
`;
listEl.appendChild(row);
});
// gắn sự kiện
listEl.querySelectorAll(".mark").forEach(btn => {
btn.addEventListener("click", () => {
const id = btn.dataset.id;
const done = getDone();
const idx = done.indexOf(id);
if(idx === -1) done.push(id);
else done.splice(idx, 1);
setDone(done);
applyFilters(); // render lại theo bộ lọc hiện tại
updateDoneCount();
});
});
listEl.querySelectorAll(".copy").forEach(btn => {
btn.addEventListener("click", async () => {
const url = btn.dataset.url;
try{
await navigator.clipboard.writeText(url);
btn.textContent = "Đã copy ✓";
setTimeout(() => btn.textContent = "Copy link", 900);
} catch(e){
alert("Không copy được. Bạn hãy copy thủ công: " + url);
}
});
});
}
function updateDoneCount(){
doneCountEl.textContent = getDone().length;
}
// ===== Filters =====
let currentTab = "all";
function applyFilters(){
const key = searchEl.value.trim().toLowerCase();
let items = LESSONS.slice();
if(currentTab !== "all"){
items = items.filter(x => x.tag === currentTab);
}
if(key){
items = items.filter(x => (x.title + " " + x.key).toLowerCase().includes(key));
}
render(items);
}
document.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
currentTab = tab.dataset.tab;
applyFilters();
hint.textContent = "Đang lọc: " + tagLabel(currentTab === "all" ? "all" : currentTab);
});
});
searchEl.addEventListener("input", applyFilters);
btnReset.addEventListener("click", () => {
searchEl.value = "";
currentTab = "all";
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
document.querySelector('.tab[data-tab="all"]').classList.add("active");
hint.textContent = "Gợi ý: bấm “Đã học” để lưu tiến độ.";
applyFilters();
});
// ===== Theme =====
btnTheme.addEventListener("click", () => {
setTheme(!hub.classList.contains("dark"));
});
// ===== Clock =====
setInterval(() => {
nowEl.textContent = new Date().toLocaleTimeString("vi-VN");
}, 1000);
// Init
setTheme(getTheme());
updateDoneCount();
applyFilters();
</script>
✅ Bạn chỉ cần thay link LINK_BAI_01…LINK_BAI_18 và LINK_MENU_KHOA_HOC là dùng được ngay.
✍️ Bài tập
- Thay link thật cho 5 bài đầu tiên và test “Copy link”.
- Đổi số “Tổng bài” theo đúng tổng khóa học của bạn.
- Thêm 1 tab mới “Nâng cao” và gắn 2 bài vào đó.
- (Nâng cao) Thêm thanh progress (%) dựa trên số bài đã học.
✅ Đáp án gợi ý
Progress % (gợi ý nhanh)
const percent = Math.round(getDone().length / LESSONS.length * 100);
📌 Danh sách bình luận