/* =========================================================
layout.js: ส่วนหัวและส่วนท้ายชุดเดียวของทุกหน้าใน dsudemy.net
สร้างจากข้อมูลใน data/site.js และ data/units.js แก้เมนูหรือส่วนท้ายที่ไฟล์นี้ที่เดียว
วิธีใช้ในหน้าใหม่ (ดู templates/subpage.html)
-
data-root คือเส้นทางกลับรากของเว็บ
หน้าหลักใช้ data-root="" และ data-page="home"
- และ
- โหลดไฟล์ข้อมูลก่อน แล้วโหลดไฟล์นี้ แล้วจึงโหลดสคริปต์ของหน้า
- หน้าหลักใส่ 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: '',
book: '',
file: '',
people: '',
table: '',
compass: '',
layers: '',
flask: '',
badge: '',
grid: '',
external: '',
arrow: ''
};
const icon = (name, size) => '';
/* ลิงก์ภายนอกเปิดแท็บใหม่ และบอกโปรแกรมอ่านหน้าจอ */
const extAttrs = h => isExternal(h) && !/^(mailto:|tel:)/.test(h) ? ' target="_blank" rel="noopener"' : '';
const extNote = h => isExternal(h) && !/^(mailto:|tel:)/.test(h) ? ' (เปิดในแท็บใหม่)' : '';
/* ปุ่มช่องทางติดต่อของหน่วยงาน (หน้าแนะแนวและหน้าศูนย์) แสดงเฉพาะช่องที่มีข้อมูล
LINE ที่ไม่มีลิงก์แสดงเป็นป้ายธรรมดา เพราะกดไปที่ใดไม่ได้ */
function contactActions(c, facebook, fbLabel) {
c = c || {};
const out = [];
if (has(c.email)) out.push('อีเมล ' + esc(c.email) + '');
if (has(c.phone)) out.push('โทร ' + esc(c.phone) + '');
if (has(c.line)) out.push(has(c.lineUrl)
? 'LINE ' + esc(c.line) + extNote(c.lineUrl) + ''
: 'LINE ' + esc(c.line) + '');
if (has(facebook)) out.push('' + esc(fbLabel || 'Facebook') + icon('external', 16) + ' (เปิดในแท็บใหม่)');
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 += ''; html += '' + TH_MON_FULL[p.m - 1] + ' ' + (p.y + 543) + '
'; cur = key; }
const tags = tagsFn ? tagsFn(e) : '';
html += '- ' + esc(fmtRange(e.start, e.end)) + '' + esc(e.title) + (tags ? '' + tags + '' : '') + '
';
});
return cur ? html + '
' : '';
}
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 =>
'' + n.label + '').join('');
const header = document.getElementById('siteHeader');
if (header) {
header.innerHTML =
'' +
'
';
}
/* ---------- ส่วนท้าย ---------- */
const footer = document.getElementById('contact');
if (footer) {
const L = SITE.links || {};
const rows = [];
if (has(C.email)) rows.push('อีเมล ' + esc(C.email) + ' ');
if (has(C.phone)) rows.push('' + esc(C.phone) + '');
if (has(C.line)) rows.push('LINE ' + (has(C.lineUrl) ? '' + esc(C.line) + '' : esc(C.line)) + '');
const links = [['school', 'เว็บไซต์โรงเรียน'], ['university', 'มหาวิทยาลัยศิลปากร'], ['studio', 'DSUdemy Studio']]
.filter(([k]) => has(L[k])).map(([k, label]) => '' + label + ' (เปิดในแท็บใหม่)' + (k === 'studio' ? '' : ''));
const channels = UNITS.filter(u => has(u.facebook)).map(u => '' + esc(u.display) + ' (Facebook) (เปิดในแท็บใหม่)');
footer.innerHTML =
'';
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 = '';
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; });
};
})();