DSUdemy-Main / shared /layout.js
dsuacademy's picture
Update shared/layout.js
0088e47 verified
Raw History Blame
21.8 kB
/* =========================================================
layout.js: ส่วนหัวและส่วนท้ายชุดเดียวของทุกหน้าใน dsudemy.net
สร้างจากข้อมูลใน data/site.js และ data/units.js แก้เมนูหรือส่วนท้ายที่ไฟล์นี้ที่เดียว
วิธีใช้ในหน้าใหม่ (ดู templates/subpage.html)
- <body data-root="../" data-page="guidance"> data-root คือเส้นทางกลับรากของเว็บ
หน้าหลักใช้ data-root="" และ data-page="home"
- <header class="site-header" id="siteHeader"></header> และ <footer class="footer" id="contact"></footer>
- โหลดไฟล์ข้อมูลก่อน แล้วโหลดไฟล์นี้ แล้วจึงโหลดสคริปต์ของหน้า
- หน้าหลักใส่ data-search="on" ที่ body เพื่อแสดงปุ่มค้นหา
========================================================= */
(function () {
const D = window.DSU = window.DSU || {};
const body = document.body;
const ROOT = body.dataset.root || '';
const PAGE = body.dataset.page || 'home';
const HOME = PAGE === 'home';
const SITE = D.site || { contact: {}, links: {} };
const UNITS = D.units || [];
/* ---------- เครื่องมือกลาง ---------- */
const TH_MON = ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];
const TH_MON_FULL = ['มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน', 'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม'];
const TH_DAY = ['อาทิตย์', 'จันทร์', 'อังคาร', 'พุธ', 'พฤหัสบดี', 'ศุกร์', 'เสาร์'];
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const has = v => Array.isArray(v) ? v.length > 0 : (typeof v === 'string' ? v.trim() !== '' : !!v);
const isExternal = h => /^(https?:|mailto:|tel:)/.test(h || '');
const isPending = h => !h || h.startsWith('[');
/* Hugging Face Spaces (*.hf.space) ไม่เปิด index.html ให้เองเมื่อ URL ลงท้ายด้วย / จึงเติมให้เฉพาะบนโดเมนนั้น
บน dsudemy.net ลิงก์คงเป็น URL สะอาดตาม docs/SITE_MAP.md */
const EXPLICIT_INDEX = /\.hf\.space$/.test(location.hostname);
function withIndex(h) {
if (!EXPLICIT_INDEX || !h || isExternal(h) || isPending(h)) return h;
const i = h.indexOf('#'), path = i < 0 ? h : h.slice(0, i), hash = i < 0 ? '' : h.slice(i);
if (path === '' || /^[a-z]+:|^\/\//i.test(path)) return h;
if (path.endsWith('/')) return path + 'index.html' + hash;
if (path === '.' || path === '..') return path + '/index.html' + hash;
return h;
}
/* แปลงลิงก์ในไฟล์ข้อมูลให้ถูกระดับโฟลเดอร์ */
function href(h) {
if (!h) return '';
if (isExternal(h)) return h;
if (h.startsWith('#')) return HOME ? h : withIndex(ROOT + h);
return withIndex(ROOT + h);
}
const store = {
get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
};
function todayISO() {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Bangkok', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); }
catch (e) { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
}
const parts = iso => { const [y, m, d] = iso.split('-').map(Number); return { y, m, d }; };
const weekday = iso => { const p = parts(iso); return new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay(); };
const daysBetween = (a, b) => { const p = parts(a), q = parts(b); return Math.round((Date.UTC(q.y, q.m - 1, q.d) - Date.UTC(p.y, p.m - 1, p.d)) / 86400000); };
const fmtDate = (iso, year = true) => { const p = parts(iso); return p.d + ' ' + TH_MON[p.m - 1] + (year ? ' ' + (p.y + 543) : ''); };
function fmtRange(s, e, year = true) {
if (!e || e === s) return fmtDate(s, year);
const a = parts(s), b = parts(e);
if (a.y === b.y && a.m === b.m) return a.d + ' ถึง ' + b.d + ' ' + TH_MON[b.m - 1] + (year ? ' ' + (b.y + 543) : '');
if (a.y === b.y) return a.d + ' ' + TH_MON[a.m - 1] + ' ถึง ' + fmtDate(e, year);
return fmtDate(s) + ' ถึง ' + fmtDate(e);
}
const inRange = (iso, s, e) => iso >= s && iso <= (e || s);
const ICONS = {
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
book: '<path d="M4 5a2 2 0 0 1 2-2h12v16H6a2 2 0 0 0-2 2z"/><path d="M4 21V5M8 7h6"/>',
file: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 14h8M8 18h5"/>',
people: '<circle cx="8" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M2.5 20a5.5 5.5 0 0 1 11 0M13.5 19a4 4 0 0 1 8 0"/>',
table: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M3 14.5h18M9 9v11"/>',
compass: '<circle cx="12" cy="12" r="9"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/>',
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
flask: '<path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.7 3h10.6a2 2 0 0 0 1.7-3l-5-9V3"/><path d="M7.5 15h9"/>',
badge: '<circle cx="12" cy="9" r="6"/><path d="M8.5 13.5L7 21l5-3 5 3-1.5-7.5"/>',
grid: '<rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/>',
external: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>'
};
const icon = (name, size) => '<svg viewBox="0 0 24 24" width="' + (size || 20) + '" height="' + (size || 20) + '" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || ICONS.file) + '</svg>';
/* ลิงก์ภายนอกเปิดแท็บใหม่ และบอกโปรแกรมอ่านหน้าจอ */
const extAttrs = h => isExternal(h) && !/^(mailto:|tel:)/.test(h) ? ' target="_blank" rel="noopener"' : '';
const extNote = h => isExternal(h) && !/^(mailto:|tel:)/.test(h) ? '<span class="sr-only"> (เปิดในแท็บใหม่)</span>' : '';
/* ปุ่มช่องทางติดต่อของหน่วยงาน (หน้าแนะแนวและหน้าศูนย์) แสดงเฉพาะช่องที่มีข้อมูล
LINE ที่ไม่มีลิงก์แสดงเป็นป้ายธรรมดา เพราะกดไปที่ใดไม่ได้ */
function contactActions(c, facebook, fbLabel) {
c = c || {};
const out = [];
if (has(c.email)) out.push('<a class="link-btn" href="mailto:' + esc(c.email) + '">อีเมล ' + esc(c.email) + '</a>');
if (has(c.phone)) out.push('<a class="link-btn ghost" href="tel:' + esc(String(c.phone).replace(/[^\d+]/g, '')) + '">โทร ' + esc(c.phone) + '</a>');
if (has(c.line)) out.push(has(c.lineUrl)
? '<a class="link-btn ghost" href="' + esc(c.lineUrl) + '" target="_blank" rel="noopener">LINE ' + esc(c.line) + extNote(c.lineUrl) + '</a>'
: '<span class="contact-chip">LINE ' + esc(c.line) + '</span>');
if (has(facebook)) out.push('<a class="link-btn ghost" href="' + esc(facebook) + '" target="_blank" rel="noopener">' + esc(fbLabel || 'Facebook') + icon('external', 16) + '<span class="sr-only"> (เปิดในแท็บใหม่)</span></a>');
return out.join('');
}
/* รายการกำหนดการแยกตามเดือน รายการที่เริ่มก่อนวันนี้จัดไว้ในเดือนปัจจุบัน
tagsFn(e) คืน HTML ของป้ายใต้ชื่อรายการ (ไม่บังคับ) คืน '' เมื่อไม่มีรายการ ผู้เรียกแสดงข้อความว่างเอง */
function monthList(events, tagsFn) {
const today = todayISO();
let html = '', cur = '';
events.forEach(e => {
const p = parts(e.start < today ? today : e.start), key = p.y + '-' + p.m;
if (key !== cur) { if (cur) html += '</ul>'; html += '<h3 class="cal-month">' + TH_MON_FULL[p.m - 1] + ' ' + (p.y + 543) + '</h3><ul class="cal-list">'; cur = key; }
const tags = tagsFn ? tagsFn(e) : '';
html += '<li><span class="cal-when tnum">' + esc(fmtRange(e.start, e.end)) + '</span><span>' + esc(e.title) + (tags ? '<span class="cal-meta">' + tags + '</span>' : '') + '</span></li>';
});
return cur ? html + '</ul>' : '';
}
D.util = { TH_MON, TH_MON_FULL, TH_DAY, esc, has, href, withIndex, isExternal, isPending, store, todayISO, parts, weekday, daysBetween, fmtDate, fmtRange, inRange, icon, extAttrs, extNote, contactActions, monthList, ROOT, PAGE, HOME };
/* ---------- ส่วนหัว ---------- */
const NAV = [
{ id: 'top', label: 'หน้าแรก', home: '#top', page: '' },
{ id: 'quick', label: 'บริการวิชาการ', home: '#quick' },
{ id: 'programs', label: 'หลักสูตรพิเศษ', home: '#programs', page: 'programs/' },
{ id: 'units', label: 'ศูนย์และหน่วยงาน', home: '#units' },
{ id: 'hub', label: 'DSUdemy Hub', home: '#hub' },
{ id: 'about', label: 'เกี่ยวกับฝ่าย', home: '#about' },
{ id: 'contact', label: 'ติดต่อ', home: '#contact', local: true }
];
const C = SITE.contact || {};
const HAS_CONTACT = ['email', 'phone', 'line'].some(k => has(C[k]));
const navHref = n => HOME || n.local ? n.home : (n.page !== undefined ? (ROOT + n.page || './') : ROOT + n.home);
/* หน้าของหน่วยงาน (ยกเว้นหลักสูตรที่มีเมนูของตัวเอง) ให้เมนู "ศูนย์และหน่วยงาน" เป็นเมนูปัจจุบัน */
const NAV_CUR = NAV.some(n => n.id === PAGE) ? PAGE : (UNITS.some(u => u.id === PAGE) ? 'units' : PAGE);
const navLinks = NAV.filter(n => n.id !== 'contact' || HAS_CONTACT).map(n =>
'<a href="' + esc(navHref(n) || './') + '" data-spy="' + n.id + '"' + (!HOME && n.id === NAV_CUR ? ' aria-current="page"' : '') + '>' + n.label + '</a>').join('');
const header = document.getElementById('siteHeader');
if (header) {
header.innerHTML =
'<div class="wrap"><div class="bar">' +
'<a class="brand" href="' + (HOME ? '#top' : (ROOT || './')) + '" aria-label="ฝ่ายวิชาการและนวัตกรรมการเรียนรู้ หน้าแรก">' +
'<span class="logo" aria-hidden="true"><img src="' + ROOT + 'assets/dsu-logo.png" width="42" height="42" alt=""></span>' +
'<span class="brand-text"><span class="brand-name"><span class="nw">ฝ่ายวิชาการและ</span><wbr><span class="nw">นวัตกรรมการเรียนรู้</span></span>' +
'<span class="brand-sub">โรงเรียนสาธิต มหาวิทยาลัยศิลปากร (มัธยมศึกษา)</span></span></a>' +
'<nav class="nav" aria-label="เมนูหลัก" id="desktopNav">' + navLinks + '</nav>' +
'<div class="tools">' +
(body.dataset.search === 'on' ? '<button class="icon-btn" type="button" id="openSearch" aria-label="ค้นหาบริการ หน่วยงาน หลักสูตร และกำหนดการ" title="ค้นหา (Ctrl+K)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg></button>' : '') +
'<button class="icon-btn" type="button" id="themeBtn" aria-label="สลับโหมดสว่างและมืด"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>' +
'<button class="icon-btn menu-btn" type="button" id="menuBtn" aria-label="เปิดเมนู" aria-expanded="false" aria-controls="mobileNav"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg></button>' +
'</div></div>' +
'<nav class="mobile-nav" id="mobileNav" aria-label="เมนูหลัก (มือถือ)" hidden>' + navLinks +
/* จอแคบมากย้ายปุ่มสลับธีมมาไว้ในเมนู เพื่อให้ชื่อฝ่ายในแคปซูลไม่ถูกปุ่มบัง */
'<button class="mobile-theme" type="button" id="themeBtnMobile"></button></nav></div>';
}
/* ---------- ส่วนท้าย ---------- */
const footer = document.getElementById('contact');
if (footer) {
const L = SITE.links || {};
const rows = [];
if (has(C.email)) rows.push('<span class="copy-row">อีเมล <span id="siteEmail">' + esc(C.email) + '</span> <button class="copy-btn" type="button" id="copyEmail">คัดลอก</button></span>');
if (has(C.phone)) rows.push('<span>' + esc(C.phone) + '</span>');
if (has(C.line)) rows.push('<span>LINE ' + (has(C.lineUrl) ? '<a href="' + esc(C.lineUrl) + '" target="_blank" rel="noopener">' + esc(C.line) + '</a>' : esc(C.line)) + '</span>');
const links = [['school', 'เว็บไซต์โรงเรียน'], ['university', 'มหาวิทยาลัยศิลปากร'], ['studio', 'DSUdemy Studio']]
.filter(([k]) => has(L[k])).map(([k, label]) => '<a href="' + esc(L[k]) + '" target="_blank" rel="noopener">' + label + '<span class="sr-only"> (เปิดในแท็บใหม่)</span></a>' + (k === 'studio' ? '<span class="footer-note">ระบบสร้างแอปสำหรับทีมพัฒนา ต้องเข้าสู่ระบบ</span>' : ''));
const channels = UNITS.filter(u => has(u.facebook)).map(u => '<a href="' + esc(u.facebook) + '" target="_blank" rel="noopener">' + esc(u.display) + ' (Facebook)<span class="sr-only"> (เปิดในแท็บใหม่)</span></a>');
footer.innerHTML =
'<div class="wrap footer-grid">' +
'<div class="footer-brand"><span class="footer-name">ฝ่ายวิชาการและนวัตกรรมการเรียนรู้</span><span>โรงเรียนสาธิต มหาวิทยาลัยศิลปากร (มัธยมศึกษา)</span>' + (has(C.address) ? '<span>' + esc(C.address) + '</span>' : '') + '</div>' +
(rows.length ? '<div><h2>ติดต่อฝ่าย</h2>' + rows.join('') + '</div>' : '') +
(channels.length ? '<div><h2>ช่องทางของศูนย์</h2>' + channels.join('') + '</div>' : '') +
'<div><h2>ลิงก์ที่เกี่ยวข้อง</h2>' + (HOME ? '' : '<a href="' + (ROOT || './') + '">หน้าหลัก dsudemy.net</a>') + links.join('') + '</div>' +
'</div><div class="wrap footer-bottom">พ.ศ. 2569 ฝ่ายวิชาการและนวัตกรรมการเรียนรู้ โรงเรียนสาธิต มหาวิทยาลัยศิลปากร (มัธยมศึกษา)</div>';
const copy = document.getElementById('copyEmail');
if (copy) copy.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(C.email); toast('คัดลอกอีเมลแล้ว'); }
catch (e) { const r = document.createRange(); r.selectNodeContents(document.getElementById('siteEmail')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); toast('เลือกข้อความอีเมลไว้แล้ว กดคัดลอกจากแป้นพิมพ์ได้'); }
});
}
/* ลิงก์ที่เขียนไว้ใน HTML ตรง ๆ และในส่วนหัวส่วนท้าย (ลิงก์ที่หน้าสร้างภายหลังผ่าน href() แล้ว) */
if (EXPLICIT_INDEX) document.querySelectorAll('a[href]').forEach(a => {
const h = a.getAttribute('href'), n = withIndex(h);
if (n !== h) a.setAttribute('href', n);
});
/* ---------- ข้อความแจ้งสั้น และลิงก์ที่ยังไม่มีปลายทาง ---------- */
let toastEl = document.getElementById('toast');
if (!toastEl) { toastEl = document.createElement('div'); toastEl.className = 'toast'; toastEl.id = 'toast'; toastEl.setAttribute('role', 'status'); toastEl.hidden = true; body.appendChild(toastEl); }
let toastTimer;
function toast(msg) { toastEl.textContent = msg; toastEl.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.hidden = true; }, 2800); }
D.util.toast = toast;
document.addEventListener('click', e => {
const a = e.target.closest('a[href]');
if (a && isPending(a.getAttribute('href'))) { e.preventDefault(); toast('ลิงก์ "' + (a.dataset.label || a.textContent.trim()).slice(0, 40) + '" ยังรอเชื่อมต่อ'); }
});
/* ---------- ธีม ---------- */
const root = document.documentElement;
const saved = store.get('theme');
if (saved === 'dark' || saved === 'light') root.dataset.theme = saved;
const themeBtn = document.getElementById('themeBtn'), themeBtnMobile = document.getElementById('themeBtnMobile');
const currentTheme = () => root.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const syncTheme = () => {
const label = currentTheme() === 'dark' ? 'เปลี่ยนเป็นโหมดสว่าง' : 'เปลี่ยนเป็นโหมดมืด';
if (themeBtn) themeBtn.setAttribute('aria-label', label);
if (themeBtnMobile) themeBtnMobile.textContent = label;
};
const toggleTheme = () => { const n = currentTheme() === 'dark' ? 'light' : 'dark'; root.dataset.theme = n; store.set('theme', n); syncTheme(); };
[themeBtn, themeBtnMobile].forEach(b => b && b.addEventListener('click', toggleTheme));
syncTheme();
/* ---------- เมนูมือถือ ---------- */
const menuBtn = document.getElementById('menuBtn'), mobileNav = document.getElementById('mobileNav');
function setMenu(open) { mobileNav.hidden = !open; menuBtn.setAttribute('aria-expanded', open); menuBtn.setAttribute('aria-label', open ? 'ปิดเมนู' : 'เปิดเมนู'); }
if (menuBtn && mobileNav) {
menuBtn.addEventListener('click', () => setMenu(mobileNav.hidden));
mobileNav.addEventListener('click', e => { if (e.target.closest('a')) setMenu(false); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !mobileNav.hidden) { setMenu(false); menuBtn.focus(); } });
/* ปิดเมื่อแตะนอกแผงเมนู หรือเมื่อขยายจอจนเมนูเต็มแสดงแล้ว */
document.addEventListener('click', e => { if (!mobileNav.hidden && !e.target.closest('#mobileNav, #menuBtn')) setMenu(false); });
matchMedia('(min-width: 1201px)').addEventListener('change', e => { if (e.matches) setMenu(false); });
}
/* ---------- ปุ่มกลับขึ้นด้านบน ---------- */
const toTop = document.createElement('button');
toTop.className = 'to-top'; toTop.type = 'button'; toTop.hidden = true; toTop.setAttribute('aria-label', 'กลับขึ้นด้านบน');
toTop.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
body.appendChild(toTop);
addEventListener('scroll', () => { toTop.hidden = scrollY < 700; }, { passive: true });
toTop.addEventListener('click', () => { scrollTo({ top: 0 }); const b = document.querySelector('.brand'); if (b) b.focus(); });
/* ซ่อนส่วนในหน้าพร้อมเมนูของส่วนนั้น */
D.util.hideSection = id => {
const el = document.getElementById(id); if (el) el.hidden = true;
document.querySelectorAll('[data-spy="' + id + '"]').forEach(a => { a.hidden = true; });
};
})();