/* ========================================================= 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; }); }; })();