Initial commit: 교육 프로젝트 배포

This commit is contained in:
송대일
2026-07-01 18:32:42 +09:00
commit be6dccd120
1483 changed files with 5082202 additions and 0 deletions
+827
View File
@@ -0,0 +1,827 @@
/**
* 공통 스크립트 (common.js)
* ========================================
* 모든 페이지에서 공통으로 사용하는 기능을 모아둔 진입점입니다.
*
* [주요 역할]
* - ScrollManager: 모바일 뷰포트 높이 동기화, 스크롤 잠금(모달 열 때)
* - ModalManager: 모달 열기/닫기 (애니메이션, 비디오 정지)
* - DeviceUtils: 모바일/태블릿/데스크톱 감지
*
* [기존 코드 호환 함수] - 레거시 코드에서 그대로 사용 가능
* - popOpen(id): 모달 열기 (예: popOpen('modal-video'))
* - popClose(element): 모달 닫기 (닫기 버튼에 사용)
* - syncHeight(), bodyLock(), bodyUnlock(), isMobile()
*
* @module CommonUtils
*/
/**
* 스크롤 및 레이아웃 관리 클래스
* - syncHeight(): CSS 변수 --window-inner-height 설정 (모바일 주소창 대응)
* - lock()/unlock(): 모달 열 때 body 스크롤 잠금/해제
*/
class ScrollManager {
constructor() {
this.scrollY = 0;
this.wrap = null;
this.isLocked = false;
}
/**
* 스크린 높이 계산 및 CSS 변수 설정
*/
syncHeight() {
try {
document.documentElement.style.setProperty(
"--window-inner-height",
`${window.innerHeight}px`
);
} catch (error) {
if (typeof ErrorHandler !== 'undefined') {
ErrorHandler.handle(error, { context: 'ScrollManager.syncHeight' });
} else {
console.error('[ScrollManager] syncHeight error:', error);
}
}
}
/**
* body 스크롤 잠금
*/
lock() {
if (this.isLocked) return;
this.scrollY = window.scrollY;
document.documentElement.classList.add("is-locked");
document.documentElement.style.scrollBehavior = "auto";
if (this.wrap) {
this.wrap.style.top = `-${this.scrollY}px`;
}
this.isLocked = true;
}
/**
* body 스크롤 잠금 해제
*/
unlock() {
if (!this.isLocked) return;
document.documentElement.classList.remove("is-locked");
window.scrollTo(0, this.scrollY);
if (this.wrap) {
this.wrap.style.top = "";
}
document.documentElement.style.scrollBehavior = "";
this.isLocked = false;
}
/**
* 초기화
*/
init() {
this.wrap = typeof DOMUtils !== 'undefined' ? DOMUtils.$(".wrap") : document.querySelector(".wrap");
// 즉시 높이 설정
this.syncHeight();
// 리사이즈 이벤트 (쓰로틀 적용)
const throttledSyncHeight = typeof Utils !== 'undefined' && Utils.throttle
? Utils.throttle(() => this.syncHeight(), 100)
: (() => {
let resizeTimer;
return () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => this.syncHeight(), 100);
};
})();
if (typeof eventManager !== 'undefined') {
eventManager.on(window, "resize", throttledSyncHeight);
eventManager.on(window, "orientationchange", () => {
setTimeout(() => this.syncHeight(), 100);
});
} else {
window.addEventListener("resize", throttledSyncHeight);
window.addEventListener("orientationchange", () => {
setTimeout(() => this.syncHeight(), 100);
});
}
}
}
/**
* 모바일 감지 유틸리티
*/
class DeviceUtils {
/**
* 모바일 기기 여부 확인
* @param {number} breakpoint - 브레이크포인트 (기본값: 1025)
* @returns {boolean}
*/
static isMobile(breakpoint = 1025) {
return window.innerWidth < breakpoint;
}
/**
* 태블릿 기기 여부 확인
* @param {number} minWidth - 최소 너비
* @param {number} maxWidth - 최대 너비
* @returns {boolean}
*/
static isTablet(minWidth = 768, maxWidth = 1024) {
const width = window.innerWidth;
return width >= minWidth && width <= maxWidth;
}
/**
* 데스크톱 기기 여부 확인
* @param {number} breakpoint - 브레이크포인트
* @returns {boolean}
*/
static isDesktop(breakpoint = 1025) {
return window.innerWidth >= breakpoint;
}
}
/**
* 모달/팝업 관리 클래스
*/
class ModalManager {
constructor(scrollManager) {
this.scrollManager = scrollManager;
this.openModals = new Set();
}
/**
* 모달 열기
* @param {string|Element} target - 모달 ID 또는 요소
* @param {Object} options - 옵션
* @returns {Promise}
*/
async open(target, options = {}) {
const {
duration = 300,
lockScroll = true,
stopVideo = true,
} = options;
try {
const element = typeof target === 'string'
? (typeof DOMUtils !== 'undefined' ? DOMUtils.$(`#${target}`) : document.getElementById(target))
: target;
if (!element) {
console.warn('[ModalManager] Element not found:', target);
return;
}
if (typeof DOMUtils !== 'undefined') {
await DOMUtils.fadeIn(element, duration);
} else if (typeof AnimationUtils !== 'undefined') {
await AnimationUtils.fade(element, 'in', duration);
} else {
element.style.display = 'block';
element.style.opacity = '1';
}
if (lockScroll && this.scrollManager) {
this.scrollManager.lock();
}
this.openModals.add(element);
return element;
} catch (error) {
if (typeof ErrorHandler !== 'undefined') {
ErrorHandler.handle(error, { context: 'ModalManager.open', target });
} else {
console.error('[ModalManager] open error:', error);
}
}
}
/**
* 모달 닫기
* @param {string|Element} target - 모달 ID 또는 요소
* @param {Object} options - 옵션
* @returns {Promise}
*/
async close(target, options = {}) {
const {
duration = 300,
unlockScroll = true,
stopVideo = true,
} = options;
try {
const element = typeof target === 'string'
? (typeof DOMUtils !== 'undefined' ? DOMUtils.$(`#${target}`) : document.getElementById(target))
: target;
if (!element) return;
if (typeof DOMUtils !== 'undefined') {
await DOMUtils.fadeOut(element, duration);
} else if (typeof AnimationUtils !== 'undefined') {
await AnimationUtils.fade(element, 'out', duration);
} else {
element.style.display = 'none';
element.style.opacity = '';
}
// 비디오 정지
if (stopVideo) {
const video = element.querySelector("video");
if (video) video.pause();
}
if (unlockScroll && this.scrollManager && this.openModals.size <= 1) {
this.scrollManager.unlock();
}
this.openModals.delete(element);
return element;
} catch (error) {
if (typeof ErrorHandler !== 'undefined') {
ErrorHandler.handle(error, { context: 'ModalManager.close', target });
} else {
console.error('[ModalManager] close error:', error);
}
}
}
/**
* 모든 모달 닫기
*/
async closeAll() {
const promises = Array.from(this.openModals).map(modal => this.close(modal));
await Promise.all(promises);
this.openModals.clear();
}
}
// 전역 인스턴스 생성
const scrollManager = new ScrollManager();
const modalManager = new ModalManager(scrollManager);
// ----------------------------------------
// 기존 함수 호환성 래퍼 (레거시 코드용)
// ----------------------------------------
// 아래 함수들은 기존 코드에서 호출하는 이름입니다.
// 새로 작성 시에는 scrollManager, modalManager를 직접 사용하는 것을 권장합니다.
let scrollY = 0;
let wrap = null;
/** 뷰포트 높이 동기화 (리사이즈 시 호출) */
function syncHeight() {
scrollManager.syncHeight();
}
/** 모바일 기기 여부 (breakpoint 1025px) */
function isMobile() {
return DeviceUtils.isMobile();
}
/** body 스크롤 잠금 (모달 열 때) */
function bodyLock() {
scrollManager.lock();
}
/** body 스크롤 해제 (모달 닫을 때) */
function bodyUnlock() {
scrollManager.unlock();
}
/**
* 모달 열기 - id가 "open-modal-video" 인 버튼 클릭 시 모달 "modal-video" 가 열림
* @param {string} id - 모달 요소의 id (앞에 # 없이)
*/
async function popOpen(id) {
return await modalManager.open(id);
}
/**
* 모달 닫기 - 닫기 버튼(.close) 또는 백드롭 클릭 시 호출
* @param {Element} obj - 클릭된 요소 (보통 this 또는 event.target)
*/
async function popClose(obj) {
const popup = obj.closest ? obj.closest(".popup") : null;
if (popup) {
return await modalManager.close(popup);
}
}
/**
* 공통 이벤트 초기화
* - ScrollManager: 뷰포트 높이, 리사이즈 대응
* - 모달: id가 "open-modal-XXX"인 요소 클릭 시 #modal-XXX 열기
* - 모달 닫기: .close 클릭 또는 모달 바깥(.modal) 클릭
*/
function initCommonEvents() {
const baseHref = window.location.href.split("#")[0];
// ScrollManager 초기화
scrollManager.init();
wrap = scrollManager.wrap;
// 모달 열기 이벤트 (이벤트 위임)
const openModalHandler = function(e) {
const modalId = this.id.replace("open-", "");
modalManager.open(modalId);
};
// 모달 닫기 이벤트
const closeModalHandler = async function(e) {
const modal = this.closest(".modal");
if (modal) {
await modalManager.close(modal);
}
};
// 모달 바깥 클릭 시 닫기
const modalBackdropHandler = async function(e) {
const modalContent = e.target.closest(".modal-content");
if (!modalContent && e.target === this) {
await modalManager.close(this);
}
};
// EventManager 사용 (있는 경우)
if (typeof eventManager !== 'undefined') {
eventManager.delegate(document, "click", "[id^=open-modal]", openModalHandler);
eventManager.delegate(document, "click", ".close", closeModalHandler);
eventManager.delegate(document, "click", ".modal", modalBackdropHandler);
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.delegate) {
DOMUtils.delegate(document, "click", "[id^=open-modal]", openModalHandler);
DOMUtils.delegate(document, "click", ".close", closeModalHandler);
DOMUtils.delegate(document, "click", ".modal", modalBackdropHandler);
} else {
// 폴백: 직접 이벤트 리스너 등록
document.addEventListener("click", (e) => {
const target = e.target.closest("[id^=open-modal]");
if (target) {
openModalHandler.call(target, e);
}
const closeBtn = e.target.closest(".close");
if (closeBtn) {
closeModalHandler.call(closeBtn, e);
}
const modal = e.target.closest(".modal");
if (modal && e.target === modal) {
modalBackdropHandler.call(modal, e);
}
});
}
// ----------------------------------------
// 모바일 검색 오버레이
// ----------------------------------------
const searchOpenBtn = document.querySelector(".btn-search");
const searchLayer = document.querySelector(".mo-search-layer");
const searchCloseBtn = searchLayer ? searchLayer.querySelector(".btn-close-search") : null;
const searchInput = searchLayer ? searchLayer.querySelector('input[type="text"]') : null;
const openSearch = () => {
if (!searchLayer) return;
searchLayer.classList.add("is-open");
searchLayer.setAttribute("aria-hidden", "false");
scrollManager.lock();
if (searchInput) {
setTimeout(() => {
searchInput.focus();
}, 50);
}
};
const closeSearch = () => {
if (!searchLayer) return;
searchLayer.classList.remove("is-open");
searchLayer.setAttribute("aria-hidden", "true");
scrollManager.unlock();
};
if (searchOpenBtn && searchLayer) {
searchOpenBtn.addEventListener("click", openSearch);
}
if (searchCloseBtn) {
searchCloseBtn.addEventListener("click", closeSearch);
}
if (searchLayer) {
const searchBackdrop = searchLayer.querySelector(".mo-search-backdrop");
searchLayer.addEventListener("click", (e) => {
if (e.target === searchLayer || e.target === searchBackdrop) {
closeSearch();
}
});
}
}
// DOMContentLoaded 시 초기화
if (document.readyState === 'loading') {
document.addEventListener("DOMContentLoaded", initCommonEvents);
} else {
initCommonEvents();
}
// 리사이즈 이벤트는 ScrollManager.init()에서 처리됨
/**
* 컨테이너 스크롤 효과 클래스
*/
class ContainerScrollEffect {
constructor(container, options = {}) {
this.container = container;
this.options = {
borderRadius: 30,
scrollThreshold: 100,
excludeClass: 'search-result',
...options,
};
this.isActive = false;
}
/**
* 효과 초기화
*/
init() {
if (!this.container) return;
// 검색 결과 페이지에서는 이 효과를 적용하지 않음
const wrap = this.container.closest(".wrap");
if (wrap && wrap.classList.contains(this.options.excludeClass)) {
return;
}
const throttledScroll = typeof Utils !== 'undefined' && Utils.throttle
? Utils.throttle(() => this._handleScroll(), 16)
: (() => {
let lastTime = 0;
return () => {
const now = performance.now();
if (now - lastTime >= 16) {
this._handleScroll();
lastTime = now;
}
};
})();
if (typeof eventManager !== 'undefined') {
eventManager.on(this.container, "scroll", throttledScroll);
} else {
this.container.addEventListener("scroll", throttledScroll);
}
this.isActive = true;
}
/**
* 스크롤 핸들러
* @private
*/
_handleScroll() {
const scrollTop = this.container.scrollTop;
const progress = Math.min(scrollTop / this.options.scrollThreshold, 1);
const currentRadius = this.options.borderRadius * (1 - progress);
this.container.style.clipPath = `inset(0 0 0 0 round ${currentRadius}px ${currentRadius}px 0 0)`;
}
/**
* 효과 제거
*/
destroy() {
if (this.container && this.isActive) {
this.container.style.clipPath = '';
this.isActive = false;
}
}
}
// 기존 함수 호환성
function initContainerScrollEffect() {
const container = typeof DOMUtils !== 'undefined'
? DOMUtils.$(".container")
: document.querySelector(".container");
if (container) {
const effect = new ContainerScrollEffect(container);
effect.init();
}
}
/**
* 컨테이너 상단 라운드 모서리 유지
* - .container에 clip-path를 적용해 상단 30px 라운드를 고정 유지
* - 스크롤, 스타일 변경 등으로 덮어써져도 복원
*/
function initContainerRoundCorners() {
// 인트로 페이지에서는 clip-path 적용 안 함
if (document.querySelector(".wrap.intro")) return;
const container = document.querySelector(".container");
if (!container) return;
const targetClipPath = "inset(0 0 0 0 round 30px 30px 0 0)";
container.style.clipPath = targetClipPath;
function maintainRoundCorners() {
const currentClipPath = container.style.clipPath || "";
if (currentClipPath !== targetClipPath && !currentClipPath.includes("30px")) {
container.style.clipPath = targetClipPath;
}
requestAnimationFrame(maintainRoundCorners);
}
container.addEventListener(
"scroll",
function () {
this.style.clipPath = targetClipPath;
},
{ passive: true, capture: true }
);
const observer = new MutationObserver(function () {
if (container.style.clipPath !== targetClipPath) {
container.style.clipPath = targetClipPath;
}
});
observer.observe(container, {
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
});
maintainRoundCorners();
}
// DOMContentLoaded 시 초기화
document.addEventListener("DOMContentLoaded", () => {
initContainerRoundCorners();
});
/**
* HTML Include 관리 클래스
*/
class HTMLIncludeManager {
constructor(options = {}) {
this.options = {
selector: "[data-include-path]",
attribute: "data-include-path",
...options,
};
}
/**
* HTML include 실행
* @returns {Promise}
*/
async include() {
const allElements = typeof DOMUtils !== 'undefined'
? DOMUtils.$$(this.options.selector)
: document.querySelectorAll(this.options.selector);
const promises = Array.from(allElements).map(async (el) => {
const includePath = el.dataset.includePath || el.getAttribute(this.options.attribute);
if (!includePath) return;
try {
const response = await fetch(includePath);
if (!response.ok) {
throw new Error(`Failed to load: ${includePath} (${response.status})`);
}
const html = await response.text();
el.innerHTML = html;
el.removeAttribute(this.options.attribute);
// 포함된 HTML에 대한 이벤트 재초기화 (필요한 경우)
this._reinitializeEvents(el);
} catch (error) {
if (typeof ErrorHandler !== 'undefined') {
ErrorHandler.handle(error, {
context: 'HTMLIncludeManager.include',
includePath,
});
} else {
console.error(`[HTMLIncludeManager] Error loading ${includePath}:`, error);
}
}
});
await Promise.all(promises);
}
/**
* 포함된 HTML의 이벤트 재초기화
* @private
*/
_reinitializeEvents(element) {
// 포함된 스크립트 실행 (보안 주의)
const scripts = element.querySelectorAll('script');
scripts.forEach((script) => {
const newScript = document.createElement('script');
if (script.src) {
newScript.src = script.src;
} else {
newScript.textContent = script.textContent;
}
script.parentNode.replaceChild(newScript, script);
});
}
}
// 전역 인스턴스
const htmlIncludeManager = new HTMLIncludeManager();
// 기존 함수 호환성
async function includehtml() {
return await htmlIncludeManager.include();
}
/**
* 사이트맵 패널 관리 클래스 (1400px 이하에서 사용)
* - 햄버거 버튼 클릭 시 .nav-wrap에 is-sitemap-open 토글
* - 아코디언: .menu-section 클릭 시 is-open 토글
*/
class SiteMapManager {
constructor() {
this.navWrap = null;
this.btnSitemap = null;
this.siteMap = null;
this.isOpen = false;
this._onKeydown = this._handleKeydown.bind(this);
}
init() {
this.navWrap = document.querySelector('.nav-wrap');
this.btnSitemap = document.querySelector('.btn-sitemap');
this.siteMap = document.getElementById('siteMap');
if (!this.btnSitemap || !this.siteMap || !this.navWrap) return;
// 햄버거 버튼 클릭
this.btnSitemap.addEventListener('click', () => this.toggle());
// 사이트맵 내부 닫기 버튼
const btnCloseSitemap = this.siteMap.querySelector('.site-map-header .btn-close');
if (btnCloseSitemap) {
btnCloseSitemap.addEventListener('click', () => this.close());
}
// 백드롭 클릭 시 닫기
const backdrop = this.siteMap.querySelector('.site-map-backdrop');
if (backdrop) {
backdrop.addEventListener('click', () => this.close());
}
// 아코디언 메뉴 초기화
this._initAccordion();
// 현재 페이지에 해당하는 하위 메뉴 링크 active 처리
this._setActiveLinks();
// 리사이즈 시 1400px 초과이면 자동으로 닫기
window.addEventListener('resize', () => {
if (window.innerWidth > 1400 && this.isOpen) {
this.close();
}
});
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.navWrap.classList.add('is-sitemap-open');
this.btnSitemap.setAttribute('aria-expanded', 'true');
this.siteMap.setAttribute('aria-hidden', 'false');
scrollManager.lock();
document.addEventListener('keydown', this._onKeydown);
}
close() {
this.isOpen = false;
this.navWrap.classList.remove('is-sitemap-open');
this.btnSitemap.setAttribute('aria-expanded', 'false');
this.siteMap.setAttribute('aria-hidden', 'true');
scrollManager.unlock();
document.removeEventListener('keydown', this._onKeydown);
}
_handleKeydown(e) {
if (e.key === 'Escape') this.close();
}
_setActiveLinks() {
const currentPath = window.location.pathname;
const currentFile = currentPath.split('/').pop();
// 하위 메뉴 링크 + 섹션 타이틀 직링크(<a>) 모두 체크
const links = this.siteMap.querySelectorAll('.menu-section-list a, a.menu-section-title');
links.forEach((link) => {
const href = link.getAttribute('href');
if (!href || href === '#') return;
const hrefFile = href.replace('./', '');
const isActive = hrefFile === currentFile
|| currentPath.endsWith(hrefFile)
|| currentPath.includes(hrefFile.replace('.html', ''));
if (isActive) {
link.classList.add('active');
// 하위 메뉴 링크인 경우 부모 섹션 자동 열기
const section = link.closest('.menu-section');
if (section && link.closest('.menu-section-list')) {
section.classList.add('is-open');
}
}
});
}
_initAccordion() {
const sections = this.siteMap.querySelectorAll('.menu-section');
sections.forEach((section) => {
const btn = section.querySelector('.menu-section-title');
const list = section.querySelector('.menu-section-list');
// 하위 메뉴가 없는 섹션은 아코디언 동작 생략
if (!btn || !list) return;
btn.addEventListener('click', () => {
const isOpen = section.classList.contains('is-open');
// 다른 섹션 닫기
sections.forEach((s) => s.classList.remove('is-open'));
// 현재 섹션 토글
if (!isOpen) {
section.classList.add('is-open');
}
});
});
}
}
const siteMapManager = new SiteMapManager();
document.addEventListener('DOMContentLoaded', () => {
siteMapManager.init();
if (typeof LearningGuideModal !== 'undefined') {
LearningGuideModal.init();
}
// 알림 더보기/접기 토글
document.querySelectorAll('.btn-alert-toggle').forEach((btn) => {
btn.addEventListener('click', function () {
const item = this.closest('.alert-item');
if (!item) return;
const isOpen = item.classList.toggle('is-open');
this.innerHTML = isOpen ? '접기 <span aria-hidden="true">∧</span>' : '더보기 <span aria-hidden="true"></span>';
});
});
});
// 전역으로 내보내기 (선택사항)
if (typeof window !== 'undefined') {
window.CommonUtils = {
ScrollManager,
DeviceUtils,
ModalManager,
ContainerScrollEffect,
HTMLIncludeManager,
SiteMapManager,
scrollManager,
modalManager,
htmlIncludeManager,
siteMapManager,
// 기존 함수들
syncHeight,
isMobile,
bodyLock,
bodyUnlock,
popOpen,
popClose,
initContainerScrollEffect,
initContainerRoundCorners,
includehtml,
};
}