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
+524
View File
@@ -0,0 +1,524 @@
/*
-- =====================================
-- 영상 모달 JS (클릭 → 모달 열기 / 추천영상 / 댓글)
-- =====================================
*/
$(function () {
var API_BASE = '/bbs/api';
var $modal = $('#leadershipVideoModal');
var currentContentId = null;
var editingCommentId = null;
// ── YouTube IFrame API 시청 추적 변수 ──
var ytPlayer = null;
var trackingInterval = null;
var lastSaveTime = 0;
var lastCurrentTime = 0;
// YouTube IFrame API 스크립트 로드
if (!document.getElementById('youtube-iframe-api')) {
var ytScript = document.createElement('script');
ytScript.id = 'youtube-iframe-api';
ytScript.src = 'https://www.youtube.com/iframe_api';
ytScript.async = true;
document.head.appendChild(ytScript);
}
// ── YouTube 비디오 ID 추출 ──
function extractVideoId(url) {
if (!url) return '';
var videoId = url;
if (url.indexOf('youtube.com/watch') > -1) {
try { videoId = new URL(url).searchParams.get('v') || url; } catch (e) {}
} else if (url.indexOf('youtu.be/') > -1) {
videoId = url.split('youtu.be/')[1].split(/[?&#]/)[0];
} else if (url.indexOf('youtube.com/embed/') > -1) {
videoId = url.split('youtube.com/embed/')[1].split(/[?&#]/)[0];
}
if (videoId && videoId.indexOf('/') === -1 && videoId.indexOf('.') === -1) {
return videoId;
}
return '';
}
// ── YT.Player 생성 ──
function initYTPlayer(videoId, startSeconds) {
destroyYTPlayer();
startSeconds = Math.floor(startSeconds || 0);
var createPlayer = function () {
ytPlayer = new YT.Player('videoPlayer', {
height: '100%',
width: '100%',
videoId: videoId,
playerVars: {
autoplay: 1,
controls: 1,
rel: 0,
modestbranding: 1,
enablejsapi: 1,
start: startSeconds
},
events: {
'onReady': function (event) {
if (event.target && typeof event.target.playVideo === 'function') {
event.target.playVideo();
}
},
'onStateChange': function (event) {
handlePlayerStateChange(event);
},
'onError': function (event) {
console.error('[Leadership] Player Error:', event.data);
}
}
});
};
if (typeof YT !== 'undefined' && YT.Player && typeof YT.Player === 'function') {
createPlayer();
} else {
// YT API 아직 로드 안 됨 → 대기
var checkYt = setInterval(function () {
if (typeof YT !== 'undefined' && YT.Player && typeof YT.Player === 'function') {
clearInterval(checkYt);
createPlayer();
}
}, 500);
setTimeout(function () { clearInterval(checkYt); }, 10000);
}
}
// ── 플레이어 파괴 ──
function destroyYTPlayer() {
stopTracking();
if (ytPlayer && typeof ytPlayer.destroy === 'function') {
try { ytPlayer.destroy(); } catch (e) {}
}
ytPlayer = null;
// YT.Player가 div를 iframe으로 교체하므로 다시 div를 복원
var $box = $modal.find('.video-box');
if (!$box.find('#videoPlayer').length) {
$box.html('<div id="videoPlayer"></div>');
}
}
// ── 플레이어 상태 변경 핸들러 (VideoModalBase 패턴) ──
function handlePlayerStateChange(event) {
if (event.data === YT.PlayerState.PLAYING) {
startTracking();
} else {
stopTracking();
if (event.data === YT.PlayerState.PAUSED || event.data === YT.PlayerState.ENDED) {
saveLearningProgress(Math.floor(lastSaveTime), 'N');
lastSaveTime = 0;
}
}
}
// ── 시청 추적 시작 (1초 주기) ──
function startTracking() {
if (trackingInterval) return;
lastSaveTime = 0;
trackingInterval = setInterval(function () {
if (!ytPlayer || typeof ytPlayer.getCurrentTime !== 'function') return;
var playerState = (typeof ytPlayer.getPlayerState === 'function')
? ytPlayer.getPlayerState() : null;
if (playerState !== YT.PlayerState.PLAYING) return;
var currentTime = ytPlayer.getCurrentTime();
var diff = currentTime - lastCurrentTime;
if (Math.abs(diff) <= 5) {
lastSaveTime += 1;
}
lastCurrentTime = currentTime;
// 5초마다 서버에 heartbeat 전송
if (lastSaveTime >= 5) {
saveLearningProgress(lastSaveTime);
lastSaveTime = 0;
}
}, 1000);
}
// ── 시청 추적 중지 ──
function stopTracking() {
if (trackingInterval) {
clearInterval(trackingInterval);
trackingInterval = null;
}
}
// ── 서버에 학습 진행상황 저장 (save_learning.php) ──
function saveLearningProgress(heartbeatSeconds, isWatching) {
if (!ytPlayer || !currentContentId) return;
if (typeof ytPlayer.getCurrentTime !== 'function') return;
var currentTime = Math.floor(ytPlayer.getCurrentTime());
var duration = Math.floor(ytPlayer.getDuration());
var incrementalWatch = Math.max(0, Math.floor(heartbeatSeconds || 0));
var params = new URLSearchParams({
content_id: String(currentContentId),
watch_tm: String(Math.max(0, currentTime)),
content_tm: String(Math.max(0, duration)),
all_tm_increment: String(incrementalWatch),
is_watching: isWatching === 'N' ? 'N' : 'Y'
});
fetch(API_BASE + '/save_learning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: params.toString(),
keepalive: true
}).then(function (r) { return r.json(); }).then(function (result) {
if (!result.success) {
console.error('[Leadership] Save Learning Failed:', result.message);
}
}).catch(function (e) {
console.error('[Leadership] Save Learning Error:', e);
});
}
// ── 영상 카드 클릭 → 모달 열기 ──
$(document).on('click', '#video-list .video-item .card-link', function (e) {
e.preventDefault();
var $item = $(this).closest('.video-item');
var contentId = $item.data('content-id') || $item.attr('data-content-id') || '';
var videoUrl = $item.data('content-url') || $item.attr('data-content-url') || '';
var title = $item.data('title') || $item.find('.item-title').text().trim() || '';
var desc = $item.data('description') || '';
var catName = $item.data('category-name') || $item.find('.item-category').text().trim() || '리더십';
var subCat = $item.data('sub-category') || '';
var watchTm = parseInt($item.data('watch-tm') || $item.attr('data-watch-tm') || '0', 10);
var contentTm = parseInt($item.data('content-tm') || $item.attr('data-content-tm') || '0', 10);
var categoryCode = $item.data('category-code') || $item.attr('data-category-code') || '';
console.warn('[VideoModal] category-code='+categoryCode);
if (!contentId) {
console.warn('[VideoModal] content-id가 없습니다.');
return;
}
openModal({
contentId: contentId,
videoUrl: videoUrl,
title: title,
description: desc,
categoryName: catName,
subCategory: subCat,
watchTm: watchTm,
contentTm: contentTm
});
});
/*
$(document).on('click', '#video-list .video-item .card-link', function (e) {
e.preventDefault();
var $item = $(this).closest('.video-item');
var contentId = $item.data('content-id') || $item.attr('data-content-id') || '';
var videoUrl = $item.data('content-url') || $item.attr('data-content-url') || '';
var title = $item.data('title') || $item.find('.item-title').text().trim() || '';
var desc = $item.data('description') || '';
var catName = $item.data('category-name') || $item.find('.item-category').text().trim() || '리더십';
var subCat = $item.data('sub-category') || '';
var watchTm = parseInt($item.data('watch-tm') || $item.attr('data-watch-tm') || '0', 10);
var contentTm = parseInt($item.data('content-tm') || $item.attr('data-content-tm') || '0', 10);
if (!contentId) {
console.warn('[VideoModal] content-id가 없습니다.');
return;
}
openModal({
contentId: contentId,
videoUrl: videoUrl,
title: title,
description: desc,
categoryName: catName,
subCategory: subCat,
watchTm: watchTm,
contentTm: contentTm
});
});
*/
// ── 모달 열기 ──
function openModal(info) {
currentContentId = info.contentId;
editingCommentId = null;
lastSaveTime = 0;
lastCurrentTime = Math.floor(info.watchTm || 0);
// YouTube IFrame API 플레이어 세팅
var videoId = extractVideoId(info.videoUrl);
if (videoId) {
initYTPlayer(videoId, info.watchTm || 0);
}
// 영상 정보
$modal.find('#modalTitle').text(info.title);
// 설명: 줄바꿈을 <br>로 변환하여 표시
var descHtml = $('<div>').text(info.description || '').html().replace(/\n/g, '<br>');
$modal.find('#modalDesc').html(descHtml);
$modal.find('#modalCategoryName').text(info.categoryName || '리더십');
$modal.find('#modalSubCategory').text(info.subCategory || '');
$modal.find('#modalBadge').text(info.categoryName || '리더십');
// 추천영상 리스트
buildRecommendedList(info.contentId);
// 댓글 로드
loadComments(info.contentId);
resetCommentForm();
// 모달 표시
$modal.show();
$('body').css('overflow', 'hidden');
}
// ── 모달 닫기 (진행상황 저장 후 닫기) ──
function closeModal() {
// 마지막 시청 진행상황 저장
stopTracking();
if (ytPlayer && currentContentId && typeof ytPlayer.getCurrentTime === 'function') {
saveLearningProgress(Math.floor(lastSaveTime), 'N');
}
lastSaveTime = 0;
destroyYTPlayer();
$modal.hide();
$('body').css('overflow', '');
currentContentId = null;
editingCommentId = null;
}
$modal.on('click', '.close', closeModal);
$modal.on('click', function (e) {
if ($(e.target).hasClass('modal')) closeModal();
});
$(document).on('keydown', function (e) {
if (e.key === 'Escape' && $modal.is(':visible')) closeModal();
});
// ── 추천영상 리스트 구성 (현재 페이지 영상 목록 활용) ──
function buildRecommendedList(excludeContentId) {
var $list = $modal.find('#recommendedList');
$list.empty();
var count = 0;
$('#video-list .video-item').each(function () {
var $item = $(this);
var cid = $item.data('content-id') || $item.attr('data-content-id') || '';
if (!cid || String(cid) === String(excludeContentId)) return;
if (count >= 10) return false; // 최대 10개
var videoUrl = $item.data('content-url') || $item.attr('data-content-url') || '';
var title = $item.data('title') || $item.find('.item-title').text().trim() || '';
var catName = $item.data('category-name') || $item.find('.item-category').text().trim() || '리더십';
var thumbSrc = $item.find('.item-thumb img, .thumb img').attr('src') || '/img/video/img_thumb_01.png';
var desc = String($item.data('description') || '');
var subCat = $item.data('sub-category') || '';
var wTm = parseInt($item.data('watch-tm') || $item.attr('data-watch-tm') || '0', 10);
var cTm = parseInt($item.data('content-tm') || $item.attr('data-content-tm') || '0', 10);
var li =
'<li>' +
'<a href="#" class="list" ' +
'data-content-id="' + cid + '" ' +
'data-content-url="' + videoUrl.replace(/"/g, '&quot;') + '" ' +
'data-title="' + title.replace(/"/g, '&quot;') + '" ' +
'data-description="' + desc.replace(/"/g, '&quot;') + '" ' +
'data-category-name="' + catName.replace(/"/g, '&quot;') + '" ' +
'data-sub-category="' + subCat.replace(/"/g, '&quot;') + '" ' +
'data-watch-tm="' + wTm + '" ' +
'data-content-tm="' + cTm + '">' +
'<div class="thumb"><img src="' + thumbSrc + '" alt="" /></div>' +
'<div class="txt-box">' +
'<div class="category leader">' + catName + '</div>' +
'<div class="title">' + title + '</div>' +
'</div>' +
'</a>' +
'</li>';
$list.append(li);
count++;
});
if (count === 0) {
$list.html('<li class="empty">추천 영상이 없습니다.</li>');
}
}
// ── 추천영상 클릭 → 영상 전환 ──
$modal.on('click', '#recommendedList .list', function (e) {
e.preventDefault();
var $a = $(this);
// 현재 영상 진행상황 저장 후 전환
stopTracking();
if (ytPlayer && currentContentId && typeof ytPlayer.getCurrentTime === 'function') {
saveLearningProgress(Math.floor(lastSaveTime), 'N');
}
lastSaveTime = 0;
openModal({
contentId: $a.data('content-id'),
videoUrl: $a.data('content-url') || '',
title: $a.data('title') || $a.find('.title').text().trim(),
description: $a.data('description') || '',
categoryName: $a.data('category-name') || '리더십',
subCategory: $a.data('sub-category') || '',
watchTm: parseInt($a.data('watch-tm') || '0', 10),
contentTm: parseInt($a.data('content-tm') || '0', 10)
});
});
// ══════════════════════════════════════════════
// 댓글 CRUD
// ══════════════════════════════════════════════
// ── 댓글 목록 로드 ──
function loadComments(contentId) {
var $commentList = $modal.find('.comment-list');
if (!contentId) return;
$.getJSON(API_BASE + '/get_comments.php', { content_id: contentId })
.done(function (data) {
if (data.success && Array.isArray(data.data)) {
renderComments(data.data);
} else {
$commentList.html('<li class="empty">등록된 댓글이 없습니다.</li>');
}
})
.fail(function () {
$commentList.html('<li class="empty">댓글을 불러올 수 없습니다.</li>');
});
}
// ── 댓글 렌더링 (온보딩 모달 동일 구조) ──
function renderComments(comments) {
var $commentList = $modal.find('.comment-list');
if (!comments.length) {
$commentList.html('<li class="empty">등록된 댓글이 없습니다.</li>');
return;
}
var userIconSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>';
var html = comments.map(function (c) {
var safeComment = (c.comment || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
var safeName = (c.member_name || '익명')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var buttons = c.is_author
? '<div class="comment-actions">' +
'<button type="button" class="btn-edit" data-id="' + c.id + '">수정</button>' +
'<button type="button" class="btn-delete" data-id="' + c.id + '">삭제</button>' +
'</div>'
: '';
return '<li data-comment-id="' + c.id + '">' +
'<div class="comment-header">' +
'<span class="user-icon">' + userIconSvg + '</span>' +
'<span class="comment-author">' + safeName + '</span>' +
'</div>' +
'<div class="comment-body">' +
'<div class="comment-content">' + safeComment + '</div>' +
buttons +
'</div>' +
'<div class="comment-footer">' +
'<span class="comment-date">' + (c.created_at || '') + '</span>' +
'</div>' +
'</li>';
}).join('');
$commentList.html(html);
}
// ── 댓글 수정 ──
$modal.on('click', '.btn-edit', function () {
var $li = $(this).closest('li');
var content = $li.find('.comment-content').text();
editingCommentId = $(this).data('id');
var $textarea = $modal.find('.comment-box textarea');
$textarea.val(content).focus();
$modal.find('.btn-cancel').prop('disabled', false);
$modal.find('.btn-save').prop('disabled', false).text('수정');
});
// ── 댓글 삭제 ──
$modal.on('click', '.btn-delete', function () {
if (!confirm('댓글을 삭제하시겠습니까?')) return;
var commentId = $(this).data('id');
$.ajax({
url: API_BASE + '/delete_comment.php',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ id: commentId }),
success: function (data) {
if (data.success) {
loadComments(currentContentId);
} else {
alert(data.message || '삭제에 실패했습니다.');
}
},
error: function () { alert('삭제 중 오류가 발생했습니다.'); }
});
});
// ── 댓글 작성/수정 저장 ──
$modal.on('click', '.comment-box .btn-save', function () {
var $textarea = $modal.find('.comment-box textarea');
var comment = $textarea.val().trim();
if (!comment || !currentContentId) return;
var payload = { content_id: currentContentId, comment: comment };
if (editingCommentId) payload.id = editingCommentId;
$.ajax({
url: API_BASE + '/save_comment.php',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function (data) {
if (data.success) {
resetCommentForm();
loadComments(currentContentId);
} else {
alert(data.message || '저장에 실패했습니다.');
}
},
error: function () { alert('저장 중 오류가 발생했습니다.'); }
});
});
// ── 댓글 취소 ──
$modal.on('click', '.comment-box .btn-cancel', function () {
resetCommentForm();
});
// ── textarea 입력 → 버튼 활성화 ──
$modal.on('input', '.comment-box textarea', function () {
var hasText = $(this).val().trim().length > 0;
$modal.find('.btn-cancel').prop('disabled', !hasText);
$modal.find('.btn-save').prop('disabled', !hasText);
});
function resetCommentForm() {
$modal.find('.comment-box textarea').val('');
$modal.find('.btn-cancel').prop('disabled', true);
$modal.find('.btn-save').prop('disabled', true).text('등록');
editingCommentId = null;
}
});
File diff suppressed because it is too large Load Diff
+484
View File
@@ -0,0 +1,484 @@
/*
-- =====================================
-- 영상 모달 JS (클릭 → 모달 열기 / 추천영상 / 댓글)
-- =====================================
*/
$(function () {
var API_BASE = '/edu/bbs/api';
var $modal = $('#leadershipVideoModal');
var currentContentId = null;
var editingCommentId = null;
// ── YouTube IFrame API 시청 추적 변수 ──
var ytPlayer = null;
var trackingInterval = null;
var lastSaveTime = 0;
var lastCurrentTime = 0;
// YouTube IFrame API 스크립트 로드
if (!document.getElementById('youtube-iframe-api')) {
var ytScript = document.createElement('script');
ytScript.id = 'youtube-iframe-api';
ytScript.src = 'https://www.youtube.com/iframe_api';
ytScript.async = true;
document.head.appendChild(ytScript);
}
// ── YouTube 비디오 ID 추출 ──
function extractVideoId(url) {
if (!url) return '';
var videoId = url;
if (url.indexOf('youtube.com/watch') > -1) {
try { videoId = new URL(url).searchParams.get('v') || url; } catch (e) {}
} else if (url.indexOf('youtu.be/') > -1) {
videoId = url.split('youtu.be/')[1].split(/[?&#]/)[0];
} else if (url.indexOf('youtube.com/embed/') > -1) {
videoId = url.split('youtube.com/embed/')[1].split(/[?&#]/)[0];
}
if (videoId && videoId.indexOf('/') === -1 && videoId.indexOf('.') === -1) {
return videoId;
}
return '';
}
// ── YT.Player 생성 ──
function initYTPlayer(videoId, startSeconds) {
destroyYTPlayer();
startSeconds = Math.floor(startSeconds || 0);
var createPlayer = function () {
ytPlayer = new YT.Player('videoPlayer', {
height: '100%',
width: '100%',
videoId: videoId,
playerVars: {
autoplay: 1,
controls: 1,
rel: 0,
modestbranding: 1,
enablejsapi: 1,
start: startSeconds
},
events: {
'onReady': function (event) {
if (event.target && typeof event.target.playVideo === 'function') {
event.target.playVideo();
}
},
'onStateChange': function (event) {
handlePlayerStateChange(event);
},
'onError': function (event) {
console.error('[Leadership] Player Error:', event.data);
}
}
});
};
if (typeof YT !== 'undefined' && YT.Player && typeof YT.Player === 'function') {
createPlayer();
} else {
// YT API 아직 로드 안 됨 → 대기
var checkYt = setInterval(function () {
if (typeof YT !== 'undefined' && YT.Player && typeof YT.Player === 'function') {
clearInterval(checkYt);
createPlayer();
}
}, 500);
setTimeout(function () { clearInterval(checkYt); }, 10000);
}
}
// ── 플레이어 파괴 ──
function destroyYTPlayer() {
stopTracking();
if (ytPlayer && typeof ytPlayer.destroy === 'function') {
try { ytPlayer.destroy(); } catch (e) {}
}
ytPlayer = null;
// YT.Player가 div를 iframe으로 교체하므로 다시 div를 복원
var $box = $modal.find('.video-box');
if (!$box.find('#videoPlayer').length) {
$box.html('<div id="videoPlayer"></div>');
}
}
// ── 플레이어 상태 변경 핸들러 (VideoModalBase 패턴) ──
function handlePlayerStateChange(event) {
if (event.data === YT.PlayerState.PLAYING) {
startTracking();
} else {
stopTracking();
if (event.data === YT.PlayerState.PAUSED || event.data === YT.PlayerState.ENDED) {
saveLearningProgress(Math.floor(lastSaveTime), 'N');
lastSaveTime = 0;
}
}
}
// ── 시청 추적 시작 (1초 주기) ──
function startTracking() {
if (trackingInterval) return;
lastSaveTime = 0;
trackingInterval = setInterval(function () {
if (!ytPlayer || typeof ytPlayer.getCurrentTime !== 'function') return;
var playerState = (typeof ytPlayer.getPlayerState === 'function')
? ytPlayer.getPlayerState() : null;
if (playerState !== YT.PlayerState.PLAYING) return;
var currentTime = ytPlayer.getCurrentTime();
var diff = currentTime - lastCurrentTime;
if (Math.abs(diff) <= 5) {
lastSaveTime += 1;
}
lastCurrentTime = currentTime;
// 5초마다 서버에 heartbeat 전송
if (lastSaveTime >= 5) {
saveLearningProgress(lastSaveTime);
lastSaveTime = 0;
}
}, 1000);
}
// ── 시청 추적 중지 ──
function stopTracking() {
if (trackingInterval) {
clearInterval(trackingInterval);
trackingInterval = null;
}
}
// ── 서버에 학습 진행상황 저장 (save_learning.php) ──
function saveLearningProgress(heartbeatSeconds, isWatching) {
if (!ytPlayer || !currentContentId) return;
if (typeof ytPlayer.getCurrentTime !== 'function') return;
var currentTime = Math.floor(ytPlayer.getCurrentTime());
var duration = Math.floor(ytPlayer.getDuration());
var incrementalWatch = Math.max(0, Math.floor(heartbeatSeconds || 0));
var params = new URLSearchParams({
content_id: String(currentContentId),
watch_tm: String(Math.max(0, currentTime)),
content_tm: String(Math.max(0, duration)),
all_tm_increment: String(incrementalWatch),
is_watching: isWatching === 'N' ? 'N' : 'Y'
});
fetch(API_BASE + '/save_learning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: params.toString(),
keepalive: true
}).then(function (r) { return r.json(); }).then(function (result) {
if (!result.success) {
console.error('[Leadership] Save Learning Failed:', result.message);
}
}).catch(function (e) {
console.error('[Leadership] Save Learning Error:', e);
});
}
// ── 영상 카드 클릭 → 모달 열기 ──
$(document).on('click', '#video-list .video-item .card-link', function (e) {
e.preventDefault();
var $item = $(this).closest('.video-item');
var contentId = $item.data('content-id') || $item.attr('data-content-id') || '';
var videoUrl = $item.data('content-url') || $item.attr('data-content-url') || '';
var title = $item.data('title') || $item.find('.item-title').text().trim() || '';
var desc = $item.data('description') || '';
var catName = $item.data('category-name') || $item.find('.item-category').text().trim() || '리더십';
var subCat = $item.data('sub-category') || '';
var watchTm = parseInt($item.data('watch-tm') || $item.attr('data-watch-tm') || '0', 10);
var contentTm = parseInt($item.data('content-tm') || $item.attr('data-content-tm') || '0', 10);
if (!contentId) {
console.warn('[VideoModal] content-id가 없습니다.');
return;
}
openModal({
contentId: contentId,
videoUrl: videoUrl,
title: title,
description: desc,
categoryName: catName,
subCategory: subCat,
watchTm: watchTm,
contentTm: contentTm
});
});
// ── 모달 열기 ──
function openModal(info) {
currentContentId = info.contentId;
editingCommentId = null;
lastSaveTime = 0;
lastCurrentTime = Math.floor(info.watchTm || 0);
// YouTube IFrame API 플레이어 세팅
var videoId = extractVideoId(info.videoUrl);
if (videoId) {
initYTPlayer(videoId, info.watchTm || 0);
}
// 영상 정보
$modal.find('#modalTitle').text(info.title);
// 설명: 줄바꿈을 <br>로 변환하여 표시
var descHtml = $('<div>').text(info.description || '').html().replace(/\n/g, '<br>');
$modal.find('#modalDesc').html(descHtml);
$modal.find('#modalCategoryName').text(info.categoryName || '리더십');
$modal.find('#modalSubCategory').text(info.subCategory || '');
$modal.find('#modalBadge').text(info.categoryName || '리더십');
// 추천영상 리스트
buildRecommendedList(info.contentId);
// 댓글 로드
loadComments(info.contentId);
resetCommentForm();
// 모달 표시
$modal.show();
$('body').css('overflow', 'hidden');
}
// ── 모달 닫기 (진행상황 저장 후 닫기) ──
function closeModal() {
// 마지막 시청 진행상황 저장
stopTracking();
if (ytPlayer && currentContentId && typeof ytPlayer.getCurrentTime === 'function') {
saveLearningProgress(Math.floor(lastSaveTime), 'N');
}
lastSaveTime = 0;
destroyYTPlayer();
$modal.hide();
$('body').css('overflow', '');
currentContentId = null;
editingCommentId = null;
}
$modal.on('click', '.close', closeModal);
$modal.on('click', function (e) {
if ($(e.target).hasClass('modal')) closeModal();
});
$(document).on('keydown', function (e) {
if (e.key === 'Escape' && $modal.is(':visible')) closeModal();
});
// ── 추천영상 리스트 구성 (현재 페이지 영상 목록 활용) ──
function buildRecommendedList(excludeContentId) {
var $list = $modal.find('#recommendedList');
$list.empty();
var count = 0;
$('#video-list .video-item').each(function () {
var $item = $(this);
var cid = $item.data('content-id') || $item.attr('data-content-id') || '';
if (!cid || String(cid) === String(excludeContentId)) return;
if (count >= 10) return false; // 최대 10개
var videoUrl = $item.data('content-url') || $item.attr('data-content-url') || '';
var title = $item.data('title') || $item.find('.item-title').text().trim() || '';
var catName = $item.data('category-name') || $item.find('.item-category').text().trim() || '리더십';
var thumbSrc = $item.find('.item-thumb img, .thumb img').attr('src') || '/edu/img/video/img_thumb_01.png';
var desc = String($item.data('description') || '');
var subCat = $item.data('sub-category') || '';
var wTm = parseInt($item.data('watch-tm') || $item.attr('data-watch-tm') || '0', 10);
var cTm = parseInt($item.data('content-tm') || $item.attr('data-content-tm') || '0', 10);
var li =
'<li>' +
'<a href="#" class="list" ' +
'data-content-id="' + cid + '" ' +
'data-content-url="' + videoUrl.replace(/"/g, '&quot;') + '" ' +
'data-title="' + title.replace(/"/g, '&quot;') + '" ' +
'data-description="' + desc.replace(/"/g, '&quot;') + '" ' +
'data-category-name="' + catName.replace(/"/g, '&quot;') + '" ' +
'data-sub-category="' + subCat.replace(/"/g, '&quot;') + '" ' +
'data-watch-tm="' + wTm + '" ' +
'data-content-tm="' + cTm + '">' +
'<div class="thumb"><img src="' + thumbSrc + '" alt="" /></div>' +
'<div class="txt-box">' +
'<div class="category leader">' + catName + '</div>' +
'<div class="title">' + title + '</div>' +
'</div>' +
'</a>' +
'</li>';
$list.append(li);
count++;
});
if (count === 0) {
$list.html('<li class="empty">추천 영상이 없습니다.</li>');
}
}
// ── 추천영상 클릭 → 영상 전환 ──
$modal.on('click', '#recommendedList .list', function (e) {
e.preventDefault();
var $a = $(this);
// 현재 영상 진행상황 저장 후 전환
stopTracking();
if (ytPlayer && currentContentId && typeof ytPlayer.getCurrentTime === 'function') {
saveLearningProgress(Math.floor(lastSaveTime), 'N');
}
lastSaveTime = 0;
openModal({
contentId: $a.data('content-id'),
videoUrl: $a.data('content-url') || '',
title: $a.data('title') || $a.find('.title').text().trim(),
description: $a.data('description') || '',
categoryName: $a.data('category-name') || '리더십',
subCategory: $a.data('sub-category') || '',
watchTm: parseInt($a.data('watch-tm') || '0', 10),
contentTm: parseInt($a.data('content-tm') || '0', 10)
});
});
// ══════════════════════════════════════════════
// 댓글 CRUD
// ══════════════════════════════════════════════
// ── 댓글 목록 로드 ──
function loadComments(contentId) {
var $commentList = $modal.find('.comment-list');
if (!contentId) return;
$.getJSON(API_BASE + '/get_comments.php', { content_id: contentId })
.done(function (data) {
if (data.success && Array.isArray(data.data)) {
renderComments(data.data);
} else {
$commentList.html('<li class="empty">등록된 댓글이 없습니다.</li>');
}
})
.fail(function () {
$commentList.html('<li class="empty">댓글을 불러올 수 없습니다.</li>');
});
}
// ── 댓글 렌더링 (온보딩 모달 동일 구조) ──
function renderComments(comments) {
var $commentList = $modal.find('.comment-list');
if (!comments.length) {
$commentList.html('<li class="empty">등록된 댓글이 없습니다.</li>');
return;
}
var userIconSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>';
var html = comments.map(function (c) {
var safeComment = (c.comment || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
var safeName = (c.member_name || '익명')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var buttons = c.is_author
? '<div class="comment-actions">' +
'<button type="button" class="btn-edit" data-id="' + c.id + '">수정</button>' +
'<button type="button" class="btn-delete" data-id="' + c.id + '">삭제</button>' +
'</div>'
: '';
return '<li data-comment-id="' + c.id + '">' +
'<div class="comment-header">' +
'<span class="user-icon">' + userIconSvg + '</span>' +
'<span class="comment-author">' + safeName + '</span>' +
'</div>' +
'<div class="comment-body">' +
'<div class="comment-content">' + safeComment + '</div>' +
buttons +
'</div>' +
'<div class="comment-footer">' +
'<span class="comment-date">' + (c.created_at || '') + '</span>' +
'</div>' +
'</li>';
}).join('');
$commentList.html(html);
}
// ── 댓글 수정 ──
$modal.on('click', '.btn-edit', function () {
var $li = $(this).closest('li');
var content = $li.find('.comment-content').text();
editingCommentId = $(this).data('id');
var $textarea = $modal.find('.comment-box textarea');
$textarea.val(content).focus();
$modal.find('.btn-cancel').prop('disabled', false);
$modal.find('.btn-save').prop('disabled', false).text('수정');
});
// ── 댓글 삭제 ──
$modal.on('click', '.btn-delete', function () {
if (!confirm('댓글을 삭제하시겠습니까?')) return;
var commentId = $(this).data('id');
$.ajax({
url: API_BASE + '/delete_comment.php',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ id: commentId }),
success: function (data) {
if (data.success) {
loadComments(currentContentId);
} else {
alert(data.message || '삭제에 실패했습니다.');
}
},
error: function () { alert('삭제 중 오류가 발생했습니다.'); }
});
});
// ── 댓글 작성/수정 저장 ──
$modal.on('click', '.comment-box .btn-save', function () {
var $textarea = $modal.find('.comment-box textarea');
var comment = $textarea.val().trim();
if (!comment || !currentContentId) return;
var payload = { content_id: currentContentId, comment: comment };
if (editingCommentId) payload.id = editingCommentId;
$.ajax({
url: API_BASE + '/save_comment.php',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function (data) {
if (data.success) {
resetCommentForm();
loadComments(currentContentId);
} else {
alert(data.message || '저장에 실패했습니다.');
}
},
error: function () { alert('저장 중 오류가 발생했습니다.'); }
});
});
// ── 댓글 취소 ──
$modal.on('click', '.comment-box .btn-cancel', function () {
resetCommentForm();
});
// ── textarea 입력 → 버튼 활성화 ──
$modal.on('input', '.comment-box textarea', function () {
var hasText = $(this).val().trim().length > 0;
$modal.find('.btn-cancel').prop('disabled', !hasText);
$modal.find('.btn-save').prop('disabled', !hasText);
});
function resetCommentForm() {
$modal.find('.comment-box textarea').val('');
$modal.find('.btn-cancel').prop('disabled', true);
$modal.find('.btn-save').prop('disabled', true).text('등록');
editingCommentId = null;
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,665 @@
/**
* =====================
* 사용페이지 : 마이페이지
* 작 성 자 : ERP기획팀 moon
* 최초작성일자: 26.03.20.
* =====================
*/
(function () {
/**
* 마이페이지 초기 진입 시 실행
* - 학습활동 데이터 로드
* - 콘텐츠 탭 클릭 재렌더링 이벤트 연결
* - 최초 로드 완료 후 활성 탭 기준으로 리스트를 다시 렌더링한다
*/
document.addEventListener("DOMContentLoaded", function () {
// 연도 select 변경 이벤트 연결
bindYearSelectChange();
// 학습활동 초기 데이터 조회
loadMypageInit();
// 콘텐츠 탭 클릭 시 재렌더링 이벤트 연결
bindContentTabReload();
});
/**
* 모든 리소스 로드 완료 후 실행
* - 퍼블리싱용 mypage.js 초기화가 끝난 뒤
* 현재 활성 탭 기준으로 콘텐츠 리스트를 다시 렌더링한다
*/
window.addEventListener("load", function () {
setTimeout(function () {
reloadActiveContentTab();
}, 150);
});
//JS 1단계 (가장 먼저)
/**
* 마이페이지 초기 데이터 로드 함수
* - 서버에서 학습활동 관련 데이터를 가져온다
* - 연도 select 및 총 학습시간을 화면에 반영한다
*/
function loadMypageInit(year = '') {
fetch('/edu/ajax/get_init_data_for_mypage.php?year=' + year)
.then(res => res.json())
.then(data => {
//-----------------------
// 실패 시 종료
if (!data.success) return;
//-----------------------
// 1. 연도 select 구성
renderYearSelect(data.year_list, data.selected_year);
//-----------------------
// 2. 총 학습시간 표시
renderMyTotalMinutes(data.my_total_minutes);
//-----------------------
// 3. 모바일 총 학습시간 표시
renderMobileMyTotalMinutes(data.my_total_minutes);
//-----------------------
// 4. 전체평균 표시
renderAvgTotalMinutes(data.avg_total_minutes);
//-----------------------
// 5. 상단 총 학습시간 게이지 표시
renderMyTotalGauge(data.my_total_minutes);
//-----------------------
// 6. 마이클래스 항목 표시
renderMyClassActivity(data.activity_items);
// 7. 온보딩 학습활동 데이터를 화면에 반영
renderOnboardingActivity(data.activity_items);
//-----------------------
// 8. 법정교육 학습활동 데이터를 화면에 반영
renderLegalEducationActivity(data.activity_items);
//-----------------------
// 9. 리더십 학습활동 데이터를 화면에 반영
renderLeadershipActivity(data.activity_items);
//-----------------------
// 10. 인사이트 학습활동 데이터를 화면에 반영
renderInsightActivity(data.activity_items);
//-----------------------
// 11. 비즈트렌드 학습활동 데이터를 화면에 반영
renderBizTrendActivity(data.activity_items);
});
}
//JS 2단계 (연도 select 채우기)
function renderYearSelect(yearList, selectedYear) {
const select = document.getElementById('mypage-year-select');
if (!select) return;
select.innerHTML = '';
yearList.forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = year + '년';
if (year == selectedYear) {
option.selected = true;
}
select.appendChild(option);
});
}
//AJAX 응답의 my_total_minutes를 #mypage-total-minutes에 넣기
/**
* 총 학습시간(분)을 화면에 표시하는 함수
* - 서버에서 받은 my_total_minutes 값을
* #mypage-total-minutes 영역에 표시한다.
* - 숫자는 천단위 콤마 포맷 적용
*/
function renderMyTotalMinutes(totalMinutes) {
// 대상 DOM 가져오기
const el = document.getElementById('mypage-total-minutes');
if (!el) return;
// 숫자 변환 및 예외 방어 (null, undefined 대비)
const value = parseInt(totalMinutes, 10) || 0;
// 화면에 출력 (예: 1,200)
el.textContent = value.toLocaleString();
}
/**
* 모바일 총 학습시간(분)을 화면에 표시하는 함수
* - 서버에서 받은 my_total_minutes 값을
* #mobile-mypage-total-minutes 영역에 표시한다.
* - 숫자는 천단위 콤마 포맷 적용
*/
function renderMobileMyTotalMinutes(totalMinutes) {
// 대상 DOM 가져오기
const el = document.getElementById('mobile-mypage-total-minutes');
if (!el) return;
// 숫자 변환 및 예외 방어
const value = parseInt(totalMinutes, 10) || 0;
// 화면에 출력
el.textContent = value.toLocaleString();
}
/**
* 전체평균 학습시간(분)을 화면에 표시하는 함수
* - 서버에서 받은 avg_total_minutes 값을
* #mypage-avg-minutes 영역에 표시한다.
* - 숫자는 천단위 콤마 포맷 적용
*/
function renderAvgTotalMinutes(avgTotalMinutes) {
// 대상 DOM 가져오기
const el = document.getElementById('mypage-avg-minutes');
if (!el) return;
// 숫자 변환 및 예외 방어
const value = parseInt(avgTotalMinutes, 10) || 0;
// 화면에 출력
el.textContent = value.toLocaleString();
}
/**
* 총 학습시간 게이지를 화면에 반영하는 함수
* - 서버에서 받은 my_total_minutes 값을 기준으로
* #mypage-total-gauge의 width 값을 변경한다.
* - 최대 기준은 40시간(2400분)으로 계산한다.
* - 2400분을 초과해도 게이지는 100%를 넘지 않도록 제한한다.
*/
function renderMyTotalGauge(totalMinutes) {
// 대상 DOM 가져오기
const el = document.getElementById('mypage-total-gauge');
if (!el) return;
// 총 학습시간(분) 숫자 변환
const value = parseInt(totalMinutes, 10) || 0;
// 게이지 최대 기준값 (40시간 = 2400분)
const maxMinutes = 2400;
// 퍼센트 계산
let percent = Math.round((value / maxMinutes) * 100);
// 0 ~ 100 범위로 제한
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
// 게이지 너비 반영
el.style.width = percent + '%';
}
/**
* 연도 select 변경 이벤트를 연결하는 함수
* - 사용자가 연도를 변경하면
* 선택한 연도값으로 마이페이지 초기 데이터를 다시 조회한다.
*/
function bindYearSelectChange() {
// 연도 select DOM 가져오기
const select = document.getElementById('mypage-year-select');
if (!select) return;
// 연도 변경 이벤트 연결
select.addEventListener('change', function () {
// 현재 선택된 연도 가져오기
const selectedYear = this.value || '';
// 선택 연도 기준으로 데이터 다시 조회
loadMypageInit(selectedYear);
});
}
/**
* 마이클래스(CA10001) 학습활동 데이터를 화면에 반영하는 함수
* - activity_items 배열에서 마이클래스 항목만 찾아서
* 해당 li[data-category="CA10001"] 영역의 수치를 갱신한다.
* - 학습시간, 퍼센트, 게이지, 전체 콘텐츠 개수를 반영한다.
*/
function renderMyClassActivity(activityItems) {
// activity_items가 배열이 아니면 종료
if (!Array.isArray(activityItems)) return;
// 마이클래스 항목 찾기
const item = activityItems.find(function (row) {
return row.category_code === 'CA10001';
});
if (!item) return;
// 마이클래스 li 찾기
const li = document.querySelector('.activity-list li[data-category="CA10001"]');
if (!li) return;
// 숫자 변환 및 예외 방어
const totalWatchTm = parseInt(item.total_watch_tm, 10) || 0;
const percent = parseInt(item.percent, 10) || 0;
const totalCount = parseInt(item.master_total_cnt, 10) || 0;
// 1. 우측 상단 표시값 갱신
// 예: 74분 (33%)
const valueEl = li.querySelector('.activity-value');
if (valueEl) {
valueEl.innerHTML = '<em>' + totalWatchTm.toLocaleString() + '</em>분 (' + percent + '%)';
}
// 2. 게이지 너비 갱신
const gaugeEl = li.querySelector('.gauge-fill');
if (gaugeEl) {
gaugeEl.style.width = percent + '%';
}
// 3. 분모 표시 갱신
// 예: 6개
const endEl = li.querySelector('.gauge-end');
if (endEl) {
endEl.textContent = totalCount.toLocaleString() + '개';
}
}
/**
* 온보딩(CA10002) 학습활동 데이터를 화면에 반영하는 함수
* - 학습시간, 퍼센트, 게이지, 분모를 표시
* - 온보딩 기간일 경우에만 "필수 시청 날짜"를 노출한다.
*/
function renderOnboardingActivity(activityItems) {
// 배열 방어
if (!Array.isArray(activityItems)) return;
// 온보딩 항목 찾기
const item = activityItems.find(function (row) {
return row.category_code === 'CA10002';
});
if (!item) return;
// 해당 li 찾기
const li = document.querySelector('.activity-list li[data-category="CA10002"]');
if (!li) return;
// 값 파싱
const totalWatchTm = parseInt(item.total_watch_tm, 10) || 0;
const percent = parseInt(item.percent, 10) || 0;
const totalCount = parseInt(item.master_total_cnt, 10) || 0;
// 1. 학습시간 + 퍼센트 표시
const valueEl = li.querySelector('.activity-value');
if (valueEl) {
valueEl.innerHTML = '<em>' + totalWatchTm.toLocaleString() + '</em>분 (' + percent + '%)';
}
// 2. 게이지
const gaugeEl = li.querySelector('.gauge-fill');
if (gaugeEl) {
gaugeEl.style.width = percent + '%';
}
// 3. 분모 표시
const endEl = li.querySelector('.gauge-end');
if (endEl) {
endEl.textContent = totalCount.toLocaleString() + '개';
}
// 4. 온보딩 안내문구 제어
const noteEl = li.querySelector('.activity-note');
const dateEl = li.querySelector('.onboarding-due-date');
if (noteEl && dateEl) {
// 날짜 표시 (yy.mm.dd 형식)
if (item.onboarding_due_date) {
const d = new Date(item.onboarding_due_date);
if (!isNaN(d.getTime())) {
const yy = String(d.getFullYear()).slice(2);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
dateEl.textContent = yy + '.' + mm + '.' + dd;
}
}
// 온보딩 기간 여부에 따라 표시/숨김
if (item.is_onboarding_period === 'Y') {
noteEl.style.display = '';
} else {
noteEl.style.display = 'none';
}
}
}
/**
* 법정교육(CA10003) 학습활동 데이터를 화면에 반영하는 함수
* - 학습시간, 퍼센트, 게이지, 분모를 표시한다.
* - activity_items 배열에서 법정교육 항목만 찾아
* 해당 li[data-category="CA10003"] 영역을 갱신한다.
*/
function renderLegalEducationActivity(activityItems) {
// 배열이 아니면 종료
if (!Array.isArray(activityItems)) return;
// 법정교육 항목 찾기
const item = activityItems.find(function (row) {
return row.category_code === 'CA10003';
});
if (!item) return;
// 해당 li 찾기
const li = document.querySelector('.activity-list li[data-category="CA10003"]');
if (!li) return;
// 숫자 변환 및 예외 방어
const totalWatchTm = parseInt(item.total_watch_tm, 10) || 0;
const percent = parseInt(item.percent, 10) || 0;
const totalCount = parseInt(item.master_total_cnt, 10) || 0;
// 1. 우측 상단 표시값 갱신
// 예: 30분 (20%)
const valueEl = li.querySelector('.activity-value');
if (valueEl) {
valueEl.innerHTML = '<em>' + totalWatchTm.toLocaleString() + '</em>분 (' + percent + '%)';
}
// 2. 게이지 너비 갱신
const gaugeEl = li.querySelector('.gauge-fill');
if (gaugeEl) {
gaugeEl.style.width = percent + '%';
}
// 3. 분모 표시 갱신
// 예: 6개
const endEl = li.querySelector('.gauge-end');
if (endEl) {
endEl.textContent = totalCount.toLocaleString() + '개';
}
}
/**
* 리더십(CA10004) 학습활동 데이터를 화면에 반영하는 함수
* - 학습시간, 게이지, 분모(분)를 표시한다.
* - activity_items 배열에서 리더십 항목만 찾아
* 해당 li[data-category="CA10004"] 영역을 갱신한다.
*/
function renderLeadershipActivity(activityItems) {
// 배열이 아니면 종료
if (!Array.isArray(activityItems)) return;
// 리더십 항목 찾기
const item = activityItems.find(function (row) {
return row.category_code === 'CA10004';
});
if (!item) return;
// 해당 li 찾기
const li = document.querySelector('.activity-list li[data-category="CA10004"]');
if (!li) return;
// 숫자 변환 및 예외 방어
const totalWatchTm = parseInt(item.total_watch_tm, 10) || 0;
const percent = parseInt(item.percent, 10) || 0;
const totalContentTm = parseInt(item.total_content_tm, 10) || 0;
// 1. 우측 상단 표시값 갱신
// 예: 60분
const valueEl = li.querySelector('.activity-value');
if (valueEl) {
valueEl.innerHTML = '<em>' + totalWatchTm.toLocaleString() + '</em>분';
}
// 2. 게이지 너비 갱신
const gaugeEl = li.querySelector('.gauge-fill');
if (gaugeEl) {
gaugeEl.style.width = percent + '%';
}
// 3. 분모 표시 갱신
// 예: 240분
const endEl = li.querySelector('.gauge-end');
if (endEl) {
endEl.textContent = totalContentTm.toLocaleString() + '분';
}
}
/**
* 인사이트(CA10005) 학습활동 데이터를 화면에 반영하는 함수
* - 학습시간, 게이지, 분모(분)를 표시한다.
* - activity_items 배열에서 인사이트 항목만 찾아
* 해당 li[data-category="CA10005"] 영역을 갱신한다.
*/
function renderInsightActivity(activityItems) {
// 배열이 아니면 종료
if (!Array.isArray(activityItems)) return;
// 인사이트 항목 찾기
const item = activityItems.find(function (row) {
return row.category_code === 'CA10005';
});
if (!item) return;
// 해당 li 찾기
const li = document.querySelector('.activity-list li[data-category="CA10005"]');
if (!li) return;
// 숫자 변환 및 예외 방어
const totalWatchTm = parseInt(item.total_watch_tm, 10) || 0;
const percent = parseInt(item.percent, 10) || 0;
const totalContentTm = parseInt(item.total_content_tm, 10) || 0;
// 1. 우측 상단 표시값 갱신
// 예: 43분
const valueEl = li.querySelector('.activity-value');
if (valueEl) {
valueEl.innerHTML = '<em>' + totalWatchTm.toLocaleString() + '</em>분';
}
// 2. 게이지 너비 갱신
const gaugeEl = li.querySelector('.gauge-fill');
if (gaugeEl) {
gaugeEl.style.width = percent + '%';
}
// 3. 분모 표시 갱신
// 예: 90분
const endEl = li.querySelector('.gauge-end');
if (endEl) {
endEl.textContent = totalContentTm.toLocaleString() + '분';
}
}
/**
* 비즈트렌드(CA10006) 학습활동 데이터를 화면에 반영하는 함수
* - 학습시간, 게이지, 분모(분)를 표시한다.
* - activity_items 배열에서 비즈트렌드 항목만 찾아
* 해당 li[data-category="CA10006"] 영역을 갱신한다.
*/
function renderBizTrendActivity(activityItems) {
// 배열이 아니면 종료
if (!Array.isArray(activityItems)) return;
// 비즈트렌드 항목 찾기
const item = activityItems.find(function (row) {
return row.category_code === 'CA10006';
});
if (!item) return;
// 해당 li 찾기
const li = document.querySelector('.activity-list li[data-category="CA10006"]');
if (!li) return;
// 숫자 변환 및 예외 방어
const totalWatchTm = parseInt(item.total_watch_tm, 10) || 0;
const percent = parseInt(item.percent, 10) || 0;
const totalContentTm = parseInt(item.total_content_tm, 10) || 0;
// 1. 우측 상단 표시값 갱신
// 예: 23분
const valueEl = li.querySelector('.activity-value');
if (valueEl) {
valueEl.innerHTML = '<em>' + totalWatchTm.toLocaleString() + '</em>분';
}
// 2. 게이지 너비 갱신
const gaugeEl = li.querySelector('.gauge-fill');
if (gaugeEl) {
gaugeEl.style.width = percent + '%';
}
// 3. 분모 표시 갱신
// 예: 80분
const endEl = li.querySelector('.gauge-end');
if (endEl) {
endEl.textContent = totalContentTm.toLocaleString() + '분';
}
}//renderBizTrendActivity
/**
* 시청중인 콘텐츠 목록을 서버에서 조회하여 화면에 표시하는 함수
* - watching 탭 기준으로 목록 HTML을 받아
* #mypage-watching-list 영역에 삽입한다.
* - mypage.js가 먼저 탭/패널 초기화를 수행한 뒤,
* 이 함수가 실제 콘텐츠 목록을 최종 반영하는 역할을 한다.
*/
function loadWatchingContentList() {
// 시청중 목록 영역 가져오기
const listEl = document.getElementById('mypage-watching-list');
if (!listEl) return;
// 서버에 시청중 탭 목록 요청
fetch('/edu/ajax/get_video_list_for_mypage.php?tab=watching&page=1')
.then(function (res) {
return res.json();
})
.then(function (data) {
// 응답 성공 여부 확인
if (!data.success) return;
// 서버에서 내려준 HTML을 최종 반영
listEl.innerHTML = data.html || '';
// 디버깅 확인용
console.log('[watching] 최종 렌더링 완료:', listEl.querySelectorAll('.content-card').length);
})
.catch(function (error) {
console.error('시청중 콘텐츠 목록을 불러오지 못했습니다.', error);
});
}
/**
* 콘텐츠 탭 클릭 이벤트 감지 후
* 해당 탭에 맞는 데이터를 다시 렌더링하는 함수
*
* - mypage.js가 탭 전환을 처리한 이후 실행된다
* - 각 탭(watching / completed / saved)에 따라
* AJAX 호출을 다시 수행하여 리스트를 덮어쓴다
*/
function bindContentTabReload() {
// 모든 탭 버튼 가져오기
const tabs = document.querySelectorAll('.content-tab');
tabs.forEach(function (tabBtn) {
tabBtn.addEventListener('click', function () {
const tab = this.getAttribute('data-tab');
// 약간의 딜레이를 줘서 mypage.js 처리 이후 실행
setTimeout(function () {
if (tab === 'watching') {
loadWatchingContentList();
} else if (tab === 'completed') {
loadCompletedContentList();
} else if (tab === 'saved') {
loadSavedContentList();
}
console.log('[탭 재렌더링]', tab);
}, 100);
});
});
}
/**
* 시청완료 콘텐츠 목록 로드
*/
function loadCompletedContentList() {
const listEl = document.querySelector('#panel-completed .content-grid');
if (!listEl) return;
fetch('/edu/ajax/get_video_list_for_mypage.php?tab=completed&page=1')
.then(res => res.json())
.then(function (data) {
if (!data.success) return;
listEl.innerHTML = data.html || '';
console.log('[completed] 렌더링 완료');
})
.catch(function (e) {
console.error('completed 로딩 실패', e);
});
}
/**
* 저장한 콘텐츠 목록 로드
*/
function loadSavedContentList() {
const listEl = document.querySelector('#panel-saved .content-grid');
if (!listEl) return;
fetch('/edu/ajax/get_video_list_for_mypage.php?tab=saved&page=1')
.then(res => res.json())
.then(function (data) {
if (!data.success) return;
listEl.innerHTML = data.html || '';
console.log('[saved] 렌더링 완료');
})
.catch(function (e) {
console.error('saved 로딩 실패', e);
});
}
/**
* 현재 활성화된 콘텐츠 탭을 기준으로
* 해당 탭의 리스트를 다시 렌더링하는 함수
*
* - 최초 페이지 진입 시 사용
* - mypage.js가 탭/패널 초기화를 끝낸 뒤 실행되어야 한다
*/
function reloadActiveContentTab() {
// 현재 active 상태의 탭 버튼 찾기
const activeTab = document.querySelector('.content-tab.active');
if (!activeTab) return;
// 활성 탭 종류 확인
const tab = activeTab.getAttribute('data-tab');
// 탭 종류에 따라 해당 리스트 다시 조회
if (tab === 'watching') {
loadWatchingContentList();
} else if (tab === 'completed') {
loadCompletedContentList();
} else if (tab === 'saved') {
loadSavedContentList();
}
console.log('[초기 활성 탭 재렌더링]', tab);
}
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+930
View File
@@ -0,0 +1,930 @@
/**
* 비즈트렌드 달력 페이지 전용 스크립트
* ===================================
* biztrend.html (성공예감&별책부록)에서 사용합니다.
* - 연·월 선택 날짜피커 (휠 UI)
* - 현재 달 기준 동적 달력 그리드 생성
* - 스크롤 바닥 감지 시 다음 달 자동 추가 (무한 스크롤)
*/
(function () {
"use strict";
// ------------------------------
// 설정 상수
// ------------------------------
var YEAR_START = 2023;
var YEAR_END = 2030;
var ITEM_HEIGHT = 36; // 휠 한 항목 높이(px)
// ------------------------------
// 무한 스크롤 상태
// ------------------------------
var loadedMonths = []; // [{year, month}]
var scrollObserver = null; // 하단 무한 스크롤 (IntersectionObserver)
var topObserver = null; // 상단 무한 스크롤 (IntersectionObserver)
var labelObserver = null; // 라벨 업데이트
var articleData = {}; // 날짜별 기사 데이터
var videoModal = null; // 공통 비디오 모달 (VideoModalBase)
var topLoadReady = false; // 상단 스크롤 활성화 플래그
// ------------------------------
// 날짜 값 읽기/쓰기
// ------------------------------
function getDateValue() {
var input = document.querySelector(".biztrend .date-select-value");
if (!input) return { year: new Date().getFullYear(), month: new Date().getMonth() + 1 };
var match = (input.value || "").match(/^(\d{4})-(\d{2})$/);
if (match) {
return {
year: parseInt(match[1], 10),
month: parseInt(match[2], 10),
};
}
var now = new Date();
return {
year: now.getFullYear(),
month: now.getMonth() + 1,
};
}
function setDateValue(year, month) {
var input = document.querySelector(".biztrend .date-select-value");
var label = document.querySelector(".biztrend .date-select-label");
if (input) {
input.value = year + "-" + String(month).padStart(2, "0");
}
if (label) {
var yearEl = label.querySelector(".date-select-year");
var monthEl = label.querySelector(".date-select-month");
if (yearEl) yearEl.textContent = year;
if (monthEl) monthEl.textContent = month;
}
}
// ------------------------------
// 달력 그리드 동적 생성
// ------------------------------
/**
* 해당 월의 총 일수를 반환합니다.
*/
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
/**
* 월의 날짜를 월~토 6열 기준 행 배열로 반환합니다.
* 일요일은 제외하며, 빈 셀은 null로 채웁니다.
* @returns {Array<Array<number|null>>}
*/
function buildMonthRows(year, month) {
var daysInMonth = getDaysInMonth(year, month);
var rows = [];
var currentRow = [];
for (var d = 1; d <= daysInMonth; d++) {
var jsDay = new Date(year, month - 1, d).getDay(); // 0=일, 1=월, ..., 6=토
if (jsDay === 0) continue; // 일요일 제외
// 월요일이면 새 행 시작 (이전 행이 있으면 6칸 채우고 저장)
if (jsDay === 1 && currentRow.length > 0) {
while (currentRow.length < 6) currentRow.push(null);
rows.push(currentRow);
currentRow = [];
}
// 1일이 월요일이 아닐 때 앞 빈 칸 추가
if (d === 1 && jsDay !== 1) {
var emptyCount = jsDay - 1; // 월=0, 화=1, ..., 토=5
for (var i = 0; i < emptyCount; i++) {
currentRow.push(null);
}
}
currentRow.push(d);
// 토요일이면 행 완료
if (jsDay === 6) {
rows.push(currentRow);
currentRow = [];
}
}
// 마지막 행 처리
if (currentRow.length > 0) {
while (currentRow.length < 6) currentRow.push(null);
rows.push(currentRow);
}
return rows;
}
// ------------------------------
// 기사 데이터 로드
// ------------------------------
/** HTML의 #biztrend-data JSON을 읽어 articleData에 저장 */
function loadArticleData() {
var el = document.getElementById("biztrend-data");
if (!el) return;
try { articleData = JSON.parse(el.textContent); } catch (e) { articleData = {}; }
}
/** XSS 방지용 HTML 이스케이프 */
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 기사 콘텐츠 블록 HTML을 반환합니다.
* @param {Object} item - articleData의 항목 하나
* @param {string} dateKey - 날짜 키 (예: "2026-02-01")
* @param {number} idx - 해당 날짜 내 인덱스
*/
function createContentHTML(item, dateKey, idx) {
// 제목 + 게이지 + 배지
var titleBoxHTML =
'<div class="article-card-title-box">' +
'<h4 class="article-card-title">' + escapeHtml(item.title) + "</h4>";
if (item.badge) {
titleBoxHTML += '<span class="article-card-badge">' + escapeHtml(item.badge) + "</span>";
}
if (item.gauge != null) {
titleBoxHTML +=
'<div class="gauge-bar"><div class="gauge-fill" style="width:' + item.gauge + '%"></div></div>';
}
titleBoxHTML += "</div>";
// 리스트
var listHTML = "";
if (item.list && item.list.length) {
listHTML = '<ul class="article-card-list">';
item.list.forEach(function (li) {
listHTML += "<li>" + escapeHtml(li) + "</li>";
});
listHTML += "</ul>";
}
// 이미지 여부에 따라 내부 구조 분기
var bodyHTML = item.image
? '<div class="article-card-image">' +
'<div class="article-card-image-placeholder" aria-hidden="true">' +
'<img src="' + escapeHtml(item.image) + '" alt="' + escapeHtml(item.title) + '">' +
"</div>" +
listHTML +
"</div>"
: listHTML;
var likedClass = item.liked ? " is-active" : "";
var likedLabel = item.liked ? "좋아요 취소" : "좋아요";
var dkAttr = dateKey ? ' data-date-key="' + escapeHtml(dateKey) + '" data-item-idx="' + idx + '"' : "";
return (
'<div class="article-card-content"' + dkAttr + '>' +
'<a href="#" class="article-card-link" data-video-id="' + escapeHtml(item.videoId || "") + '">' +
titleBoxHTML +
bodyHTML +
"</a>" +
'<button type="button" class="article-card-like' + likedClass + '" aria-label="' + likedLabel + '"></button>' +
"</div>"
);
}
/**
* 날짜 카드 HTML을 반환합니다.
*/
function createCardHTML(day, year, month) {
var today = new Date();
today.setHours(0, 0, 0, 0);
var cardDate = new Date(year, month - 1, day);
cardDate.setHours(0, 0, 0, 0);
var jsDay = new Date(year, month - 1, day).getDay();
var isWeekend = jsDay === 6; // 토요일
var isToday = cardDate.getTime() === today.getTime();
var isFuture = cardDate > today;
var classes = ["article-card"];
if (isWeekend) classes.push("weekend");
if (isToday) classes.push("today");
if (isFuture) classes.push("is-future");
// 날짜 키로 기사 데이터 조회 (예: "2026-02-01")
var dateKey =
year + "-" +
String(month).padStart(2, "0") + "-" +
String(day).padStart(2, "0");
var items = articleData[dateKey] || [];
var contentsHTML = items.map(function (item, idx) {
return createContentHTML(item, dateKey, idx);
}).join("");
return (
'<article class="' + classes.join(" ") + '">' +
'<span class="article-card-num" aria-hidden="true">' + day + "</span>" +
contentsHTML +
"</article>"
);
}
/**
* 빈 셀 HTML을 반환합니다.
*/
function createEmptyCardHTML() {
return '<article class="article-card is-empty" aria-hidden="true"></article>';
}
/**
* 월 구분선 HTML을 반환합니다.
*/
function createMonthSeparatorHTML(year, month) {
return (
'<div class="article-grid-month-sep" data-year="' + year + '" data-month="' + month + '">' +
'<span class="article-grid-month-label">' + year + "년 " + month + "월</span>" +
"</div>"
);
}
/**
* 특정 월의 모든 행 HTML을 반환합니다.
*/
function buildMonthHTML(year, month, addSeparator) {
var rows = buildMonthRows(year, month);
var html = addSeparator ? createMonthSeparatorHTML(year, month) : "";
rows.forEach(function (row) {
html += '<div class="article-grid-row">';
row.forEach(function (day) {
if (day === null) {
html += createEmptyCardHTML();
} else {
html += createCardHTML(day, year, month);
}
});
html += "</div>";
});
return html;
}
/**
* 다음 연·월을 반환합니다.
*/
function getNextMonth(year, month) {
if (month === 12) return { year: year + 1, month: 1 };
return { year: year, month: month + 1 };
}
/**
* 이전 연·월을 반환합니다.
*/
function getPrevMonth(year, month) {
if (month === 1) return { year: year - 1, month: 12 };
return { year: year, month: month - 1 };
}
/**
* 그리드 상단에 특정 월을 추가합니다. (스크롤 위치 유지)
*/
function prependMonth(year, month) {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 이미 로드된 월 체크
var alreadyLoaded = loadedMonths.some(function (m) {
return m.year === year && m.month === month;
});
if (alreadyLoaded) return;
// YEAR_START 이전은 추가하지 않음
if (year < YEAR_START || (year === YEAR_START && month < 1)) return;
// 기존 첫 달 정보 (새 달 삽입 후 구분선 필요)
var oldFirst = loadedMonths[0];
loadedMonths.unshift({ year: year, month: month });
// 삽입 전 스크롤 높이 저장
var prevScrollY = window.scrollY;
var prevScrollHeight = document.documentElement.scrollHeight;
// 새 달 HTML(구분선 없음) + 기존 첫 달 구분선
var html =
buildMonthHTML(year, month, false) +
createMonthSeparatorHTML(oldFirst.year, oldFirst.month);
gridBody.insertAdjacentHTML("afterbegin", html);
// 삽입된 높이만큼 스크롤 위치 보정 (화면 안 튀게)
var addedHeight = document.documentElement.scrollHeight - prevScrollHeight;
window.scrollTo(0, prevScrollY + addedHeight);
// 새 구분선 라벨 옵저버 등록
observeMonthSeparators();
// 상단 센티넬을 맨 앞으로 복원 후 재관찰
gridBody.insertAdjacentHTML(
"afterbegin",
'<div id="article-grid-sentinel-top" class="article-grid-sentinel-top"></div>'
);
if (topObserver) {
var newTop = document.getElementById("article-grid-sentinel-top");
if (newTop) topObserver.observe(newTop);
}
// 새로 추가된 달이 화면 중앙에 올 때까지 상단 로드 비활성화
topLoadReady = false;
var newSep = gridBody.querySelector(
'.article-grid-month-sep[data-year="' + year + '"][data-month="' + month + '"]'
);
if (newSep) {
var centerObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
topLoadReady = true;
centerObserver.disconnect();
}
});
},
{ root: null, rootMargin: "0px", threshold: 0 }
);
centerObserver.observe(newSep);
}
}
/**
* 그리드에 특정 월을 추가합니다.
*/
function appendMonth(year, month) {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 이미 로드된 월 체크
var alreadyLoaded = loadedMonths.some(function (m) {
return m.year === year && m.month === month;
});
if (alreadyLoaded) return;
loadedMonths.push({ year: year, month: month });
var isFirst = loadedMonths.length === 1;
var html = buildMonthHTML(year, month, !isFirst);
// 센티넬 제거 → 콘텐츠 추가 → 센티넬 재추가
var sentinel = document.getElementById("article-grid-sentinel");
if (sentinel) sentinel.remove();
gridBody.insertAdjacentHTML("beforeend", html);
gridBody.insertAdjacentHTML(
"beforeend",
'<div id="article-grid-sentinel" class="article-grid-sentinel"></div>'
);
// 새로 추가된 센티넬 관찰
if (scrollObserver) {
var newSentinel = document.getElementById("article-grid-sentinel");
if (newSentinel) scrollObserver.observe(newSentinel);
}
// 새로 추가된 월 구분선을 라벨 옵저버에 등록
observeMonthSeparators();
}
/**
* 그리드를 초기화하고 지정 월부터 렌더링합니다.
*/
function renderCalendar(year, month) {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 기존 옵저버 해제
if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
if (topObserver) { topObserver.disconnect(); topObserver = null; }
// 라벨 옵저버 초기화
initLabelObserver();
// 상태 초기화
loadedMonths = [];
topLoadReady = false;
gridBody.innerHTML = "";
// 첫 달 렌더링
appendMonth(year, month);
// 하단·상단 무한 스크롤 초기화
initInfiniteScroll();
initTopScroll();
}
// ------------------------------
// 월 구분선 진입 시 라벨 업데이트 (IntersectionObserver)
// ------------------------------
/**
* article-grid-month-sep가 뷰포트 상단 영역에 진입·이탈할 때 라벨을 갱신합니다.
* - 진입(isIntersecting): 해당 월로 라벨 변경
* - 이탈(위쪽으로 빠져나감): 이미 지나간 상태이므로 유지
* - 이탈(아래쪽으로 빠져나감, 스크롤 업): 이전 달로 라벨 복원
*/
function initLabelObserver() {
if (labelObserver) labelObserver.disconnect();
labelObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
var year = parseInt(entry.target.dataset.year, 10);
var month = parseInt(entry.target.dataset.month, 10);
if (entry.isIntersecting) {
// 구분선이 뷰포트 상단 영역에 진입 → 해당 월로 업데이트
setDateValue(year, month);
} else if (entry.boundingClientRect.top > 0) {
// 구분선이 아래쪽으로 빠져나감 (위로 스크롤) → 이전 달로 복원
var idx = loadedMonths.findIndex(function (m) {
return m.year === year && m.month === month;
});
if (idx > 0) {
var prev = loadedMonths[idx - 1];
setDateValue(prev.year, prev.month);
}
}
});
},
{
root: null,
rootMargin: "0px 0px 0px 0px", // 구분선이 뷰포트에 진입하는 즉시 감지
threshold: 0,
}
);
}
/**
* 새로 추가된 월 구분선을 라벨 옵저버에 등록합니다.
*/
function observeMonthSeparators() {
if (!labelObserver) return;
document.querySelectorAll(".biztrend .article-grid-month-sep").forEach(function (sep) {
labelObserver.observe(sep);
});
}
// ------------------------------
// 무한 스크롤 (IntersectionObserver)
// ------------------------------
/** 하단: 다음 달 추가 */
function initInfiniteScroll() {
var sentinel = document.getElementById("article-grid-sentinel");
if (!sentinel) return;
scrollObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting && loadedMonths.length > 0) {
var last = loadedMonths[loadedMonths.length - 1];
var next = getNextMonth(last.year, last.month);
// 현재 달 이후는 로드하지 않음
var now = new Date();
var nowYear = now.getFullYear();
var nowMonth = now.getMonth() + 1;
if (next.year > nowYear || (next.year === nowYear && next.month > nowMonth)) return;
appendMonth(next.year, next.month);
}
});
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
scrollObserver.observe(sentinel);
}
/** 상단: 이전 달 추가 (IntersectionObserver — 로드 직후 즉시 발화는 setTimeout으로 방지)
* 이전 달이 로드된 후에는 해당 달이 화면 중앙에 올 때까지 추가 로드 비활성화
*/
function initTopScroll() {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 상단 센티넬 생성
gridBody.insertAdjacentHTML(
"afterbegin",
'<div id="article-grid-sentinel-top" class="article-grid-sentinel-top"></div>'
);
if (topObserver) topObserver.disconnect();
topObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting || !topLoadReady || loadedMonths.length === 0) return;
var first = loadedMonths[0];
var prev = getPrevMonth(first.year, first.month);
if (prev.year < YEAR_START) return;
prependMonth(prev.year, prev.month);
});
},
{ root: null, rootMargin: "0px", threshold: 0 }
);
var topSentinel = document.getElementById("article-grid-sentinel-top");
if (topSentinel) topObserver.observe(topSentinel);
// 페이지가 안정화된 후 활성화 (로드 시 즉시 발화 방지)
setTimeout(function () { topLoadReady = true; }, 300);
}
// ------------------------------
// 모달 팝업
// ------------------------------
/**
* 공통 비디오 모달 인스턴스를 준비합니다.
*/
function initModal() {
if (videoModal) return;
if (typeof VideoModalBase === "undefined") {
console.warn("[biztrend] VideoModalBase를 찾을 수 없습니다.");
return;
}
videoModal = new VideoModalBase({
modalPath: "./_modal/video.php",
modalPathTemplate: "./_modal/video-{type}.php",
});
}
/**
* 비디오 팝업을 열고 item 데이터를 채웁니다. (공통 비디오 모달 사용)
*/
function openModal(dateKey, idx) {
var items = articleData[dateKey];
if (!items || !items[idx]) return;
var item = items[idx];
if (!item.videoId) {
if (typeof alert === "function") alert("방송 영상을 준비 중입니다.");
return;
}
initModal();
if (!videoModal) return;
var descText = "";
if (item.list && item.list.length) {
descText = item.list.join("\n");
}
var videoData = {
id: item.contentId,
content_id: item.contentId,
url: item.videoId,
title: item.title || "",
category: "비즈트렌드",
subcate: "성공예감&별책부록",
type: "main",
watch_tm: item.watchTm || 0,
bookmark: !!item.liked,
_biztrendDesc: descText,
};
videoModal.openVideo(videoData).then(function (instance) {
var modalEl = instance && instance.currentModalElement ? instance.currentModalElement : null;
if (!modalEl) return;
var descEl = modalEl.querySelector(".video-info .desc");
if (!descEl) return;
if (videoData._biztrendDesc) {
descEl.innerHTML = escapeHtml(videoData._biztrendDesc).replace(/\n/g, "<br />");
} else {
descEl.textContent = "";
}
}).catch(function () {
// openVideo 내부에서 에러 처리(알림)하므로 여기서는 조용히 무시
});
}
/**
* 그리드 클릭 이벤트 위임 — 주말 카드 제외, article-card-content 클릭 시 비디오 팝업 오픈
*/
function initModalEvents() {
var grid = document.querySelector(".biztrend .article-grid");
if (!grid) return;
grid.addEventListener("click", function (e) {
// 링크 기본 동작 막기
var link = e.target.closest(".article-card-link");
if (link) e.preventDefault();
// 좋아요(북마크) 버튼 클릭
var likeBtn = e.target.closest(".article-card-like");
if (likeBtn) {
e.preventDefault();
e.stopPropagation();
handleLikeClick(likeBtn);
return;
}
var content = e.target.closest(".article-card-content");
if (!content) return;
// 주말(별책부록) 카드는 팝업 없음
var card = e.target.closest(".article-card");
if (card && card.classList.contains("weekend")) return;
var dateKey = content.dataset.dateKey;
var idx = parseInt(content.dataset.itemIdx, 10);
if (!dateKey || isNaN(idx)) return;
openModal(dateKey, idx);
});
}
/**
* 좋아요(북마크) 버튼 클릭 핸들러
*/
function handleLikeClick(btn) {
var contentEl = btn.closest(".article-card-content");
if (!contentEl) return;
var dateKey = contentEl.dataset.dateKey;
var idx = parseInt(contentEl.dataset.itemIdx, 10);
if (!dateKey || isNaN(idx)) return;
var items = articleData[dateKey];
if (!items || !items[idx]) return;
var item = items[idx];
if (!item.contentId) return;
// 현재 상태 토글
var isCurrentlyLiked = btn.classList.contains("is-active");
var nextActive = isCurrentlyLiked ? "0" : "1";
// UI 즉시 반영 (낙관적 업데이트)
btn.disabled = true;
btn.classList.toggle("is-active");
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요" : "좋아요 취소");
var params = new URLSearchParams({
content_id: String(item.contentId),
is_active: nextActive
});
fetch("/bbs/api/save_wishlist.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: params.toString()
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (!data || !data.success) {
// 실패 시 롤백
btn.classList.toggle("is-active");
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요");
console.warn("[biztrend] 북마크 저장 실패", data);
return;
}
// articleData 동기화
item.liked = !isCurrentlyLiked;
})
.catch(function (err) {
// 에러 시 롤백
btn.classList.toggle("is-active");
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요");
console.warn("[biztrend] 북마크 요청 오류", err);
})
.finally(function () {
btn.disabled = false;
});
}
// ------------------------------
// 날짜피커 휠 (연·월)
// ------------------------------
function buildWheelItems() {
var years = [];
for (var y = YEAR_START; y <= YEAR_END; y++) {
years.push({ value: y, label: y + "년" });
}
var months = [];
for (var m = 1; m <= 12; m++) {
months.push({ value: m, label: m + "월" });
}
return { years: years, months: months };
}
function renderWheel(trackEl, items, selectedValue) {
var idx = items.findIndex(function (i) {
return i.value === selectedValue;
});
if (idx < 0) idx = 0;
var html = items
.map(function (item, i) {
var isSelected = i === idx ? " is-selected" : "";
return (
'<div class="datepicker-wheel-item' +
isSelected +
'" data-value="' +
item.value +
'">' +
item.label +
"</div>"
);
})
.join("");
trackEl.innerHTML = html;
trackEl.scrollTop = idx * ITEM_HEIGHT;
}
function getSelectedFromWheel(trackEl) {
var idx = Math.round(trackEl.scrollTop / ITEM_HEIGHT);
var items = trackEl.querySelectorAll(".datepicker-wheel-item");
idx = Math.max(0, Math.min(idx, items.length - 1));
var item = items[idx];
return item ? parseInt(item.dataset.value, 10) : null;
}
function scrollToSelected(trackEl, value) {
var item = trackEl.querySelector(
'.datepicker-wheel-item[data-value="' + value + '"]'
);
if (!item) return;
var allItems = trackEl.querySelectorAll(".datepicker-wheel-item");
var idx = Array.from(allItems).indexOf(item);
if (idx < 0) return;
trackEl.scrollTop = idx * ITEM_HEIGHT;
allItems.forEach(function (el) {
el.classList.toggle(
"is-selected",
parseInt(el.dataset.value, 10) === value
);
});
}
// ------------------------------
// 날짜피커 드롭다운 초기화
// ------------------------------
function initDatepickerDropdown() {
var trigger = document.getElementById("datepicker-trigger");
var dropdown = document.getElementById("datepicker-dropdown");
if (!trigger || !dropdown) return;
var yearTrack = dropdown.querySelector('[data-wheel="year"]');
var monthTrack = dropdown.querySelector('[data-wheel="month"]');
var items = buildWheelItems();
function openDropdown() {
var v = getDateValue();
renderWheel(yearTrack, items.years, v.year);
renderWheel(monthTrack, items.months, v.month);
dropdown.classList.add("is-open");
dropdown.setAttribute("aria-hidden", "false");
trigger.setAttribute("aria-expanded", "true");
document.addEventListener("keydown", onEscape);
setTimeout(function () {
document.addEventListener("click", onDocumentClick);
}, 0);
}
function closeDropdown() {
dropdown.classList.remove("is-open");
dropdown.setAttribute("aria-hidden", "true");
trigger.setAttribute("aria-expanded", "false");
document.removeEventListener("keydown", onEscape);
document.removeEventListener("click", onDocumentClick);
}
function onEscape(e) {
if (e.key === "Escape") closeDropdown();
}
function onDocumentClick(e) {
if (!dropdown.contains(e.target) && e.target !== trigger) {
closeDropdown();
}
}
function onConfirm() {
var year = getSelectedFromWheel(yearTrack);
var month = getSelectedFromWheel(monthTrack);
if (year != null && month != null) {
setDateValue(year, month);
renderCalendar(year, month); // 선택한 달로 그리드 재생성
}
closeDropdown();
}
trigger.addEventListener("click", function (e) {
e.stopPropagation();
if (dropdown.classList.contains("is-open")) {
closeDropdown();
} else {
openDropdown();
}
});
dropdown
.querySelector(".btn-datepicker-cancel")
.addEventListener("click", function (e) {
e.stopPropagation();
closeDropdown();
});
dropdown
.querySelector(".btn-datepicker-confirm")
.addEventListener("click", function (e) {
e.stopPropagation();
onConfirm();
});
function updateSelectedClass(trackEl) {
var val = getSelectedFromWheel(trackEl);
if (val != null) {
trackEl.querySelectorAll(".datepicker-wheel-item").forEach(function (el) {
el.classList.toggle(
"is-selected",
parseInt(el.dataset.value, 10) === val
);
});
}
}
yearTrack.addEventListener("scroll", function () {
updateSelectedClass(yearTrack);
});
monthTrack.addEventListener("scroll", function () {
updateSelectedClass(monthTrack);
});
yearTrack.addEventListener("click", function (e) {
var item = e.target.closest(".datepicker-wheel-item");
if (item) {
scrollToSelected(yearTrack, parseInt(item.dataset.value, 10));
}
});
monthTrack.addEventListener("click", function (e) {
var item = e.target.closest(".datepicker-wheel-item");
if (item) {
scrollToSelected(monthTrack, parseInt(item.dataset.value, 10));
}
});
}
// ------------------------------
// 페이지 초기화
// ------------------------------
function initBiztrendCalendar() {
if (!document.querySelector(".wrap.biztrend")) return;
// 기사 데이터 로드 (#biztrend-data JSON)
loadArticleData();
// 현재 날짜로 날짜피커 라벨 초기화
var now = new Date();
var initYear = now.getFullYear();
var initMonth = now.getMonth() + 1;
setDateValue(initYear, initMonth);
// 현재 달 기준 동적 그리드 렌더링
renderCalendar(initYear, initMonth);
// 그리드 스크롤을 54px 아래에서 시작 (위로 스크롤 시 이전 달 추가 가능하도록)
var grid = document.querySelector(".biztrend .article-grid");
if (grid) grid.scrollTop = 54;
initDatepickerDropdown();
initModal();
initModalEvents();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initBiztrendCalendar);
} else {
initBiztrendCalendar();
}
})();
+1417
View File
File diff suppressed because it is too large Load Diff
+906
View File
@@ -0,0 +1,906 @@
/**
* 비즈트렌드 달력 페이지 전용 스크립트
* ===================================
* biztrend.html (성공예감&별책부록)에서 사용합니다.
* - 연·월 선택 날짜피커 (휠 UI)
* - 현재 달 기준 동적 달력 그리드 생성
* - 스크롤 바닥 감지 시 다음 달 자동 추가 (무한 스크롤)
*/
(function () {
"use strict";
// ------------------------------
// 설정 상수
// ------------------------------
var YEAR_START = 2023;
var YEAR_END = 2030;
var ITEM_HEIGHT = 36; // 휠 한 항목 높이(px)
// ------------------------------
// 무한 스크롤 상태
// ------------------------------
var loadedMonths = []; // [{year, month}]
var scrollObserver = null; // 하단 무한 스크롤 (IntersectionObserver)
var topObserver = null; // 상단 무한 스크롤 (IntersectionObserver)
var labelObserver = null; // 라벨 업데이트
var articleData = {}; // 날짜별 기사 데이터
var videoModal = null; // 공통 비디오 모달 (VideoModalBase)
var topLoadReady = false; // 상단 스크롤 활성화 플래그
// ------------------------------
// 날짜 값 읽기/쓰기
// ------------------------------
function getDateValue() {
var input = document.querySelector(".biztrend .date-select-value");
if (!input) return { year: new Date().getFullYear(), month: new Date().getMonth() + 1 };
var match = (input.value || "").match(/^(\d{4})-(\d{2})$/);
if (match) {
return {
year: parseInt(match[1], 10),
month: parseInt(match[2], 10),
};
}
var now = new Date();
return {
year: now.getFullYear(),
month: now.getMonth() + 1,
};
}
function setDateValue(year, month) {
var input = document.querySelector(".biztrend .date-select-value");
var label = document.querySelector(".biztrend .date-select-label");
if (input) {
input.value = year + "-" + String(month).padStart(2, "0");
}
if (label) {
var yearEl = label.querySelector(".date-select-year");
var monthEl = label.querySelector(".date-select-month");
if (yearEl) yearEl.textContent = year;
if (monthEl) monthEl.textContent = month;
}
}
// ------------------------------
// 달력 그리드 동적 생성
// ------------------------------
/**
* 해당 월의 총 일수를 반환합니다.
*/
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
/**
* 월의 날짜를 월~토 6열 기준 행 배열로 반환합니다.
* 일요일은 제외하며, 빈 셀은 null로 채웁니다.
* @returns {Array<Array<number|null>>}
*/
function buildMonthRows(year, month) {
var daysInMonth = getDaysInMonth(year, month);
var rows = [];
var currentRow = [];
for (var d = 1; d <= daysInMonth; d++) {
var jsDay = new Date(year, month - 1, d).getDay(); // 0=일, 1=월, ..., 6=토
if (jsDay === 0) continue; // 일요일 제외
// 월요일이면 새 행 시작 (이전 행이 있으면 6칸 채우고 저장)
if (jsDay === 1 && currentRow.length > 0) {
while (currentRow.length < 6) currentRow.push(null);
rows.push(currentRow);
currentRow = [];
}
// 1일이 월요일이 아닐 때 앞 빈 칸 추가
if (d === 1 && jsDay !== 1) {
var emptyCount = jsDay - 1; // 월=0, 화=1, ..., 토=5
for (var i = 0; i < emptyCount; i++) {
currentRow.push(null);
}
}
currentRow.push(d);
// 토요일이면 행 완료
if (jsDay === 6) {
rows.push(currentRow);
currentRow = [];
}
}
// 마지막 행 처리
if (currentRow.length > 0) {
while (currentRow.length < 6) currentRow.push(null);
rows.push(currentRow);
}
return rows;
}
// ------------------------------
// 기사 데이터 로드
// ------------------------------
/** HTML의 #biztrend-data JSON을 읽어 articleData에 저장 */
function loadArticleData() {
var el = document.getElementById("biztrend-data");
if (!el) return;
try { articleData = JSON.parse(el.textContent); } catch (e) { articleData = {}; }
}
/** XSS 방지용 HTML 이스케이프 */
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 기사 콘텐츠 블록 HTML을 반환합니다.
* @param {Object} item - articleData의 항목 하나
* @param {string} dateKey - 날짜 키 (예: "2026-02-01")
* @param {number} idx - 해당 날짜 내 인덱스
*/
function createContentHTML(item, dateKey, idx) {
// 제목 + 게이지 + 배지
var titleBoxHTML =
'<div class="article-card-title-box">' +
'<h4 class="article-card-title">' + escapeHtml(item.title) + "</h4>";
if (item.badge) {
titleBoxHTML += '<span class="article-card-badge">' + escapeHtml(item.badge) + "</span>";
}
if (item.gauge != null) {
titleBoxHTML +=
'<div class="gauge-bar"><div class="gauge-fill" style="width:' + item.gauge + '%"></div></div>';
}
titleBoxHTML += "</div>";
// 리스트
var listHTML = "";
if (item.list && item.list.length) {
listHTML = '<ul class="article-card-list">';
item.list.forEach(function (li) {
listHTML += "<li>" + escapeHtml(li) + "</li>";
});
listHTML += "</ul>";
}
// 이미지 여부에 따라 내부 구조 분기
var bodyHTML = item.image
? '<div class="article-card-image">' +
'<div class="article-card-image-placeholder" aria-hidden="true">' +
'<img src="' + escapeHtml(item.image) + '" alt="' + escapeHtml(item.title) + '">' +
"</div>" +
listHTML +
"</div>"
: listHTML;
var likedClass = item.liked ? " is-active" : "";
var likedLabel = item.liked ? "좋아요 취소" : "좋아요";
var dkAttr = dateKey ? ' data-date-key="' + escapeHtml(dateKey) + '" data-item-idx="' + idx + '"' : "";
return (
'<div class="article-card-content"' + dkAttr + '>' +
'<a href="#" class="article-card-link" data-video-id="' + escapeHtml(item.videoId || "") + '">' +
titleBoxHTML +
bodyHTML +
"</a>" +
'<button type="button" class="article-card-like' + likedClass + '" aria-label="' + likedLabel + '"></button>' +
"</div>"
);
}
/**
* 날짜 카드 HTML을 반환합니다.
*/
function createCardHTML(day, year, month) {
var today = new Date();
today.setHours(0, 0, 0, 0);
var cardDate = new Date(year, month - 1, day);
cardDate.setHours(0, 0, 0, 0);
var jsDay = new Date(year, month - 1, day).getDay();
var isWeekend = jsDay === 6; // 토요일
var isToday = cardDate.getTime() === today.getTime();
var isFuture = cardDate > today;
var classes = ["article-card"];
if (isWeekend) classes.push("weekend");
if (isToday) classes.push("today");
if (isFuture) classes.push("is-future");
// 날짜 키로 기사 데이터 조회 (예: "2026-02-01")
var dateKey =
year + "-" +
String(month).padStart(2, "0") + "-" +
String(day).padStart(2, "0");
var items = articleData[dateKey] || [];
var contentsHTML = items.map(function (item, idx) {
return createContentHTML(item, dateKey, idx);
}).join("");
return (
'<article class="' + classes.join(" ") + '">' +
'<span class="article-card-num" aria-hidden="true">' + day + "</span>" +
contentsHTML +
"</article>"
);
}
/**
* 빈 셀 HTML을 반환합니다.
*/
function createEmptyCardHTML() {
return '<article class="article-card is-empty" aria-hidden="true"></article>';
}
/**
* 월 구분선 HTML을 반환합니다.
*/
function createMonthSeparatorHTML(year, month) {
return (
'<div class="article-grid-month-sep" data-year="' + year + '" data-month="' + month + '">' +
'<span class="article-grid-month-label">' + year + "년 " + month + "월</span>" +
"</div>"
);
}
/**
* 특정 월의 모든 행 HTML을 반환합니다.
*/
function buildMonthHTML(year, month, addSeparator) {
var rows = buildMonthRows(year, month);
var html = addSeparator ? createMonthSeparatorHTML(year, month) : "";
rows.forEach(function (row) {
html += '<div class="article-grid-row">';
row.forEach(function (day) {
if (day === null) {
html += createEmptyCardHTML();
} else {
html += createCardHTML(day, year, month);
}
});
html += "</div>";
});
return html;
}
/**
* 다음 연·월을 반환합니다.
*/
function getNextMonth(year, month) {
if (month === 12) return { year: year + 1, month: 1 };
return { year: year, month: month + 1 };
}
/**
* 이전 연·월을 반환합니다.
*/
function getPrevMonth(year, month) {
if (month === 1) return { year: year - 1, month: 12 };
return { year: year, month: month - 1 };
}
/**
* 그리드 상단에 특정 월을 추가합니다. (스크롤 위치 유지)
*/
function prependMonth(year, month) {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 이미 로드된 월 체크
var alreadyLoaded = loadedMonths.some(function (m) {
return m.year === year && m.month === month;
});
if (alreadyLoaded) return;
// YEAR_START 이전은 추가하지 않음
if (year < YEAR_START || (year === YEAR_START && month < 1)) return;
// 기존 첫 달 정보 (새 달 삽입 후 구분선 필요)
var oldFirst = loadedMonths[0];
loadedMonths.unshift({ year: year, month: month });
// 삽입 전 스크롤 높이 저장
var prevScrollY = window.scrollY;
var prevScrollHeight = document.documentElement.scrollHeight;
// 새 달 HTML(구분선 없음) + 기존 첫 달 구분선
var html =
buildMonthHTML(year, month, false) +
createMonthSeparatorHTML(oldFirst.year, oldFirst.month);
gridBody.insertAdjacentHTML("afterbegin", html);
// 삽입된 높이만큼 스크롤 위치 보정 (화면 안 튀게)
var addedHeight = document.documentElement.scrollHeight - prevScrollHeight;
window.scrollTo(0, prevScrollY + addedHeight);
// 새 구분선 라벨 옵저버 등록
observeMonthSeparators();
// 상단 센티넬을 맨 앞으로 복원 후 재관찰
gridBody.insertAdjacentHTML(
"afterbegin",
'<div id="article-grid-sentinel-top" class="article-grid-sentinel-top"></div>'
);
if (topObserver) {
var newTop = document.getElementById("article-grid-sentinel-top");
if (newTop) topObserver.observe(newTop);
}
// 새로 추가된 달이 화면 중앙에 올 때까지 상단 로드 비활성화
topLoadReady = false;
var newSep = gridBody.querySelector(
'.article-grid-month-sep[data-year="' + year + '"][data-month="' + month + '"]'
);
if (newSep) {
var centerObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
topLoadReady = true;
centerObserver.disconnect();
}
});
},
{ root: null, rootMargin: "0px", threshold: 0 }
);
centerObserver.observe(newSep);
}
}
/**
* 그리드에 특정 월을 추가합니다.
*/
function appendMonth(year, month) {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 이미 로드된 월 체크
var alreadyLoaded = loadedMonths.some(function (m) {
return m.year === year && m.month === month;
});
if (alreadyLoaded) return;
loadedMonths.push({ year: year, month: month });
var isFirst = loadedMonths.length === 1;
var html = buildMonthHTML(year, month, !isFirst);
// 센티넬 제거 → 콘텐츠 추가 → 센티넬 재추가
var sentinel = document.getElementById("article-grid-sentinel");
if (sentinel) sentinel.remove();
gridBody.insertAdjacentHTML("beforeend", html);
gridBody.insertAdjacentHTML(
"beforeend",
'<div id="article-grid-sentinel" class="article-grid-sentinel"></div>'
);
// 새로 추가된 센티넬 관찰
if (scrollObserver) {
var newSentinel = document.getElementById("article-grid-sentinel");
if (newSentinel) scrollObserver.observe(newSentinel);
}
// 새로 추가된 월 구분선을 라벨 옵저버에 등록
observeMonthSeparators();
}
/**
* 그리드를 초기화하고 지정 월부터 렌더링합니다.
*/
function renderCalendar(year, month) {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 기존 옵저버 해제
if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
if (topObserver) { topObserver.disconnect(); topObserver = null; }
// 라벨 옵저버 초기화
initLabelObserver();
// 상태 초기화
loadedMonths = [];
topLoadReady = false;
gridBody.innerHTML = "";
// 첫 달 렌더링
appendMonth(year, month);
// 하단·상단 무한 스크롤 초기화
initInfiniteScroll();
initTopScroll();
}
// ------------------------------
// 월 구분선 진입 시 라벨 업데이트 (IntersectionObserver)
// ------------------------------
/**
* article-grid-month-sep가 뷰포트 상단 영역에 진입·이탈할 때 라벨을 갱신합니다.
* - 진입(isIntersecting): 해당 월로 라벨 변경
* - 이탈(위쪽으로 빠져나감): 이미 지나간 상태이므로 유지
* - 이탈(아래쪽으로 빠져나감, 스크롤 업): 이전 달로 라벨 복원
*/
function initLabelObserver() {
if (labelObserver) labelObserver.disconnect();
labelObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
var year = parseInt(entry.target.dataset.year, 10);
var month = parseInt(entry.target.dataset.month, 10);
if (entry.isIntersecting) {
// 구분선이 뷰포트 상단 영역에 진입 → 해당 월로 업데이트
setDateValue(year, month);
} else if (entry.boundingClientRect.top > 0) {
// 구분선이 아래쪽으로 빠져나감 (위로 스크롤) → 이전 달로 복원
var idx = loadedMonths.findIndex(function (m) {
return m.year === year && m.month === month;
});
if (idx > 0) {
var prev = loadedMonths[idx - 1];
setDateValue(prev.year, prev.month);
}
}
});
},
{
root: null,
rootMargin: "0px 0px 0px 0px", // 구분선이 뷰포트에 진입하는 즉시 감지
threshold: 0,
}
);
}
/**
* 새로 추가된 월 구분선을 라벨 옵저버에 등록합니다.
*/
function observeMonthSeparators() {
if (!labelObserver) return;
document.querySelectorAll(".biztrend .article-grid-month-sep").forEach(function (sep) {
labelObserver.observe(sep);
});
}
// ------------------------------
// 무한 스크롤 (IntersectionObserver)
// ------------------------------
/** 하단: 다음 달 추가 */
function initInfiniteScroll() {
var sentinel = document.getElementById("article-grid-sentinel");
if (!sentinel) return;
scrollObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting && loadedMonths.length > 0) {
var last = loadedMonths[loadedMonths.length - 1];
var next = getNextMonth(last.year, last.month);
// 현재 달 이후는 로드하지 않음
var now = new Date();
var nowYear = now.getFullYear();
var nowMonth = now.getMonth() + 1;
if (next.year > nowYear || (next.year === nowYear && next.month > nowMonth)) return;
appendMonth(next.year, next.month);
}
});
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
scrollObserver.observe(sentinel);
}
/** 상단: 이전 달 추가 (IntersectionObserver — 로드 직후 즉시 발화는 setTimeout으로 방지)
* 이전 달이 로드된 후에는 해당 달이 화면 중앙에 올 때까지 추가 로드 비활성화
*/
function initTopScroll() {
var gridBody = document.querySelector(".biztrend .article-grid-body");
if (!gridBody) return;
// 상단 센티넬 생성
gridBody.insertAdjacentHTML(
"afterbegin",
'<div id="article-grid-sentinel-top" class="article-grid-sentinel-top"></div>'
);
if (topObserver) topObserver.disconnect();
topObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting || !topLoadReady || loadedMonths.length === 0) return;
var first = loadedMonths[0];
var prev = getPrevMonth(first.year, first.month);
if (prev.year < YEAR_START) return;
prependMonth(prev.year, prev.month);
});
},
{ root: null, rootMargin: "0px", threshold: 0 }
);
var topSentinel = document.getElementById("article-grid-sentinel-top");
if (topSentinel) topObserver.observe(topSentinel);
// 페이지가 안정화된 후 활성화 (로드 시 즉시 발화 방지)
setTimeout(function () { topLoadReady = true; }, 300);
}
// ------------------------------
// 모달 팝업
// ------------------------------
/**
* 공통 비디오 모달 인스턴스를 준비합니다.
*/
function initModal() {
if (videoModal) return;
if (typeof VideoModalBase === "undefined") {
console.warn("[biztrend] VideoModalBase를 찾을 수 없습니다.");
return;
}
videoModal = new VideoModalBase({
modalPath: "./_modal/video.php",
modalPathTemplate: "./_modal/video-{type}.php",
});
}
/**
* 비디오 팝업을 열고 item 데이터를 채웁니다. (공통 비디오 모달 사용)
*/
function openModal(dateKey, idx) {
var items = articleData[dateKey];
if (!items || !items[idx]) return;
var item = items[idx];
if (!item.videoId) {
if (typeof alert === "function") alert("방송 영상을 준비 중입니다.");
return;
}
// 인라인 모달(#biztrendVideoModal)을 통해 열기
if (typeof window._biztrendOpenModal === "function") {
window._biztrendOpenModal({
contentId: item.contentId,
videoUrl: item.contentUrl || item.videoId,
title: item.title || "",
description: item.description || "",
categoryName: "비즈트렌드",
subCategory: item.badge || "성공예감&별책부록",
watchTm: item.watchTm || 0,
contentTm: item.contentTm || 0,
bookmark: !!item.liked
});
} else {
console.warn("[biztrend] _biztrendOpenModal을 찾을 수 없습니다.");
}
}
/**
* 그리드 클릭 이벤트 위임 — 주말 카드 제외, article-card-content 클릭 시 비디오 팝업 오픈
*/
function initModalEvents() {
var grid = document.querySelector(".biztrend .article-grid");
if (!grid) return;
grid.addEventListener("click", function (e) {
// 링크 기본 동작 막기
var link = e.target.closest(".article-card-link");
if (link) e.preventDefault();
// 좋아요(북마크) 버튼 클릭
var likeBtn = e.target.closest(".article-card-like");
if (likeBtn) {
e.preventDefault();
e.stopPropagation();
handleLikeClick(likeBtn);
return;
}
var content = e.target.closest(".article-card-content");
if (!content) return;
var dateKey = content.dataset.dateKey;
var idx = parseInt(content.dataset.itemIdx, 10);
if (!dateKey || isNaN(idx)) return;
openModal(dateKey, idx);
});
}
/**
* 좋아요(북마크) 버튼 클릭 핸들러
*/
function handleLikeClick(btn) {
var contentEl = btn.closest(".article-card-content");
if (!contentEl) return;
var dateKey = contentEl.dataset.dateKey;
var idx = parseInt(contentEl.dataset.itemIdx, 10);
if (!dateKey || isNaN(idx)) return;
var items = articleData[dateKey];
if (!items || !items[idx]) return;
var item = items[idx];
if (!item.contentId) return;
// 현재 상태 토글
var isCurrentlyLiked = btn.classList.contains("is-active");
var nextActive = isCurrentlyLiked ? "0" : "1";
// UI 즉시 반영 (낙관적 업데이트)
btn.disabled = true;
btn.classList.toggle("is-active");
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요" : "좋아요 취소");
var params = new URLSearchParams({
content_id: String(item.contentId),
is_active: nextActive
});
fetch("/edu/bbs/api/save_wishlist.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: params.toString()
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (!data || !data.success) {
// 실패 시 롤백
btn.classList.toggle("is-active");
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요");
console.warn("[biztrend] 북마크 저장 실패", data);
return;
}
// articleData 동기화
item.liked = !isCurrentlyLiked;
})
.catch(function (err) {
// 에러 시 롤백
btn.classList.toggle("is-active");
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요");
console.warn("[biztrend] 북마크 요청 오류", err);
})
.finally(function () {
btn.disabled = false;
});
}
// ------------------------------
// 날짜피커 휠 (연·월)
// ------------------------------
function buildWheelItems() {
var years = [];
for (var y = YEAR_START; y <= YEAR_END; y++) {
years.push({ value: y, label: y + "년" });
}
var months = [];
for (var m = 1; m <= 12; m++) {
months.push({ value: m, label: m + "월" });
}
return { years: years, months: months };
}
function renderWheel(trackEl, items, selectedValue) {
var idx = items.findIndex(function (i) {
return i.value === selectedValue;
});
if (idx < 0) idx = 0;
var html = items
.map(function (item, i) {
var isSelected = i === idx ? " is-selected" : "";
return (
'<div class="datepicker-wheel-item' +
isSelected +
'" data-value="' +
item.value +
'">' +
item.label +
"</div>"
);
})
.join("");
trackEl.innerHTML = html;
trackEl.scrollTop = idx * ITEM_HEIGHT;
}
function getSelectedFromWheel(trackEl) {
var idx = Math.round(trackEl.scrollTop / ITEM_HEIGHT);
var items = trackEl.querySelectorAll(".datepicker-wheel-item");
idx = Math.max(0, Math.min(idx, items.length - 1));
var item = items[idx];
return item ? parseInt(item.dataset.value, 10) : null;
}
function scrollToSelected(trackEl, value) {
var item = trackEl.querySelector(
'.datepicker-wheel-item[data-value="' + value + '"]'
);
if (!item) return;
var allItems = trackEl.querySelectorAll(".datepicker-wheel-item");
var idx = Array.from(allItems).indexOf(item);
if (idx < 0) return;
trackEl.scrollTop = idx * ITEM_HEIGHT;
allItems.forEach(function (el) {
el.classList.toggle(
"is-selected",
parseInt(el.dataset.value, 10) === value
);
});
}
// ------------------------------
// 날짜피커 드롭다운 초기화
// ------------------------------
function initDatepickerDropdown() {
var trigger = document.getElementById("datepicker-trigger");
var dropdown = document.getElementById("datepicker-dropdown");
if (!trigger || !dropdown) return;
var yearTrack = dropdown.querySelector('[data-wheel="year"]');
var monthTrack = dropdown.querySelector('[data-wheel="month"]');
var items = buildWheelItems();
function openDropdown() {
var v = getDateValue();
renderWheel(yearTrack, items.years, v.year);
renderWheel(monthTrack, items.months, v.month);
dropdown.classList.add("is-open");
dropdown.setAttribute("aria-hidden", "false");
trigger.setAttribute("aria-expanded", "true");
document.addEventListener("keydown", onEscape);
setTimeout(function () {
document.addEventListener("click", onDocumentClick);
}, 0);
}
function closeDropdown() {
dropdown.classList.remove("is-open");
dropdown.setAttribute("aria-hidden", "true");
trigger.setAttribute("aria-expanded", "false");
document.removeEventListener("keydown", onEscape);
document.removeEventListener("click", onDocumentClick);
}
function onEscape(e) {
if (e.key === "Escape") closeDropdown();
}
function onDocumentClick(e) {
if (!dropdown.contains(e.target) && e.target !== trigger) {
closeDropdown();
}
}
function onConfirm() {
var year = getSelectedFromWheel(yearTrack);
var month = getSelectedFromWheel(monthTrack);
if (year != null && month != null) {
setDateValue(year, month);
renderCalendar(year, month); // 선택한 달로 그리드 재생성
}
closeDropdown();
}
trigger.addEventListener("click", function (e) {
e.stopPropagation();
if (dropdown.classList.contains("is-open")) {
closeDropdown();
} else {
openDropdown();
}
});
dropdown
.querySelector(".btn-datepicker-cancel")
.addEventListener("click", function (e) {
e.stopPropagation();
closeDropdown();
});
dropdown
.querySelector(".btn-datepicker-confirm")
.addEventListener("click", function (e) {
e.stopPropagation();
onConfirm();
});
function updateSelectedClass(trackEl) {
var val = getSelectedFromWheel(trackEl);
if (val != null) {
trackEl.querySelectorAll(".datepicker-wheel-item").forEach(function (el) {
el.classList.toggle(
"is-selected",
parseInt(el.dataset.value, 10) === val
);
});
}
}
yearTrack.addEventListener("scroll", function () {
updateSelectedClass(yearTrack);
});
monthTrack.addEventListener("scroll", function () {
updateSelectedClass(monthTrack);
});
yearTrack.addEventListener("click", function (e) {
var item = e.target.closest(".datepicker-wheel-item");
if (item) {
scrollToSelected(yearTrack, parseInt(item.dataset.value, 10));
}
});
monthTrack.addEventListener("click", function (e) {
var item = e.target.closest(".datepicker-wheel-item");
if (item) {
scrollToSelected(monthTrack, parseInt(item.dataset.value, 10));
}
});
}
// ------------------------------
// 페이지 초기화
// ------------------------------
function initBiztrendCalendar() {
if (!document.querySelector(".wrap.biztrend")) return;
// 기사 데이터 로드 (#biztrend-data JSON)
loadArticleData();
// 현재 날짜로 날짜피커 라벨 초기화
var now = new Date();
var initYear = now.getFullYear();
var initMonth = now.getMonth() + 1;
setDateValue(initYear, initMonth);
// 현재 달 기준 동적 그리드 렌더링
renderCalendar(initYear, initMonth);
// 그리드 스크롤을 54px 아래에서 시작 (위로 스크롤 시 이전 달 추가 가능하도록)
var grid = document.querySelector(".biztrend .article-grid");
if (grid) grid.scrollTop = 54;
initDatepickerDropdown();
initModal();
initModalEvents();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initBiztrendCalendar);
} else {
initBiztrendCalendar();
}
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
(function () {
const learningBridgeState = {
initialized: false,
config: null,
markerManager: null,
modal: null,
};
function getLearningConfig() {
if (typeof LEARNING_CONFIG !== 'undefined') {
return LEARNING_CONFIG;
}
if (typeof window.LEARNING_CONFIG !== 'undefined') {
return window.LEARNING_CONFIG;
}
return null;
}
function getMarkerManagerClass() {
if (typeof MarkerManager !== 'undefined') {
return MarkerManager;
}
if (typeof window.MarkerManager !== 'undefined') {
return window.MarkerManager;
}
return null;
}
function getVideoModalClass() {
if (typeof VideoModal !== 'undefined') {
return VideoModal;
}
if (typeof window.VideoModal !== 'undefined') {
return window.VideoModal;
}
return null;
}
function ensureDependencies() {
if (!getLearningConfig()) {
throw new Error('LEARNING_CONFIG is not loaded');
}
if (!getMarkerManagerClass()) {
throw new Error('MarkerManager is not loaded');
}
if (!getVideoModalClass()) {
throw new Error('VideoModal is not loaded');
}
}
function ensureHiddenLearningBridgeContainer() {
let markersContainer = document.getElementById('markers-container');
if (!markersContainer) {
markersContainer = document.createElement('div');
markersContainer.id = 'markers-container';
markersContainer.style.display = 'none';
document.body.appendChild(markersContainer);
}
return markersContainer;
}
function createLearningGaugeStub() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('id', 'gauge-svg-search-result-bridge');
return {
gaugeSvg: svg,
isMobile: false,
};
}
function buildLearningBridgeChapters(apiChapters) {
const learningConfig = getLearningConfig();
const templateChapters = Array.isArray(learningConfig?.chapters)
? learningConfig.chapters
: [];
const templateByCode = new Map();
templateChapters.forEach(function (chapter, index) {
const code = String(chapter?.code || `__index_${index}`);
templateByCode.set(code, chapter || {});
});
return (Array.isArray(apiChapters) ? apiChapters : []).map(function (apiChapter, chapterIndex) {
const templateChapter = templateByCode.get(String(apiChapter?.code || '')) || templateChapters[chapterIndex] || {};
const templateLessons = Array.isArray(templateChapter.lessons) ? templateChapter.lessons : [];
const lessons = (Array.isArray(apiChapter?.lessons) ? apiChapter.lessons : []).map(function (apiLesson, lessonIndex) {
const templateLesson = templateLessons[lessonIndex] || {};
const label = String(
apiLesson?.title ||
templateLesson.label ||
`${apiChapter?.name || templateChapter.name || '법정교육'} ${lessonIndex + 1}`
);
return {
...templateLesson,
type: 'normal',
label: label,
title: label,
url: String(apiLesson?.url || templateLesson.url || ''),
content_id: String(apiLesson?.content_id || ''),
watch_tm: Number(apiLesson?.watch_tm || 0),
content_tm: Number(apiLesson?.content_tm || 0),
all_tm: Number(apiLesson?.all_tm || 0),
description: String(apiLesson?.description || templateLesson.description || ''),
completed: !!apiLesson?.completed,
};
});
return {
...templateChapter,
id: Number(templateChapter.id || (chapterIndex + 1)),
code: String(apiChapter?.code || templateChapter.code || ''),
name: String(apiChapter?.name || templateChapter.name || `챕터 ${chapterIndex + 1}`),
type: 'chapter',
url: String(lessons[0]?.url || templateChapter.url || ''),
completed: lessons.length > 0 && lessons.every(function (lesson) { return lesson.completed === true; }),
lessons: lessons,
};
});
}
async function hydrateLearningBridge() {
ensureDependencies();
const learningConfig = getLearningConfig();
const MarkerManagerClass = getMarkerManagerClass();
const VideoModalClass = getVideoModalClass();
const response = await fetch('/bbs/api/get_learning_chapters.php', {
credentials: 'same-origin',
});
const payload = await response.json();
if (!response.ok || !payload || payload.success !== true || !Array.isArray(payload.chapters)) {
throw new Error(payload?.message || 'failed_to_load_learning_chapters');
}
const bridgeChapters = buildLearningBridgeChapters(payload.chapters);
const bridgeConfig = {
...learningConfig,
chapters: bridgeChapters,
settings: {
...(learningConfig.settings || {}),
},
};
if (!learningBridgeState.initialized) {
ensureHiddenLearningBridgeContainer();
learningBridgeState.config = bridgeConfig;
learningBridgeState.markerManager = new MarkerManagerClass(createLearningGaugeStub(), bridgeConfig);
learningBridgeState.modal = new VideoModalClass(bridgeConfig, learningBridgeState.markerManager);
if (typeof learningBridgeState.markerManager.setModalInstance === 'function') {
learningBridgeState.markerManager.setModalInstance(learningBridgeState.modal);
}
learningBridgeState.initialized = true;
} else {
learningBridgeState.config = bridgeConfig;
learningBridgeState.markerManager.config = bridgeConfig;
learningBridgeState.markerManager.allMarkers = bridgeConfig.getAllMarkers();
learningBridgeState.modal.config = bridgeConfig;
learningBridgeState.modal.markerManager = learningBridgeState.markerManager;
}
return learningBridgeState;
}
window._learningOpenModal = async function (info) {
const bridge = await hydrateLearningBridge();
const contentId = String(info?.contentId || '');
const allMarkers = typeof bridge.config?.getAllMarkers === 'function'
? bridge.config.getAllMarkers()
: bridge.markerManager.allMarkers;
const globalIndex = allMarkers.findIndex(function (marker) {
return marker && marker.isChapterMarker !== true && String(marker.content_id || '') === contentId;
});
if (globalIndex < 0) {
throw new Error(`learning lesson not found: ${contentId}`);
}
bridge.markerManager.allMarkers = allMarkers;
const lessonData = allMarkers[globalIndex];
await bridge.modal.load(lessonData, globalIndex);
};
})();
+106
View File
@@ -0,0 +1,106 @@
(function () {
const onboardingBridgeState = {
chapters: null,
pending: null,
};
function getPuzzleModalManagerClass() {
if (typeof PuzzleModalManager !== 'undefined') {
return PuzzleModalManager;
}
if (typeof window.PuzzleModalManager !== 'undefined') {
return window.PuzzleModalManager;
}
return null;
}
function ensureDependencies() {
if (!getPuzzleModalManagerClass()) {
throw new Error('PuzzleModalManager is not loaded');
}
}
async function loadOnboardingChapters() {
if (Array.isArray(onboardingBridgeState.chapters)) {
return onboardingBridgeState.chapters;
}
if (onboardingBridgeState.pending) {
return onboardingBridgeState.pending;
}
onboardingBridgeState.pending = fetch('/bbs/api/get_onboarding_chapters.php', {
credentials: 'same-origin',
})
.then(async function (response) {
const payload = await response.json();
if (!response.ok || !payload || payload.success !== true || !Array.isArray(payload.chapters)) {
throw new Error(payload?.message || 'failed_to_load_onboarding_chapters');
}
onboardingBridgeState.chapters = payload.chapters;
window.puzzleChapterData = payload.chapters;
return payload.chapters;
})
.finally(function () {
onboardingBridgeState.pending = null;
});
return onboardingBridgeState.pending;
}
function cloneChapter(chapter) {
return JSON.parse(JSON.stringify(chapter || {}));
}
function findChapterIndexByContentId(chapters, contentId) {
const id = String(contentId || '');
if (id === '') {
return -1;
}
return chapters.findIndex(function (chapter) {
const lessons = Array.isArray(chapter?.lessons) ? chapter.lessons : [];
return lessons.some(function (lesson) {
return String(lesson?.content_id || '') === id;
});
});
}
function applyBridgeOverrides(chapter, info) {
const contentId = String(info?.contentId || '');
if (!contentId || !Array.isArray(chapter?.lessons)) {
return chapter;
}
chapter.lessons = chapter.lessons.map(function (lesson) {
if (String(lesson?.content_id || '') !== contentId) {
return lesson;
}
return {
...lesson,
watch_tm: Math.max(Number(lesson?.watch_tm || 0), Number(info?.watchTm || 0)),
content_tm: Math.max(Number(lesson?.content_tm || 0), Number(info?.contentTm || 0)),
is_bookmarked: info?.bookmark === true ? true : lesson?.is_bookmarked === true,
};
});
return chapter;
}
window._onboardingOpenModal = async function (info) {
ensureDependencies();
const PuzzleModalManagerClass = getPuzzleModalManagerClass();
const chapters = await loadOnboardingChapters();
const chapterIndex = findChapterIndexByContentId(chapters, info?.contentId);
if (chapterIndex < 0) {
throw new Error('onboarding chapter not found');
}
const chapter = applyBridgeOverrides(cloneChapter(chapters[chapterIndex]), info);
await PuzzleModalManagerClass.openChapterModal(chapterIndex, chapter);
};
})();
+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,
};
}
+505
View File
@@ -0,0 +1,505 @@
/**
* 애니메이션 유틸리티 모듈 (AnimationUtils.js)
* ========================================
* 페이드, 슬라이드, 카운트업 등 애니메이션을 제공합니다.
*
* [초보자용] AnimationUtils.fade(el, 'in', 300) / AnimationUtils.fade(el, 'out', 300)
*
* @module AnimationUtils
*/
class AnimationUtils {
/**
* 순차적 요소 애니메이션
* @param {Array|NodeList} elements - 요소 배열
* @param {string} className - 추가할 클래스
* @param {number} delay - 각 요소 간 지연 시간 (ms)
* @param {Function} callback - 각 요소 애니메이션 후 콜백
*/
static async sequentialAnimate(elements, className = "show", delay = 50, callback = null) {
const items = Array.isArray(elements) ? elements : Array.from(elements);
for (let i = 0; i < items.length; i++) {
await new Promise((resolve) => {
setTimeout(() => {
items[i].classList.add(className);
if (callback) callback(items[i], i);
resolve();
}, delay * i);
});
}
}
/**
* 요소 페이드 인/아웃
* @param {Element} element - 대상 요소
* @param {string} type - "in" 또는 "out"
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async fade(element, type = "in", duration = 300) {
if (!element) return;
return new Promise((resolve) => {
// 기존 display 값 저장 (grid, flex 등 유지)
const originalDisplay = element.style.display || window.getComputedStyle(element).display;
const isGridOrFlex = originalDisplay === "grid" || originalDisplay === "flex" ||
originalDisplay.includes("grid") || originalDisplay.includes("flex");
if (type === "in") {
// grid/flex인 경우 display를 설정하지 않음
if (!isGridOrFlex) {
element.style.display = "block";
}
element.style.opacity = "0";
element.style.transition = `opacity ${duration}ms ease`;
requestAnimationFrame(() => {
element.style.opacity = "1";
setTimeout(() => {
element.style.transition = "";
// grid/flex인 경우 display 스타일 제거
if (isGridOrFlex) {
element.style.display = "";
}
resolve();
}, duration);
});
} else {
element.style.opacity = "1";
element.style.transition = `opacity ${duration}ms ease`;
requestAnimationFrame(() => {
element.style.opacity = "0";
setTimeout(() => {
// grid/flex인 경우 display를 none으로 설정하지 않음
if (!isGridOrFlex) {
element.style.display = "none";
}
element.style.transition = "";
element.style.opacity = ""; // 재오픈 시 opacity 초기화
resolve();
}, duration);
});
}
});
}
/**
* 요소 슬라이드
* @param {Element} element - 대상 요소
* @param {string} type - "up", "down", "left", "right"
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async slide(element, type = "down", duration = 300) {
if (!element) return;
return new Promise((resolve) => {
const isShow = type === "down" || type === "right";
const property = type === "up" || type === "down" ? "height" : "width";
const overflow = element.style.overflow;
element.style.overflow = "hidden";
if (isShow) {
element.style.display = "block";
const size = element[property === "height" ? "scrollHeight" : "scrollWidth"];
element.style[property] = "0";
element.style.transition = `${property} ${duration}ms ease`;
requestAnimationFrame(() => {
element.style[property] = `${size}px`;
setTimeout(() => {
element.style[property] = "";
element.style.overflow = overflow;
element.style.transition = "";
resolve();
}, duration);
});
} else {
const size = element[property === "height" ? "offsetHeight" : "offsetWidth"];
element.style[property] = `${size}px`;
element.style.transition = `${property} ${duration}ms ease`;
requestAnimationFrame(() => {
element.style[property] = "0";
setTimeout(() => {
element.style.display = "none";
element.style[property] = "";
element.style.overflow = overflow;
element.style.transition = "";
resolve();
}, duration);
});
}
});
}
/**
* 숫자 카운팅 애니메이션
* @param {Element} element - 대상 요소
* @param {number} start - 시작 값
* @param {number} end - 종료 값
* @param {number} duration - 지속 시간 (ms)
* @param {Function} format - 포맷 함수
* @returns {Promise}
*/
static async countUp(element, start, end, duration = 1000, format = null) {
if (!element) return;
return new Promise((resolve) => {
const startTime = performance.now();
const range = end - start;
const update = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out 효과
const easeProgress = 1 - Math.pow(1 - progress, 3);
const current = start + range * easeProgress;
element.textContent = format ? format(current) : Math.round(current);
if (progress < 1) {
requestAnimationFrame(update);
} else {
element.textContent = format ? format(end) : end;
resolve();
}
};
requestAnimationFrame(update);
});
}
/**
* 스크롤 애니메이션
* @param {Element|string} target - 대상 요소 또는 선택자
* @param {Object} options - 옵션
* @returns {Promise}
*/
static async scrollTo(target, options = {}) {
const {
duration = 500,
offset = 0,
easing = "ease-in-out",
container = window,
} = options;
const element = typeof target === "string" ? document.querySelector(target) : target;
if (!element) return;
return new Promise((resolve) => {
const targetPosition =
element.getBoundingClientRect().top +
(container === window ? window.pageYOffset : container.scrollTop) +
offset;
const startPosition = container === window ? window.pageYOffset : container.scrollTop;
const distance = targetPosition - startPosition;
const startTime = performance.now();
const easingFunctions = {
linear: (t) => t,
"ease-in": (t) => t * t,
"ease-out": (t) => t * (2 - t),
"ease-in-out": (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
};
const easingFunc = easingFunctions[easing] || easingFunctions["ease-in-out"];
const animation = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easingFunc(progress);
const position = startPosition + distance * easedProgress;
if (container === window) {
window.scrollTo(0, position);
} else {
container.scrollTop = position;
}
if (progress < 1) {
requestAnimationFrame(animation);
} else {
resolve();
}
};
requestAnimationFrame(animation);
});
}
/**
* 흔들기 애니메이션
* @param {Element} element - 대상 요소
* @param {number} intensity - 강도
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async shake(element, intensity = 5, duration = 500) {
if (!element) return;
return new Promise((resolve) => {
const startTime = performance.now();
const originalTransform = element.style.transform;
const animation = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = elapsed / duration;
if (progress < 1) {
const x = Math.sin(progress * Math.PI * 4) * intensity * (1 - progress);
element.style.transform = `translateX(${x}px)`;
requestAnimationFrame(animation);
} else {
element.style.transform = originalTransform;
resolve();
}
};
requestAnimationFrame(animation);
});
}
/**
* 펄스 애니메이션
* @param {Element} element - 대상 요소
* @param {number} scale - 스케일
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async pulse(element, scale = 1.1, duration = 500) {
if (!element) return;
return new Promise((resolve) => {
const originalTransform = element.style.transform;
element.style.transition = `transform ${duration / 2}ms ease-in-out`;
element.style.transform = `scale(${scale})`;
setTimeout(() => {
element.style.transform = originalTransform;
setTimeout(() => {
element.style.transition = "";
resolve();
}, duration / 2);
}, duration / 2);
});
}
/**
* 바운스 애니메이션
* @param {Element} element - 대상 요소
* @param {number} height - 바운스 높이
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async bounce(element, height = 20, duration = 600) {
if (!element) return;
return new Promise((resolve) => {
const startTime = performance.now();
const originalTransform = element.style.transform;
const animation = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = elapsed / duration;
if (progress < 1) {
const bounceProgress = Math.sin(progress * Math.PI);
const y = -height * bounceProgress;
element.style.transform = `translateY(${y}px)`;
requestAnimationFrame(animation);
} else {
element.style.transform = originalTransform;
resolve();
}
};
requestAnimationFrame(animation);
});
}
/**
* 회전 애니메이션
* @param {Element} element - 대상 요소
* @param {number} degrees - 회전 각도
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async rotate(element, degrees = 360, duration = 500) {
if (!element) return;
return new Promise((resolve) => {
element.style.transition = `transform ${duration}ms ease`;
element.style.transform = `rotate(${degrees}deg)`;
setTimeout(() => {
element.style.transition = "";
resolve();
}, duration);
});
}
/**
* 타이핑 효과
* @param {Element} element - 대상 요소
* @param {string} text - 타이핑할 텍스트
* @param {number} speed - 타이핑 속도 (ms)
* @returns {Promise}
*/
static async typing(element, text, speed = 50) {
if (!element) return;
return new Promise((resolve) => {
let index = 0;
element.textContent = "";
const type = () => {
if (index < text.length) {
element.textContent += text.charAt(index);
index++;
setTimeout(type, speed);
} else {
resolve();
}
};
type();
});
}
/**
* 프로그레스 바 애니메이션
* @param {Element} element - 대상 요소
* @param {number} percent - 진행률 (0-100)
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static async progressBar(element, percent, duration = 500) {
if (!element) return;
return new Promise((resolve) => {
element.style.transition = `width ${duration}ms ease-out`;
element.style.width = `${percent}%`;
setTimeout(() => {
element.style.transition = "";
resolve();
}, duration);
});
}
/**
* 파티클 효과
* @param {Element} container - 컨테이너 요소
* @param {Object} options - 옵션
*/
static particles(container, options = {}) {
const {
count = 30,
color = "#4CAF50",
size = 5,
duration = 2000,
spread = 100,
} = options;
const rect = container.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
for (let i = 0; i < count; i++) {
const particle = document.createElement("div");
particle.style.cssText = `
position: absolute;
width: ${size}px;
height: ${size}px;
background: ${color};
border-radius: 50%;
left: ${centerX}px;
top: ${centerY}px;
pointer-events: none;
`;
container.appendChild(particle);
const angle = (Math.PI * 2 * i) / count;
const velocity = spread * (0.5 + Math.random() * 0.5);
const x = Math.cos(angle) * velocity;
const y = Math.sin(angle) * velocity;
particle.animate(
[
{ transform: "translate(0, 0) scale(1)", opacity: 1 },
{ transform: `translate(${x}px, ${y}px) scale(0)`, opacity: 0 },
],
{
duration: duration,
easing: "cubic-bezier(0, 0.5, 0.5, 1)",
}
).onfinish = () => {
particle.remove();
};
}
}
/**
* 리플 효과
* @param {Element} element - 대상 요소
* @param {Event} event - 클릭 이벤트
* @param {Object} options - 옵션
*/
static ripple(element, event, options = {}) {
const { color = "rgba(255, 255, 255, 0.6)", duration = 600 } = options;
const rect = element.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
const ripple = document.createElement("span");
ripple.style.cssText = `
position: absolute;
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${color};
left: ${x}px;
top: ${y}px;
transform: scale(0);
pointer-events: none;
`;
// 상대 위치 설정
const position = window.getComputedStyle(element).position;
if (position !== "relative" && position !== "absolute") {
element.style.position = "relative";
}
element.style.overflow = "hidden";
element.appendChild(ripple);
ripple.animate(
[
{ transform: "scale(0)", opacity: 1 },
{ transform: "scale(2)", opacity: 0 },
],
{
duration: duration,
easing: "ease-out",
}
).onfinish = () => {
ripple.remove();
};
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = AnimationUtils;
}
+414
View File
@@ -0,0 +1,414 @@
/**
* 설정 관리 모듈
* @module ConfigManager
*/
class ConfigManager {
constructor(defaults = {}) {
this.defaults = defaults;
this.config = { ...defaults };
}
/**
* 설정 값 가져오기
* @param {string} key - 키 (점 표기법 지원)
* @param {*} defaultValue - 기본값
* @returns {*}
*/
get(key, defaultValue = null) {
return this._getNestedValue(this.config, key, defaultValue);
}
/**
* 설정 값 설정
* @param {string} key - 키 (점 표기법 지원)
* @param {*} value - 값
*/
set(key, value) {
this._setNestedValue(this.config, key, value);
}
/**
* 여러 설정 값 설정
* @param {Object} config - 설정 객체
*/
setMultiple(config) {
this.config = this._deepMerge(this.config, config);
}
/**
* 설정 값 삭제
* @param {string} key - 키
*/
remove(key) {
this._deleteNestedValue(this.config, key);
}
/**
* 설정 값 존재 확인
* @param {string} key - 키
* @returns {boolean}
*/
has(key) {
return this._getNestedValue(this.config, key) !== undefined;
}
/**
* 모든 설정 가져오기
* @returns {Object}
*/
getAll() {
return { ...this.config };
}
/**
* 설정 초기화
*/
reset() {
this.config = { ...this.defaults };
}
/**
* 기본값으로 병합
* @param {Object} config - 설정 객체
* @returns {Object}
*/
mergeWithDefaults(config) {
return this._deepMerge({ ...this.defaults }, config);
}
/**
* JSON 문자열로 변환
* @returns {string}
*/
toJSON() {
return JSON.stringify(this.config, null, 2);
}
/**
* JSON 문자열에서 로드
* @param {string} json - JSON 문자열
*/
fromJSON(json) {
try {
const parsed = JSON.parse(json);
this.config = this._deepMerge({ ...this.defaults }, parsed);
} catch (error) {
console.error("Failed to parse JSON:", error);
}
}
/**
* 로컬 스토리지에 저장
* @param {string} key - 저장 키
*/
saveToStorage(key = "app_config") {
try {
localStorage.setItem(key, JSON.stringify(this.config));
return true;
} catch (error) {
console.error("Failed to save to storage:", error);
return false;
}
}
/**
* 로컬 스토리지에서 로드
* @param {string} key - 저장 키
*/
loadFromStorage(key = "app_config") {
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
this.config = this._deepMerge({ ...this.defaults }, parsed);
return true;
}
return false;
} catch (error) {
console.error("Failed to load from storage:", error);
return false;
}
}
/**
* 중첩된 객체 값 가져오기
* @private
*/
_getNestedValue(obj, key, defaultValue = null) {
const keys = key.split(".");
let value = obj;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = value[k];
} else {
return defaultValue;
}
}
return value !== undefined ? value : defaultValue;
}
/**
* 중첩된 객체 값 설정
* @private
*/
_setNestedValue(obj, key, value) {
const keys = key.split(".");
const lastKey = keys.pop();
let target = obj;
for (const k of keys) {
if (!(k in target) || typeof target[k] !== "object") {
target[k] = {};
}
target = target[k];
}
target[lastKey] = value;
}
/**
* 중첩된 객체 값 삭제
* @private
*/
_deleteNestedValue(obj, key) {
const keys = key.split(".");
const lastKey = keys.pop();
let target = obj;
for (const k of keys) {
if (!(k in target) || typeof target[k] !== "object") {
return;
}
target = target[k];
}
delete target[lastKey];
}
/**
* 깊은 병합
* @private
*/
_deepMerge(target, source) {
const output = { ...target };
if (this._isObject(target) && this._isObject(source)) {
Object.keys(source).forEach((key) => {
if (this._isObject(source[key])) {
if (!(key in target)) {
output[key] = source[key];
} else {
output[key] = this._deepMerge(target[key], source[key]);
}
} else {
output[key] = source[key];
}
});
}
return output;
}
/**
* 객체 확인
* @private
*/
_isObject(item) {
return item && typeof item === "object" && !Array.isArray(item);
}
}
/**
* 앱 설정 관리 (싱글톤)
*/
class AppConfig extends ConfigManager {
static instance = null;
constructor(defaults = {}) {
if (AppConfig.instance) {
return AppConfig.instance;
}
super({
app: {
name: "My App",
version: "1.0.0",
debug: false,
},
ui: {
theme: "light",
language: "ko",
animations: true,
},
features: {
search: true,
notifications: true,
autoSave: true,
},
...defaults,
});
AppConfig.instance = this;
}
/**
* 싱글톤 인스턴스 가져오기
* @returns {AppConfig}
*/
static getInstance() {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
}
return AppConfig.instance;
}
/**
* 앱 이름 가져오기
* @returns {string}
*/
getAppName() {
return this.get("app.name");
}
/**
* 앱 버전 가져오기
* @returns {string}
*/
getAppVersion() {
return this.get("app.version");
}
/**
* 디버그 모드 확인
* @returns {boolean}
*/
isDebugMode() {
return this.get("app.debug", false);
}
/**
* 테마 가져오기
* @returns {string}
*/
getTheme() {
return this.get("ui.theme", "light");
}
/**
* 테마 설정
* @param {string} theme - 테마
*/
setTheme(theme) {
this.set("ui.theme", theme);
this.saveToStorage();
}
/**
* 언어 가져오기
* @returns {string}
*/
getLanguage() {
return this.get("ui.language", "ko");
}
/**
* 언어 설정
* @param {string} language - 언어
*/
setLanguage(language) {
this.set("ui.language", language);
this.saveToStorage();
}
/**
* 애니메이션 사용 여부
* @returns {boolean}
*/
useAnimations() {
return this.get("ui.animations", true);
}
/**
* 기능 활성화 여부
* @param {string} feature - 기능 이름
* @returns {boolean}
*/
isFeatureEnabled(feature) {
return this.get(`features.${feature}`, false);
}
/**
* 기능 토글
* @param {string} feature - 기능 이름
*/
toggleFeature(feature) {
const current = this.isFeatureEnabled(feature);
this.set(`features.${feature}`, !current);
this.saveToStorage();
}
}
/**
* HTML data 속성에서 설정 로드
*/
class DataAttributeConfig {
/**
* 요소에서 설정 로드
* @param {Element} element - 대상 요소
* @param {string} prefix - 속성 접두사
* @returns {Object}
*/
static loadFromElement(element, prefix = "data-") {
if (!element) return {};
const config = {};
const attributes = element.attributes;
for (let i = 0; i < attributes.length; i++) {
const attr = attributes[i];
if (attr.name.startsWith(prefix)) {
const key = attr.name
.substring(prefix.length)
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
config[key] = this._parseValue(attr.value);
}
}
return config;
}
/**
* 값 파싱
* @private
*/
static _parseValue(value) {
// boolean
if (value === "true") return true;
if (value === "false") return false;
// number
if (!isNaN(value) && value !== "") {
return parseFloat(value);
}
// JSON
if ((value.startsWith("{") || value.startsWith("[")) &&
(value.endsWith("}") || value.endsWith("]"))) {
try {
return JSON.parse(value);
} catch (e) {
// JSON 파싱 실패 시 문자열 반환
}
}
// string
return value;
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { ConfigManager, AppConfig, DataAttributeConfig };
}
+414
View File
@@ -0,0 +1,414 @@
/**
* DOM 조작 유틸리티 모듈 (DOMUtils.js)
* ========================================
* document.querySelector 대신 쓰는 간편 함수들입니다.
*
* [초보자용 사용 예]
* DOMUtils.$('.my-class') → 첫 번째 요소 (querySelector)
* DOMUtils.$$('.my-class') → 모든 요소 (querySelectorAll)
* DOMUtils.fadeIn(el, 300) → 페이드 인 (300ms)
* DOMUtils.fadeOut(el, 300) → 페이드 아웃
* DOMUtils.delegate(parent, 'click', '.btn', handler) → 동적 요소에 이벤트 위임
* DOMUtils.htmlToElement('<div>') → HTML 문자열 → Element
*
* @module DOMUtils
*/
class DOMUtils {
/**
* 요소 선택 (단일)
* @param {string} selector - CSS 선택자
* @param {Element} parent - 부모 요소
* @returns {Element|null}
*/
static $(selector, parent = document) {
return parent.querySelector(selector);
}
/**
* 요소 선택 (다중)
* @param {string} selector - CSS 선택자
* @param {Element} parent - 부모 요소
* @returns {NodeList}
*/
static $$(selector, parent = document) {
return parent.querySelectorAll(selector);
}
/**
* 요소 생성
* @param {string} tag - 태그명
* @param {Object} attrs - 속성 객체
* @param {string} content - 내용
* @returns {Element}
*/
static createElement(tag, attrs = {}, content = "") {
const element = document.createElement(tag);
Object.entries(attrs).forEach(([key, value]) => {
if (key === "class" || key === "className") {
element.className = value;
} else if (key === "style" && typeof value === "object") {
Object.assign(element.style, value);
} else if (key.startsWith("data-")) {
element.setAttribute(key, value);
} else {
element[key] = value;
}
});
if (content) {
if (typeof content === "string") {
element.innerHTML = content;
} else if (content instanceof Node) {
element.appendChild(content);
}
}
return element;
}
/**
* 클래스 토글
* @param {Element} element - 대상 요소
* @param {string} className - 클래스명
* @param {boolean} force - 강제 적용 여부
*/
static toggleClass(element, className, force) {
if (!element) return;
if (force !== undefined) {
element.classList.toggle(className, force);
} else {
element.classList.toggle(className);
}
}
/**
* 여러 클래스 추가
* @param {Element} element - 대상 요소
* @param {...string} classNames - 클래스명들
*/
static addClasses(element, ...classNames) {
if (!element) return;
element.classList.add(...classNames);
}
/**
* 여러 클래스 제거
* @param {Element} element - 대상 요소
* @param {...string} classNames - 클래스명들
*/
static removeClasses(element, ...classNames) {
if (!element) return;
element.classList.remove(...classNames);
}
/**
* 요소의 위치 정보 가져오기
* @param {Element} element - 대상 요소
* @returns {Object}
*/
static getPosition(element) {
if (!element) return null;
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
};
}
/**
* 요소를 퍼센트 위치로 설정
* @param {Element} element - 대상 요소
* @param {number} x - X 위치 (%)
* @param {number} y - Y 위치 (%)
* @param {string} transform - 추가 transform
*/
static setPercentPosition(element, x, y, transform = "translate(-50%, -50%)") {
if (!element) return;
element.style.position = "absolute";
element.style.left = `${x}%`;
element.style.top = `${y}%`;
if (transform) {
element.style.transform = transform;
}
}
/**
* 요소 페이드 인
* @param {Element} element - 대상 요소
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static fadeIn(element, duration = 300) {
if (!element) return Promise.resolve();
return new Promise((resolve) => {
// 기존 display 값 저장 (grid, flex 등 유지)
const originalDisplay = element.style.display || window.getComputedStyle(element).display;
const isGridOrFlex = originalDisplay === "grid" || originalDisplay === "flex" ||
originalDisplay.includes("grid") || originalDisplay.includes("flex");
element.style.opacity = "0";
// grid/flex인 경우 display를 설정하지 않음
if (!isGridOrFlex) {
element.style.display = "block";
}
element.style.transition = `opacity ${duration}ms ease-in-out`;
setTimeout(() => {
element.style.opacity = "1";
}, 10);
setTimeout(() => {
element.style.transition = "";
// grid/flex인 경우 display 스타일 제거
if (isGridOrFlex) {
element.style.display = "";
}
resolve();
}, duration);
});
}
/**
* 요소 페이드 아웃
* @param {Element} element - 대상 요소
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static fadeOut(element, duration = 300) {
if (!element) return Promise.resolve();
return new Promise((resolve) => {
element.style.opacity = "1";
element.style.transition = `opacity ${duration}ms ease-in-out`;
setTimeout(() => {
element.style.opacity = "0";
}, 10);
setTimeout(() => {
element.style.display = "none";
element.style.transition = "";
element.style.opacity = ""; // 재오픈 시 opacity 초기화
resolve();
}, duration);
});
}
/**
* 요소 슬라이드 다운
* @param {Element} element - 대상 요소
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static slideDown(element, duration = 300) {
if (!element) return Promise.resolve();
return new Promise((resolve) => {
element.style.display = "block";
const height = element.scrollHeight;
element.style.height = "0";
element.style.overflow = "hidden";
element.style.transition = `height ${duration}ms ease-in-out`;
setTimeout(() => {
element.style.height = `${height}px`;
}, 10);
setTimeout(() => {
element.style.height = "";
element.style.overflow = "";
element.style.transition = "";
resolve();
}, duration);
});
}
/**
* 요소 슬라이드 업
* @param {Element} element - 대상 요소
* @param {number} duration - 지속 시간 (ms)
* @returns {Promise}
*/
static slideUp(element, duration = 300) {
if (!element) return Promise.resolve();
return new Promise((resolve) => {
const height = element.scrollHeight;
element.style.height = `${height}px`;
element.style.overflow = "hidden";
element.style.transition = `height ${duration}ms ease-in-out`;
setTimeout(() => {
element.style.height = "0";
}, 10);
setTimeout(() => {
element.style.display = "none";
element.style.height = "";
element.style.overflow = "";
element.style.transition = "";
resolve();
}, duration);
});
}
/**
* 이벤트 위임
* @param {Element} parent - 부모 요소
* @param {string} eventType - 이벤트 타입
* @param {string} selector - 자식 선택자
* @param {Function} handler - 핸들러 함수
*/
static delegate(parent, eventType, selector, handler) {
if (!parent) return;
parent.addEventListener(eventType, (event) => {
const target = event.target.closest(selector);
if (target && parent.contains(target)) {
handler.call(target, event);
}
});
}
/**
* HTML 문자열을 요소로 변환
* @param {string} html - HTML 문자열
* @returns {Element}
*/
static htmlToElement(html) {
const template = document.createElement("template");
template.innerHTML = html.trim();
return template.content.firstElementChild;
}
/**
* HTML 문자열을 요소 배열로 변환
* @param {string} html - HTML 문자열
* @returns {Array}
*/
static htmlToElements(html) {
const template = document.createElement("template");
template.innerHTML = html.trim();
return Array.from(template.content.children);
}
/**
* 요소가 뷰포트에 있는지 확인
* @param {Element} element - 대상 요소
* @returns {boolean}
*/
static isInViewport(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* 부드러운 스크롤
* @param {Element|string} target - 대상 요소 또는 선택자
* @param {Object} options - 옵션
*/
static smoothScroll(target, options = {}) {
const element = typeof target === "string" ? this.$(target) : target;
if (!element) return;
const defaultOptions = {
behavior: "smooth",
block: "start",
inline: "nearest",
};
element.scrollIntoView({ ...defaultOptions, ...options });
}
/**
* 전체 화면 토글
* @param {Element} element - 대상 요소
*/
static toggleFullscreen(element = document.documentElement) {
if (!document.fullscreenElement) {
element.requestFullscreen?.() ||
element.webkitRequestFullscreen?.() ||
element.msRequestFullscreen?.();
} else {
document.exitFullscreen?.() ||
document.webkitExitFullscreen?.() ||
document.msExitFullscreen?.();
}
}
/**
* 클립보드에 복사
* @param {string} text - 복사할 텍스트
* @returns {Promise}
*/
static async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy:", err);
return false;
}
}
/**
* 요소의 스타일 가져오기
* @param {Element} element - 대상 요소
* @param {string} property - CSS 속성
* @returns {string}
*/
static getStyle(element, property) {
if (!element) return null;
return window.getComputedStyle(element).getPropertyValue(property);
}
/**
* 여러 스타일 설정
* @param {Element} element - 대상 요소
* @param {Object} styles - 스타일 객체
*/
static setStyles(element, styles) {
if (!element || !styles) return;
Object.assign(element.style, styles);
}
/**
* 요소 제거
* @param {Element} element - 대상 요소
*/
static remove(element) {
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
}
/**
* 요소 내용 비우기
* @param {Element} element - 대상 요소
*/
static empty(element) {
if (!element) return;
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = DOMUtils;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 의존성 주입 컨테이너
* 명확한 의존성 관리 및 테스트 용이성 향상
* @module DependencyInjector
*/
class DependencyInjector {
constructor() {
this.services = new Map();
this.singletons = new Map();
}
/**
* 서비스 등록
* @param {string} name - 서비스 이름
* @param {Function|Object} factory - 팩토리 함수 또는 인스턴스
* @param {boolean} singleton - 싱글톤 여부
*/
register(name, factory, singleton = true) {
if (typeof factory === 'function') {
this.services.set(name, { factory, singleton });
} else {
// 이미 인스턴스인 경우
this.singletons.set(name, factory);
this.services.set(name, { factory: () => factory, singleton: true });
}
}
/**
* 서비스 가져오기
* @param {string} name - 서비스 이름
* @returns {*}
*/
get(name) {
// 싱글톤 캐시 확인
if (this.singletons.has(name)) {
return this.singletons.get(name);
}
const service = this.services.get(name);
if (!service) {
throw new Error(`Service "${name}" is not registered`);
}
const instance = service.factory(this);
// 싱글톤인 경우 캐시
if (service.singleton) {
this.singletons.set(name, instance);
}
return instance;
}
/**
* 서비스 존재 여부 확인
* @param {string} name - 서비스 이름
* @returns {boolean}
*/
has(name) {
return this.services.has(name);
}
/**
* 서비스 제거
* @param {string} name - 서비스 이름
*/
remove(name) {
this.services.delete(name);
this.singletons.delete(name);
}
/**
* 모든 서비스 초기화
*/
clear() {
this.services.clear();
this.singletons.clear();
}
/**
* 여러 서비스 한 번에 등록
* @param {Object} services - 서비스 객체
*/
registerAll(services) {
Object.entries(services).forEach(([name, factory]) => {
this.register(name, factory);
});
}
}
/**
* 전역 의존성 주입 컨테이너
*/
const di = new DependencyInjector();
// 기본 서비스 등록 (있는 경우)
if (typeof DOMUtils !== 'undefined') {
di.register('DOMUtils', () => DOMUtils, true);
}
if (typeof Utils !== 'undefined') {
di.register('Utils', () => Utils, true);
}
if (typeof AnimationUtils !== 'undefined') {
di.register('AnimationUtils', () => AnimationUtils, true);
}
if (typeof eventManager !== 'undefined') {
di.register('eventManager', () => eventManager, true);
}
if (typeof ErrorHandler !== 'undefined') {
di.register('ErrorHandler', () => ErrorHandler, true);
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { DependencyInjector, di };
}
+221
View File
@@ -0,0 +1,221 @@
/**
* 에러 처리 모듈 (ErrorHandler.js)
* ========================================
* 에러를 한곳에서 처리하고 로깅합니다.
*
* [초보자용]
* ErrorHandler.safeExecute(() => 위험한함수(), 기본값)
* ErrorHandler.handle(error, { context: '내모듈' })
*
* @module ErrorHandler
*/
class ErrorHandler {
constructor() {
this.errorLog = [];
this.maxLogSize = 100;
this.onErrorCallbacks = [];
}
/**
* 에러 처리
* @param {Error|string} error - 에러 객체 또는 메시지
* @param {Object} context - 컨텍스트 정보
* @param {boolean} showToUser - 사용자에게 표시할지 여부
*/
static handle(error, context = {}, showToUser = false) {
const errorInfo = this._normalizeError(error, context);
// 콘솔에 로그
console.error('[ErrorHandler]', errorInfo);
// 에러 로그에 추가
if (this.instance) {
this.instance._addToLog(errorInfo);
}
// 콜백 실행
if (this.instance) {
this.instance.onErrorCallbacks.forEach(callback => {
try {
callback(errorInfo);
} catch (e) {
console.error('[ErrorHandler] Error in callback:', e);
}
});
}
// 사용자에게 표시
if (showToUser) {
this._showToUser(errorInfo);
}
return errorInfo;
}
/**
* 에러를 정규화
* @private
*/
static _normalizeError(error, context) {
const errorInfo = {
message: '',
stack: '',
timestamp: new Date().toISOString(),
context: {},
...context,
};
if (error instanceof Error) {
errorInfo.message = error.message;
errorInfo.stack = error.stack;
errorInfo.name = error.name;
} else if (typeof error === 'string') {
errorInfo.message = error;
} else {
errorInfo.message = 'Unknown error';
errorInfo.originalError = error;
}
return errorInfo;
}
/**
* 사용자에게 에러 표시
* @private
*/
static _showToUser(errorInfo) {
// ModalBase가 있으면 사용, 없으면 alert
if (typeof AlertModal !== 'undefined') {
const alert = new AlertModal({
title: '오류 발생',
message: errorInfo.message || '알 수 없는 오류가 발생했습니다.',
});
alert.show();
} else if (typeof ModalBase !== 'undefined') {
// 간단한 알림 모달 생성
const modal = new ModalBase();
modal.create({
content: `<div style="padding: 20px;">${errorInfo.message || '오류가 발생했습니다.'}</div>`,
});
modal.open();
} else {
// 최후의 수단: alert
alert(errorInfo.message || '오류가 발생했습니다.');
}
}
/**
* 에러 로그에 추가
* @private
*/
_addToLog(errorInfo) {
this.errorLog.push(errorInfo);
// 최대 크기 초과 시 오래된 항목 제거
if (this.errorLog.length > this.maxLogSize) {
this.errorLog.shift();
}
}
/**
* 에러 콜백 등록
* @param {Function} callback - 콜백 함수
*/
onError(callback) {
if (typeof callback === 'function') {
this.onErrorCallbacks.push(callback);
}
}
/**
* 에러 콜백 제거
* @param {Function} callback - 콜백 함수
*/
offError(callback) {
const index = this.onErrorCallbacks.indexOf(callback);
if (index > -1) {
this.onErrorCallbacks.splice(index, 1);
}
}
/**
* 에러 로그 가져오기
* @param {number} limit - 최대 개수
* @returns {Array}
*/
getErrorLog(limit = null) {
if (limit) {
return this.errorLog.slice(-limit);
}
return [...this.errorLog];
}
/**
* 에러 로그 초기화
*/
clearErrorLog() {
this.errorLog = [];
}
/**
* 안전한 함수 실행 (에러 처리 포함)
* @param {Function} fn - 실행할 함수
* @param {*} defaultValue - 에러 발생 시 반환할 기본값
* @param {Object} context - 컨텍스트 정보
* @returns {*}
*/
static safeExecute(fn, defaultValue = null, context = {}) {
try {
return fn();
} catch (error) {
this.handle(error, context, false);
return defaultValue;
}
}
/**
* 안전한 비동기 함수 실행 (에러 처리 포함)
* @param {Function} fn - 실행할 함수
* @param {*} defaultValue - 에러 발생 시 반환할 기본값
* @param {Object} context - 컨텍스트 정보
* @returns {Promise}
*/
static async safeExecuteAsync(fn, defaultValue = null, context = {}) {
try {
return await fn();
} catch (error) {
this.handle(error, context, false);
return defaultValue;
}
}
/**
* 전역 에러 핸들러 설정
*/
static setupGlobalHandlers() {
// 전역 에러 핸들러
window.addEventListener('error', (event) => {
this.handle(event.error || event.message, {
type: 'global',
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
}, false);
});
// Promise rejection 핸들러
window.addEventListener('unhandledrejection', (event) => {
this.handle(event.reason, {
type: 'unhandledRejection',
}, false);
});
}
}
// 싱글톤 인스턴스
ErrorHandler.instance = new ErrorHandler();
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = ErrorHandler;
}
+276
View File
@@ -0,0 +1,276 @@
/**
* 이벤트 관리 모듈 (EventManager.js)
* ========================================
* 이벤트 리스너를 중앙에서 관리합니다.
* 리스너 ID를 저장해두면 나중에 off()로 제거 가능 (메모리 누수 방지).
*
* [초보자용] 전역 변수 eventManager 로 사용:
* eventManager.on(element, 'click', handler)
* eventManager.delegate(parent, 'click', '.btn', handler)
*
* @module EventManager
*/
class EventManager {
constructor() {
this.listeners = new Map();
this.delegatedListeners = new Map();
}
/**
* 이벤트 리스너 등록
* @param {Element|Window|Document} target - 대상 요소
* @param {string} eventType - 이벤트 타입
* @param {Function} handler - 핸들러 함수
* @param {Object} options - 이벤트 옵션
* @returns {string} 리스너 ID
*/
on(target, eventType, handler, options = {}) {
if (!target || typeof handler !== 'function') {
console.warn('[EventManager] Invalid target or handler');
return null;
}
const listenerId = this._generateId();
const wrappedHandler = this._wrapHandler(handler, listenerId);
target.addEventListener(eventType, wrappedHandler, options);
if (!this.listeners.has(target)) {
this.listeners.set(target, new Map());
}
this.listeners.get(target).set(listenerId, {
eventType,
handler: wrappedHandler,
originalHandler: handler,
options,
});
return listenerId;
}
/**
* 이벤트 리스너 제거
* @param {Element|Window|Document} target - 대상 요소
* @param {string} listenerId - 리스너 ID
*/
off(target, listenerId) {
if (!target || !listenerId) return;
const targetListeners = this.listeners.get(target);
if (!targetListeners) return;
const listener = targetListeners.get(listenerId);
if (!listener) return;
target.removeEventListener(listener.eventType, listener.handler, listener.options);
targetListeners.delete(listenerId);
if (targetListeners.size === 0) {
this.listeners.delete(target);
}
}
/**
* 이벤트 위임 등록
* @param {Element|Window|Document} parent - 부모 요소
* @param {string} eventType - 이벤트 타입
* @param {string} selector - 자식 선택자
* @param {Function} handler - 핸들러 함수
* @param {Object} options - 이벤트 옵션
* @returns {string} 리스너 ID
*/
delegate(parent, eventType, selector, handler, options = {}) {
if (!parent || typeof handler !== 'function') {
console.warn('[EventManager] Invalid parent or handler');
return null;
}
const listenerId = this._generateId();
const wrappedHandler = (event) => {
const target = event.target.closest(selector);
if (target && parent.contains(target)) {
handler.call(target, event);
}
};
parent.addEventListener(eventType, wrappedHandler, options);
if (!this.delegatedListeners.has(parent)) {
this.delegatedListeners.set(parent, new Map());
}
this.delegatedListeners.get(parent).set(listenerId, {
eventType,
selector,
handler: wrappedHandler,
originalHandler: handler,
options,
});
return listenerId;
}
/**
* 이벤트 위임 제거
* @param {Element|Window|Document} parent - 부모 요소
* @param {string} listenerId - 리스너 ID
*/
undelegate(parent, listenerId) {
if (!parent || !listenerId) return;
const delegatedListeners = this.delegatedListeners.get(parent);
if (!delegatedListeners) return;
const listener = delegatedListeners.get(listenerId);
if (!listener) return;
parent.removeEventListener(listener.eventType, listener.handler, listener.options);
delegatedListeners.delete(listenerId);
if (delegatedListeners.size === 0) {
this.delegatedListeners.delete(parent);
}
}
/**
* 특정 요소의 모든 리스너 제거
* @param {Element|Window|Document} target - 대상 요소
*/
removeAll(target) {
// 일반 리스너 제거
const targetListeners = this.listeners.get(target);
if (targetListeners) {
targetListeners.forEach((listener, listenerId) => {
target.removeEventListener(listener.eventType, listener.handler, listener.options);
});
this.listeners.delete(target);
}
// 위임 리스너 제거
const delegatedListeners = this.delegatedListeners.get(target);
if (delegatedListeners) {
delegatedListeners.forEach((listener, listenerId) => {
target.removeEventListener(listener.eventType, listener.handler, listener.options);
});
this.delegatedListeners.delete(target);
}
}
/**
* 모든 리스너 제거
*/
removeAllListeners() {
// 일반 리스너 제거
this.listeners.forEach((targetListeners, target) => {
targetListeners.forEach((listener) => {
target.removeEventListener(listener.eventType, listener.handler, listener.options);
});
});
this.listeners.clear();
// 위임 리스너 제거
this.delegatedListeners.forEach((delegatedListeners, parent) => {
delegatedListeners.forEach((listener) => {
parent.removeEventListener(listener.eventType, listener.handler, listener.options);
});
});
this.delegatedListeners.clear();
}
/**
* 한 번만 실행되는 이벤트 리스너
* @param {Element|Window|Document} target - 대상 요소
* @param {string} eventType - 이벤트 타입
* @param {Function} handler - 핸들러 함수
* @param {Object} options - 이벤트 옵션
* @returns {string} 리스너 ID
*/
once(target, eventType, handler, options = {}) {
const listenerId = this._generateId();
const wrappedHandler = (event) => {
handler(event);
this.off(target, listenerId);
};
return this.on(target, eventType, wrappedHandler, options);
}
/**
* 이벤트 발생 (커스텀 이벤트)
* @param {Element|Window|Document} target - 대상 요소
* @param {string} eventType - 이벤트 타입
* @param {Object} detail - 이벤트 데이터
*/
emit(target, eventType, detail = {}) {
if (!target) return;
const event = new CustomEvent(eventType, {
detail,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
}
/**
* 핸들러 래핑 (에러 처리 포함)
* @private
*/
_wrapHandler(handler, listenerId) {
return (event) => {
try {
handler(event);
} catch (error) {
console.error(`[EventManager] Error in event handler (${listenerId}):`, error);
if (typeof ErrorHandler !== 'undefined') {
ErrorHandler.handle(error, { context: 'EventManager', listenerId });
}
}
};
}
/**
* 고유 ID 생성
* @private
*/
_generateId() {
return `listener_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 등록된 리스너 정보 가져오기
* @returns {Object}
*/
getListenersInfo() {
const info = {
regular: {},
delegated: {},
};
this.listeners.forEach((targetListeners, target) => {
const targetKey = target === window ? 'window' :
target === document ? 'document' :
target.id || target.className || 'unknown';
info.regular[targetKey] = Array.from(targetListeners.keys());
});
this.delegatedListeners.forEach((delegatedListeners, parent) => {
const parentKey = parent === window ? 'window' :
parent === document ? 'document' :
parent.id || parent.className || 'unknown';
info.delegated[parentKey] = Array.from(delegatedListeners.keys());
});
return info;
}
}
/**
* 전역 EventManager 인스턴스 (싱글톤)
*/
const eventManager = new EventManager();
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { EventManager, eventManager };
}
+434
View File
@@ -0,0 +1,434 @@
/**
* 게이지 관련 기본 클래스
* @module GaugeBase
*/
class GaugeBase {
constructor(config = {}) {
this.config = {
size: 400,
strokeWidth: 20,
maxValue: 100,
currentValue: 0,
padding: 10,
startAngle: -90,
endAngle: 270,
animationDuration: 800,
easing: "ease-out",
...config,
};
this.svg = null;
this.path = null;
this.pathLength = 0;
}
/**
* 각도를 라디안으로 변환
* @param {number} angle - 각도
* @returns {number}
*/
static degreesToRadians(angle) {
return (angle * Math.PI) / 180;
}
/**
* 라디안을 각도로 변환
* @param {number} radians - 라디안
* @returns {number}
*/
static radiansToDegrees(radians) {
return (radians * 180) / Math.PI;
}
/**
* 원형 경로의 좌표 계산
* @param {number} cx - 중심 X
* @param {number} cy - 중심 Y
* @param {number} radius - 반지름
* @param {number} angle - 각도
* @returns {Object}
*/
static polarToCartesian(cx, cy, radius, angle) {
const radians = this.degreesToRadians(angle);
return {
x: cx + radius * Math.cos(radians),
y: cy + radius * Math.sin(radians),
};
}
/**
* SVG 원호 경로 생성
* @param {number} cx - 중심 X
* @param {number} cy - 중심 Y
* @param {number} radius - 반지름
* @param {number} startAngle - 시작 각도
* @param {number} endAngle - 종료 각도
* @returns {string}
*/
static describeArc(cx, cy, radius, startAngle, endAngle) {
const start = this.polarToCartesian(cx, cy, radius, endAngle);
const end = this.polarToCartesian(cx, cy, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return [
"M",
start.x,
start.y,
"A",
radius,
radius,
0,
largeArcFlag,
0,
end.x,
end.y,
].join(" ");
}
/**
* 진행률을 각도로 변환
* @param {number} percent - 진행률 (0-1)
* @param {number} startAngle - 시작 각도
* @param {number} endAngle - 종료 각도
* @returns {number}
*/
static percentToAngle(percent, startAngle = -90, endAngle = 270) {
const totalAngle = endAngle - startAngle;
return startAngle + totalAngle * percent;
}
/**
* SVG 요소 생성
* @param {string} tag - 태그명
* @param {Object} attrs - 속성
* @returns {SVGElement}
*/
static createSVGElement(tag, attrs = {}) {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attrs).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
}
/**
* 경로 길이 계산
* @param {SVGPathElement} path - SVG 경로 요소
* @returns {number}
*/
static getPathLength(path) {
return path.getTotalLength();
}
/**
* 경로상의 특정 지점 좌표
* @param {SVGPathElement} path - SVG 경로 요소
* @param {number} percent - 위치 (0-1)
* @returns {DOMPoint}
*/
static getPointAtPercent(path, percent) {
const length = path.getTotalLength();
return path.getPointAtLength(length * percent);
}
/**
* 이징 함수
* @param {number} t - 시간 (0-1)
* @param {string} type - 이징 타입
* @returns {number}
*/
static easing(t, type = "ease-out") {
const easings = {
linear: (t) => t,
"ease-in": (t) => t * t,
"ease-out": (t) => t * (2 - t),
"ease-in-out": (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
"ease-in-cubic": (t) => t * t * t,
"ease-out-cubic": (t) => --t * t * t + 1,
"ease-in-out-cubic": (t) =>
t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
bounce: (t) => {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
} else if (t < 2 / 2.75) {
return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
} else if (t < 2.5 / 2.75) {
return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
} else {
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
},
};
return easings[type] ? easings[type](t) : easings["ease-out"](t);
}
/**
* 값을 범위 내로 제한
* @param {number} value - 값
* @param {number} min - 최소값
* @param {number} max - 최대값
* @returns {number}
*/
static clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* 값을 범위로 매핑
* @param {number} value - 값
* @param {number} inMin - 입력 최소값
* @param {number} inMax - 입력 최대값
* @param {number} outMin - 출력 최소값
* @param {number} outMax - 출력 최대값
* @returns {number}
*/
static map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
/**
* 선형 보간
* @param {number} start - 시작값
* @param {number} end - 종료값
* @param {number} t - 시간 (0-1)
* @returns {number}
*/
static lerp(start, end, t) {
return start + (end - start) * t;
}
}
/**
* 원형 게이지 클래스
*/
class CircularGauge extends GaugeBase {
constructor(config = {}) {
super(config);
this.centerX = this.config.size / 2;
this.centerY = this.config.size / 2;
this.radius =
(this.config.size - this.config.strokeWidth - this.config.padding * 2) / 2;
}
/**
* 게이지 초기화
* @param {string|Element} container - 컨테이너 선택자 또는 요소
* @returns {SVGElement}
*/
init(container) {
const element =
typeof container === "string" ? document.querySelector(container) : container;
if (!element) {
console.error("Container not found");
return null;
}
// SVG 생성
this.svg = GaugeBase.createSVGElement("svg", {
width: this.config.size,
height: this.config.size,
viewBox: `0 0 ${this.config.size} ${this.config.size}`,
});
// 배경 원
const bgPath = this._createPath("background");
this.svg.appendChild(bgPath);
// 진행률 원
this.path = this._createPath("progress");
this.svg.appendChild(this.path);
// 경로 길이 설정
this.pathLength = GaugeBase.getPathLength(this.path);
this.path.style.strokeDasharray = this.pathLength;
this.path.style.strokeDashoffset = this.pathLength;
element.appendChild(this.svg);
return this.svg;
}
/**
* 경로 생성
* @private
*/
_createPath(type = "progress") {
const pathData = GaugeBase.describeArc(
this.centerX,
this.centerY,
this.radius,
this.config.startAngle,
this.config.endAngle
);
const attrs = {
d: pathData,
fill: "none",
stroke: type === "background" ? "#e0e0e0" : "#4CAF50",
"stroke-width": this.config.strokeWidth,
"stroke-linecap": "round",
};
if (type === "background") {
attrs.opacity = "0.3";
}
return GaugeBase.createSVGElement("path", attrs);
}
/**
* 진행률 업데이트
* @param {number} value - 값
* @param {boolean} animate - 애니메이션 적용 여부
*/
update(value, animate = true) {
const percent = GaugeBase.clamp(value / this.config.maxValue, 0, 1);
const targetOffset = this.pathLength * (1 - percent);
if (animate) {
this._animateProgress(targetOffset);
} else {
this.path.style.strokeDashoffset = targetOffset;
}
}
/**
* 진행률 애니메이션
* @private
*/
_animateProgress(targetOffset) {
const startOffset = parseFloat(this.path.style.strokeDashoffset) || this.pathLength;
const startTime = performance.now();
const duration = this.config.animationDuration;
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = GaugeBase.easing(progress, this.config.easing);
const currentOffset = GaugeBase.lerp(startOffset, targetOffset, easedProgress);
this.path.style.strokeDashoffset = currentOffset;
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
/**
* 색상 변경
* @param {string} color - 색상
*/
setColor(color) {
this.path.setAttribute("stroke", color);
}
/**
* 리셋
*/
reset() {
this.path.style.strokeDashoffset = this.pathLength;
}
}
/**
* 선형 게이지 클래스
*/
class LinearGauge extends GaugeBase {
constructor(config = {}) {
super({
width: 300,
height: 20,
...config,
});
}
/**
* 게이지 초기화
* @param {string|Element} container - 컨테이너 선택자 또는 요소
* @returns {HTMLElement}
*/
init(container) {
const element =
typeof container === "string" ? document.querySelector(container) : container;
if (!element) {
console.error("Container not found");
return null;
}
// 컨테이너 생성
this.container = document.createElement("div");
this.container.className = "linear-gauge";
this.container.style.cssText = `
width: ${this.config.width}px;
height: ${this.config.height}px;
background: #e0e0e0;
border-radius: ${this.config.height / 2}px;
overflow: hidden;
position: relative;
`;
// 진행률 바 생성
this.bar = document.createElement("div");
this.bar.className = "gauge-bar";
this.bar.style.cssText = `
width: 0%;
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
transition: width ${this.config.animationDuration}ms ${this.config.easing};
`;
this.container.appendChild(this.bar);
element.appendChild(this.container);
return this.container;
}
/**
* 진행률 업데이트
* @param {number} value - 값
* @param {boolean} animate - 애니메이션 적용 여부
*/
update(value, animate = true) {
const percent = GaugeBase.clamp((value / this.config.maxValue) * 100, 0, 100);
if (!animate) {
this.bar.style.transition = "none";
void this.bar.offsetHeight; // 강제 리플로우
}
this.bar.style.width = `${percent}%`;
if (!animate) {
// 다음 프레임에서 transition 복원
requestAnimationFrame(() => {
this.bar.style.transition = `width ${this.config.animationDuration}ms ${this.config.easing}`;
});
}
}
/**
* 색상 변경
* @param {string} color - 색상
*/
setColor(color) {
this.bar.style.background = color;
}
/**
* 리셋
*/
reset() {
this.bar.style.width = "0%";
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { GaugeBase, CircularGauge, LinearGauge };
}
+375
View File
@@ -0,0 +1,375 @@
/**
* 학습 가이드 PDF 팝업
* - btn-guide 클릭 시 레이어 오픈
* - 탭 전환 시 pdf.js viewer iframe src 교체
*/
const LearningGuideModal = (function () {
const DEFAULT_TAB_KEY = "player";
const VIEWER_PATH = "/js/lib/pdfjs-viewer/web/viewer.html";
let layer = null;
let viewer = null;
let closeBtn = null;
let triggerBtn = null;
let tabSwiper = null;
let tabs = [];
let activeTabKey = DEFAULT_TAB_KEY;
let isOpen = false;
function resolvePdfUrl(pdfPath) {
if (!pdfPath) return "";
try {
return new URL(pdfPath, window.location.origin).href;
} catch (error) {
console.error("[LearningGuideModal] PDF URL resolve failed:", error);
return pdfPath;
}
}
function buildViewerUrl(pdfPath) {
if (!pdfPath) return "";
const viewerUrl = new URL(VIEWER_PATH, window.location.origin);
const fileUrl = resolvePdfUrl(pdfPath);
viewerUrl.searchParams.set("file", fileUrl);
viewerUrl.hash = "zoom=60&textlayer=off";
return viewerUrl.href;
}
function getTabButtons() {
if (!layer) return [];
return Array.from(layer.querySelectorAll(".learning-guide-tab"));
}
function getTabButton(key) {
return getTabButtons().find(function (tab) {
return tab.dataset.guideKey === key;
});
}
function detectTabKeyFromPath() {
const path = window.location.pathname.toLowerCase();
const pathMap = [
["index", "main"],
["main", "main"],
["myclass", "myclass"],
["onboarding", "onboarding"],
["learning", "legal"],
["legal_edu", "legal"],
["legal", "legal"],
["leadership", "leadership"],
["insight", "insight"],
["biztrend", "biztrend"],
["mypage", "mypage"],
["player", "player"],
];
for (let i = 0; i < pathMap.length; i += 1) {
const needle = pathMap[i][0];
const key = pathMap[i][1];
if (path.indexOf(needle) !== -1 && getTabButton(key)) return key;
}
return null;
}
function getPageDefaultTabKey() {
if (!layer) return DEFAULT_TAB_KEY;
const fromPath = detectTabKeyFromPath();
if (fromPath) return fromPath;
const btnKey =
triggerBtn && (triggerBtn.dataset.defaultGuideKey || "").trim();
if (btnKey && getTabButton(btnKey)) return btnKey;
const key = (layer.dataset.defaultGuideKey || "").trim();
if (key && getTabButton(key)) return key;
return DEFAULT_TAB_KEY;
}
function getFirstAvailableTabKey() {
const availableTab = getTabButtons().find(function (tab) {
return tab.dataset.pdf && !tab.disabled;
});
return availableTab ? availableTab.dataset.guideKey : getPageDefaultTabKey();
}
function initDefaultTab() {
activeTabKey = getPageDefaultTabKey();
updateTabStates(activeTabKey);
}
function syncOpenState(tabKey) {
const nextKey = tabKey || getPageDefaultTabKey();
loadPdfByKey(nextKey, { forceReload: true });
}
function setDefaultTab(key) {
if (!layer || !key) return;
const nextKey = String(key).trim();
if (!getTabButton(nextKey)) return;
layer.dataset.defaultGuideKey = nextKey;
activeTabKey = nextKey;
updateTabStates(nextKey);
if (isOpen) {
loadPdfByKey(nextKey, { forceReload: true });
return;
}
updateTabLayout(0);
}
function updateTabStates(key) {
getTabButtons().forEach(function (tab) {
const isActive = tab.dataset.guideKey === key;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", isActive ? "true" : "false");
});
activeTabKey = key;
}
function getTabIndex(key) {
const tab = getTabButton(key);
if (!tab || !tabSwiper) return -1;
const slide = tab.closest(".swiper-slide");
if (!slide) return -1;
return Array.from(tabSwiper.slides).indexOf(slide);
}
function isTabOverflow() {
if (!tabSwiper) return false;
const swiperEl = tabSwiper.el;
// 자연 너비 기준 측정을 위해 scroll 모드로 일시 전환
swiperEl.classList.remove("is-tab-even");
swiperEl.classList.add("is-tab-scroll");
tabSwiper.update();
return tabSwiper.wrapperEl.scrollWidth > swiperEl.clientWidth + 1;
}
function updateTabLayout(speed) {
if (!tabSwiper) return;
const swiperEl = tabSwiper.el;
const overflow = isTabOverflow();
swiperEl.classList.toggle("is-tab-scroll", overflow);
swiperEl.classList.toggle("is-tab-even", !overflow);
tabSwiper.params.centeredSlides = overflow;
tabSwiper.params.centeredSlidesBounds = overflow;
tabSwiper.update();
const index = getTabIndex(activeTabKey);
if (index >= 0) {
tabSwiper.slideTo(index, speed !== undefined ? speed : 300);
}
}
function isViewerBlank() {
if (!viewer) return true;
const attrSrc = viewer.getAttribute("src");
return !attrSrc || !attrSrc.trim();
}
function loadPdfByKey(key, options) {
const tab = getTabButton(key);
if (!tab || !tab.dataset.pdf) return;
const forceReload = options && options.forceReload;
updateTabStates(key);
if (viewer) {
const nextSrc = buildViewerUrl(tab.dataset.pdf);
if (forceReload) {
viewer.removeAttribute("src");
requestAnimationFrame(function () {
viewer.src = nextSrc;
});
} else {
viewer.src = nextSrc;
}
}
updateTabLayout(300);
}
function initTabSwiper() {
if (typeof Swiper === "undefined" || !layer) return;
const swiperEl = layer.querySelector(".learning-guide-tab-swiper");
if (!swiperEl) return;
tabs = getTabButtons();
tabSwiper = new Swiper(swiperEl, {
slidesPerView: "auto",
spaceBetween: 1,
centeredSlides: false,
centeredSlidesBounds: false,
slideToClickedSlide: true,
watchOverflow: true,
speed: 300,
resistanceRatio: 0.65,
});
}
function destroyTabSwiper() {
if (tabSwiper) {
tabSwiper.destroy(true, true);
tabSwiper = null;
}
}
function open(tabKey) {
if (!layer || isOpen) return;
layer.classList.remove("hidden");
layer.classList.add("is-open");
layer.setAttribute("aria-hidden", "false");
isOpen = true;
if (typeof scrollManager !== "undefined") {
scrollManager.lock();
} else if (typeof bodyLock === "function") {
bodyLock();
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
syncOpenState(tabKey);
});
});
if (closeBtn) {
closeBtn.focus();
}
}
function close() {
if (!layer || !isOpen) return;
layer.classList.remove("is-open");
layer.classList.add("hidden");
layer.setAttribute("aria-hidden", "true");
isOpen = false;
if (typeof scrollManager !== "undefined") {
scrollManager.unlock();
} else if (typeof bodyUnlock === "function") {
bodyUnlock();
}
if (triggerBtn) {
triggerBtn.focus();
}
}
function handleTabClick(event) {
const tab = event.currentTarget;
if (!tab || tab.disabled || tab.classList.contains("is-disabled")) return;
const key = tab.dataset.guideKey;
if (!key || !tab.dataset.pdf) return;
if (key === activeTabKey) return;
loadPdfByKey(key);
}
function handleLayerClick(event) {
if (event.target === layer) {
close();
}
}
function handleKeydown(event) {
if (!isOpen || event.key !== "Escape") return;
close();
}
let resizeTimer = null;
function handleResize() {
if (!tabSwiper) return;
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
tabSwiper.update();
updateTabLayout(0);
}, 150);
}
function bindEvents() {
if (!triggerBtn) {
triggerBtn = document.querySelector(".btn-guide");
}
if (triggerBtn) {
triggerBtn.addEventListener("click", function () {
open();
});
}
if (closeBtn) {
closeBtn.addEventListener("click", function (event) {
event.stopPropagation();
close();
});
}
if (layer) {
layer.addEventListener("click", handleLayerClick);
const content = layer.querySelector(".learning-guide-content");
if (content) {
content.addEventListener("click", function (event) {
event.stopPropagation();
});
}
}
getTabButtons().forEach(function (tab) {
tab.addEventListener("click", handleTabClick);
});
document.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", handleResize);
}
function init() {
layer = document.getElementById("learningGuideLayer");
viewer = document.getElementById("learningGuideViewer");
closeBtn = document.getElementById("btnCloseLearningGuide");
triggerBtn = document.querySelector(".btn-guide");
if (!layer || !viewer) return;
initTabSwiper();
initDefaultTab();
bindEvents();
}
return {
init: init,
open: open,
close: close,
switchTab: loadPdfByKey,
setDefaultTab: setDefaultTab,
getDefaultTab: getPageDefaultTabKey,
buildViewerUrl: buildViewerUrl,
destroy: destroyTabSwiper,
};
})();
if (typeof window !== "undefined") {
window.LearningGuideModal = LearningGuideModal;
}
+520
View File
@@ -0,0 +1,520 @@
/**
* 모달 관련 기본 클래스
* @module ModalBase
*/
class ModalBase {
constructor(config = {}) {
this.config = {
closeOnEscape: true,
closeOnBackdrop: true,
showCloseButton: true,
animation: "fade",
animationDuration: 300,
backdrop: true,
keyboard: true,
...config,
};
this.modal = null;
this.backdrop = null;
this.isOpen = false;
this.onOpen = config.onOpen || null;
this.onClose = config.onClose || null;
}
/**
* 모달 생성
* @param {Object} options - 옵션
* @returns {HTMLElement}
*/
create(options = {}) {
const {
id = `modal-${Date.now()}`,
className = "",
content = "",
header = null,
footer = null,
} = options;
// 모달 컨테이너
this.modal = document.createElement("div");
this.modal.id = id;
this.modal.className = `modal ${className}`;
this.modal.setAttribute("role", "dialog");
this.modal.setAttribute("aria-modal", "true");
this.modal.style.cssText = `
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
overflow: auto;
`;
// 백드롭
if (this.config.backdrop) {
this.backdrop = document.createElement("div");
this.backdrop.className = "modal-backdrop";
this.backdrop.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
`;
this.modal.appendChild(this.backdrop);
}
// 모달 다이얼로그
const dialog = document.createElement("div");
dialog.className = "modal-dialog";
dialog.style.cssText = `
position: relative;
margin: 50px auto;
max-width: 600px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
// 모달 컨텐츠
const modalContent = document.createElement("div");
modalContent.className = "modal-content";
// 헤더
if (header !== null) {
const modalHeader = document.createElement("div");
modalHeader.className = "modal-header";
modalHeader.style.cssText = `
padding: 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
`;
if (typeof header === "string") {
modalHeader.innerHTML = header;
} else {
modalHeader.appendChild(header);
}
// 닫기 버튼
if (this.config.showCloseButton) {
const closeBtn = document.createElement("button");
closeBtn.className = "modal-close";
closeBtn.innerHTML = "&times;";
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #999;
`;
closeBtn.onclick = () => this.close();
modalHeader.appendChild(closeBtn);
}
modalContent.appendChild(modalHeader);
}
// 바디
const modalBody = document.createElement("div");
modalBody.className = "modal-body";
modalBody.style.cssText = `
padding: 20px;
`;
if (typeof content === "string") {
modalBody.innerHTML = content;
} else {
modalBody.appendChild(content);
}
modalContent.appendChild(modalBody);
// 푸터
if (footer !== null) {
const modalFooter = document.createElement("div");
modalFooter.className = "modal-footer";
modalFooter.style.cssText = `
padding: 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 10px;
`;
if (typeof footer === "string") {
modalFooter.innerHTML = footer;
} else {
modalFooter.appendChild(footer);
}
modalContent.appendChild(modalFooter);
}
dialog.appendChild(modalContent);
this.modal.appendChild(dialog);
// 이벤트 리스너 등록
this._setupEventListeners();
return this.modal;
}
/**
* 이벤트 리스너 설정
* @private
*/
_setupEventListeners() {
// 백드롭 클릭
if (this.config.closeOnBackdrop && this.backdrop) {
this.backdrop.onclick = () => this.close();
}
// ESC 키
if (this.config.closeOnEscape) {
this._escapeHandler = (e) => {
if (e.key === "Escape" && this.isOpen) {
this.close();
}
};
document.addEventListener("keydown", this._escapeHandler);
}
// 모달 외부 클릭
if (this.config.closeOnBackdrop) {
this.modal.onclick = (e) => {
if (e.target === this.modal) {
this.close();
}
};
}
}
/**
* 모달 열기
* @param {Object} data - 전달할 데이터
* @returns {Promise}
*/
async open(data = null) {
if (this.isOpen) return;
if (!this.modal) {
console.error("Modal not created");
return;
}
// DOM에 추가
if (!this.modal.parentElement) {
document.body.appendChild(this.modal);
}
// onOpen 콜백
if (this.onOpen) {
await this.onOpen(data);
}
// 애니메이션
this.modal.style.display = "block";
await this._animate("in");
this.isOpen = true;
// body 스크롤 방지
document.body.style.overflow = "hidden";
return this;
}
/**
* 모달 닫기
* @returns {Promise}
*/
async close() {
if (!this.isOpen) return;
// 애니메이션
await this._animate("out");
this.modal.style.display = "none";
this.isOpen = false;
// body 스크롤 복원
document.body.style.overflow = "";
// onClose 콜백
if (this.onClose) {
await this.onClose();
}
return this;
}
/**
* 모달 토글
* @param {Object} data - 전달할 데이터
*/
toggle(data = null) {
if (this.isOpen) {
this.close();
} else {
this.open(data);
}
}
/**
* 애니메이션 처리
* @private
*/
async _animate(direction) {
const dialog = this.modal.querySelector(".modal-dialog");
const { animation, animationDuration } = this.config;
if (animation === "fade") {
if (direction === "in") {
this.modal.style.opacity = "0";
await this._delay(10);
this.modal.style.transition = `opacity ${animationDuration}ms`;
this.modal.style.opacity = "1";
await this._delay(animationDuration);
} else {
this.modal.style.transition = `opacity ${animationDuration}ms`;
this.modal.style.opacity = "0";
await this._delay(animationDuration);
}
} else if (animation === "slide") {
if (direction === "in") {
dialog.style.transform = "translateY(-50px)";
dialog.style.opacity = "0";
await this._delay(10);
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
dialog.style.transform = "translateY(0)";
dialog.style.opacity = "1";
await this._delay(animationDuration);
} else {
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
dialog.style.transform = "translateY(-50px)";
dialog.style.opacity = "0";
await this._delay(animationDuration);
}
} else if (animation === "zoom") {
if (direction === "in") {
dialog.style.transform = "scale(0.7)";
dialog.style.opacity = "0";
await this._delay(10);
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
dialog.style.transform = "scale(1)";
dialog.style.opacity = "1";
await this._delay(animationDuration);
} else {
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
dialog.style.transform = "scale(0.7)";
dialog.style.opacity = "0";
await this._delay(animationDuration);
}
}
// transition 초기화
this.modal.style.transition = "";
if (dialog) {
dialog.style.transition = "";
}
}
/**
* 딜레이
* @private
*/
_delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 모달 파괴
*/
destroy() {
if (this.isOpen) {
this.close();
}
// 이벤트 리스너 제거
if (this._escapeHandler) {
document.removeEventListener("keydown", this._escapeHandler);
}
// DOM에서 제거
if (this.modal && this.modal.parentElement) {
this.modal.parentElement.removeChild(this.modal);
}
this.modal = null;
this.backdrop = null;
}
/**
* 모달 컨텐츠 업데이트
* @param {string|Element} content - 새 컨텐츠
*/
updateContent(content) {
const modalBody = this.modal.querySelector(".modal-body");
if (modalBody) {
if (typeof content === "string") {
modalBody.innerHTML = content;
} else {
modalBody.innerHTML = "";
modalBody.appendChild(content);
}
}
}
/**
* 모달 헤더 업데이트
* @param {string|Element} header - 새 헤더
*/
updateHeader(header) {
const modalHeader = this.modal.querySelector(".modal-header");
if (modalHeader) {
if (typeof header === "string") {
modalHeader.innerHTML = header;
} else {
modalHeader.innerHTML = "";
modalHeader.appendChild(header);
}
// 닫기 버튼 재추가
if (this.config.showCloseButton) {
const closeBtn = document.createElement("button");
closeBtn.className = "modal-close";
closeBtn.innerHTML = "&times;";
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #999;
`;
closeBtn.onclick = () => this.close();
modalHeader.appendChild(closeBtn);
}
}
}
}
/**
* 확인 모달 (Confirm Dialog)
*/
class ConfirmModal extends ModalBase {
constructor(config = {}) {
super(config);
this.promise = null;
}
/**
* 확인 모달 표시
* @param {Object} options - 옵션
* @returns {Promise<boolean>}
*/
show(options = {}) {
const {
title = "확인",
message = "계속하시겠습니까?",
confirmText = "확인",
cancelText = "취소",
confirmClass = "btn-primary",
cancelClass = "btn-secondary",
} = options;
return new Promise((resolve) => {
// 헤더
const header = document.createElement("div");
header.innerHTML = `<h3 style="margin: 0;">${title}</h3>`;
// 컨텐츠
const content = document.createElement("div");
content.innerHTML = message;
// 푸터
const footer = document.createElement("div");
const cancelBtn = document.createElement("button");
cancelBtn.className = `btn ${cancelClass}`;
cancelBtn.textContent = cancelText;
cancelBtn.onclick = () => {
this.close();
resolve(false);
};
const confirmBtn = document.createElement("button");
confirmBtn.className = `btn ${confirmClass}`;
confirmBtn.textContent = confirmText;
confirmBtn.onclick = () => {
this.close();
resolve(true);
};
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
// 모달 생성 및 열기
this.create({ header, content, footer });
this.open();
});
}
}
/**
* 알림 모달 (Alert Dialog)
*/
class AlertModal extends ModalBase {
/**
* 알림 모달 표시
* @param {Object} options - 옵션
* @returns {Promise}
*/
show(options = {}) {
const {
title = "알림",
message = "",
confirmText = "확인",
confirmClass = "btn-primary",
} = options;
return new Promise((resolve) => {
// 헤더
const header = document.createElement("div");
header.innerHTML = `<h3 style="margin: 0;">${title}</h3>`;
// 컨텐츠
const content = document.createElement("div");
content.innerHTML = message;
// 푸터
const footer = document.createElement("div");
const confirmBtn = document.createElement("button");
confirmBtn.className = `btn ${confirmClass}`;
confirmBtn.textContent = confirmText;
confirmBtn.onclick = () => {
this.close();
resolve();
};
footer.appendChild(confirmBtn);
// 모달 생성 및 열기
this.create({ header, content, footer });
this.open();
});
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { ModalBase, ConfirmModal, AlertModal };
}
+194
View File
@@ -0,0 +1,194 @@
/**
* 모달 공통 유틸리티 모듈
* 모든 모달에서 공통으로 사용되는 기능들을 모듈화
* @module ModalUtils
*/
class ModalUtils {
/**
* 모달 닫기 이벤트 설정 (공통)
* @param {HTMLElement} modalElement - 모달 요소
* @param {Object} options - 옵션
* @param {Function} options.onClose - 닫기 시 실행할 콜백 함수
* @param {Function} options.onCleanup - 정리 작업 콜백 함수
* @param {string} options.closeSelector - 닫기 버튼 셀렉터 (기본: ".close")
* @param {boolean} options.closeOnBackdrop - 배경 클릭 시 닫기 (기본: true)
* @param {boolean} options.closeOnEscape - ESC 키로 닫기 (기본: true)
* @returns {Object} 정리 함수들을 담은 객체
*/
static setupCloseEvents(modalElement, options = {}) {
const {
onClose = null,
onCleanup = null,
closeSelector = ".close",
closeOnBackdrop = true,
closeOnEscape = true,
} = options;
const cleanupFunctions = [];
// 닫기 함수
const closeModal = () => {
if (onClose && typeof onClose === "function") {
onClose();
} else {
// 기본 닫기 동작
ModalUtils.stopVideo(modalElement);
modalElement.style.display = "none";
setTimeout(() => {
if (modalElement && modalElement.parentNode) {
modalElement.parentNode.removeChild(modalElement);
}
}, 300);
}
// 정리 작업 실행
if (onCleanup && typeof onCleanup === "function") {
onCleanup();
}
// 등록된 정리 함수들 실행
cleanupFunctions.forEach((fn) => {
if (typeof fn === "function") {
fn();
}
});
};
// 닫기 버튼 이벤트
const closeBtn = modalElement.querySelector(closeSelector);
if (closeBtn) {
closeBtn.onclick = closeModal;
}
// 배경 클릭 이벤트
if (closeOnBackdrop) {
modalElement.onclick = (e) => {
if (e.target === modalElement) {
closeModal();
}
};
}
// ESC 키 이벤트
let escHandler = null;
if (closeOnEscape) {
escHandler = (e) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
// 정리 함수 반환
return {
close: closeModal,
cleanup: () => {
if (escHandler) {
document.removeEventListener("keydown", escHandler);
}
if (closeBtn) {
closeBtn.onclick = null;
}
modalElement.onclick = null;
},
addCleanup: (fn) => {
if (typeof fn === "function") {
cleanupFunctions.push(fn);
}
},
};
}
/**
* 비디오 정지 (공통)
* @param {HTMLElement} modalElement - 모달 요소
*/
static stopVideo(modalElement) {
// iframe 비디오 정지
const iframe = modalElement.querySelector("#videoFrame");
if (iframe) {
// VideoBase가 있으면 사용, 없으면 직접 정지
if (typeof VideoBase !== "undefined" && VideoBase.stop) {
VideoBase.stop(iframe);
} else {
iframe.src = "";
}
}
// video 태그 비디오 정지
const video = modalElement.querySelector("video");
if (video) {
video.pause();
video.currentTime = 0;
}
}
/**
* Observer 정리 (공통)
* @param {HTMLElement} modalElement - 모달 요소
*/
static cleanupObservers(modalElement) {
// ResizeObserver 정리
if (modalElement._resizeObserver) {
modalElement._resizeObserver.disconnect();
modalElement._resizeObserver = null;
}
// MutationObserver 정리
if (modalElement._mutationObserver) {
modalElement._mutationObserver.disconnect();
modalElement._mutationObserver = null;
}
// 타이머 정리
if (modalElement._heightAdjustTimer) {
clearTimeout(modalElement._heightAdjustTimer);
modalElement._heightAdjustTimer = null;
}
// window resize 이벤트 리스너 제거
if (modalElement._windowResizeHandler) {
window.removeEventListener("resize", modalElement._windowResizeHandler);
modalElement._windowResizeHandler = null;
}
}
/**
* 모달 완전 정리 (비디오 정지 + Observer 정리)
* @param {HTMLElement} modalElement - 모달 요소
*/
static cleanup(modalElement) {
ModalUtils.stopVideo(modalElement);
ModalUtils.cleanupObservers(modalElement);
}
/**
* 모달 제거 (애니메이션 포함)
* @param {HTMLElement} modalElement - 모달 요소
* @param {Object} options - 옵션
* @param {number} options.duration - 애니메이션 지속 시간 (기본: 300ms)
* @param {Function} options.onComplete - 완료 콜백
*/
static remove(modalElement, options = {}) {
const { duration = 300, onComplete = null } = options;
// 정리 작업
ModalUtils.cleanup(modalElement);
// 애니메이션
modalElement.style.opacity = "0";
modalElement.style.transition = `opacity ${duration}ms ease`;
setTimeout(() => {
if (modalElement && modalElement.parentNode) {
modalElement.parentNode.removeChild(modalElement);
}
if (onComplete && typeof onComplete === "function") {
onComplete();
}
}, duration);
}
}
+277
View File
@@ -0,0 +1,277 @@
/**
* 공통 유틸리티 함수 모듈 (Utils.js)
* ========================================
* 자주 쓰는 헬퍼 함수들을 모아둔 정적 클래스입니다.
*
* [초보자용 사용 예]
* Utils.delay(1000) → 1초 대기 후 Promise 반환 (async/await와 함께)
* Utils.debounce(fn, 300) → 입력 등 연속 호출 방지 (마지막 호출만 실행)
* Utils.throttle(fn, 100) → resize 등 빈번한 이벤트 제한 (100ms마다 1번)
* Utils.formatDate(date) → "YYYY-MM-DD" 형식
* Utils.formatNumber(1234) → "1,234" (천단위 콤마)
* Utils.storage.set('key', value) → localStorage 쉽게 사용
*
* @module Utils
*/
class Utils {
/**
* 딜레이 함수
* @param {number} ms - 지연 시간 (밀리초)
* @returns {Promise}
*/
static delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 디바운스 함수
* @param {Function} func - 실행할 함수
* @param {number} wait - 대기 시간
* @returns {Function}
*/
static debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 쓰로틀 함수
* @param {Function} func - 실행할 함수
* @param {number} limit - 제한 시간
* @returns {Function}
*/
static throttle(func, limit) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
/**
* 랜덤 ID 생성
* @param {number} length - ID 길이
* @returns {string}
*/
static generateId(length = 8) {
return Math.random()
.toString(36)
.substring(2, length + 2);
}
/**
* 깊은 복사
* @param {*} obj - 복사할 객체
* @returns {*}
*/
static deepClone(obj) {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof Array) return obj.map((item) => this.deepClone(item));
const clonedObj = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = this.deepClone(obj[key]);
}
}
return clonedObj;
}
/**
* 객체 병합
* @param {Object} target - 대상 객체
* @param {Object} source - 소스 객체
* @returns {Object}
*/
static mergeDeep(target, source) {
const output = { ...target };
if (this.isObject(target) && this.isObject(source)) {
Object.keys(source).forEach((key) => {
if (this.isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = this.mergeDeep(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}
/**
* 객체 확인
* @param {*} item - 확인할 항목
* @returns {boolean}
*/
static isObject(item) {
return item && typeof item === "object" && !Array.isArray(item);
}
/**
* URL 파라미터 파싱
* @param {string} url - URL 문자열
* @returns {Object}
*/
static parseUrlParams(url = window.location.search) {
const params = new URLSearchParams(url);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
}
/**
* 퍼센트 계산
* @param {number} current - 현재 값
* @param {number} total - 전체 값
* @param {number} decimals - 소수점 자리수
* @returns {number}
*/
static calculatePercent(current, total, decimals = 0) {
if (total === 0) return 0;
const percent = (current / total) * 100;
return decimals > 0 ? parseFloat(percent.toFixed(decimals)) : Math.round(percent);
}
/**
* 배열 섞기 (Fisher-Yates shuffle)
* @param {Array} array - 섞을 배열
* @returns {Array}
*/
static shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* 배열 청크 분할
* @param {Array} array - 분할할 배열
* @param {number} size - 청크 크기
* @returns {Array}
*/
static chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* 로컬 스토리지 관리
*/
static storage = {
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error("Storage set error:", e);
return false;
}
},
get(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (e) {
console.error("Storage get error:", e);
return defaultValue;
}
},
remove(key) {
try {
localStorage.removeItem(key);
return true;
} catch (e) {
console.error("Storage remove error:", e);
return false;
}
},
clear() {
try {
localStorage.clear();
return true;
} catch (e) {
console.error("Storage clear error:", e);
return false;
}
},
};
/**
* 날짜 포맷팅
* @param {Date} date - 포맷할 날짜
* @param {string} format - 포맷 문자열 (YYYY-MM-DD, YYYY.MM.DD 등)
* @returns {string}
*/
static formatDate(date, format = "YYYY-MM-DD") {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hour = String(d.getHours()).padStart(2, "0");
const minute = String(d.getMinutes()).padStart(2, "0");
const second = String(d.getSeconds()).padStart(2, "0");
return format
.replace("YYYY", year)
.replace("MM", month)
.replace("DD", day)
.replace("HH", hour)
.replace("mm", minute)
.replace("ss", second);
}
/**
* 숫자 포맷팅 (천단위 콤마)
* @param {number} num - 포맷할 숫자
* @returns {string}
*/
static formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/**
* 파일 크기 포맷팅
* @param {number} bytes - 바이트 크기
* @param {number} decimals - 소수점 자리수
* @returns {string}
*/
static formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = Utils;
}
+409
View File
@@ -0,0 +1,409 @@
/**
* 비디오 관련 기본 클래스
* @module VideoBase
*/
class VideoBase {
constructor(config = {}) {
this.config = {
autoplay: false,
controls: true,
loop: false,
muted: false,
...config,
};
}
/**
* YouTube 비디오 URL 생성
* @param {string} videoId - YouTube 비디오 ID
* @param {Object} options - 추가 옵션
* @returns {string}
*/
static getYouTubeUrl(videoId, options = {}) {
const {
autoplay = 0,
controls = 1,
loop = 0,
muted = 0,
rel = 0,
modestbranding = 1,
start = 0,
} = options;
const params = new URLSearchParams({
autoplay,
controls,
loop,
muted,
rel,
modestbranding,
...(start > 0 && { start }),
});
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
}
/**
* YouTube 썸네일 URL 생성
* @param {string} videoId - YouTube 비디오 ID
* @param {string} quality - 품질 (default, hq, mq, sd, maxres)
* @returns {string}
*/
static getYouTubeThumbnail(videoId, quality = "sddefault") {
const qualities = {
default: "default.jpg",
hq: "hqdefault.jpg",
mq: "mqdefault.jpg",
sd: "sddefault.jpg",
maxres: "maxresdefault.jpg",
};
const thumbnailFile = qualities[quality] || qualities.sd;
return `https://img.youtube.com/vi/${videoId}/${thumbnailFile}`;
}
/**
* 비디오 ID 추출 (YouTube URL에서)
* @param {string} url - YouTube URL
* @returns {string|null}
*/
static extractYouTubeId(url) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/v\/([^&\n?#]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}
/**
* 비디오 iframe 생성
* @param {string} videoId - 비디오 ID
* @param {Object} options - iframe 옵션
* @returns {HTMLIFrameElement}
*/
static createIframe(videoId, options = {}) {
const {
width = "100%",
height = "100%",
autoplay = 0,
controls = 1,
className = "",
id = "",
} = options;
const iframe = document.createElement("iframe");
iframe.width = width;
iframe.height = height;
iframe.src = this.getYouTubeUrl(videoId, { autoplay, controls });
iframe.frameBorder = "0";
iframe.allow =
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
iframe.allowFullscreen = true;
if (className) iframe.className = className;
if (id) iframe.id = id;
return iframe;
}
/**
* 비디오 재생
* @param {HTMLIFrameElement} iframe - iframe 요소
*/
static play(iframe) {
if (!iframe || !iframe.contentWindow) return;
iframe.contentWindow.postMessage(
'{"event":"command","func":"playVideo","args":""}',
"*"
);
}
/**
* 비디오 일시정지
* @param {HTMLIFrameElement} iframe - iframe 요소
*/
static pause(iframe) {
if (!iframe || !iframe.contentWindow) return;
iframe.contentWindow.postMessage(
'{"event":"command","func":"pauseVideo","args":""}',
"*"
);
}
/**
* 비디오 정지
* @param {HTMLIFrameElement} iframe - iframe 요소
*/
static stop(iframe) {
if (!iframe || !iframe.contentWindow) return;
iframe.contentWindow.postMessage(
'{"event":"command","func":"stopVideo","args":""}',
"*"
);
}
/**
* 비디오 시간 이동
* @param {HTMLIFrameElement} iframe - iframe 요소
* @param {number} seconds - 이동할 시간 (초)
*/
static seekTo(iframe, seconds) {
if (!iframe || !iframe.contentWindow) return;
iframe.contentWindow.postMessage(
`{"event":"command","func":"seekTo","args":[${seconds}, true]}`,
"*"
);
}
/**
* 비디오 볼륨 설정
* @param {HTMLIFrameElement} iframe - iframe 요소
* @param {number} volume - 볼륨 (0-100)
*/
static setVolume(iframe, volume) {
if (!iframe || !iframe.contentWindow) return;
const vol = Math.max(0, Math.min(100, volume));
iframe.contentWindow.postMessage(
`{"event":"command","func":"setVolume","args":[${vol}]}`,
"*"
);
}
/**
* 비디오 음소거 토글
* @param {HTMLIFrameElement} iframe - iframe 요소
* @param {boolean} mute - 음소거 여부
*/
static toggleMute(iframe, mute) {
if (!iframe || !iframe.contentWindow) return;
const func = mute ? "mute" : "unMute";
iframe.contentWindow.postMessage(
`{"event":"command","func":"${func}","args":""}`,
"*"
);
}
}
/**
* 비디오 데이터 모델
*/
class VideoModel {
constructor(data = {}) {
this.id = data.id || null;
this.url = data.url || "";
this.title = data.title || "";
this.category = data.category || "";
this.subcate = data.subcate || "";
this.keywords = data.keywords || [];
this.bookmark = data.bookmark || false;
this.completed = data.completed || false;
this.picker = data.picker || "";
this.type = data.type || "main";
this.gauge = data.gauge || 0;
this.description = data.description || "";
this.duration = data.duration || 0;
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
}
/**
* 비디오 ID 가져오기
* @returns {string}
*/
getVideoId() {
return VideoBase.extractYouTubeId(this.url) || this.url;
}
/**
* 썸네일 URL 가져오기
* @param {string} quality - 품질
* @returns {string}
*/
getThumbnailUrl(quality = "sd") {
const videoId = this.getVideoId();
return VideoBase.getYouTubeThumbnail(videoId, quality);
}
/**
* 임베드 URL 가져오기
* @param {Object} options - 옵션
* @returns {string}
*/
getEmbedUrl(options = {}) {
const videoId = this.getVideoId();
return VideoBase.getYouTubeUrl(videoId, options);
}
/**
* 북마크 토글
*/
toggleBookmark() {
this.bookmark = !this.bookmark;
this.updatedAt = new Date();
}
/**
* 완료 상태 설정
* @param {boolean} completed - 완료 여부
*/
setCompleted(completed) {
this.completed = completed;
this.updatedAt = new Date();
}
/**
* JSON으로 변환
* @returns {Object}
*/
toJSON() {
return {
id: this.id,
url: this.url,
title: this.title,
category: this.category,
subcate: this.subcate,
keywords: this.keywords,
bookmark: this.bookmark,
completed: this.completed,
picker: this.picker,
type: this.type,
gauge: this.gauge,
description: this.description,
duration: this.duration,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
/**
* 비디오 컬렉션 관리
*/
class VideoCollection {
constructor(videos = []) {
this.videos = videos.map((v) => (v instanceof VideoModel ? v : new VideoModel(v)));
}
/**
* 비디오 추가
* @param {Object|VideoModel} video - 비디오 데이터
*/
add(video) {
const model = video instanceof VideoModel ? video : new VideoModel(video);
this.videos.push(model);
}
/**
* 비디오 제거
* @param {number|string} id - 비디오 ID
*/
remove(id) {
this.videos = this.videos.filter((v) => v.id !== id);
}
/**
* ID로 비디오 찾기
* @param {number|string} id - 비디오 ID
* @returns {VideoModel|null}
*/
findById(id) {
return this.videos.find((v) => v.id === id) || null;
}
/**
* 필터링
* @param {Function} predicate - 필터 함수
* @returns {VideoCollection}
*/
filter(predicate) {
return new VideoCollection(this.videos.filter(predicate));
}
/**
* 카테고리로 필터링
* @param {string} category - 카테고리
* @returns {VideoCollection}
*/
filterByCategory(category) {
return this.filter((v) => v.category === category);
}
/**
* 키워드로 필터링
* @param {Array} keywords - 키워드 배열
* @returns {VideoCollection}
*/
filterByKeywords(keywords) {
return this.filter((v) => keywords.some((k) => v.keywords.includes(k)));
}
/**
* 북마크된 비디오만
* @returns {VideoCollection}
*/
getBookmarked() {
return this.filter((v) => v.bookmark);
}
/**
* 완료된 비디오만
* @returns {VideoCollection}
*/
getCompleted() {
return this.filter((v) => v.completed);
}
/**
* 미완료 비디오만
* @returns {VideoCollection}
*/
getIncomplete() {
return this.filter((v) => !v.completed);
}
/**
* 정렬
* @param {Function} compareFn - 비교 함수
* @returns {VideoCollection}
*/
sort(compareFn) {
return new VideoCollection([...this.videos].sort(compareFn));
}
/**
* 개수
* @returns {number}
*/
count() {
return this.videos.length;
}
/**
* 배열로 변환
* @returns {Array}
*/
toArray() {
return this.videos;
}
/**
* JSON으로 변환
* @returns {Array}
*/
toJSON() {
return this.videos.map((v) => v.toJSON());
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { VideoBase, VideoModel, VideoCollection };
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
/**
* intro-animation.js
* 인트로 페이지 애니메이션 함수들
* 공통 모듈 활용 (ErrorHandler, DOMUtils, Utils, AnimationUtils)
*/
// 전역 의존성 (폴백 포함)
const _domUtils = typeof DOMUtils !== 'undefined' ? DOMUtils : null;
const _errorHandler = typeof ErrorHandler !== 'undefined' ? ErrorHandler : null;
const _utils = typeof Utils !== 'undefined' ? Utils : null;
const _animationUtils = typeof AnimationUtils !== 'undefined' ? AnimationUtils : null;
/**
* 에러 처리 헬퍼
* @private
*/
function _handleError(error, context, additionalInfo = {}) {
if (_errorHandler) {
_errorHandler.handle(error, {
context: `IntroAnimation.${context}`,
component: 'IntroAnimation',
...additionalInfo
}, false);
} else {
console.error(`[IntroAnimation] ${context}:`, error, additionalInfo);
}
}
/**
* Section 1: 이름 타이핑 애니메이션
*/
function typeName() {
try {
// 안전성 검사
if (typeof INTRO_CONFIG === 'undefined' || typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_CONFIG or INTRO_STATE is not defined'), 'typeName');
return;
}
const typedNameEl = _domUtils?.$("#typedName") || document.getElementById("typedName");
const cursorEl = _domUtils?.$("#cursor") || document.getElementById("cursor");
if (!typedNameEl) {
_handleError(new Error('typedName element not found'), 'typeName');
return;
}
if (!INTRO_CONFIG.fullName || typeof INTRO_CONFIG.fullName !== 'string' || INTRO_CONFIG.fullName.length === 0) {
_handleError(new Error('INTRO_CONFIG.fullName is empty or invalid'), 'typeName');
return;
}
if (INTRO_STATE.typedIndex < INTRO_CONFIG.fullName.length) {
const currentText = INTRO_CONFIG.fullName.slice(0, INTRO_STATE.typedIndex + 1);
typedNameEl.textContent = currentText;
INTRO_STATE.typedIndex++;
const delay = INTRO_CONFIG.typingSpeed || 100;
if (_utils && _utils.delay) {
_utils.delay(delay).then(() => typeName());
} else {
setTimeout(typeName, delay);
}
} else {
if (cursorEl) {
if (_domUtils && _domUtils.setStyles) {
_domUtils.setStyles(cursorEl, { opacity: '0' });
} else {
cursorEl.style.opacity = "0";
}
}
const delay = 600;
if (_utils && _utils.delay) {
_utils.delay(delay).then(() => animateWelcome());
} else {
setTimeout(animateWelcome, delay);
}
}
} catch (error) {
_handleError(error, 'typeName');
}
}
/**
* Section 1: 환영 메시지 애니메이션
*/
function animateWelcome() {
try {
if (typeof INTRO_CONFIG === 'undefined' || typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_CONFIG or INTRO_STATE is not defined'), 'animateWelcome');
return;
}
const container = _domUtils?.$("#welcome") || document.getElementById("welcome");
if (!container) {
_handleError(new Error('welcome element not found'), 'animateWelcome');
return;
}
if (!INTRO_CONFIG.welcomeText || typeof INTRO_CONFIG.welcomeText !== 'string') {
_handleError(new Error('INTRO_CONFIG.welcomeText is empty or invalid'), 'animateWelcome');
return;
}
const tempDiv = _domUtils?.createElement('div') || document.createElement("div");
tempDiv.innerHTML = INTRO_CONFIG.welcomeText;
let html = "";
tempDiv.childNodes.forEach((node) => {
try {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
html += text
.split("")
.map((c) => {
const escaped = c === " " ? "&nbsp;" : (c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c);
return `<span>${escaped}</span>`;
})
.join("");
} else if (node.nodeType === Node.ELEMENT_NODE) {
const innerText = node.textContent || '';
const tagName = node.tagName.toLowerCase();
const escapedText = innerText
.split("")
.map((c) => {
const escaped = c === " " ? "&nbsp;" : (c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c);
return `<span>${escaped}</span>`;
})
.join("");
html += `<${tagName}>${escapedText}</${tagName}>`;
}
} catch (error) {
_handleError(error, 'animateWelcome.processNode', { node });
}
});
container.innerHTML = html;
const spans = container.querySelectorAll("span");
const charSpeed = INTRO_CONFIG.welcomeCharSpeed || 50;
spans.forEach((char, i) => {
const delay = i * charSpeed;
if (_utils && _utils.delay) {
_utils.delay(delay).then(() => {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(char, 'show');
} else {
char.classList.add("show");
}
});
} else {
setTimeout(() => {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(char, 'show');
} else {
char.classList.add("show");
}
}, delay);
}
});
// 애니메이션 완료 후 자동 스크롤 타이머 시작
const fullNameLength = INTRO_CONFIG.fullName ? INTRO_CONFIG.fullName.length : 0;
const totalDelay = fullNameLength * charSpeed + 1000;
if (_utils && _utils.delay) {
_utils.delay(totalDelay).then(() => startAutoScrollTimer());
} else {
setTimeout(() => startAutoScrollTimer(), totalDelay);
}
} catch (error) {
_handleError(error, 'animateWelcome');
}
}
/**
* Section 2: 업데이트 텍스트 + 카드 애니메이션
* - 모바일(≤992px): section2Text 애니메이션 → 페이드아웃 → card-list 표시
* - 데스크탑(>992px): section2Text + card-list 동시 표시
*/
function animateSection2() {
try {
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE is not defined'), 'animateSection2');
return;
}
const isMobile = window.innerWidth <= 992;
const lines = ["line1", "line2", "line3"];
const cards = ["card1", "card2", "card3", "card4"];
const lineDelay = 1200;
const cardDelay = isMobile ? 750 : 500;
const totalLineDelay = lines.length * lineDelay + 300;
const delayFn = (_utils && _utils.delay)
? (ms) => _utils.delay(ms)
: (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 라인 순차 애니메이션
lines.forEach((id, i) => {
const element = _domUtils?.$(`#${id}`) || document.getElementById(id);
if (!element) {
console.warn(`[IntroAnimation] Element #${id} not found`);
return;
}
delayFn(i * lineDelay).then(() => {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(element, 'show');
} else {
element.classList.add("show");
}
});
});
// 카드 순차 애니메이션 헬퍼
const showCards = () => {
cards.forEach((id, i) => {
const cardElement = _domUtils?.$(`#${id}`) || document.getElementById(id);
if (!cardElement) {
console.warn(`[IntroAnimation] Element #${id} not found`);
return;
}
delayFn(i * cardDelay).then(() => {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(cardElement, 'show');
} else {
cardElement.classList.add("show");
}
if (i === 3) {
INTRO_STATE.section2AnimDone = true;
startAutoScrollTimer();
}
});
});
};
delayFn(totalLineDelay).then(() => {
if (isMobile) {
// 모바일: section2Text 페이드아웃 → cardsContainer 표시 → 카드 애니메이션
const section2Text = _domUtils?.$("#section2Text") || document.getElementById("section2Text");
const cardsContainer = _domUtils?.$("#cardsContainer") || document.getElementById("cardsContainer");
if (section2Text) {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(section2Text, 'fade-out');
} else {
section2Text.classList.add("fade-out");
}
}
delayFn(800).then(() => {
if (section2Text) {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(section2Text, 'hidden');
_domUtils.removeClasses(section2Text, 'fade-out');
} else {
section2Text.classList.add("hidden");
section2Text.classList.remove("fade-out");
}
}
// 카드 표시 시 스크롤 인디케이터 숨기기
const scrollIndicator = _domUtils?.$("#scrollIndicator") || document.getElementById("scrollIndicator");
if (scrollIndicator) {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(scrollIndicator, 'hidden');
} else {
scrollIndicator.classList.add("hidden");
}
}
if (cardsContainer) {
if (_domUtils && _domUtils.setStyles) {
_domUtils.setStyles(cardsContainer, { display: 'flex' });
} else {
cardsContainer.style.display = "flex";
}
}
showCards();
});
} else {
// 데스크탑: 바로 카드 표시
showCards();
}
});
} catch (error) {
_handleError(error, 'animateSection2');
}
}
/**
* Section 2: 애니메이션 리셋
*/
function resetSection2() {
try {
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE is not defined'), 'resetSection2');
return;
}
const lines = ["line1", "line2", "line3"];
const cards = ["card1", "card2", "card3", "card4"];
lines.forEach((id) => {
const element = _domUtils?.$(`#${id}`) || document.getElementById(id);
if (element) {
if (_domUtils && _domUtils.removeClasses) {
_domUtils.removeClasses(element, 'show');
} else {
element.classList.remove("show");
}
}
});
cards.forEach((id) => {
const element = _domUtils?.$(`#${id}`) || document.getElementById(id);
if (element) {
if (_domUtils && _domUtils.removeClasses) {
_domUtils.removeClasses(element, 'show');
} else {
element.classList.remove("show");
}
}
});
INTRO_STATE.section2AnimDone = false;
} catch (error) {
_handleError(error, 'resetSection2');
}
}
/**
* Section 3: CTA 애니메이션
*/
function animateSection3() {
try {
const ctaBtn = _domUtils?.$("#ctaBtn") || document.getElementById("ctaBtn");
const ctaLine1 = _domUtils?.$("#ctaLine1") || document.getElementById("ctaLine1");
const ctaLine2 = _domUtils?.$("#ctaLine2") || document.getElementById("ctaLine2");
const addShowClass = (element, delay) => {
if (!element) {
console.warn(`[IntroAnimation] Element not found`);
return;
}
if (_utils && _utils.delay) {
_utils.delay(delay).then(() => {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(element, 'show');
} else {
element.classList.add("show");
}
});
} else {
setTimeout(() => {
if (_domUtils && _domUtils.addClasses) {
_domUtils.addClasses(element, 'show');
} else {
element.classList.add("show");
}
}, delay);
}
};
addShowClass(ctaBtn, 100);
addShowClass(ctaLine1, 300);
addShowClass(ctaLine2, 500);
// 모바일: 마스크 중앙에서 전체 화면으로 자동 확장 (CTA 섹션 진입 후 약 1초 뒤)
if (window.innerWidth <= 992) {
const maskContainer = _domUtils?.$("#maskContainer") || document.getElementById("maskContainer");
if (maskContainer && typeof INTRO_STATE !== "undefined") {
if (INTRO_STATE.mobileMaskExpandTimer) {
clearTimeout(INTRO_STATE.mobileMaskExpandTimer);
INTRO_STATE.mobileMaskExpandTimer = null;
}
const mobileMaskDelayMs = 1500;
const runExpand = () => {
INTRO_STATE.mobileMaskExpandTimer = null;
if (INTRO_STATE.currentSection === 2) {
maskContainer.classList.add("expand");
}
};
INTRO_STATE.mobileMaskExpandTimer = setTimeout(runExpand, mobileMaskDelayMs);
}
}
} catch (error) {
_handleError(error, 'animateSection3');
}
}
/**
* Section 3: 애니메이션 리셋
*/
function resetSection3() {
try {
const ctaLine1 = _domUtils?.$("#ctaLine1") || document.getElementById("ctaLine1");
const ctaLine2 = _domUtils?.$("#ctaLine2") || document.getElementById("ctaLine2");
const ctaBtn = _domUtils?.$("#ctaBtn") || document.getElementById("ctaBtn");
const removeShowClass = (element) => {
if (element) {
if (_domUtils && _domUtils.removeClasses) {
_domUtils.removeClasses(element, 'show');
} else {
element.classList.remove("show");
}
}
};
removeShowClass(ctaLine1);
removeShowClass(ctaLine2);
removeShowClass(ctaBtn);
// 모바일: 마스크 확장 리셋 · 진행 중인 자동 확장 타이머 취소
if (typeof INTRO_STATE !== "undefined" && INTRO_STATE.mobileMaskExpandTimer) {
clearTimeout(INTRO_STATE.mobileMaskExpandTimer);
INTRO_STATE.mobileMaskExpandTimer = null;
}
const maskContainer = _domUtils?.$("#maskContainer") || document.getElementById("maskContainer");
if (maskContainer) {
maskContainer.classList.remove("expand");
}
} catch (error) {
_handleError(error, 'resetSection3');
}
}
/**
* 자동 스크롤 타이머 시작
*/
function startAutoScrollTimer() {
try {
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE is not defined'), 'startAutoScrollTimer');
return;
}
if (typeof handleScrollDown !== 'function') {
_handleError(new Error('handleScrollDown function is not defined'), 'startAutoScrollTimer');
return;
}
// 기존 타이머 정리
if (INTRO_STATE.autoScrollTimer) {
clearTimeout(INTRO_STATE.autoScrollTimer);
INTRO_STATE.autoScrollTimer = null;
}
// 섹션 0: 1500ms, 섹션 1(업데이트+카드): 6000ms, 그 외: 3000ms
const delay =
INTRO_STATE.currentSection === 0
? 1500
: INTRO_STATE.currentSection === 1
? 6000
: 3000;
if (_utils && _utils.delay) {
_utils.delay(delay).then(() => {
// 마지막 상호작용 후 설정된 시간이 지났는지 확인
const now = Date.now();
const lastInteraction = INTRO_STATE.lastInteractionTime || 0;
if (now - lastInteraction >= delay) {
try {
handleScrollDown();
} catch (error) {
_handleError(error, 'startAutoScrollTimer.handleScrollDown');
}
}
});
} else {
INTRO_STATE.autoScrollTimer = setTimeout(() => {
try {
// 마지막 상호작용 후 설정된 시간이 지났는지 확인
const now = Date.now();
const lastInteraction = INTRO_STATE.lastInteractionTime || 0;
if (now - lastInteraction >= delay) {
handleScrollDown();
}
} catch (error) {
_handleError(error, 'startAutoScrollTimer.handleScrollDown');
}
}, delay);
}
} catch (error) {
_handleError(error, 'startAutoScrollTimer');
}
}
/**
* 사용자 상호작용 감지 - 자동 스크롤 타이머 리셋
*/
function resetAutoScrollTimer() {
try {
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE is not defined'), 'resetAutoScrollTimer');
return;
}
INTRO_STATE.lastInteractionTime = Date.now();
startAutoScrollTimer();
} catch (error) {
_handleError(error, 'resetAutoScrollTimer');
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* intro-events.js
* 이벤트 리스너 설정
*/
/**
* 모든 이벤트 리스너 등록
*/
function initEventListeners() {
// 마우스 휠 이벤트
document.addEventListener("wheel", (e) => {
resetAutoScrollTimer();
if (e.deltaY > 0) handleScrollDown();
else handleScrollUp();
});
// 터치 이벤트
let touchStartY = 0;
document.addEventListener(
"touchstart",
(e) => {
touchStartY = e.touches[0].clientY;
resetAutoScrollTimer();
},
{ passive: true }
);
document.addEventListener(
"touchend",
(e) => {
const diff = touchStartY - e.changedTouches[0].clientY;
if (Math.abs(diff) > 60) {
if (diff > 0) handleScrollDown();
else handleScrollUp();
}
},
{ passive: true }
);
// 키보드 이벤트
document.addEventListener("keydown", (e) => {
if (["ArrowDown", "PageDown", " "].includes(e.key)) {
e.preventDefault();
resetAutoScrollTimer();
handleScrollDown();
} else if (["ArrowUp", "PageUp"].includes(e.key)) {
e.preventDefault();
resetAutoScrollTimer();
handleScrollUp();
}
});
// 마우스 움직임 이벤트 (Section 3 마스크 효과)
// requestAnimationFrame을 사용하여 부드러운 업데이트 보장
let rafId = null;
let lastMouseEvent = null;
document.addEventListener("mousemove", (e) => {
if (typeof INTRO_STATE !== 'undefined' && INTRO_STATE.currentSection === 2) {
// 마지막 마우스 이벤트 저장
lastMouseEvent = e;
// 이미 요청된 애니메이션 프레임이 없으면 새로 요청
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (lastMouseEvent) {
handleMouseMoveOnSection3(lastMouseEvent);
}
rafId = null;
lastMouseEvent = null;
});
}
}
});
}
/**
* Section 3의 마우스 움직임 처리 (마스크 효과)
* @param {MouseEvent} e - 마우스 이벤트
*/
function handleMouseMoveOnSection3(e) {
try {
// 입력 검증
if (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') {
return;
}
const maskContainer = document.getElementById("maskContainer");
if (!maskContainer) {
return; // 요소가 없으면 조용히 종료
}
const mouseX = e.clientX;
const mouseY = e.clientY;
const mainContainer = document.getElementById("mainContainer");
if (!mainContainer) {
return;
}
const mainContainerRect = mainContainer.getBoundingClientRect();
const h1Elements = mainContainer.querySelectorAll("p");
const ctaBtn = document.querySelector(".cta-btn");
let hovering = false;
let isCtaBtnHovering = false;
// .cta-btn 위에 마우스가 있는지 먼저 체크
if (ctaBtn) {
try {
const ctaRect = ctaBtn.getBoundingClientRect();
if (
mouseX >= ctaRect.left &&
mouseX <= ctaRect.right &&
mouseY >= ctaRect.top &&
mouseY <= ctaRect.bottom
) {
hovering = true;
isCtaBtnHovering = true;
}
} catch (error) {
console.warn('[IntroEvents] CTA 버튼 체크 중 오류:', error);
}
}
// p 요소들 체크
try {
h1Elements.forEach((element) => {
try {
const h1Rect = element.getBoundingClientRect();
if (
mouseX >= h1Rect.left &&
mouseX <= h1Rect.right &&
mouseY >= h1Rect.top &&
mouseY <= h1Rect.bottom
) {
hovering = true;
}
} catch (error) {
// 개별 요소 체크 실패는 무시
}
});
} catch (error) {
console.warn('[IntroEvents] 요소 체크 중 오류:', error);
}
// .cta-btn 위에 있을 때만 세로 위치 고정, 그 외에는 마우스 위치 따라감
const targetY = isCtaBtnHovering
? mainContainerRect.top + mainContainerRect.height / 2.3
: mouseY;
// CSS 변수 설정 (안전하게)
const x = Math.max(0, Math.min(mouseX, window.innerWidth));
const y = Math.max(0, Math.min(targetY, window.innerHeight));
const targetSize = hovering ? 380 : 30;
maskContainer.style.setProperty("--x", `${x}px`);
maskContainer.style.setProperty("--y", `${y}px`);
maskContainer.style.setProperty("--size", `${targetSize}px`);
} catch (error) {
console.error('[IntroEvents] handleMouseMoveOnSection3 오류:', error);
}
}
+267
View File
@@ -0,0 +1,267 @@
/**
* intro-init.js
* 인트로 페이지 초기화
* 공통 모듈 활용 (ErrorHandler, DOMUtils, Utils, EventManager)
*/
// 전역 의존성 (폴백 포함)
const _initDomUtils = typeof DOMUtils !== 'undefined' ? DOMUtils : null;
const _initErrorHandler = typeof ErrorHandler !== 'undefined' ? ErrorHandler : null;
const _initUtils = typeof Utils !== 'undefined' ? Utils : null;
const _initEventManager = typeof eventManager !== 'undefined' ? eventManager : null;
/**
* 에러 처리 헬퍼
* @private
*/
function _handleError(error, context, additionalInfo = {}) {
if (_initErrorHandler) {
_initErrorHandler.handle(error, {
context: `IntroInit.${context}`,
component: 'IntroInit',
...additionalInfo
}, false);
} else {
console.error(`[IntroInit] ${context}:`, error, additionalInfo);
}
}
/**
* 페이지 초기 설정
*/
function initializeIntroPage() {
try {
// DOM 요소 선택
const cardsContainer = _initDomUtils?.$("#cardsContainer") || document.getElementById("cardsContainer");
const ctaBtn = _initDomUtils?.$("#ctaBtn") || document.getElementById("ctaBtn");
const maskContainer = _initDomUtils?.$("#maskContainer") || document.getElementById("maskContainer");
const typedNameEl = _initDomUtils?.$("#typedName") || document.getElementById("typedName");
const cursorEl = _initDomUtils?.$("#cursor") || document.getElementById("cursor");
// 초기 요소 숨김 처리
if (cardsContainer) {
if (_initDomUtils && _initDomUtils.setStyles) {
_initDomUtils.setStyles(cardsContainer, { display: 'none' });
} else {
cardsContainer.style.display = "none";
}
} else {
console.warn('[IntroInit] cardsContainer 요소를 찾을 수 없습니다.');
}
if (ctaBtn) {
if (_initDomUtils && _initDomUtils.setStyles) {
_initDomUtils.setStyles(ctaBtn, { display: 'none' });
} else {
ctaBtn.style.display = "none";
}
} else {
console.warn('[IntroInit] ctaBtn 요소를 찾을 수 없습니다.');
}
if (maskContainer) {
maskContainer.style.setProperty("--size", "30px");
} else {
console.warn('[IntroInit] maskContainer 요소를 찾을 수 없습니다.');
}
// 이벤트 리스너 등록
if (typeof initEventListeners === 'function') {
try {
initEventListeners();
} catch (error) {
_handleError(error, 'initializeIntroPage.initEventListeners');
}
} else {
console.warn('[IntroInit] initEventListeners 함수를 찾을 수 없습니다.');
}
// typedIndex 초기화 및 typedName 요소 초기화
if (typeof INTRO_STATE !== 'undefined') {
INTRO_STATE.typedIndex = 0;
} else {
console.warn('[IntroInit] INTRO_STATE가 정의되지 않았습니다.');
}
if (typedNameEl) {
typedNameEl.textContent = "";
} else {
console.warn('[IntroInit] typedName 요소를 찾을 수 없습니다.');
}
if (cursorEl) {
if (_initDomUtils && _initDomUtils.setStyles) {
_initDomUtils.setStyles(cursorEl, { opacity: '1' });
} else {
cursorEl.style.opacity = "1";
}
} else {
console.warn('[IntroInit] cursor 요소를 찾을 수 없습니다.');
}
// 타이핑 애니메이션 시작 (INTRO_CONFIG가 준비된 후)
const animationDelay = 800;
const delayFn = _initUtils && _initUtils.delay ? _initUtils.delay : (ms) => new Promise(resolve => setTimeout(resolve, ms));
delayFn(animationDelay).then(() => {
try {
if (typeof INTRO_CONFIG !== 'undefined' && typeof INTRO_STATE !== 'undefined' && INTRO_CONFIG.fullName) {
// text-ani 섹션 표시 (CSS에서 display: none이므로 animating 클래스 추가)
const textAniSection = _initDomUtils?.$('.text-ani') || document.querySelector('.text-ani');
if (textAniSection) {
if (_initDomUtils && _initDomUtils.addClasses) {
_initDomUtils.addClasses(textAniSection, 'animating');
} else {
textAniSection.classList.add('animating');
}
}
INTRO_STATE.typedIndex = 0;
if (typeof typeName === 'function') {
typeName();
} else {
console.warn('[IntroInit] typeName 함수를 찾을 수 없습니다.');
}
} else {
console.warn('[IntroInit] INTRO_CONFIG 또는 INTRO_STATE가 준비되지 않았거나 fullName이 없습니다.');
}
} catch (error) {
_handleError(error, 'initializeIntroPage.animationStart');
}
});
} catch (error) {
_handleError(error, 'initializeIntroPage');
}
}
// DOM이 로드되면 초기화 실행
function setupInitialization() {
try {
if (document.readyState === "loading") {
// EventManager를 사용하여 이벤트 등록 (폴백 포함)
if (_initEventManager) {
_initEventManager.once(document, "DOMContentLoaded", initializeIntroPage);
} else {
document.addEventListener("DOMContentLoaded", initializeIntroPage, { once: true });
}
} else {
// DOMContentLoaded 이벤트가 이미 발생한 경우
initializeIntroPage();
}
} catch (error) {
_handleError(error, 'setupInitialization');
// 폴백: 에러가 발생해도 초기화 시도
if (document.readyState !== "loading") {
initializeIntroPage();
}
}
}
setupInitialization();
/**
* 외부에서 사용자 이름을 설정하고 다시 시작하는 함수
* @param {string} name - 사용자 이름
*/
function restartIntroWithName(name) {
try {
// 입력 검증
if (typeof name !== 'string' || name.trim() === '') {
_handleError(new Error('유효하지 않은 사용자 이름'), 'restartIntroWithName', { name });
return;
}
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE가 정의되지 않았습니다'), 'restartIntroWithName');
return;
}
// 이름 설정
if (typeof setUserName === 'function') {
try {
setUserName(name);
} catch (error) {
_handleError(error, 'restartIntroWithName.setUserName');
}
} else {
console.warn('[IntroInit] setUserName 함수를 찾을 수 없습니다.');
}
// 상태 초기화
INTRO_STATE.currentSection = 0;
INTRO_STATE.typedIndex = 0;
INTRO_STATE.isScrolling = false;
INTRO_STATE.isAnimating = false;
INTRO_STATE.section2AnimDone = false;
clearTimeout(INTRO_STATE.autoScrollTimer);
INTRO_STATE.lastInteractionTime = Date.now();
// DOM 요소 선택
const typedNameEl = _initDomUtils?.$("#typedName") || document.getElementById("typedName");
const cursorEl = _initDomUtils?.$("#cursor") || document.getElementById("cursor");
const welcomeEl = _initDomUtils?.$("#welcome") || document.getElementById("welcome");
const textAniSection = _initDomUtils?.$('.text-ani') || document.querySelector('.text-ani');
// 화면 리셋
if (typedNameEl) {
typedNameEl.textContent = "";
} else {
console.warn('[IntroInit] typedName 요소를 찾을 수 없습니다.');
}
if (cursorEl) {
if (_initDomUtils && _initDomUtils.setStyles) {
_initDomUtils.setStyles(cursorEl, { opacity: '1' });
} else {
cursorEl.style.opacity = "1";
}
} else {
console.warn('[IntroInit] cursor 요소를 찾을 수 없습니다.');
}
if (welcomeEl) {
welcomeEl.innerHTML = "";
} else {
console.warn('[IntroInit] welcome 요소를 찾을 수 없습니다.');
}
// text-ani 섹션 표시
if (textAniSection) {
if (_initDomUtils && _initDomUtils.addClasses) {
_initDomUtils.addClasses(textAniSection, 'animating');
} else {
textAniSection.classList.add('animating');
}
}
// 섹션 1로 이동
if (typeof goToSection === 'function') {
try {
goToSection(0);
} catch (error) {
_handleError(error, 'restartIntroWithName.goToSection');
}
} else {
console.warn('[IntroInit] goToSection 함수를 찾을 수 없습니다.');
}
// 타이핑 재시작
const animationDelay = 800;
const delayFn = _initUtils && _initUtils.delay ? _initUtils.delay : (ms) => new Promise(resolve => setTimeout(resolve, ms));
delayFn(animationDelay).then(() => {
try {
if (typeof typeName === 'function') {
typeName();
} else {
console.warn('[IntroInit] typeName 함수를 찾을 수 없습니다.');
}
} catch (error) {
_handleError(error, 'restartIntroWithName.typeName');
}
});
} catch (error) {
_handleError(error, 'restartIntroWithName');
}
}
+279
View File
@@ -0,0 +1,279 @@
/**
* intro-section.js
* 섹션 전환 및 스크롤 처리 로직
* 공통 모듈 활용 (ErrorHandler, DOMUtils, AnimationUtils, Utils)
*/
// 전역 의존성 (폴백 포함)
const _sectionDomUtils = typeof DOMUtils !== 'undefined' ? DOMUtils : null;
const _sectionErrorHandler = typeof ErrorHandler !== 'undefined' ? ErrorHandler : null;
const _sectionUtils = typeof Utils !== 'undefined' ? Utils : null;
const _sectionAnimationUtils = typeof AnimationUtils !== 'undefined' ? AnimationUtils : null;
/**
* 에러 처리 헬퍼
* @private
*/
function _handleError(error, context, additionalInfo = {}) {
if (_sectionErrorHandler) {
_sectionErrorHandler.handle(error, {
context: `IntroSection.${context}`,
component: 'IntroSection',
...additionalInfo
}, false);
} else {
console.error(`[IntroSection] ${context}:`, error, additionalInfo);
}
}
/**
* 특정 섹션으로 이동
* @param {number} index - 이동할 섹션 인덱스 (0, 1, 2)
*/
function goToSection(index) {
try {
// 입력 검증
if (typeof index !== 'number' || index < 0 || index > 2) {
_handleError(new Error(`유효하지 않은 섹션 인덱스: ${index}`), 'goToSection');
return;
}
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE가 정의되지 않았습니다'), 'goToSection');
return;
}
if (INTRO_STATE.isAnimating) return;
INTRO_STATE.isAnimating = true;
// 자동 스크롤 타이머 정지
clearTimeout(INTRO_STATE.autoScrollTimer);
// DOM 요소 선택
const section1Text = _sectionDomUtils?.$("#section1Text") || document.getElementById("section1Text");
const section2Text = _sectionDomUtils?.$("#section2Text") || document.getElementById("section2Text");
const section3Text = _sectionDomUtils?.$("#section3Text") || document.getElementById("section3Text");
const cardsContainer = _sectionDomUtils?.$("#cardsContainer") || document.getElementById("cardsContainer");
const ctaBtn = _sectionDomUtils?.$("#ctaBtn") || document.getElementById("ctaBtn");
const scrollIndicator = _sectionDomUtils?.$("#scrollIndicator") || document.getElementById("scrollIndicator");
// 요소 존재 확인
if (!section1Text || !section2Text || !section3Text || !cardsContainer || !ctaBtn || !scrollIndicator) {
_handleError(new Error('필수 DOM 요소를 찾을 수 없습니다'), 'goToSection');
INTRO_STATE.isAnimating = false;
return;
}
// 현재 섹션 페이드 아웃
const currentSection = INTRO_STATE.currentSection;
if (currentSection === 0) {
if (_sectionDomUtils && _sectionDomUtils.addClasses) {
_sectionDomUtils.addClasses(section1Text, 'fade-out');
} else {
section1Text.classList.add("fade-out");
}
} else if (currentSection === 1) {
if (_sectionDomUtils && _sectionDomUtils.addClasses) {
_sectionDomUtils.addClasses(section2Text, 'fade-out');
} else {
section2Text.classList.add("fade-out");
}
// 카드 숨기기
["card1", "card2", "card3", "card4"].forEach((id) => {
const card = _sectionDomUtils?.$(`#${id}`) || document.getElementById(id);
if (card) {
if (_sectionDomUtils && _sectionDomUtils.removeClasses) {
_sectionDomUtils.removeClasses(card, 'show');
} else {
card.classList.remove("show");
}
}
});
} else if (currentSection === 2) {
if (_sectionDomUtils && _sectionDomUtils.addClasses) {
_sectionDomUtils.addClasses(section3Text, 'fade-out');
} else {
section3Text.classList.add("fade-out");
}
if (_sectionDomUtils && _sectionDomUtils.removeClasses) {
_sectionDomUtils.removeClasses(ctaBtn, 'show');
} else {
ctaBtn.classList.remove("show");
}
}
// 전환 애니메이션 지연
const transitionDelay = 400;
const delayFn = _sectionUtils && _sectionUtils.delay ? _sectionUtils.delay : (ms) => new Promise(resolve => setTimeout(resolve, ms));
delayFn(transitionDelay).then(() => {
try {
// 모든 섹션 숨기기
if (_sectionDomUtils && _sectionDomUtils.addClasses) {
_sectionDomUtils.addClasses(section1Text, 'hidden');
_sectionDomUtils.addClasses(section2Text, 'hidden');
_sectionDomUtils.addClasses(section3Text, 'hidden');
} else {
section1Text.classList.add("hidden");
section2Text.classList.add("hidden");
section3Text.classList.add("hidden");
}
if (_sectionDomUtils && _sectionDomUtils.removeClasses) {
_sectionDomUtils.removeClasses(scrollIndicator, 'sec2');
_sectionDomUtils.removeClasses(section1Text, 'fade-out');
_sectionDomUtils.removeClasses(section2Text, 'fade-out');
_sectionDomUtils.removeClasses(section3Text, 'fade-out');
} else {
scrollIndicator.classList.remove("sec2");
section1Text.classList.remove("fade-out");
section2Text.classList.remove("fade-out");
section3Text.classList.remove("fade-out");
}
// 목표 섹션 표시
if (index === 0) {
if (_sectionDomUtils && _sectionDomUtils.removeClasses) {
_sectionDomUtils.removeClasses(section1Text, 'hidden');
_sectionDomUtils.removeClasses(scrollIndicator, 'hidden');
} else {
section1Text.classList.remove("hidden");
scrollIndicator.classList.remove("hidden");
}
if (_sectionDomUtils && _sectionDomUtils.setStyles) {
_sectionDomUtils.setStyles(cardsContainer, { display: 'none' });
_sectionDomUtils.setStyles(ctaBtn, { display: 'none' });
} else {
cardsContainer.style.display = "none";
ctaBtn.style.display = "none";
}
} else if (index === 1) {
if (_sectionDomUtils && _sectionDomUtils.addClasses) {
_sectionDomUtils.addClasses(scrollIndicator, 'sec2');
_sectionDomUtils.removeClasses(section2Text, 'hidden');
_sectionDomUtils.removeClasses(scrollIndicator, 'hidden');
} else {
scrollIndicator.classList.add("sec2");
section2Text.classList.remove("hidden");
scrollIndicator.classList.remove("hidden");
}
// 모바일(≤992px): section2Text 먼저 보여준 뒤 animateSection2에서 카드 표시
const isMobile = window.innerWidth <= 992;
if (_sectionDomUtils && _sectionDomUtils.setStyles) {
_sectionDomUtils.setStyles(cardsContainer, { display: isMobile ? 'none' : 'flex' });
_sectionDomUtils.setStyles(ctaBtn, { display: 'none' });
} else {
cardsContainer.style.display = isMobile ? "none" : "flex";
ctaBtn.style.display = "none";
}
resetSection2();
setTimeout(animateSection2, 200);
} else if (index === 2) {
if (_sectionDomUtils && _sectionDomUtils.removeClasses) {
_sectionDomUtils.removeClasses(section3Text, 'hidden');
_sectionDomUtils.addClasses(scrollIndicator, 'hidden');
_sectionDomUtils.removeClasses(scrollIndicator, 'sec2');
} else {
section3Text.classList.remove("hidden");
scrollIndicator.classList.add("hidden");
scrollIndicator.classList.remove("sec2");
}
if (_sectionDomUtils && _sectionDomUtils.setStyles) {
_sectionDomUtils.setStyles(cardsContainer, { display: 'none' });
_sectionDomUtils.setStyles(ctaBtn, { display: 'block' });
} else {
cardsContainer.style.display = "none";
ctaBtn.style.display = "block";
}
resetSection3();
setTimeout(animateSection3, 200);
}
INTRO_STATE.currentSection = index;
// 애니메이션 완료 플래그 리셋
const resetDelay = 500;
delayFn(resetDelay).then(() => {
INTRO_STATE.isAnimating = false;
});
} catch (error) {
_handleError(error, 'goToSection.transition');
INTRO_STATE.isAnimating = false;
}
});
} catch (error) {
_handleError(error, 'goToSection');
if (typeof INTRO_STATE !== 'undefined') {
INTRO_STATE.isAnimating = false;
}
}
}
/**
* 다음 섹션으로 스크롤
*/
function handleScrollDown() {
try {
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE가 정의되지 않았습니다'), 'handleScrollDown');
return;
}
if (INTRO_STATE.isScrolling || INTRO_STATE.isAnimating) return;
const delayFn = _sectionUtils && _sectionUtils.delay ? _sectionUtils.delay : (ms) => new Promise(resolve => setTimeout(resolve, ms));
const scrollCooldown = 700;
if (INTRO_STATE.currentSection === 0) {
INTRO_STATE.isScrolling = true;
delayFn(scrollCooldown).then(() => {
INTRO_STATE.isScrolling = false;
});
goToSection(1);
} else if (INTRO_STATE.currentSection === 1 && INTRO_STATE.section2AnimDone) {
INTRO_STATE.isScrolling = true;
delayFn(scrollCooldown).then(() => {
INTRO_STATE.isScrolling = false;
});
goToSection(2);
}
} catch (error) {
_handleError(error, 'handleScrollDown');
}
}
/**
* 이전 섹션으로 스크롤
*/
function handleScrollUp() {
try {
if (typeof INTRO_STATE === 'undefined') {
_handleError(new Error('INTRO_STATE가 정의되지 않았습니다'), 'handleScrollUp');
return;
}
if (INTRO_STATE.isScrolling || INTRO_STATE.isAnimating) return;
INTRO_STATE.isScrolling = true;
const delayFn = _sectionUtils && _sectionUtils.delay ? _sectionUtils.delay : (ms) => new Promise(resolve => setTimeout(resolve, ms));
const scrollCooldown = 700;
delayFn(scrollCooldown).then(() => {
INTRO_STATE.isScrolling = false;
});
if (INTRO_STATE.currentSection === 2) {
goToSection(1);
} else if (INTRO_STATE.currentSection === 1) {
goToSection(0);
}
} catch (error) {
_handleError(error, 'handleScrollUp');
}
}
+594
View File
@@ -0,0 +1,594 @@
/**
* 챕터 카드 관리 클래스 (CSS 기반 디자인)
* 개선된 공통 모듈 활용 (EventManager, ErrorHandler, DOMUtils)
*/
class ChapterCardManager {
constructor(config, gaugeManager, dependencies = {}) {
this.config = config;
this.gaugeManager = gaugeManager;
this.chapterCards = [];
this.cardsContainer = null;
this.modalInstance = null;
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
// CSS 스타일 주입
this._injectStyles();
}
/**
* CSS 스타일 주입
* @private
*/
_injectStyles() {
try {
if (document.getElementById("chapter-card-styles")) return;
const style = this.domUtils?.createElement('style', { id: 'chapter-card-styles' }) || document.createElement("style");
style.id = "chapter-card-styles";
style.textContent = `
.chapter-card:hover .card-play-button {
transform: scale(1.1);
}
/* 호버 효과 */
.chapter-card:hover .chapter-card-inner {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
}
.chapter-card.current:hover .chapter-card-inner {
box-shadow: 0 12px 32px rgba(31, 155, 118, 0.3);
}
.chapter-card.completed:hover .chapter-card-inner {
box-shadow: 0 12px 32px rgba(171, 61, 0, 0.25);
}
`;
document.head.appendChild(style);
} catch (error) {
this._handleError(error, 'ChapterCardManager._injectStyles');
}
}
/**
* 모달 인스턴스 설정
* @param {VideoModal} modal - 모달 인스턴스
*/
setModalInstance(modal) {
this.modalInstance = modal;
}
/**
* 챕터 카드 생성
*/
createChapterCards() {
try {
const gaugeElement = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge");
if (!gaugeElement) {
console.warn('[ChapterCardManager] .lessons-gauge 요소를 찾을 수 없습니다.');
return;
}
this.cardsContainer = this.domUtils?.$(".chapter-list", gaugeElement) || gaugeElement.querySelector(".chapter-list");
if (!this.cardsContainer) {
this.cardsContainer = this.domUtils?.createElement('ul', { class: 'chapter-list' }) || document.createElement("ul");
this.cardsContainer.className = "chapter-list";
gaugeElement.appendChild(this.cardsContainer);
}
this.domUtils?.empty(this.cardsContainer) || (this.cardsContainer.innerHTML = "");
this.chapterCards = [];
this.config.chapters.forEach((chapter, chapterIndex) => {
// 새 구조: 챕터 자체가 마커이므로 chapter에서 직접 정보 가져오기
if (chapter.type === "chapter") {
this._createCard(chapter, chapterIndex, chapter);
}
});
this._setupResizeHandler();
console.log(
`[ChapterCardManager] ${this.chapterCards.length}개의 챕터 카드 생성 완료`
);
} catch (error) {
this._handleError(error, 'ChapterCardManager.createChapterCards');
}
}
/**
* 리사이즈 핸들러 설정 (PC/모바일 전환 시 카드 위치 재계산)
* @private
*/
_setupResizeHandler() {
try {
const resizeHandler = () => {
try {
if (this.gaugeManager && typeof this.gaugeManager.updateMobileState === 'function') {
this.gaugeManager.updateMobileState();
}
this._repositionCards();
} catch (error) {
this._handleError(error, 'ChapterCardManager._setupResizeHandler.resizeHandler');
}
};
if (this.utils && this.utils.throttle) {
const throttledResize = this.utils.throttle(resizeHandler, 100);
if (this.eventManager) {
const listenerId = this.eventManager.on(window, "resize", throttledResize);
this.listenerIds.push({ element: window, id: listenerId, type: 'resize' });
} else {
window.addEventListener("resize", throttledResize);
}
} else {
let resizeTimer;
const debouncedResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resizeHandler, 100);
};
if (this.eventManager) {
const listenerId = this.eventManager.on(window, "resize", debouncedResize);
this.listenerIds.push({ element: window, id: listenerId, type: 'resize' });
} else {
window.addEventListener("resize", debouncedResize);
}
}
} catch (error) {
this._handleError(error, 'ChapterCardManager._setupResizeHandler');
}
}
/**
* 카드 위치 재설정 (리사이즈 시)
* @private
*/
_repositionCards() {
try {
this.chapterCards.forEach((card) => {
if (card && card.element && card.chapterLesson) {
this._positionCard(card.element, card.chapterLesson);
}
});
} catch (error) {
this._handleError(error, 'ChapterCardManager._repositionCards');
}
}
/**
* 개별 챕터 카드 생성
* @private
*/
_createCard(chapter, chapterIndex, chapterLesson) {
try {
const li = this.domUtils?.createElement('li', { class: 'chapter-card' }) || document.createElement("li");
li.classList.add("chapter-card");
const state = this._getChapterState(chapter);
if (state) {
this.domUtils?.addClasses(li, state) || li.classList.add(state);
}
li.style.cursor = "pointer";
// 이벤트 리스너 등록 (EventManager 사용)
const clickHandler = () => {
this._handleCardClick(chapter, chapterIndex);
};
if (this.eventManager) {
const listenerId = this.eventManager.on(li, "click", clickHandler);
this.listenerIds.push({ element: li, id: listenerId });
} else {
li.addEventListener("click", clickHandler);
}
const cardContent = this._createCardContent(chapter, state, chapterIndex);
li.appendChild(cardContent);
this._positionCard(li, chapterLesson);
this.cardsContainer.appendChild(li);
this.chapterCards.push({
element: li,
chapter: chapter,
state: state,
chapterIndex: chapterIndex,
chapterLesson: chapterLesson,
});
} catch (error) {
this._handleError(error, 'ChapterCardManager._createCard', { chapter, chapterIndex });
}
}
/**
* 카드 콘텐츠 생성
* @private
*/
_createCardContent(chapter, state, chapterIndex) {
try {
const inner = this.domUtils?.createElement('div', { class: 'chapter-card-inner' }) || document.createElement("div");
inner.className = "chapter-card-inner";
// 썸네일 영역
const thumbnailContainer = this.domUtils?.createElement('div', { class: 'card-thumbnail-container' }) || document.createElement("div");
thumbnailContainer.className = "card-thumbnail-container";
const thumbnail = this.domUtils?.createElement('img', {
class: 'card-thumbnail',
src: `/img/learning/img_learning_0${(chapterIndex % 6) + 1}.jpg`,
alt: chapter.name,
loading: 'lazy'
}) || document.createElement("img");
if (!this.domUtils) {
thumbnail.className = "card-thumbnail";
thumbnail.src = `/img/learning/img_learning_0${(chapterIndex % 6) + 1}.jpg`;
thumbnail.alt = chapter.name;
thumbnail.loading = "lazy";
}
thumbnailContainer.appendChild(thumbnail);
// 플레이 버튼
const playButton = this.domUtils?.createElement('img', {
class: 'card-play-button',
src: this._getPlayButtonImagePath(state),
alt: '재생',
loading: 'lazy'
}) || document.createElement("img");
if (!this.domUtils) {
playButton.className = "card-play-button";
playButton.src = this._getPlayButtonImagePath(state);
playButton.alt = "재생";
playButton.loading = "lazy";
}
thumbnailContainer.appendChild(playButton);
// 게이지바 추가
const gaugeBar = this.domUtils?.createElement('div', { class: 'card-gauge-bar' }) || document.createElement("div");
gaugeBar.className = "card-gauge-bar";
const gaugeFill = this.domUtils?.createElement('div', { class: 'card-gauge-fill' }) || document.createElement("div");
gaugeFill.className = "card-gauge-fill";
const progressPercent = this._calculateChapterProgress(chapter);
gaugeFill.style.width = progressPercent + "%";
gaugeBar.appendChild(gaugeFill);
thumbnailContainer.appendChild(gaugeBar);
inner.appendChild(thumbnailContainer);
// 제목
const title = this.domUtils?.createElement('div', { class: 'card-title' }, chapter.name) || document.createElement("div");
if (!this.domUtils) {
title.className = "card-title";
title.textContent = chapter.name;
}
inner.appendChild(title);
// 스탬프
const stamp = this.domUtils?.createElement('div', { class: 'card-stamp' }) || document.createElement("div");
stamp.className = "card-stamp";
inner.appendChild(stamp);
// 그림자
const shadow = this.domUtils?.createElement('div', { class: 'shadow-effect' }) || document.createElement("div");
shadow.className = "shadow-effect";
inner.appendChild(shadow);
return inner;
} catch (error) {
this._handleError(error, 'ChapterCardManager._createCardContent', { chapter, state, chapterIndex });
// 에러 발생 시 최소한의 요소라도 반환
const fallback = document.createElement("div");
fallback.className = "chapter-card-inner";
fallback.textContent = chapter.name || "Chapter";
return fallback;
}
}
/**
* 카드 클릭 핸들러
* @private
*/
_handleCardClick(chapter, chapterIndex) {
try {
if (!this.modalInstance) {
console.warn('[ChapterCardManager] 모달 인스턴스가 설정되지 않았습니다.');
return;
}
console.log(
`[ChapterCardManager] 챕터 카드 클릭: ${chapter.name} (챕터 ${chapterIndex + 1})`
);
// 챕터는 시작점 표시용이므로 항상 첫 번째 미완료 lesson부터 시작
let targetLessonIndex = 0; // 첫 번째 lesson
// 첫 번째 미완료 lesson 찾기
for (let i = 0; i < chapter.lessons.length; i++) {
if (!chapter.lessons[i].completed) {
targetLessonIndex = i;
break;
}
}
// 모든 lesson이 완료된 경우 첫 번째 lesson으로
if (targetLessonIndex >= chapter.lessons.length) {
targetLessonIndex = 0;
}
const globalIndex = this.config.toGlobalIndex(
chapterIndex,
targetLessonIndex
);
const targetLesson = chapter.lessons[targetLessonIndex];
if (!targetLesson) {
console.error('[ChapterCardManager] 대상 lesson을 찾을 수 없습니다.');
return;
}
const targetLabel = targetLesson.label;
console.log(
`[ChapterCardManager] 대상 학습: ${targetLabel} (글로벌 인덱스: ${globalIndex})`
);
this.modalInstance.loadChapter(chapter, chapterIndex, globalIndex);
} catch (error) {
this._handleError(error, 'ChapterCardManager._handleCardClick', { chapter, chapterIndex });
}
}
/**
* 챕터 상태 결정
* @private
*/
_getChapterState(chapter) {
// 새 구조: chapter.completed 사용 (자동 업데이트됨)
if (chapter.completed) return "completed";
const anyStarted = chapter.lessons.some((lesson) => this._isLessonStarted(lesson));
if (anyStarted) return "current";
// 챕터 자체가 활성화되어 있는지 확인
const isActive = this._isChapterActive(chapter);
if (isActive) return "current";
return "base";
}
/**
* 학습 시작 여부 확인 (완료 또는 1초 이상 시청)
* @private
*/
_isLessonStarted(lesson) {
if (!lesson) return false;
if (lesson.completed === true) return true;
const watchTm = Number.parseInt(lesson.watch_tm ?? 0, 10) || 0;
return watchTm >= 1;
}
/**
* 챕터 활성화 여부 확인
* @private
*/
_isChapterActive(chapter) {
const allMarkers = this.config.getAllMarkers();
const chapterMarkerIndex = allMarkers.findIndex(
(m) => m.pathPercent === chapter.pathPercent && m.isChapterMarker === true
);
if (chapterMarkerIndex === -1) return false;
if (chapterMarkerIndex === 0) return true;
return allMarkers[chapterMarkerIndex - 1].completed;
}
/**
* 플레이 버튼 이미지 경로 반환
* @private
*/
_getPlayButtonImagePath(state) {
switch (state) {
case "completed":
return "/img/learning/btn_play_completed.png";
case "current":
return "/img/learning/btn_play_current.png";
default:
return "/img/learning/btn_play_base.png";
}
}
/**
* 챕터 진행률 계산
* @private
* @param {Object} chapter - 챕터 객체
* @returns {number} 진행률 (0-100)
*/
_calculateChapterProgress(chapter) {
const completedCount = chapter.lessons.filter(
(lesson) => lesson.completed
).length;
const totalCount = chapter.lessons.length;
const progressPercent = Math.round((completedCount / totalCount) * 100);
console.log(
`[ChapterCardManager] 챕터 "${chapter.name}" 진행률: ${completedCount}/${totalCount} (${progressPercent}%)`
);
return progressPercent;
}
/**
* 카드 위치 설정
* @private
*/
_positionCard(li, chapterLesson) {
try {
const gaugeSvg = this.gaugeManager.gaugeSvg || document.getElementById("gauge-svg") || document.getElementById("gauge-svg-mo");
if (!gaugeSvg) {
console.warn('[ChapterCardManager] gauge-svg 요소를 찾을 수 없습니다.');
return;
}
const viewBox = gaugeSvg.viewBox.baseVal;
if (!viewBox || !viewBox.width || !viewBox.height) {
console.warn('[ChapterCardManager] SVG viewBox가 유효하지 않습니다.');
return;
}
const isMobile = this.gaugeManager.isMobile;
const pathPercent = (isMobile && chapterLesson.pathPercentMo != null) ? chapterLesson.pathPercentMo : (chapterLesson.pathPercent || 0);
const point = this.gaugeManager.getPointAtPercent(pathPercent);
if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
console.warn('[ChapterCardManager] 유효하지 않은 포인트입니다.');
return;
}
// hanmac_study 기준 보정값 적용
const percentX = (point.x / viewBox.width) * 100 + 1.3;
const percentY = (point.y / viewBox.height) * 100 - 3;
if (this.domUtils) {
this.domUtils.setStyles(li, {
position: "absolute",
left: `${percentX}%`,
top: `${percentY}%`,
transform: "translate(-50%, -105%)",
zIndex: "10"
});
} else {
li.style.position = "absolute";
li.style.left = `${percentX}%`;
li.style.top = `${percentY}%`;
li.style.transform = "translate(-50%, -105%)";
li.style.zIndex = "10";
}
console.log(
`[ChapterCardManager] 카드 위치: (${percentX.toFixed(2)}%, ${percentY.toFixed(2)}%)`
);
} catch (error) {
this._handleError(error, 'ChapterCardManager._positionCard', { chapterLesson });
}
}
/**
* 챕터 카드 상태 업데이트
* @param {boolean} forceUpdate - 강제 업데이트 여부
*/
updateChapterCards(forceUpdate = false) {
try {
this.chapterCards.forEach((card, index) => {
try {
const chapter = card.chapter;
const newState = this._getChapterState(chapter);
const newProgress = this._calculateChapterProgress(chapter);
const gaugeFill = this.domUtils?.$(".card-gauge-fill", card.element) || card.element.querySelector(".card-gauge-fill");
const currentProgress = gaugeFill
? parseInt(gaugeFill.style.width) || 0
: 0;
const shouldUpdate =
forceUpdate ||
card.state !== newState ||
currentProgress !== newProgress;
if (shouldUpdate) {
console.log(
`[ChapterCardManager] 챕터 ${index + 1} ${forceUpdate ? "강제 " : ""}업데이트`
);
// 클래스 업데이트
card.element.className = "chapter-card";
if (newState) {
if (this.domUtils) {
this.domUtils.addClasses(card.element, newState);
} else {
card.element.classList.add(newState);
}
}
// 플레이 버튼 업데이트
const playButton = this.domUtils?.$(".card-play-button", card.element) || card.element.querySelector(".card-play-button");
if (playButton) {
playButton.src = this._getPlayButtonImagePath(newState);
}
// 게이지바 업데이트 (애니메이션 적용 가능)
if (gaugeFill) {
if (this.animationUtils) {
this.animationUtils.progressBar(gaugeFill, newProgress, 300);
} else {
gaugeFill.style.width = newProgress + "%";
}
}
card.state = newState;
}
} catch (error) {
this._handleError(error, 'ChapterCardManager.updateChapterCards.card', { index });
}
});
} catch (error) {
this._handleError(error, 'ChapterCardManager.updateChapterCards');
}
}
/**
* 에러 처리 헬퍼 메서드
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context,
...additionalInfo,
component: 'ChapterCardManager'
}, false);
} else {
console.error(`[ChapterCardManager] ${context}:`, error, additionalInfo);
}
}
/**
* 리소스 정리 (이벤트 리스너 제거)
*/
destroy() {
try {
// 이벤트 리스너 제거
if (this.eventManager) {
this.listenerIds.forEach(({ element, id }) => {
this.eventManager.off(element, id);
});
this.listenerIds = [];
}
// 카드 배열 초기화
this.chapterCards = [];
this.cardsContainer = null;
this.modalInstance = null;
} catch (error) {
this._handleError(error, 'ChapterCardManager.destroy');
}
}
}
+820
View File
@@ -0,0 +1,820 @@
/**
* 학습 경로 설정
* 공통 모듈 활용 (ErrorHandler, Utils, ConfigManager)
*/
const LEARNING_CONFIG = {
// 마커 설정 - 챕터별로 그룹화
chapters: [
{
id: 1,
code: "CA200C01",
name: "개인정보보호",
type: "chapter",
pathPercent: 0.108,
pathPercentMo: 0.108, // PC와 동일 순서
gaugePercent: 0.108,
gaugePercentMo: 0.108,
url: "ddILV5cbdQo",
completed: false, // 하위 lessons가 모두 완료되면 자동으로 true
lessons: [
{
pathPercent: 0.137,
pathPercentMo: 0.137,
gaugePercent: 0.137,
gaugePercentMo: 0.137,
type: "normal",
label: "개인정보보호 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.159,
pathPercentMo: 0.159,
gaugePercent: 0.156,
gaugePercentMo: 0.156,
type: "normal",
label: "개인정보보호 2",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.182,
pathPercentMo: 0.182,
gaugePercent: 0.178,
gaugePercentMo: 0.178,
type: "normal",
label: "개인정보보호 3",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.205,
pathPercentMo: 0.205,
gaugePercent: 0.202,
gaugePercentMo: 0.202,
type: "normal",
label: "개인정보보호 4",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.228,
pathPercentMo: 0.228,
gaugePercent: 0.226,
gaugePercentMo: 0.226,
type: "normal",
label: "개인정보보호 5",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.25,
pathPercentMo: 0.25,
gaugePercent: 0.246,
gaugePercentMo: 0.246,
type: "normal",
label: "개인정보보호 6",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.272,
pathPercentMo: 0.272,
gaugePercent: 0.268,
gaugePercentMo: 0.268,
type: "normal",
label: "개인정보보호 7",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.298,
pathPercentMo: 0.298,
gaugePercent: 0.296,
gaugePercentMo: 0.296,
type: "normal",
label: "개인정보보호 8",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 2,
code: "CA200C02",
name: "직장내 괴롭힘 예방",
type: "chapter",
pathPercent: 0.325,
pathPercentMo: 0.325,
gaugePercent: 0.325,
gaugePercentMo: 0.325,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.367,
pathPercentMo: 0.367,
gaugePercent: 0.358,
gaugePercentMo: 0.358,
type: "normal",
label: "직장내 괴롭힘 예방 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.41,
pathPercentMo: 0.41,
gaugePercent: 0.40,
gaugePercentMo: 0.40,
type: "normal",
label: "직장내 괴롭힘 예방 2",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 3,
code: "CA200C03",
name: "성희롱 예방 교육",
type: "chapter",
pathPercent: 0.442,
pathPercentMo: 0.442,
gaugePercent: 0.442,
gaugePercentMo: 0.442,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.486,
pathPercentMo: 0.486,
gaugePercent: 0.476,
gaugePercentMo: 0.476,
type: "normal",
label: "성희롱 예방 교육 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.531,
pathPercentMo: 0.531,
gaugePercent: 0.526,
gaugePercentMo: 0.526,
type: "normal",
label: "성희롱 예방 교육 2",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 4,
code: "CA200C04",
name: "산업안전 보건",
type: "chapter",
pathPercent: 0.555,
pathPercentMo: 0.555,
gaugePercent: 0.555,
gaugePercentMo: 0.555,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.585,
pathPercentMo: 0.585,
gaugePercent: 0.582,
gaugePercentMo: 0.582,
type: "normal",
label: "산업안전 보건 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.635,
pathPercentMo: 0.635,
gaugePercent: 0.632,
gaugePercentMo: 0.632,
type: "normal",
label: "산업안전 보건 2",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 5,
code: "CA200C05",
name: "장애인 인식 개선",
type: "chapter",
pathPercent: 0.67,
pathPercentMo: 0.67,
gaugePercent: 0.67,
gaugePercentMo: 0.67,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.69,
pathPercentMo: 0.69,
gaugePercent: 0.686,
gaugePercentMo: 0.686,
type: "normal",
label: "장애인 인식 개선 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.718,
pathPercentMo: 0.718,
gaugePercent: 0.71,
gaugePercentMo: 0.71,
type: "normal",
label: "장애인 인식 개선 2",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.736,
pathPercentMo: 0.736,
gaugePercent: 0.728,
gaugePercentMo: 0.728,
type: "normal",
label: "장애인 인식 개선 3",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.785,
pathPercentMo: 0.785,
gaugePercent: 0.778,
gaugePercentMo: 0.778,
type: "normal",
label: "장애인 인식 개선 4",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.82,
pathPercentMo: 0.82,
gaugePercent: 0.812,
gaugePercentMo: 0.812,
type: "normal",
label: "장애인 인식 개선 5",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.86,
pathPercentMo: 0.86,
gaugePercent: 0.85,
gaugePercentMo: 0.85,
type: "normal",
label: "장애인 인식 개선 6",
url: "ddILV5cbdQo",
completed: false,
},
],
},
],
// 평균 학습량 설정 (전체 학습 항목 대비 %)
averageProgress: {
threshold: 60, // 평균 학습량: 전체의 60%
},
// 마커 이미지 경로
markerImages: {
normal: {
base: "/img/learning/mark_base.png",
current: "/img/learning/mark_current.png",
completed: "/img/learning/mark_completed.png",
},
chapter: {
base: "/img/learning/mark_chapter_base.png",
current: "/img/learning/mark_chapter_current.png",
completed: "/img/learning/mark_chapter_completed.png",
},
},
// 상태 이미지 경로
stateImages: {
below: "/img/learning/img_state_01.svg", // 평균 이하
average: "/img/learning/img_state_02.svg", // 평균
above: "/img/learning/img_state_03.svg", // 평균 이상
},
// 모달 경로
modalPath: "./_modal/video-learning.php",
// 비활성 마커 클릭 설정
settings: {
allowDisabledClick: true, // true: 비활성 마커도 클릭 가능, false: 비활성 마커 클릭 불가
disabledClickMessage: "이전 학습을 먼저 완료해주세요.", // 비활성 마커 클릭 시 메시지
showDisabledAlert: false, // true: 알림 표시, false: 콘솔 로그만
showStateIndicator: {
pc: true, // PC에서 상태 인디케이터 표시
mo: false, // 모바일에서도 상태 인디케이터 표시 (PC와 동일)
},
},
/**
* 챕터의 완료 상태 자동 업데이트
* 하위 lessons가 모두 완료되면 챕터도 completed = true로 변경
*/
updateChapterCompletionStatus() {
try {
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'updateChapterCompletionStatus');
return;
}
this.chapters.forEach((chapter, index) => {
try {
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
return;
}
const allLessonsCompleted = chapter.lessons.every(
(lesson) => lesson && lesson.completed === true
);
chapter.completed = allLessonsCompleted;
} catch (error) {
this._handleError(error, 'updateChapterCompletionStatus.chapter', { chapterIndex: index });
}
});
} catch (error) {
this._handleError(error, 'updateChapterCompletionStatus');
}
},
/**
* 전체 마커 배열 반환 (챕터 + lessons flat)
* @returns {Array}
*/
getAllMarkers() {
try {
// 챕터 완료 상태 업데이트
this.updateChapterCompletionStatus();
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'getAllMarkers');
return [];
}
const markers = [];
this.chapters.forEach((chapter, chapterIndex) => {
try {
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 null입니다.`);
return;
}
// 챕터 자체를 마커로 추가 (시작점 표시용, 클릭 불가)
markers.push({
pathPercent: chapter.pathPercent || 0,
pathPercentMo: chapter.pathPercentMo,
gaugePercent: chapter.gaugePercent !== undefined ? chapter.gaugePercent : (chapter.pathPercent || 0),
gaugePercentMo: chapter.gaugePercentMo,
type: chapter.type || 'chapter',
label: chapter.name || `챕터 ${chapterIndex + 1}`,
url: chapter.url || '',
completed: chapter.completed === true,
chapterId: chapter.id || chapterIndex + 1,
isChapterMarker: true,
isLearningContent: false, // 강의 아님
isClickable: false, // 클릭 불가
});
// 하위 lessons 추가 (실제 강의)
if (chapter.lessons && Array.isArray(chapter.lessons)) {
chapter.lessons.forEach((lesson, lessonIndex) => {
try {
if (!lesson) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 레슨 ${lessonIndex}가 null입니다.`);
return;
}
markers.push({
pathPercent: lesson.pathPercent || 0,
pathPercentMo: lesson.pathPercentMo,
gaugePercent: lesson.gaugePercent !== undefined ? lesson.gaugePercent : (lesson.pathPercent || 0),
gaugePercentMo: lesson.gaugePercentMo,
type: lesson.type || 'normal',
label: lesson.label || `레슨 ${lessonIndex + 1}`,
url: lesson.url || '',
content_id: lesson.content_id || '',
watch_tm: Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0),
content_tm: Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0),
all_tm: Math.max(0, Number.parseInt(lesson.all_tm ?? 0, 10) || 0),
completed: lesson.completed === true,
chapterId: chapter.id || chapterIndex + 1,
isChapterMarker: false,
isLearningContent: true, // 실제 강의
isClickable: true, // 클릭 가능
});
} catch (error) {
this._handleError(error, 'getAllMarkers.lesson', { chapterIndex, lessonIndex });
}
});
}
} catch (error) {
this._handleError(error, 'getAllMarkers.chapter', { chapterIndex });
}
});
return markers;
} catch (error) {
this._handleError(error, 'getAllMarkers');
return [];
}
},
/**
* 특정 인덱스의 챕터 정보 반환
* @param {number} globalIndex - 전체 마커 기준 인덱스
* @returns {Object|null} { chapterIndex, chapterData, lessonIndex, lessonData, isChapterMarker }
*/
getChapterByGlobalIndex(globalIndex) {
try {
if (typeof globalIndex !== 'number' || globalIndex < 0) {
this._handleError(new Error(`유효하지 않은 globalIndex: ${globalIndex}`), 'getChapterByGlobalIndex');
return null;
}
const allMarkers = this.getAllMarkers();
if (!Array.isArray(allMarkers) || globalIndex >= allMarkers.length) {
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}가 범위를 벗어났습니다. (총 ${allMarkers.length}개)`);
return null;
}
const marker = allMarkers[globalIndex];
if (!marker) {
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}의 마커를 찾을 수 없습니다.`);
return null;
}
const chapterIndex = this.chapters.findIndex(
(ch) => ch && ch.id === marker.chapterId
);
if (chapterIndex === -1) {
console.warn(`[LEARNING_CONFIG] chapterId ${marker.chapterId}에 해당하는 챕터를 찾을 수 없습니다.`);
return null;
}
const chapter = this.chapters[chapterIndex];
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${chapterIndex}의 데이터가 없습니다.`);
return null;
}
if (marker.isChapterMarker) {
// 챕터 마커인 경우
return {
chapterIndex,
chapterData: chapter,
lessonIndex: -1, // 챕터 자체이므로 -1
lessonData: marker,
isChapterMarker: true,
};
} else {
// 일반 레슨인 경우
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
return null;
}
const lessonIndex = chapter.lessons.findIndex(
(lesson) => lesson && lesson.pathPercent === marker.pathPercent
);
if (lessonIndex === -1) {
console.warn(`[LEARNING_CONFIG] pathPercent ${marker.pathPercent}에 해당하는 레슨을 찾을 수 없습니다.`);
return null;
}
return {
chapterIndex,
chapterData: chapter,
lessonIndex,
lessonData: marker,
isChapterMarker: false,
};
}
} catch (error) {
this._handleError(error, 'getChapterByGlobalIndex', { globalIndex });
return null;
}
},
/**
* 로컬 인덱스를 글로벌 인덱스로 변환
* @param {number} chapterIndex - 챕터 인덱스
* @param {number} lessonIndex - 챕터 내 학습 인덱스 (-1이면 챕터 자체)
* @returns {number|null}
*/
toGlobalIndex(chapterIndex, lessonIndex) {
try {
if (typeof chapterIndex !== 'number' || chapterIndex < 0) {
this._handleError(new Error(`유효하지 않은 chapterIndex: ${chapterIndex}`), 'toGlobalIndex');
return null;
}
if (typeof lessonIndex !== 'number') {
this._handleError(new Error(`유효하지 않은 lessonIndex: ${lessonIndex}`), 'toGlobalIndex');
return null;
}
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'toGlobalIndex');
return null;
}
if (chapterIndex >= this.chapters.length) {
console.warn(`[LEARNING_CONFIG] chapterIndex ${chapterIndex}가 범위를 벗어났습니다. (총 ${this.chapters.length}개)`);
return null;
}
let globalIndex = 0;
for (let i = 0; i < chapterIndex; i++) {
const chapter = this.chapters[i];
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${i}가 null입니다.`);
continue;
}
globalIndex += 1; // 챕터 마커
if (chapter.lessons && Array.isArray(chapter.lessons)) {
globalIndex += chapter.lessons.length; // 하위 lessons
}
}
if (lessonIndex === -1) {
// 챕터 마커 자체
return globalIndex;
} else {
// 하위 lesson
const chapter = this.chapters[chapterIndex];
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
return null;
}
if (lessonIndex >= chapter.lessons.length) {
console.warn(`[LEARNING_CONFIG] lessonIndex ${lessonIndex}가 범위를 벗어났습니다. (총 ${chapter.lessons.length}개)`);
return null;
}
return globalIndex + 1 + lessonIndex;
}
} catch (error) {
this._handleError(error, 'toGlobalIndex', { chapterIndex, lessonIndex });
return null;
}
},
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: `LEARNING_CONFIG.${context}`,
component: 'LEARNING_CONFIG',
...additionalInfo
}, false);
} else {
console.error(`[LEARNING_CONFIG] ${context}:`, error, additionalInfo);
}
},
/**
* 설정 유효성 검증
* @returns {boolean}
*/
validate() {
try {
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'validate');
return false;
}
let isValid = true;
this.chapters.forEach((chapter, index) => {
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}가 null입니다.`);
isValid = false;
return;
}
if (!chapter.id) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 id가 없습니다.`);
isValid = false;
}
if (!chapter.name) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 name이 없습니다.`);
isValid = false;
}
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
isValid = false;
} else {
chapter.lessons.forEach((lesson, lessonIndex) => {
if (!lesson) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}가 null입니다.`);
isValid = false;
} else {
if (!lesson.label) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 label이 없습니다.`);
isValid = false;
}
if (typeof lesson.pathPercent !== 'number') {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 pathPercent가 없거나 숫자가 아닙니다.`);
isValid = false;
}
}
});
}
});
return isValid;
} catch (error) {
this._handleError(error, 'validate');
return false;
}
},
};
/**
* HTML에서 내려준 learningChapterData를 코드 기준으로 반영
* - category_group(code) 기준 챕터 매핑
* - lesson title/url/completed 동기화
*/
if (typeof window !== "undefined" && Array.isArray(window.learningChapterData)) {
try {
const serverChapters = window.learningChapterData;
const chapterByCode = new Map(
serverChapters
.filter((ch) => ch && typeof ch === 'object' && ch.code)
.map((ch) => [String(ch.code), ch])
);
LEARNING_CONFIG.chapters.forEach((chapter, chapterIndex) => {
const serverChapter = chapterByCode.get(String(chapter.code || ''))
|| serverChapters.find((ch) => Number(ch?.id) === Number(chapter.id));
if (!serverChapter) {
return;
}
if (serverChapter.name) {
chapter.name = String(serverChapter.name);
}
const serverLessons = Array.isArray(serverChapter.lessons) ? serverChapter.lessons : [];
if (serverLessons.length === 0) {
chapter.completed = false;
chapter.lessons.forEach((lesson) => {
lesson.completed = false;
});
return;
}
const applyCount = Math.min(chapter.lessons.length, serverLessons.length);
for (let i = 0; i < applyCount; i++) {
const localLesson = chapter.lessons[i];
const serverLesson = serverLessons[i] || {};
if (serverLesson.title) {
localLesson.label = String(serverLesson.title);
}
if (serverLesson.url) {
localLesson.url = String(serverLesson.url);
}
if (serverLesson.content_id) {
localLesson.content_id = String(serverLesson.content_id);
}
if (serverLesson.watch_tm !== undefined) {
localLesson.watch_tm = Math.max(0, Number.parseInt(serverLesson.watch_tm, 10) || 0);
}
if (serverLesson.content_tm !== undefined) {
localLesson.content_tm = Math.max(0, Number.parseInt(serverLesson.content_tm, 10) || 0);
}
if (serverLesson.all_tm !== undefined) {
localLesson.all_tm = Math.max(0, Number.parseInt(serverLesson.all_tm, 10) || 0);
}
if (serverLesson.completed !== undefined) {
localLesson.completed = serverLesson.completed === true;
}
}
for (let i = applyCount; i < chapter.lessons.length; i++) {
chapter.lessons[i].completed = false;
}
chapter.completed = chapter.lessons.length > 0
&& chapter.lessons.every((lesson) => lesson && lesson.completed === true);
if (serverLessons.length !== chapter.lessons.length) {
console.warn(
`[LEARNING_CONFIG] 챕터 ${chapterIndex + 1}(${chapter.code}) 차시 수 불일치: local=${chapter.lessons.length}, server=${serverLessons.length}`
);
}
});
} catch (error) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: 'LEARNING_CONFIG.applyLearningChapterData'
}, false);
} else {
console.error('[LEARNING_CONFIG] learningChapterData 적용 에러:', error);
}
}
}
/**
* HTML에서 설정된 learningConfigData를 LEARNING_CONFIG에 적용
* window.learningConfigData가 있으면 completed 상태를 업데이트
*/
if (typeof window !== "undefined" && window.learningConfigData) {
try {
const configData = window.learningConfigData;
if (!configData || typeof configData !== 'object') {
console.warn('[LEARNING_CONFIG] learningConfigData가 유효하지 않습니다.');
} else {
LEARNING_CONFIG.chapters.forEach((chapter, chapterIndex) => {
try {
if (!chapter || !chapter.id) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 유효하지 않습니다.`);
return;
}
const chapterData = configData[chapter.code] ?? configData[chapter.id];
if (chapterData) {
// 챕터 완료 상태 업데이트
if (chapterData.completed !== undefined) {
chapter.completed = chapterData.completed === true;
}
// 레슨 완료 상태 업데이트
if (chapterData.lessons && Array.isArray(chapterData.lessons)) {
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
return;
}
chapterData.lessons.forEach((lessonData, index) => {
try {
if (chapter.lessons[index] && lessonData && lessonData.completed !== undefined) {
chapter.lessons[index].completed = lessonData.completed === true;
}
} catch (error) {
console.error(`[LEARNING_CONFIG] 레슨 ${index} 업데이트 에러:`, error);
}
});
}
}
} catch (error) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: 'LEARNING_CONFIG.loadFromHTML.chapter',
chapterIndex
}, false);
} else {
console.error(`[LEARNING_CONFIG] 챕터 ${chapterIndex} 업데이트 에러:`, error);
}
}
});
console.log(
"[LEARNING_CONFIG] learningConfigData 적용 완료:",
LEARNING_CONFIG
);
// 설정 유효성 검증
if (LEARNING_CONFIG.validate) {
const isValid = LEARNING_CONFIG.validate();
if (!isValid) {
console.warn('[LEARNING_CONFIG] 설정 유효성 검증 실패');
}
}
}
} catch (error) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: 'LEARNING_CONFIG.loadFromHTML'
}, false);
} else {
console.error('[LEARNING_CONFIG] learningConfigData 적용 에러:', error);
}
}
}
+835
View File
@@ -0,0 +1,835 @@
/**
* 학습 경로 설정
* 공통 모듈 활용 (ErrorHandler, Utils, ConfigManager)
*/
const LEARNING_CONFIG = {
// 마커 설정 - 챕터별로 그룹화
chapters: [
{
id: 1,
code: "CA200C01",
name: "개인정보보호",
type: "chapter",
pathPercent: 0.108,
pathPercentMo: 0.108, // PC와 동일 순서
gaugePercent: 0.108,
gaugePercentMo: 0.108,
url: "ddILV5cbdQo",
completed: false, // 하위 lessons가 모두 완료되면 자동으로 true
lessons: [
{
pathPercent: 0.137,
pathPercentMo: 0.137,
gaugePercent: 0.137,
gaugePercentMo: 0.137,
type: "normal",
label: "개인정보보호 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.159,
pathPercentMo: 0.159,
gaugePercent: 0.156,
gaugePercentMo: 0.156,
type: "normal",
label: "개인정보보호 2",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.182,
pathPercentMo: 0.182,
gaugePercent: 0.178,
gaugePercentMo: 0.178,
type: "normal",
label: "개인정보보호 3",
url: "ddILV5cbdQo",
completed: false,
},
// {
// pathPercent: 0.205,
// pathPercentMo: 0.205,
// gaugePercent: 0.202,
// gaugePercentMo: 0.202,
// type: "normal",
// label: "개인정보보호 4",
// url: "ddILV5cbdQo",
// completed: false,
// },
// {
// pathPercent: 0.228,
// pathPercentMo: 0.228,
// gaugePercent: 0.226,
// gaugePercentMo: 0.226,
// type: "normal",
// label: "개인정보보호 5",
// url: "ddILV5cbdQo",
// completed: false,
// },
// {
// pathPercent: 0.25,
// pathPercentMo: 0.25,
// gaugePercent: 0.246,
// gaugePercentMo: 0.246,
// type: "normal",
// label: "개인정보보호 6",
// url: "ddILV5cbdQo",
// completed: false,
// },
// {
// pathPercent: 0.272,
// pathPercentMo: 0.272,
// gaugePercent: 0.268,
// gaugePercentMo: 0.268,
// type: "normal",
// label: "개인정보보호 7",
// url: "ddILV5cbdQo",
// completed: false,
// },
// {
// pathPercent: 0.298,
// pathPercentMo: 0.298,
// gaugePercent: 0.296,
// gaugePercentMo: 0.296,
// type: "normal",
// label: "개인정보보호 8",
// url: "ddILV5cbdQo",
// completed: false,
// },
],
},
{
id: 2,
code: "CA200C02",
name: "직장내 괴롭힘 예방",
type: "chapter",
pathPercent: 0.325,
pathPercentMo: 0.325,
gaugePercent: 0.325,
gaugePercentMo: 0.325,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.367,
pathPercentMo: 0.367,
gaugePercent: 0.358,
gaugePercentMo: 0.358,
type: "normal",
label: "직장내 괴롭힘 예방 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.41,
pathPercentMo: 0.41,
gaugePercent: 0.40,
gaugePercentMo: 0.40,
type: "normal",
label: "직장내 괴롭힘 예방 2",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 3,
code: "CA200C03",
name: "장애인 인식 개선",
type: "chapter",
pathPercent: 0.442,
pathPercentMo: 0.442,
gaugePercent: 0.442,
gaugePercentMo: 0.442,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.486,
pathPercentMo: 0.486,
gaugePercent: 0.476,
gaugePercentMo: 0.476,
type: "normal",
label: "장애인 인식 개선 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.531,
pathPercentMo: 0.531,
gaugePercent: 0.526,
gaugePercentMo: 0.526,
type: "normal",
label: "장애인 인식 개선 2",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 4,
code: "CA200C04",
name: "성희롱 예방 교육",
type: "chapter",
pathPercent: 0.555,
pathPercentMo: 0.555,
gaugePercent: 0.555,
gaugePercentMo: 0.555,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.585,
pathPercentMo: 0.585,
gaugePercent: 0.582,
gaugePercentMo: 0.582,
type: "normal",
label: "성희롱 예방 교육 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.635,
pathPercentMo: 0.635,
gaugePercent: 0.632,
gaugePercentMo: 0.632,
type: "normal",
label: "성희롱 예방 교육 2",
url: "ddILV5cbdQo",
completed: false,
},
],
},
{
id: 5,
code: "CA200C05",
name: "산업안전 보건",
type: "chapter",
pathPercent: 0.67,
pathPercentMo: 0.67,
gaugePercent: 0.67,
gaugePercentMo: 0.67,
url: "ddILV5cbdQo",
completed: false,
lessons: [
{
pathPercent: 0.69,
pathPercentMo: 0.69,
gaugePercent: 0.686,
gaugePercentMo: 0.686,
type: "normal",
label: "퇴직금 교육 1",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.718,
pathPercentMo: 0.718,
gaugePercent: 0.71,
gaugePercentMo: 0.71,
type: "normal",
label: "퇴직금 교육 2",
url: "ddILV5cbdQo",
completed: false,
},
{
pathPercent: 0.736,
pathPercentMo: 0.736,
gaugePercent: 0.728,
gaugePercentMo: 0.728,
type: "normal",
label: "퇴직금 교육 3",
url: "ddILV5cbdQo",
completed: false,
},
// {
// pathPercent: 0.785,
// pathPercentMo: 0.785,
// gaugePercent: 0.778,
// gaugePercentMo: 0.778,
// type: "normal",
// label: "장애인 인식 개선 4",
// url: "ddILV5cbdQo",
// completed: false,
// },
// {
// pathPercent: 0.82,
// pathPercentMo: 0.82,
// gaugePercent: 0.812,
// gaugePercentMo: 0.812,
// type: "normal",
// label: "장애인 인식 개선 5",
// url: "ddILV5cbdQo",
// completed: false,
// },
// {
// pathPercent: 0.86,
// pathPercentMo: 0.86,
// gaugePercent: 0.85,
// gaugePercentMo: 0.85,
// type: "normal",
// label: "장애인 인식 개선 6",
// url: "ddILV5cbdQo",
// completed: false,
// },
],
},
],
// 평균 학습량 설정 (전체 학습 항목 대비 %)
averageProgress: {
threshold: 60, // 평균 학습량: 전체의 60%
},
// 마커 이미지 경로
markerImages: {
normal: {
base: "/img/learning/mark_base.png",
current: "/img/learning/mark_current.png",
completed: "/img/learning/mark_completed.png",
},
chapter: {
base: "/img/learning/mark_chapter_base.png",
current: "/img/learning/mark_chapter_current.png",
completed: "/img/learning/mark_chapter_completed.png",
},
},
// 상태 이미지 경로
stateImages: {
below: "/img/learning/img_state_01.svg", // 평균 이하
average: "/img/learning/img_state_02.svg", // 평균
above: "/img/learning/img_state_03.svg", // 평균 이상
},
// 모달 경로
modalPath: "./_modal/video-learning.php",
// 비활성 마커 클릭 설정
settings: {
useMarkers: true, // false: 마커 UI 미사용
useLessonMarkers: false, // false: 하위 레슨 마커 숨김 (챕터 마커만 표시)
allowDisabledClick: true, // true: 비활성 마커도 클릭 가능, false: 비활성 마커 클릭 불가
disabledClickMessage: "이전 학습을 먼저 완료해주세요.", // 비활성 마커 클릭 시 메시지
showDisabledAlert: false, // true: 알림 표시, false: 콘솔 로그만
showStateIndicator: {
pc: false, // PC에서 상태 인디케이터 표시
mo: false, // 모바일에서도 상태 인디케이터 표시 (PC와 동일)
},
},
/**
* 챕터의 완료 상태 자동 업데이트
* 하위 lessons가 모두 완료되면 챕터도 completed = true로 변경
*/
updateChapterCompletionStatus() {
try {
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'updateChapterCompletionStatus');
return;
}
this.chapters.forEach((chapter, index) => {
try {
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
return;
}
const allLessonsCompleted = chapter.lessons.every(
(lesson) => lesson && lesson.completed === true
);
chapter.completed = allLessonsCompleted;
} catch (error) {
this._handleError(error, 'updateChapterCompletionStatus.chapter', { chapterIndex: index });
}
});
} catch (error) {
this._handleError(error, 'updateChapterCompletionStatus');
}
},
/**
* 전체 마커 배열 반환 (챕터 + lessons flat)
* @returns {Array}
*/
getAllMarkers() {
try {
// 챕터 완료 상태 업데이트
this.updateChapterCompletionStatus();
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'getAllMarkers');
return [];
}
const markers = [];
this.chapters.forEach((chapter, chapterIndex) => {
try {
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 null입니다.`);
return;
}
// 챕터 자체를 마커로 추가 (시작점 표시용, 클릭 불가)
markers.push({
pathPercent: chapter.pathPercent || 0,
pathPercentMo: chapter.pathPercentMo,
gaugePercent: chapter.gaugePercent !== undefined ? chapter.gaugePercent : (chapter.pathPercent || 0),
gaugePercentMo: chapter.gaugePercentMo,
type: chapter.type || 'chapter',
label: chapter.name || `챕터 ${chapterIndex + 1}`,
url: chapter.url || '',
completed: chapter.completed === true,
chapterId: chapter.id || chapterIndex + 1,
isChapterMarker: true,
isLearningContent: false, // 강의 아님
isClickable: false, // 클릭 불가
});
// 하위 lessons 추가 (실제 강의)
if (chapter.lessons && Array.isArray(chapter.lessons)) {
chapter.lessons.forEach((lesson, lessonIndex) => {
try {
if (!lesson) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 레슨 ${lessonIndex}가 null입니다.`);
return;
}
markers.push({
pathPercent: lesson.pathPercent || 0,
pathPercentMo: lesson.pathPercentMo,
gaugePercent: lesson.gaugePercent !== undefined ? lesson.gaugePercent : (lesson.pathPercent || 0),
gaugePercentMo: lesson.gaugePercentMo,
type: lesson.type || 'normal',
label: lesson.label || `레슨 ${lessonIndex + 1}`,
url: lesson.url || '',
content_id: lesson.content_id || '',
watch_tm: Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0),
content_tm: Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0),
all_tm: Math.max(0, Number.parseInt(lesson.all_tm ?? 0, 10) || 0),
completed: lesson.completed === true,
description: lesson.description || '',
chapterId: chapter.id || chapterIndex + 1,
isChapterMarker: false,
isLearningContent: true, // 실제 강의
isClickable: true, // 클릭 가능
});
} catch (error) {
this._handleError(error, 'getAllMarkers.lesson', { chapterIndex, lessonIndex });
}
});
}
} catch (error) {
this._handleError(error, 'getAllMarkers.chapter', { chapterIndex });
}
});
return markers;
} catch (error) {
this._handleError(error, 'getAllMarkers');
return [];
}
},
/**
* 특정 인덱스의 챕터 정보 반환
* @param {number} globalIndex - 전체 마커 기준 인덱스
* @returns {Object|null} { chapterIndex, chapterData, lessonIndex, lessonData, isChapterMarker }
*/
getChapterByGlobalIndex(globalIndex) {
try {
if (typeof globalIndex !== 'number' || globalIndex < 0) {
this._handleError(new Error(`유효하지 않은 globalIndex: ${globalIndex}`), 'getChapterByGlobalIndex');
return null;
}
const allMarkers = this.getAllMarkers();
if (!Array.isArray(allMarkers) || globalIndex >= allMarkers.length) {
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}가 범위를 벗어났습니다. (총 ${allMarkers.length}개)`);
return null;
}
const marker = allMarkers[globalIndex];
if (!marker) {
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}의 마커를 찾을 수 없습니다.`);
return null;
}
const chapterIndex = this.chapters.findIndex(
(ch) => ch && ch.id === marker.chapterId
);
if (chapterIndex === -1) {
console.warn(`[LEARNING_CONFIG] chapterId ${marker.chapterId}에 해당하는 챕터를 찾을 수 없습니다.`);
return null;
}
const chapter = this.chapters[chapterIndex];
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${chapterIndex}의 데이터가 없습니다.`);
return null;
}
if (marker.isChapterMarker) {
// 챕터 마커인 경우
return {
chapterIndex,
chapterData: chapter,
lessonIndex: -1, // 챕터 자체이므로 -1
lessonData: marker,
isChapterMarker: true,
};
} else {
// 일반 레슨인 경우
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
return null;
}
const lessonIndex = chapter.lessons.findIndex(
(lesson) => lesson && lesson.pathPercent === marker.pathPercent
);
if (lessonIndex === -1) {
console.warn(`[LEARNING_CONFIG] pathPercent ${marker.pathPercent}에 해당하는 레슨을 찾을 수 없습니다.`);
return null;
}
return {
chapterIndex,
chapterData: chapter,
lessonIndex,
lessonData: marker,
isChapterMarker: false,
};
}
} catch (error) {
this._handleError(error, 'getChapterByGlobalIndex', { globalIndex });
return null;
}
},
/**
* 로컬 인덱스를 글로벌 인덱스로 변환
* @param {number} chapterIndex - 챕터 인덱스
* @param {number} lessonIndex - 챕터 내 학습 인덱스 (-1이면 챕터 자체)
* @returns {number|null}
*/
toGlobalIndex(chapterIndex, lessonIndex) {
try {
if (typeof chapterIndex !== 'number' || chapterIndex < 0) {
this._handleError(new Error(`유효하지 않은 chapterIndex: ${chapterIndex}`), 'toGlobalIndex');
return null;
}
if (typeof lessonIndex !== 'number') {
this._handleError(new Error(`유효하지 않은 lessonIndex: ${lessonIndex}`), 'toGlobalIndex');
return null;
}
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'toGlobalIndex');
return null;
}
if (chapterIndex >= this.chapters.length) {
console.warn(`[LEARNING_CONFIG] chapterIndex ${chapterIndex}가 범위를 벗어났습니다. (총 ${this.chapters.length}개)`);
return null;
}
let globalIndex = 0;
for (let i = 0; i < chapterIndex; i++) {
const chapter = this.chapters[i];
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${i}가 null입니다.`);
continue;
}
globalIndex += 1; // 챕터 마커
if (chapter.lessons && Array.isArray(chapter.lessons)) {
globalIndex += chapter.lessons.length; // 하위 lessons
}
}
if (lessonIndex === -1) {
// 챕터 마커 자체
return globalIndex;
} else {
// 하위 lesson
const chapter = this.chapters[chapterIndex];
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
return null;
}
if (lessonIndex >= chapter.lessons.length) {
console.warn(`[LEARNING_CONFIG] lessonIndex ${lessonIndex}가 범위를 벗어났습니다. (총 ${chapter.lessons.length}개)`);
return null;
}
return globalIndex + 1 + lessonIndex;
}
} catch (error) {
this._handleError(error, 'toGlobalIndex', { chapterIndex, lessonIndex });
return null;
}
},
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: `LEARNING_CONFIG.${context}`,
component: 'LEARNING_CONFIG',
...additionalInfo
}, false);
} else {
console.error(`[LEARNING_CONFIG] ${context}:`, error, additionalInfo);
}
},
/**
* 설정 유효성 검증
* @returns {boolean}
*/
validate() {
try {
if (!this.chapters || !Array.isArray(this.chapters)) {
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'validate');
return false;
}
let isValid = true;
this.chapters.forEach((chapter, index) => {
if (!chapter) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}가 null입니다.`);
isValid = false;
return;
}
if (!chapter.id) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 id가 없습니다.`);
isValid = false;
}
if (!chapter.name) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 name이 없습니다.`);
isValid = false;
}
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
isValid = false;
} else {
chapter.lessons.forEach((lesson, lessonIndex) => {
if (!lesson) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}가 null입니다.`);
isValid = false;
} else {
if (!lesson.label) {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 label이 없습니다.`);
isValid = false;
}
if (typeof lesson.pathPercent !== 'number') {
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 pathPercent가 없거나 숫자가 아닙니다.`);
isValid = false;
}
}
});
}
});
return isValid;
} catch (error) {
this._handleError(error, 'validate');
return false;
}
},
};
/**
* HTML에서 내려준 learningChapterData를 기반으로 chapters 동적 생성
* - category_group(code) 기준 챕터 그룹핑
* - DB 데이터가 있으면 하드코딩 대신 동적으로 chapters 배열을 재구성
* - pathPercent/gaugePercent를 총 항목 수에 맞게 균등 분배
*/
if (typeof window !== "undefined" && Array.isArray(window.learningChapterData) && window.learningChapterData.length > 0) {
try {
const serverChapters = window.learningChapterData;
// ── pathPercent / gaugePercent 균등 분배 계산 ──
const PATH_START = 0.10;
const PATH_END = 0.88;
// 총 마커 수 계산 (각 챕터 1개 + 각 챕터의 lessons 수)
const totalItems = serverChapters.reduce((sum, ch) => {
const lessonCount = Array.isArray(ch.lessons) ? ch.lessons.length : 0;
return sum + 1 + lessonCount; // 1 for chapter marker + lessons
}, 0);
const step = totalItems > 1 ? (PATH_END - PATH_START) / (totalItems - 1) : 0;
let positionIndex = 0;
// ── 서버 데이터로 chapters 동적 생성 ──
const dynamicChapters = serverChapters.map((serverChapter, chapterIndex) => {
// 챕터 위치는 하드코딩값 우선 유지 (DB 데이터가 있어도 챕터 카드 배치 고정)
const hardcodedChapter = LEARNING_CONFIG.chapters.find((ch) =>
(ch?.code && serverChapter?.code && ch.code === serverChapter.code) ||
(ch?.id && serverChapter?.id && Number(ch.id) === Number(serverChapter.id))
) || LEARNING_CONFIG.chapters[chapterIndex];
const chapterPercent =
typeof hardcodedChapter?.pathPercent === "number"
? hardcodedChapter.pathPercent
: (PATH_START + (step * positionIndex));
positionIndex++;
const serverLessons = Array.isArray(serverChapter.lessons) ? serverChapter.lessons : [];
const lessons = serverLessons.map((sl, lessonIndex) => {
const lessonPercent = PATH_START + (step * positionIndex);
positionIndex++;
return {
pathPercent: Math.round(lessonPercent * 1000) / 1000,
pathPercentMo: Math.round(lessonPercent * 1000) / 1000,
gaugePercent: Math.round(lessonPercent * 1000) / 1000,
gaugePercentMo: Math.round(lessonPercent * 1000) / 1000,
type: "normal",
label: String(sl.title || `차시 ${lessonIndex + 1}`),
url: String(sl.url || ''),
content_id: String(sl.content_id || ''),
watch_tm: Math.max(0, Number.parseInt(sl.watch_tm ?? 0, 10) || 0),
content_tm: Math.max(0, Number.parseInt(sl.content_tm ?? 0, 10) || 0),
all_tm: Math.max(0, Number.parseInt(sl.all_tm ?? 0, 10) || 0),
completed: sl.completed === true,
description: String(sl.description || ''),
};
});
return {
id: chapterIndex + 1,
code: String(serverChapter.code || ''),
name: String(serverChapter.name || `챕터 ${chapterIndex + 1}`),
type: "chapter",
pathPercent: Math.round(chapterPercent * 1000) / 1000,
pathPercentMo: typeof hardcodedChapter?.pathPercentMo === "number"
? hardcodedChapter.pathPercentMo
: Math.round(chapterPercent * 1000) / 1000,
gaugePercent: typeof hardcodedChapter?.gaugePercent === "number"
? hardcodedChapter.gaugePercent
: Math.round(chapterPercent * 1000) / 1000,
gaugePercentMo: typeof hardcodedChapter?.gaugePercentMo === "number"
? hardcodedChapter.gaugePercentMo
: Math.round(chapterPercent * 1000) / 1000,
url: lessons.length > 0 ? lessons[0].url : '',
completed: lessons.length > 0 && lessons.every((l) => l.completed === true),
lessons: lessons,
};
});
// 하드코딩된 chapters를 서버 데이터로 교체
LEARNING_CONFIG.chapters = dynamicChapters;
console.log(
`[LEARNING_CONFIG] DB 데이터에서 ${dynamicChapters.length}개 챕터, ` +
`${dynamicChapters.reduce((s, ch) => s + ch.lessons.length, 0)}개 차시 동적 생성 완료`
);
} catch (error) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: 'LEARNING_CONFIG.applyLearningChapterData'
}, false);
} else {
console.error('[LEARNING_CONFIG] learningChapterData 동적 생성 에러:', error);
}
// 에러 발생 시 하드코딩된 chapters 유지 (폴백)
}
}
/**
* HTML에서 설정된 learningConfigData를 LEARNING_CONFIG에 적용
* window.learningConfigData가 있으면 completed 상태를 업데이트
*/
if (typeof window !== "undefined" && window.learningConfigData) {
try {
const configData = window.learningConfigData;
if (!configData || typeof configData !== 'object') {
console.warn('[LEARNING_CONFIG] learningConfigData가 유효하지 않습니다.');
} else {
LEARNING_CONFIG.chapters.forEach((chapter, chapterIndex) => {
try {
if (!chapter || !chapter.id) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 유효하지 않습니다.`);
return;
}
const chapterData = configData[chapter.code] ?? configData[chapter.id];
if (chapterData) {
// 챕터 완료 상태 업데이트
if (chapterData.completed !== undefined) {
chapter.completed = chapterData.completed === true;
}
// 레슨 완료 상태 업데이트
if (chapterData.lessons && Array.isArray(chapterData.lessons)) {
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
return;
}
chapterData.lessons.forEach((lessonData, index) => {
try {
if (chapter.lessons[index] && lessonData && lessonData.completed !== undefined) {
chapter.lessons[index].completed = lessonData.completed === true;
}
} catch (error) {
console.error(`[LEARNING_CONFIG] 레슨 ${index} 업데이트 에러:`, error);
}
});
}
}
} catch (error) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: 'LEARNING_CONFIG.loadFromHTML.chapter',
chapterIndex
}, false);
} else {
console.error(`[LEARNING_CONFIG] 챕터 ${chapterIndex} 업데이트 에러:`, error);
}
}
});
console.log(
"[LEARNING_CONFIG] learningConfigData 적용 완료:",
LEARNING_CONFIG
);
// 설정 유효성 검증
if (LEARNING_CONFIG.validate) {
const isValid = LEARNING_CONFIG.validate();
if (!isValid) {
console.warn('[LEARNING_CONFIG] 설정 유효성 검증 실패');
}
}
}
} catch (error) {
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
ErrorHandler.handle(error, {
context: 'LEARNING_CONFIG.loadFromHTML'
}, false);
} else {
console.error('[LEARNING_CONFIG] learningConfigData 적용 에러:', error);
}
}
}
+506
View File
@@ -0,0 +1,506 @@
/**
* 게이지 진행률 관리 클래스
* 공통 모듈 활용 (ErrorHandler, DOMUtils, AnimationUtils, Utils)
*/
class GaugeManager {
constructor(dependencies = {}) {
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
try {
// PC/모바일 구분: 768px 미만이면 모바일 게이지 SVG 사용
this.isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
const gaugeSvgId = this.isMobile ? 'gauge-svg-mo' : 'gauge-svg';
const maskPathId = this.isMobile ? 'maskPath-mo' : 'maskPath';
this.maskPath = this.domUtils?.$("#" + maskPathId) || document.getElementById(maskPathId);
this.gaugeSvg = this.domUtils?.$("#" + gaugeSvgId) || document.getElementById(gaugeSvgId);
this.pathLength = 0;
if (!this.maskPath) {
this._handleError(new Error('maskPath 요소를 찾을 수 없습니다.'), 'GaugeManager.constructor');
}
if (!this.gaugeSvg) {
this._handleError(new Error('gauge-svg 요소를 찾을 수 없습니다.'), 'GaugeManager.constructor');
}
} catch (error) {
this._handleError(error, 'GaugeManager.constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `GaugeManager.${context}`,
component: 'GaugeManager',
...additionalInfo
}, false);
} else {
console.error(`[GaugeManager] ${context}:`, error, additionalInfo);
}
}
/**
* 진행률 설정
* @param {number} percent - 진행률 (0-100) 또는 pathPercent (0-1)
* @param {boolean} isPathPercent - percent가 pathPercent인지 여부 (기본값: false)
* @param {boolean} animate - 애니메이션 적용 여부 (기본값: true)
*/
setProgress(percent, isPathPercent = false, animate = true) {
try {
if (!this.maskPath) {
this._handleError(new Error('maskPath 요소를 찾을 수 없습니다.'), 'setProgress');
return;
}
// 입력값 유효성 검증
if (typeof percent !== 'number' || isNaN(percent)) {
this._handleError(new Error(`유효하지 않은 percent 값: ${percent}`), 'setProgress');
return;
}
if (this.pathLength === 0) {
this.pathLength = this.maskPath.getTotalLength();
if (this.pathLength === 0) {
this._handleError(new Error('pathLength가 0입니다.'), 'setProgress');
return;
}
}
let targetPathPercent;
if (isPathPercent) {
// pathPercent를 직접 사용 (0-1)
targetPathPercent = Math.max(0, Math.min(1, percent));
} else {
// percent를 pathPercent로 변환 (0-100 -> 0-1)
targetPathPercent = Math.max(0, Math.min(1, percent / 100));
}
// maskPath: path 0%=시작부터 채움. 시작점(하단)→끝점(상단) 방향이 PC/MO 동일하므로 같은 offset 공식 사용
const targetLength = this.pathLength * targetPathPercent;
const targetOffset = this.pathLength - targetLength;
// 애니메이션 처리
if (animate) {
// AnimationUtils 활용 (있는 경우)
if (this.animationUtils) {
// progressBar 애니메이션을 SVG path에 적용하기 어려우므로 기존 방식 유지
// 하지만 transition은 DOMUtils로 관리 가능
if (this.domUtils) {
this.domUtils.setStyles(this.maskPath, {
transition: "stroke-dashoffset 0.8s ease-out"
});
} else {
if (!this.maskPath.style.transition) {
this.maskPath.style.transition = "stroke-dashoffset 0.8s ease-out";
}
}
} else {
// 애니메이션을 위한 transition 추가
if (!this.maskPath.style.transition) {
this.maskPath.style.transition = "stroke-dashoffset 0.8s ease-out";
}
}
} else {
// 초기 로딩 시 애니메이션 없이 즉시 적용
// transition을 먼저 none으로 설정하여 이전 애니메이션 방지
if (this.domUtils) {
this.domUtils.setStyles(this.maskPath, {
transition: "none"
});
} else {
this.maskPath.style.transition = "none";
}
// 강제로 레이아웃 계산하여 transition 변경사항 즉시 적용
void this.maskPath.offsetHeight;
}
// stroke-dasharray를 인라인 스타일로 설정 (단일 값 → dash=pathLength, gap=pathLength)
// SVG 속성(setAttribute) 대신 인라인 스타일 사용: PC/MO 모두 동일하게 dashoffset으로 제어 가능
if (this.domUtils) {
this.domUtils.setStyles(this.maskPath, {
strokeDasharray: `${this.pathLength}`,
strokeDashoffset: targetOffset
});
} else {
this.maskPath.style.strokeDasharray = `${this.pathLength}`;
this.maskPath.style.strokeDashoffset = targetOffset;
}
// 애니메이션 비활성화 후 다음 업데이트를 위해 transition 복원
if (!animate) {
// 강제로 레이아웃 계산하여 값 변경사항 즉시 적용
void this.maskPath.offsetHeight;
// 다음 프레임에서 transition 복원 (현재 변경사항 적용 후)
requestAnimationFrame(() => {
if (this.domUtils) {
this.domUtils.setStyles(this.maskPath, {
transition: "stroke-dashoffset 0.8s ease-out"
});
} else {
this.maskPath.style.transition = "stroke-dashoffset 0.8s ease-out";
}
});
}
console.log(
`[GaugeManager] setProgress: pathPercent=${targetPathPercent.toFixed(4)}, targetOffset=${targetOffset.toFixed(2)}, pathLength=${this.pathLength.toFixed(2)}, animate=${animate}`
);
} catch (error) {
this._handleError(error, 'setProgress', { percent, isPathPercent, animate });
}
}
/**
* 경로상의 특정 위치 좌표 반환
* @param {number} percent - 위치 (0-1)
* @returns {DOMPoint|null} 좌표
*/
getPointAtPercent(percent) {
try {
if (!this.maskPath) {
this._handleError(new Error('maskPath 요소를 찾을 수 없습니다.'), 'getPointAtPercent');
return null;
}
// 입력값 유효성 검증
if (typeof percent !== 'number' || isNaN(percent)) {
this._handleError(new Error(`유효하지 않은 percent 값: ${percent}`), 'getPointAtPercent');
return null;
}
// percent를 0-1 범위로 제한
const clampedPercent = Math.max(0, Math.min(1, percent));
if (this.pathLength === 0) {
this.pathLength = this.maskPath.getTotalLength();
if (this.pathLength === 0) {
this._handleError(new Error('pathLength가 0입니다.'), 'getPointAtPercent');
return null;
}
}
// PC/MO 모두 path가 START(0%)→트로피(100%) 방향. 동일 공식 사용
const lengthPercent = clampedPercent;
return this.maskPath.getPointAtLength(this.pathLength * lengthPercent);
} catch (error) {
this._handleError(error, 'getPointAtPercent', { percent });
return null;
}
}
/**
* 마커의 실제 DOM 위치에 가장 가까운 maskPath 지점 찾기
* @param {number} markerPercentX - 마커의 X 위치 (퍼센트)
* @param {number} markerPercentY - 마커의 Y 위치 (퍼센트)
* @returns {number} 가장 가까운 지점의 pathPercent (0-1)
*/
findClosestPathPercent(markerPercentX, markerPercentY) {
try {
if (!this.maskPath || !this.gaugeSvg) {
this._handleError(new Error('maskPath 또는 gaugeSvg 요소를 찾을 수 없습니다.'), 'findClosestPathPercent');
return 0;
}
// 입력값 유효성 검증
if (typeof markerPercentX !== 'number' || isNaN(markerPercentX) ||
typeof markerPercentY !== 'number' || isNaN(markerPercentY)) {
this._handleError(new Error(`유효하지 않은 좌표 값: (${markerPercentX}, ${markerPercentY})`), 'findClosestPathPercent');
return 0;
}
if (this.pathLength === 0) {
this.pathLength = this.maskPath.getTotalLength();
if (this.pathLength === 0) {
this._handleError(new Error('pathLength가 0입니다.'), 'findClosestPathPercent');
return 0;
}
}
const viewBox = this.gaugeSvg.viewBox.baseVal;
if (!viewBox || !viewBox.width || !viewBox.height) {
this._handleError(new Error('viewBox가 유효하지 않습니다.'), 'findClosestPathPercent');
return 0;
}
const markerX = (markerPercentX / 100) * viewBox.width;
const markerY = (markerPercentY / 100) * viewBox.height;
// maskPath를 따라 여러 지점을 샘플링하여 가장 가까운 지점 찾기
const samples = 200; // 샘플링 개수 (정확도와 성능의 균형)
let closestDistance = Infinity;
let closestPercent = 0;
for (let i = 0; i <= samples; i++) {
try {
const percent = i / samples;
const point = this.maskPath.getPointAtLength(this.pathLength * percent);
if (!point) {
continue;
}
// 마커 위치와의 거리 계산
const dx = point.x - markerX;
const dy = point.y - markerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < closestDistance) {
closestDistance = distance;
closestPercent = percent;
}
} catch (error) {
// 개별 샘플링 에러는 무시하고 계속 진행
continue;
}
}
const resultPercent = closestPercent;
return Math.max(0, Math.min(1, resultPercent));
} catch (error) {
this._handleError(error, 'findClosestPathPercent', { markerPercentX, markerPercentY });
return 0;
}
}
/**
* PC/모바일 상태 업데이트 (리사이즈 시 호출)
* isMobile 플래그와 gaugeSvg, maskPath를 현재 창 너비에 맞게 전환
*/
updateMobileState() {
try {
const newIsMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
if (newIsMobile === this.isMobile) return; // 변경 없으면 스킵
this.isMobile = newIsMobile;
const gaugeSvgId = this.isMobile ? 'gauge-svg-mo' : 'gauge-svg';
const maskPathId = this.isMobile ? 'maskPath-mo' : 'maskPath';
this.maskPath = this.domUtils?.('#' + maskPathId) || document.getElementById(maskPathId);
this.gaugeSvg = this.domUtils?.('#' + gaugeSvgId) || document.getElementById(gaugeSvgId);
this.pathLength = 0; // 재계산을 위해 초기화
console.log(`[GaugeManager] 상태 전환: ${this.isMobile ? '모바일' : 'PC'}`);
} catch (error) {
this._handleError(error, 'updateMobileState');
}
}
/**
* 초기 진행률 계산 (타겟 마커 config 반환)
* @param {Array} allMarkers - 전체 마커 배열
* @param {Object} config - 설정 객체
* @returns {Object|null} 타겟 마커 config 객체
*/
calculateInitialProgress(allMarkers, config) {
try {
// 입력값 유효성 검증
if (!allMarkers || !Array.isArray(allMarkers)) {
this._handleError(new Error('allMarkers가 배열이 아닙니다.'), 'calculateInitialProgress');
return null;
}
if (!config || typeof config !== 'object') {
this._handleError(new Error('config가 유효하지 않습니다.'), 'calculateInitialProgress');
return null;
}
const settings = config?.settings || {};
if (settings.allowDisabledClick) {
// 비활성 마커 클릭 허용 모드: 완료된 개수만큼 앞에서부터 채우기
return this._calculateProgressByCount(allMarkers);
} else {
// 순차 학습 모드: 다음 학습 위치
return this._calculateProgressBySequence(allMarkers);
}
} catch (error) {
this._handleError(error, 'calculateInitialProgress', { allMarkers, config });
return null;
}
}
/**
* 완료된 개수 기준 진행률 계산 (allowDisabledClick: true)
* @private
*/
_calculateProgressByCount(allMarkers) {
try {
if (!allMarkers || !Array.isArray(allMarkers)) {
this._handleError(new Error('allMarkers가 배열이 아닙니다.'), '_calculateProgressByCount');
return null;
}
// 실제 강의만 필터링 (챕터 제외)
const learningMarkers = allMarkers.filter(
(m) => m && m.isLearningContent !== false
);
const completedLearningCount = learningMarkers.filter(
(m) => m && m.completed === true
).length;
if (completedLearningCount === 0) {
// 완료된 학습 없음 → 첫 번째 챕터 마커까지
const firstChapterMarker = allMarkers.find(
(m) => m && m.isChapterMarker === true
);
if (firstChapterMarker) {
console.log(
`[GaugeManager] 완료 기준 진행률: 첫 챕터 마커 (0개 강의 완료)`
);
return firstChapterMarker; // 마커 config 반환
}
return null;
}
if (completedLearningCount >= learningMarkers.length) {
// 모든 강의 완료 → 마지막 마커 위치
const lastMarker = allMarkers[allMarkers.length - 1];
if (lastMarker) {
console.log(
`[GaugeManager] 완료 기준 진행률: 마지막 마커 (전체 ${learningMarkers.length}개 강의 완료)`
);
return lastMarker; // 마커 config 반환
}
return null;
}
// 다음 학습할 강의 위치 (현재 학습 중인 마커)
const nextLearningMarker = learningMarkers[completedLearningCount];
if (!nextLearningMarker) {
console.warn(`[GaugeManager] 다음 학습 마커를 찾을 수 없습니다. (인덱스: ${completedLearningCount})`);
return null;
}
const nextMarkerIndex = allMarkers.findIndex(
(m) =>
m &&
m.pathPercent === nextLearningMarker.pathPercent &&
m.label === nextLearningMarker.label
);
if (nextMarkerIndex === -1) {
console.warn(`[GaugeManager] 타겟 마커를 찾을 수 없습니다.`);
return null;
}
const targetMarker = allMarkers[nextMarkerIndex];
if (!targetMarker) {
console.warn(`[GaugeManager] 타겟 마커가 null입니다.`);
return null;
}
console.log(
`[GaugeManager] 완료 기준 진행률: ${completedLearningCount}/${learningMarkers.length}개 강의 완료, 현재 학습: ${nextLearningMarker.label}`
);
return targetMarker; // 마커 config 반환
} catch (error) {
this._handleError(error, '_calculateProgressByCount');
return null;
}
}
/**
* 순차 학습 기준 진행률 계산 (allowDisabledClick: false)
* @private
*/
_calculateProgressBySequence(allMarkers) {
try {
if (!allMarkers || !Array.isArray(allMarkers)) {
this._handleError(new Error('allMarkers가 배열이 아닙니다.'), '_calculateProgressBySequence');
return null;
}
// 실제 강의만 필터링 (챕터 제외)
const learningMarkers = allMarkers.filter(
(m) => m && m.isLearningContent !== false
);
// 마지막으로 완료된 강의의 인덱스 찾기 (순차적)
let lastCompletedLearningIndex = -1;
for (let i = 0; i < learningMarkers.length; i++) {
if (learningMarkers[i] && learningMarkers[i].completed === true) {
lastCompletedLearningIndex = i;
} else {
// 완료되지 않은 학습을 만나면 중단
break;
}
}
// 완료된 강의가 없는 경우 → 첫 번째 챕터 마커까지
if (lastCompletedLearningIndex === -1) {
const firstChapterMarker = allMarkers.find(
(m) => m && m.isChapterMarker === true
);
if (firstChapterMarker) {
console.log(
`[GaugeManager] 순차 진행률: 첫 챕터 마커 (강의 완료 없음)`
);
return firstChapterMarker; // 마커 config 반환
}
return null;
}
// 모든 강의가 완료된 경우 → 마지막 마커 위치
if (lastCompletedLearningIndex === learningMarkers.length - 1) {
const lastMarker = allMarkers[allMarkers.length - 1];
if (lastMarker) {
console.log(
`[GaugeManager] 순차 진행률: 마지막 마커 (전체 ${learningMarkers.length}개 강의 완료)`
);
return lastMarker; // 마커 config 반환
}
return null;
}
// 다음 학습할 강의 위치 (현재 학습 중인 마커)
const nextIndex = lastCompletedLearningIndex + 1;
if (nextIndex >= learningMarkers.length) {
console.warn(`[GaugeManager] 다음 학습 인덱스가 범위를 벗어났습니다.`);
return null;
}
const nextLearningMarker = learningMarkers[nextIndex];
if (!nextLearningMarker) {
console.warn(`[GaugeManager] 다음 학습 마커를 찾을 수 없습니다.`);
return null;
}
const nextMarkerIndex = allMarkers.findIndex(
(m) =>
m &&
m.pathPercent === nextLearningMarker.pathPercent &&
m.label === nextLearningMarker.label
);
if (nextMarkerIndex === -1) {
console.warn(`[GaugeManager] 타겟 마커를 찾을 수 없습니다.`);
return null;
}
const targetMarker = allMarkers[nextMarkerIndex];
if (!targetMarker) {
console.warn(`[GaugeManager] 타겟 마커가 null입니다.`);
return null;
}
console.log(
`[GaugeManager] 순차 진행률: 현재 학습: ${nextLearningMarker.label}`
);
return targetMarker; // 마커 config 반환
} catch (error) {
this._handleError(error, '_calculateProgressBySequence');
return null;
}
}
}
+537
View File
@@ -0,0 +1,537 @@
/**
* 학습 페이지 초기화 및 관리
* 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils)
*/
class LearningApp {
constructor(dependencies = {}) {
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
try {
// HTML data 속성에서 설정 읽기
this._loadSettingsFromHTML();
// 의존성 전달을 위한 객체 생성
const commonDependencies = {
domUtils: this.domUtils,
errorHandler: this.errorHandler,
eventManager: this.eventManager,
utils: this.utils,
animationUtils: this.animationUtils
};
// GaugeManager 초기화 (의존성 주입)
this.gauge = new GaugeManager(commonDependencies);
// MarkerManager 초기화
this.markerManager = new MarkerManager(this.gauge, LEARNING_CONFIG);
// ChapterCardManager 초기화 (의존성 주입)
this.chapterCardManager = new ChapterCardManager(
LEARNING_CONFIG,
this.gauge,
commonDependencies
);
// ProgressIndicator 초기화
this.progressIndicator = new ProgressIndicator(
LEARNING_CONFIG,
this.gauge,
this.markerManager
);
// VideoModal은 선택 의존성으로 처리하여 모달 스크립트 문제 시에도
// 경로/마커/챕터 카드는 정상 렌더링되도록 한다.
this.modal = null;
if (typeof VideoModal !== 'undefined') {
this.modal = new VideoModal(LEARNING_CONFIG, this.markerManager);
if (this.markerManager && typeof this.markerManager.setModalInstance === 'function') {
this.markerManager.setModalInstance(this.modal);
}
if (this.chapterCardManager && typeof this.chapterCardManager.setModalInstance === 'function') {
this.chapterCardManager.setModalInstance(this.modal);
}
} else {
console.warn('[LearningApp] VideoModal이 없어 모달 기능은 비활성화됩니다.');
this.modal = this._createFallbackModalHandler();
if (this.markerManager && typeof this.markerManager.setModalInstance === 'function') {
this.markerManager.setModalInstance(this.modal);
}
if (this.chapterCardManager && typeof this.chapterCardManager.setModalInstance === 'function') {
this.chapterCardManager.setModalInstance(this.modal);
}
}
this.init();
} catch (error) {
this._handleError(error, 'LearningApp.constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `LearningApp.${context}`,
component: 'LearningApp',
...additionalInfo
}, false);
} else {
console.error(`[LearningApp] ${context}:`, error, additionalInfo);
}
}
/**
* HTML data 속성에서 설정 읽기
* @private
*/
_loadSettingsFromHTML() {
try {
const learningGauge = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge");
if (!learningGauge) {
console.warn("[LearningApp] .lessons-gauge 요소를 찾을 수 없습니다.");
return;
}
// data-allow-disabled-click
const allowDisabledClick = learningGauge.dataset.allowDisabledClick;
if (allowDisabledClick !== undefined) {
LEARNING_CONFIG.settings.allowDisabledClick =
allowDisabledClick === "true";
}
// data-show-disabled-alert
const showDisabledAlert = learningGauge.dataset.showDisabledAlert;
if (showDisabledAlert !== undefined) {
LEARNING_CONFIG.settings.showDisabledAlert = showDisabledAlert === "true";
}
// data-disabled-click-message
const disabledClickMessage = learningGauge.dataset.disabledClickMessage;
if (disabledClickMessage) {
LEARNING_CONFIG.settings.disabledClickMessage = disabledClickMessage;
}
console.log("[LearningApp] HTML 설정 로드 완료:", LEARNING_CONFIG.settings);
} catch (error) {
this._handleError(error, '_loadSettingsFromHTML');
}
}
/**
* 초기화
*/
init() {
try {
const initHandler = () => {
try {
this._initializeComponents();
} catch (error) {
this._handleError(error, 'init.initHandler');
}
};
// DOMContentLoaded 이벤트 처리
if (document.readyState === 'loading') {
if (this.eventManager) {
const listenerId = this.eventManager.on(window, "DOMContentLoaded", initHandler);
this.listenerIds.push({ element: window, id: listenerId, type: 'DOMContentLoaded' });
} else {
window.addEventListener("DOMContentLoaded", initHandler);
}
} else {
// 이미 로드된 경우 즉시 실행
initHandler();
}
} catch (error) {
this._handleError(error, 'init');
}
}
/**
* 컴포넌트 초기화
* @private
*/
_initializeComponents() {
try {
const useMarkers = LEARNING_CONFIG?.settings?.useMarkers !== false;
// 마커 생성 (설정에서 비활성화 가능)
if (useMarkers && this.markerManager && typeof this.markerManager.createMarkers === 'function') {
this.markerManager.createMarkers();
} else if (!useMarkers) {
console.log("[LearningApp] 마커 UI 비활성화: createMarkers 생략");
} else {
console.warn("[LearningApp] markerManager.createMarkers를 호출할 수 없습니다.");
}
// 챕터 카드 생성
if (this.chapterCardManager && typeof this.chapterCardManager.createChapterCards === 'function') {
this.chapterCardManager.createChapterCards();
} else {
console.warn("[LearningApp] chapterCardManager.createChapterCards를 호출할 수 없습니다.");
}
// 진행률 표시 생성
if (this.progressIndicator && typeof this.progressIndicator.createIndicator === 'function') {
this.progressIndicator.createIndicator();
} else {
console.warn("[LearningApp] progressIndicator.createIndicator를 호출할 수 없습니다.");
}
// 초기 진행률 설정
this._initializeProgress();
} catch (error) {
this._handleError(error, '_initializeComponents');
}
}
/**
* 초기 진행률 설정
* @private
*/
_initializeProgress() {
try {
if (!this.gauge || !this.markerManager) {
console.warn("[LearningApp] gauge 또는 markerManager가 없습니다.");
return;
}
// 초기 진행률 설정 (마커의 실제 DOM 위치 기반)
const targetMarkerConfig = this.gauge.calculateInitialProgress(
this.markerManager.allMarkers,
LEARNING_CONFIG
);
if (!targetMarkerConfig) {
console.warn("[LearningApp] 타겟 마커 설정을 찾을 수 없습니다.");
return;
}
// 실제 강의만 카운트 (챕터 제외)
const learningMarkers = (this.markerManager.allMarkers || []).filter(
(m) => m && m.isLearningContent !== false
);
const completedLearningCount = learningMarkers.filter(
(m) => m && m.completed === true
).length;
// 마커의 실제 DOM 위치를 찾아서 가장 가까운 pathPercent 계산
let initialPathPercent = 0;
// 100% 완료 시 게이지바를 100%로 설정
if (completedLearningCount >= learningMarkers.length) {
initialPathPercent = 1.0; // 100% 완료
console.log(
`[LearningApp] 초기 진행률: 모든 학습 완료, 게이지바 100%로 설정`
);
} else if (targetMarkerConfig) {
// 타겟 마커 찾기 (pathPercent와 label로 비교)
const targetMarker = (this.markerManager.markers || []).find(
(m) =>
m &&
m.config &&
m.config.pathPercent === targetMarkerConfig.pathPercent &&
m.config.label === targetMarkerConfig.label
);
if (targetMarker && targetMarker.element) {
// gaugePercent가 있으면 우선 사용, 없으면 마커의 실제 DOM 위치 기반으로 계산
if (targetMarkerConfig.gaugePercent !== undefined) {
initialPathPercent = targetMarkerConfig.gaugePercent;
console.log(
`[LearningApp] 초기 진행률: gaugePercent 사용: ${(initialPathPercent * 100).toFixed(1)}%`
);
} else {
// 마커의 실제 DOM 위치 가져오기
const markerLeft = parseFloat(targetMarker.element.style.left) || 0;
const markerTop = parseFloat(targetMarker.element.style.top) || 0;
// maskPath에서 마커 위치에 가장 가까운 지점 찾기
const closestPercent = this.gauge.findClosestPathPercent(markerLeft, markerTop);
if (closestPercent !== null && closestPercent !== undefined) {
initialPathPercent = closestPercent;
}
console.log(
`[LearningApp] 초기 진행률: 마커 실제 위치 (${markerLeft.toFixed(2)}%, ${markerTop.toFixed(2)}%) → pathPercent: ${initialPathPercent.toFixed(4)}`
);
}
} else {
// 마커를 찾을 수 없는 경우 gaugePercent 우선 사용, 없으면 pathPercent 사용
initialPathPercent = targetMarkerConfig.gaugePercent !== undefined
? targetMarkerConfig.gaugePercent
: (targetMarkerConfig.pathPercent || 0);
console.log(
`[LearningApp] 초기 진행률: 마커를 찾을 수 없음, ${targetMarkerConfig.gaugePercent !== undefined ? 'gaugePercent' : 'pathPercent'} 직접 사용: ${(initialPathPercent * 100).toFixed(1)}%`
);
}
}
// 마커 실제 위치에 가장 가까운 pathPercent를 사용하여 채움 (초기 로딩 시 애니메이션 없음)
if (this.gauge && typeof this.gauge.setProgress === 'function') {
this.gauge.setProgress(initialPathPercent, true, false);
}
// 진행률 표시 업데이트
if (this.progressIndicator && typeof this.progressIndicator.updateProgress === 'function') {
this.progressIndicator.updateProgress(this.markerManager.allMarkers);
}
} catch (error) {
this._handleError(error, '_initializeProgress');
}
}
/**
* 학습 완료 후 챕터 카드 및 진행률 표시 업데이트
*/
updateChapterCards() {
try {
if (this.chapterCardManager && typeof this.chapterCardManager.updateChapterCards === 'function') {
this.chapterCardManager.updateChapterCards();
}
if (this.progressIndicator && typeof this.progressIndicator.updateProgress === 'function' && this.markerManager) {
this.progressIndicator.updateProgress(this.markerManager.allMarkers);
}
} catch (error) {
this._handleError(error, 'updateChapterCards');
}
}
_createFallbackModalHandler() {
const app = this;
let modalEl = null;
const escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const toEmbedUrl = (rawUrl) => {
const raw = String(rawUrl ?? '').trim();
const match = raw.match(/(?:v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{11})/);
const id = match ? match[1] : raw;
return `https://www.youtube.com/embed/${id}?rel=0&modestbranding=1`;
};
const closeModal = () => {
if (!modalEl) return;
modalEl.remove();
modalEl = null;
if (typeof bodyUnlock === 'function') bodyUnlock();
};
const renderChapter = (chapter, lessonIndex) => {
if (!modalEl || !chapter || !Array.isArray(chapter.lessons)) return;
const safeIndex = Math.max(0, Math.min(chapter.lessons.length - 1, lessonIndex));
const lesson = chapter.lessons[safeIndex] || {};
const title = lesson.title || chapter.name || '학습 영상';
const iframe = modalEl.querySelector('#videoFrame');
const heading = modalEl.querySelector('.video-info h3');
const subTitle = modalEl.querySelector('.video-header .sub-txt');
if (iframe) iframe.src = toEmbedUrl(lesson.url);
if (heading) heading.textContent = chapter.name || '';
if (subTitle) subTitle.textContent = title;
const total = chapter.lessons.length;
const percent = total > 0 ? Math.round(((safeIndex + 1) / total) * 100) : 0;
const gaugeFill = modalEl.querySelector('#gaugeFill');
const gaugeLabel = modalEl.querySelector('#currentValue em');
const stepLabel = modalEl.querySelector('.gauge-labels .label em');
if (gaugeFill) gaugeFill.style.width = `${percent}%`;
if (gaugeLabel) gaugeLabel.textContent = String(percent);
if (stepLabel) stepLabel.textContent = String(safeIndex + 1);
const listWrap = modalEl.querySelector('.learning-list');
if (!listWrap) return;
listWrap.innerHTML = chapter.lessons.map((item, idx) => {
const activeClass = idx === safeIndex ? 'active' : (item.completed ? 'complet' : '');
const stateText = idx === safeIndex ? '학습중' : (item.completed ? '학습완료' : '미진행');
return `
<li class="${activeClass}">
<a href="#" class="list" data-lesson-index="${idx}">
<span class="seq">${idx + 1}차시</span>
<div class="learning-box">
<div class="txt-box">
<div class="title">${escapeHtml(item.title || chapter.name || '')}</div>
<span class="state">${stateText}</span>
</div>
</div>
</a>
</li>
`;
}).join('');
listWrap.querySelectorAll('a.list[data-lesson-index]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
const next = Number.parseInt(a.getAttribute('data-lesson-index') || '0', 10);
renderChapter(chapter, Number.isFinite(next) ? next : 0);
});
});
};
return {
async loadChapter(chapter, chapterIndex, initialLessonIndex) {
if (!chapter || !Array.isArray(chapter.lessons) || chapter.lessons.length === 0) return;
const chapterInfo = LEARNING_CONFIG.getChapterByGlobalIndex
? LEARNING_CONFIG.getChapterByGlobalIndex(initialLessonIndex)
: null;
const lessonIndex = chapterInfo && typeof chapterInfo.lessonIndex === 'number' && chapterInfo.lessonIndex >= 0
? chapterInfo.lessonIndex
: 0;
closeModal();
modalEl = document.createElement('div');
modalEl.innerHTML = `
<div class="modal video on" style="display:block;">
<div class="modal-content">
<div class="modal-body">
<div class="video-contents">
<div class="video-area"><div class="video-box"><iframe id="videoFrame" width="100%" height="100%" src="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div></div>
<div class="video-info"><div class="tit-box"><div class="meta"><span>법정교육</span><em></em></div><h3></h3></div></div>
</div>
<div class="video-side step">
<div class="video-header">
<div class="tit-box"><h5 class="tit">현재강의</h5><p class="sub-txt"></p></div>
<div class="gauge-container"><div class="gauge-bar"><div class="gauge-fill" id="gaugeFill" style="width:0%"></div></div><div class="gauge-labels"><span class="label"><em>1</em>/${chapter.lessons.length} 강</span><span class="label current" id="currentValue">진도율 <em>0</em>%</span></div></div>
<span class="close" role="button" tabindex="0">&times;</span>
</div>
<div class="video-list"><h5 class="tit">학습목차</h5><ul class="learning-list"></ul></div>
</div>
</div>
</div>
</div>
`;
const rootModal = modalEl.firstElementChild;
if (!rootModal) return;
document.body.appendChild(rootModal);
modalEl = rootModal;
const closeBtn = modalEl.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', closeModal);
}
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) closeModal();
});
if (typeof bodyLock === 'function') bodyLock();
renderChapter(chapter, lessonIndex);
}
};
}
/**
* 리소스 정리 (이벤트 리스너 제거)
*/
destroy() {
try {
// 이벤트 리스너 제거
if (this.eventManager && this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, id }) => {
this.eventManager.off(element, id);
});
this.listenerIds = [];
}
// 컴포넌트 정리
if (this.chapterCardManager && typeof this.chapterCardManager.destroy === 'function') {
this.chapterCardManager.destroy();
}
// 참조 정리
this.gauge = null;
this.markerManager = null;
this.chapterCardManager = null;
this.progressIndicator = null;
this.modal = null;
} catch (error) {
this._handleError(error, 'destroy');
}
}
}
/**
* 학습 앱 초기화 함수 (에러 처리 포함)
* @param {Object} dependencies - 의존성 객체
*/
function initLearningApp(dependencies = {}) {
try {
// 의존성 주입 (없으면 자동 감지)
const finalDependencies = {
domUtils: dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null),
errorHandler: dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null),
eventManager: dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null),
utils: dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null),
animationUtils: dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null),
...dependencies
};
// LEARNING_CONFIG 유효성 검증
if (typeof LEARNING_CONFIG === 'undefined') {
const error = new Error('LEARNING_CONFIG가 정의되지 않았습니다.');
if (finalDependencies.errorHandler) {
finalDependencies.errorHandler.handle(error, {
context: 'initLearningApp'
}, true); // 사용자에게 표시
} else {
console.error('[LearningApp]', error);
alert('학습 설정을 불러올 수 없습니다.');
}
return;
}
// LearningApp 인스턴스 생성
window.learningApp = new LearningApp(finalDependencies);
if (!window.learningApp) {
throw new Error('LearningApp 인스턴스 생성 실패');
}
console.log('[LearningApp] 초기화 완료');
} catch (error) {
const errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
if (errorHandler) {
errorHandler.handle(error, {
context: 'initLearningApp'
}, true); // 사용자에게 표시
} else {
console.error('[LearningApp] 초기화 에러:', error);
alert('학습 앱 초기화 중 오류가 발생했습니다.');
}
}
}
// 초기화 실행
if (document.readyState === 'loading') {
// DOMContentLoaded는 defer 스크립트가 모두 로드된 후에 발생
if (typeof eventManager !== 'undefined' && eventManager) {
eventManager.on(document, 'DOMContentLoaded', () => initLearningApp());
} else {
document.addEventListener('DOMContentLoaded', () => initLearningApp());
}
} else {
// 이미 로드된 경우 즉시 시도
initLearningApp();
}
File diff suppressed because it is too large Load Diff
+2263
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1533
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+10
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+15
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
<IfModule mod_mime.c>
AddType application/javascript .mjs
</IfModule>
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More