Initial commit: 교육 프로젝트 배포
This commit is contained in:
@@ -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, '"') + '" ' +
|
||||
'data-title="' + title.replace(/"/g, '"') + '" ' +
|
||||
'data-description="' + desc.replace(/"/g, '"') + '" ' +
|
||||
'data-category-name="' + catName.replace(/"/g, '"') + '" ' +
|
||||
'data-sub-category="' + subCat.replace(/"/g, '"') + '" ' +
|
||||
'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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
var safeName = (c.member_name || '익명')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user