Files
edu/js/common/VideoModalBase.js
T

2321 lines
79 KiB
JavaScript

/**
* 비디오 모달 기본 클래스
* ModalBase와 VideoBase를 활용한 통합 비디오 모달 모듈
* @module VideoModalBase
*/
class VideoModalBase extends ModalBase {
constructor(config = {}) {
super({
animation: "fade",
animationDuration: 300,
closeOnEscape: true,
closeOnBackdrop: true,
...config,
});
this.config = {
// 비디오 관련 설정
videos: config.videos || [],
modalPath: config.modalPath || "/skin/_modal/video.php",
modalPathTemplate: config.modalPathTemplate || "/skin/_modal/video-{type}.php",
// 이벤트 콜백
onVideoLoad: config.onVideoLoad || null,
onModalReady: config.onModalReady || null,
// 레이아웃 설정
enableHeightAdjustment: config.enableHeightAdjustment !== false,
enableCommentResizer: config.enableCommentResizer !== false,
enableCommentBox: config.enableCommentBox !== false,
...config,
};
this.currentVideo = null;
this.currentModalElement = null;
this.resizeObserver = null;
this.mutationObserver = null;
this.heightAdjustTimer = null;
this.resizerCleanup = null;
this._retryCount = 0;
this._isAdjustingHeight = false;
this._pendingHeightAdjust = null; // 배치 처리를 위한 대기 중인 조정
this._lastHeightValues = {}; // 마지막 높이 값 캐시 (불필요한 재계산 방지)
// 유튜브 플레이어 및 학습 추적 관련
this.ytPlayer = null;
this.trackingInterval = null;
this.maxTimeReached = 0;
this.lastSaveTime = 0;
this.lastCurrentTime = 0;
this.isSkipRestricted = false;
this.isPlaybackRateRestricted = false;
this._codeNameCache = {};
}
_normalizeCode(code) {
return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
_looksLikeCode(value) {
const normalized = this._normalizeCode(value);
return /^[A-Z]{2}[A-Z0-9]{3,}$/.test(normalized);
}
async _resolveCodeName(rawCode) {
const normalized = this._normalizeCode(rawCode);
if (!normalized) return '';
if (this._codeNameCache[normalized] !== undefined) {
return this._codeNameCache[normalized];
}
try {
const res = await fetch('/bbs/api/code_name.php?code=' + encodeURIComponent(normalized));
const data = await res.json();
const codeName = (data && data.success && data.code_name) ? String(data.code_name).trim() : '';
this._codeNameCache[normalized] = codeName;
return codeName;
} catch (e) {
this._codeNameCache[normalized] = '';
return '';
}
}
/**
* 유튜브 URL/ID에서 videoId를 안정적으로 추출
* @param {string} input
* @returns {string}
*/
_resolveYoutubeVideoId(input) {
const raw = String(input || '').trim();
if (!raw) return '';
// 이미 11자리 videoId 형태인 경우
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) {
return raw;
}
// URL 파싱 시도
try {
const parsed = new URL(raw, window.location.origin);
const host = (parsed.hostname || '').toLowerCase();
// youtu.be/<id>
if (host.includes('youtu.be')) {
const idFromPath = (parsed.pathname || '').replace(/^\//, '').split('/')[0];
if (/^[a-zA-Z0-9_-]{11}$/.test(idFromPath)) {
return idFromPath;
}
}
// youtube.com/watch?v=<id>
const vParam = parsed.searchParams.get('v');
if (vParam && /^[a-zA-Z0-9_-]{11}$/.test(vParam)) {
return vParam;
}
// youtube.com/embed/<id> 또는 /shorts/<id>
const segments = (parsed.pathname || '').split('/').filter(Boolean);
const embedIndex = segments.indexOf('embed');
if (embedIndex >= 0 && segments[embedIndex + 1] && /^[a-zA-Z0-9_-]{11}$/.test(segments[embedIndex + 1])) {
return segments[embedIndex + 1];
}
const shortsIndex = segments.indexOf('shorts');
if (shortsIndex >= 0 && segments[shortsIndex + 1] && /^[a-zA-Z0-9_-]{11}$/.test(segments[shortsIndex + 1])) {
return segments[shortsIndex + 1];
}
} catch (e) {
// URL 형태가 아니면 아래 폴백 regex로 처리
}
// 마지막 폴백: 문자열에서 v= 또는 youtu.be 패턴 추출
const match = raw.match(/(?:v=|youtu\.be\/|embed\/|shorts\/)([a-zA-Z0-9_-]{11})/);
if (match && match[1]) {
return match[1];
}
return '';
}
/**
* 비디오 모달 로드 및 열기
* @param {number|string|Object} videoIdOrData - 비디오 ID 또는 비디오 데이터 객체
* @returns {Promise}
*/
async openVideo(videoIdOrData) {
try {
// 기존 모달 정리 (동기 처리하여 race condition 방지)
await this.close();
// 비디오 데이터 가져오기
let videoData;
if (typeof videoIdOrData === "object") {
videoData = videoIdOrData;
} else {
const videoId = videoIdOrData;
videoData = this.config.videos.find((v) => String(v.id) === String(videoId));
}
if (!videoData) {
throw new Error("비디오 데이터를 찾을 수 없습니다");
}
this.currentVideo = videoData;
console.log('[북마크 추적 #1] openVideo 진입:', {
content_id: videoData.content_id,
id: videoData.id,
bookmark: videoData.bookmark,
is_bookmarked: videoData.is_bookmarked,
liked: videoData.liked,
description: (videoData.description || '').substring(0, 20) || '(없음)'
});
// 모달 타입 결정
const modalType = videoData.type || "main";
// 모달 HTML 로드
const modalHTML = await this.loadModalHTML(modalType);
// 모달 생성
const modalElement = this.createModalFromHTML(modalHTML, modalType);
// DOM에 추가
document.body.appendChild(modalElement);
this.currentModalElement = modalElement;
// 저장된 시청 시간 + 영상 상세(description, bookmark) 조회
try {
const response = await fetch('/bbs/api/get_video_time.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: videoData.content_id || videoData.id
})
});
const result = await response.json();
if (result.success) {
if (result.watch_tm !== undefined) {
videoData.watch_tm = result.watch_tm;
}
if (result.content_tm !== undefined && result.content_tm > 0) {
videoData.content_tm = result.content_tm;
}
// description이 비어있으면 서버에서 받은 값으로 보충
if (!videoData.description && result.description) {
videoData.description = result.description;
}
// bookmark 상태가 없으면 서버에서 받은 값으로 보충
if (videoData.bookmark === undefined && videoData.is_bookmarked === undefined && videoData.liked === undefined) {
videoData.is_bookmarked = !!result.is_bookmarked;
videoData.bookmark = !!result.is_bookmarked;
}
console.log('[VideoModalBase] video detail loaded:', {
content_id: videoData.content_id || videoData.id,
watch_tm: result.watch_tm,
completed: result.completed_at ? '완료' : '진행중',
description: (result.description || '').substring(0, 30) + '...',
is_bookmarked: result.is_bookmarked
});
}
} catch (error) {
console.warn('[VideoModalBase] video detail API error:', error);
}
console.log('[북마크 추적 #2] API 보충 후 최종 videoData:', {
content_id: videoData.content_id,
id: videoData.id,
bookmark: videoData.bookmark,
is_bookmarked: videoData.is_bookmarked,
liked: videoData.liked,
description: (videoData.description || '').substring(0, 20) || '(없음)'
});
// 비디오 설정
this.setupVideo(modalElement, videoData);
// 모달 컨텐츠 업데이트
this.updateModalContent(modalElement, videoData);
// 추천 영상 로드 (goal_code 기반)
this.loadRecommendedVideos(modalElement, videoData);
// 스크립트 실행
this.executeModalScripts(modalElement);
// 모달 열기 (애니메이션)
await Utils.delay(50);
await AnimationUtils.fade(modalElement, "in", 300);
// user-text 높이 조정 (모달이 완전히 열린 후)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.adjustUserTextHeights(modalElement);
});
});
await Utils.delay(100);
// 이벤트 설정
this.setupModalEvents(modalType, modalElement);
// onVideoLoad 콜백
if (this.config.onVideoLoad) {
await this.config.onVideoLoad(videoData, modalElement);
}
// onModalReady 콜백
if (this.config.onModalReady) {
await this.config.onModalReady(modalElement);
}
// body 스크롤 잠금
if (typeof bodyLock === "function") {
bodyLock();
}
return this;
} catch (error) {
console.error("비디오 모달 로드 오류:", error);
if (typeof alert === "function") {
alert("비디오를 로드하는 중 오류가 발생했습니다.");
}
throw error;
}
}
/**
* 모달 HTML 로드
* @param {string} modalType - 모달 타입
* @returns {Promise<string>}
*/
async loadModalHTML(modalType) {
let modalPath = this.config.modalPath;
if (modalType !== "main" && this.config.modalPathTemplate) {
modalPath = this.config.modalPathTemplate.replace("{type}", modalType);
}
if (!modalPath) {
throw new Error("모달 경로가 설정되지 않았습니다");
}
const response = await fetch(`${modalPath}?t=${Date.now()}`);
if (!response.ok) {
throw new Error(`모달 로드 실패: ${modalPath}`);
}
return await response.text();
}
/**
* HTML에서 모달 요소 생성
* @param {string} modalHTML - 모달 HTML 문자열
* @param {string} modalType - 모달 타입
* @returns {HTMLElement}
*/
createModalFromHTML(modalHTML, modalType) {
const parser = new DOMParser();
const doc = parser.parseFromString(modalHTML, "text/html");
const modalElement = doc.querySelector(".modal.video");
if (!modalElement) {
throw new Error("모달 요소를 찾을 수 없습니다");
}
modalElement.id = "videoModal";
modalElement.setAttribute("data-type", modalType);
return modalElement;
}
/**
* 비디오 설정
* @param {HTMLElement} modalElement - 모달 요소
* @param {Object} videoData - 비디오 데이터
*/
setupVideo(modalElement, videoData) {
let iframePlaceholder =
(typeof DOMUtils !== 'undefined' && typeof DOMUtils.$ === 'function'
? DOMUtils.$("#videoFrame", modalElement)
: null) ||
(modalElement && typeof modalElement.querySelector === 'function'
? modalElement.querySelector(".video-area .video-box iframe, .video-box iframe, iframe")
: null) ||
(modalElement && typeof modalElement.querySelector === 'function'
? modalElement.querySelector("#videoFrame")
: null);
if (!iframePlaceholder && modalElement && typeof modalElement.querySelector === 'function') {
const videoBox =
modalElement.querySelector('.video-area .video-box') ||
modalElement.querySelector('.video-box') ||
modalElement.querySelector('.video-area');
if (videoBox) {
const newIframe = document.createElement('iframe');
newIframe.id = 'videoFrame';
newIframe.width = '100%';
newIframe.height = '100%';
newIframe.setAttribute('frameborder', '0');
newIframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
newIframe.setAttribute('allowfullscreen', '');
videoBox.appendChild(newIframe);
iframePlaceholder = newIframe;
console.warn('[VideoModalBase] #videoFrame missing; created fallback iframe in video container');
}
}
console.log('[VideoModalBase] setupVideo iframe lookup:', {
found: !!iframePlaceholder,
tagName: iframePlaceholder?.tagName || null
});
if (!iframePlaceholder) return;
// onboarding은 openVideo() 대신 setupVideo()를 직접 호출하므로 현재 비디오를 여기서 동기화한다.
this.currentVideo = videoData || null;
const rawVideoValue = videoData?.url || videoData?.id || '';
let videoId = '';
if (typeof VideoModel !== "undefined") {
try {
videoId = new VideoModel(videoData).getVideoId();
} catch (e) {
console.warn('[VideoModalBase] VideoModel parsing failed, fallback parser will be used', e);
}
}
if (!videoId) {
videoId = this._resolveYoutubeVideoId(rawVideoValue);
}
if (!videoId) {
console.error('[VideoModalBase] Invalid YouTube video source:', {
rawVideoValue,
content_id: videoData?.content_id,
title: videoData?.title
});
if (iframePlaceholder && iframePlaceholder.tagName === 'IFRAME') {
iframePlaceholder.removeAttribute('src');
}
return;
}
console.log("[VideoModalBase] Initializing/Switching video with ID:", videoId);
// 초기 시청 시점 및 스킵 제한 여부 설정
const startSeconds = Math.floor(videoData.watch_tm || 0);
this.maxTimeReached = startSeconds;
this.lastCurrentTime = startSeconds;
const restrictedCodes = ['ONBOARD', 'LEGAL', 'CA10001', 'MYCLASS'];
const reservedNames = ['마이클래스', '온보딩', '법정교육'];
this.isSkipRestricted = restrictedCodes.includes(videoData.category_code) ||
reservedNames.some(name => (videoData.category || '').includes(name));
// 배속 강제는 법정교육과 온보딩에 적용한다.
const playbackRestrictedCodes = ['LEGAL', 'ONBOARD'];
const playbackReservedNames = ['법정교육', '온보딩'];
this.isPlaybackRateRestricted =
playbackRestrictedCodes.includes(String(videoData.category_code || '').toUpperCase()) ||
playbackReservedNames.some((name) => String(videoData.category || '').includes(name));
if (this.isSkipRestricted) {
console.log("[VideoModalBase] 스킵 제한 영상입니다.");
}
// 1. 우선 IFrame src를 직접 설정 (API 로드 전이라도 영상이 보이게 함)
if (iframePlaceholder.tagName === 'IFRAME') {
const params = new URLSearchParams({
autoplay: 1,
controls: 1,
rel: 0,
modestbranding: 1,
enablejsapi: 1,
...(startSeconds > 0 && { start: startSeconds })
});
iframePlaceholder.src = `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
}
const self = this;
const initPlayer = () => {
try {
if (this.ytPlayer) {
try { this.ytPlayer.destroy(); } catch (e) { }
this.ytPlayer = null;
}
console.log("[VideoModalBase] Connecting YT.Player to element for ID:", videoId);
// iframe일 경우 videoId를 넘기지 않아도 이미 src에 설정됨
// 하지만 API 제어를 위해 videoId를 명시하거나 기존 iframe을 활용
this.ytPlayer = new YT.Player(iframePlaceholder, {
height: '100%',
width: '100%',
videoId: videoId,
playerVars: {
autoplay: 1,
controls: 1,
rel: 0,
modestbranding: 1,
enablejsapi: 1,
start: startSeconds
},
events: {
'onReady': (event) => {
console.log('[VideoModalBase] Player Ready via API');
// 브라우저 정책에 따라 playVideo()가 필요할 수 있음
if (event.target && typeof event.target.playVideo === 'function') {
event.target.playVideo();
}
if (this.isPlaybackRateRestricted) {
this._enforcePlaybackRateOne(event.target, 'onReady');
}
},
'onStateChange': (event) => {
self._handlePlayerStateChange(event);
},
'onPlaybackRateChange': (event) => {
self._handlePlaybackRateChange(event);
},
'onError': (event) => {
console.error('[VideoModalBase] Player Error Status:', event.data);
}
}
});
} catch (e) {
console.error("[VideoModalBase] YT.Player Init Exception:", e);
}
};
// YT API 로드 대기 및 실행
if (typeof YT === 'undefined' || !YT.Player || typeof YT.Player !== 'function') {
console.log("[VideoModalBase] YT API not ready yet, video should be showing via src");
// 온보딩 페이지 등에서 API 스크립트가 누락된 경우를 대비해 동적 로드
if (!document.getElementById('youtube-iframe-api')) {
const ytScript = document.createElement('script');
ytScript.id = 'youtube-iframe-api';
ytScript.src = 'https://www.youtube.com/iframe_api';
ytScript.async = true;
document.head.appendChild(ytScript);
console.log('[VideoModalBase] Injected YouTube IFrame API script');
}
const checkYt = setInterval(() => {
if (typeof YT !== 'undefined' && YT.Player && typeof YT.Player === 'function') {
console.log("[VideoModalBase] YT API discovered, attaching controller");
clearInterval(checkYt);
initPlayer();
}
}, 500);
setTimeout(() => {
if (typeof checkYt !== 'undefined') {
clearInterval(checkYt);
}
if (typeof YT === 'undefined' || !YT.Player || typeof YT.Player !== 'function') {
console.warn('[VideoModalBase] YT API attach timeout - tracking/save may not work');
}
}, 10000);
} else {
initPlayer();
}
}
/**
* 플레이어 상태 변경 핸들러
* @private
*/
_handlePlayerStateChange(event) {
console.log('[VideoModalBase] Player State Change:', event.data);
if (this.isPlaybackRateRestricted) {
this._enforcePlaybackRateOne(this.ytPlayer, 'onStateChange');
}
if (event.data === YT.PlayerState.PLAYING) {
this._startTracking();
} else {
this._stopTracking();
// 일시정지나 정지 시 현재 지점 즉시 저장
if (event.data === YT.PlayerState.PAUSED || event.data === YT.PlayerState.ENDED) {
this._saveLearningProgress(Math.floor(this.lastSaveTime), 'N');
this.lastSaveTime = 0;
}
}
}
/**
* 스킵 제한 영상에서 1배속을 강제한다.
* @private
*/
_enforcePlaybackRateOne(player, source = '') {
if (!this.isPlaybackRateRestricted || !player) {
return;
}
if (typeof player.getPlaybackRate !== 'function' || typeof player.setPlaybackRate !== 'function') {
return;
}
try {
const currentRate = Number(player.getPlaybackRate());
if (!Number.isFinite(currentRate)) {
return;
}
if (currentRate !== 1) {
player.setPlaybackRate(1);
console.warn(`[VideoModalBase] playback rate reset to 1x (${source || 'unknown'})`);
}
} catch (error) {
console.warn('[VideoModalBase] playback rate enforcement failed:', error);
}
}
/**
* 배속 변경 이벤트 처리
* @private
*/
_handlePlaybackRateChange(event) {
const player = event?.target || this.ytPlayer;
this._enforcePlaybackRateOne(player, 'onPlaybackRateChange');
}
/**
* 시청 추적 시작 (1초 주기로 체크)
* @private
*/
_startTracking() {
if (this.trackingInterval) return;
this.lastSaveTime = 0; // 재생 시작마다 리셋
console.log('[VideoModalBase] Tracking started');
this.trackingInterval = setInterval(() => {
if (!this.ytPlayer || typeof this.ytPlayer.getCurrentTime !== 'function') return;
// 실제 PLAYING 상태일 때만 누적 (버퍼링·에러 상태 제외)
const playerState = (typeof this.ytPlayer.getPlayerState === 'function')
? this.ytPlayer.getPlayerState()
: null;
if (playerState !== YT.PlayerState.PLAYING) return;
const currentTime = this.ytPlayer.getCurrentTime();
const duration = this.ytPlayer.getDuration();
// 1. 스킵 제한 로직
if (this.isSkipRestricted) {
if (currentTime > this.maxTimeReached + 3) {
console.warn("[VideoModalBase] 스킵이 제한된 영상입니다.");
this.ytPlayer.seekTo(this.maxTimeReached, true);
} else {
this.maxTimeReached = Math.max(this.maxTimeReached, currentTime);
}
}
// 2. Wall-clock 기반 누적 (1틱 = 1초)
// seek 감지: currentTime이 5초 이상 점프하면 해당 틱은 건너뜀
const diff = currentTime - this.lastCurrentTime;
if (Math.abs(diff) <= 5) {
this.lastSaveTime += 1; // 실제 PLAYING 틱 1회 = 1초
}
this.lastCurrentTime = currentTime;
if (this.lastSaveTime >= 30) {
console.log('[VideoModalBase] Tracking threshold reached, saving heartbeat:', this.lastSaveTime);
this._saveLearningProgress(this.lastSaveTime);
this.lastSaveTime = 0;
}
}, 1000);
}
/**
* 시청 추적 중지
* @private
*/
_stopTracking() {
if (this.trackingInterval) {
clearInterval(this.trackingInterval);
this.trackingInterval = null;
console.log('[VideoModalBase] Tracking stopped');
}
}
/**
* 서버에 학습 진행 상황 저장
* @private
* @param {number} heartbeatSeconds - 이번 주기에 추가된 시청 초
*/
async _saveLearningProgress(heartbeatSeconds = 0, isWatching = 'Y') {
if (!this.ytPlayer || !this.currentVideo) {
console.warn('[VideoModalBase] Skip save: missing player or currentVideo', {
hasPlayer: !!this.ytPlayer,
hasCurrentVideo: !!this.currentVideo
});
return;
}
if (typeof this.ytPlayer.getCurrentTime !== 'function') {
console.warn('[VideoModalBase] Skip save: getCurrentTime is not available');
return;
}
const currentTime = Math.floor(this.ytPlayer.getCurrentTime());
const duration = Math.floor(this.ytPlayer.getDuration());
const incrementalWatch = Math.max(0, Math.floor(heartbeatSeconds || 0));
const contentId = this.currentVideo.content_id || this.currentVideo.id;
if (!contentId) {
console.warn('[VideoModalBase] Skip save: missing content_id in currentVideo', this.currentVideo);
return;
}
const params = new URLSearchParams({
content_id: String(contentId),
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'
});
console.log('[VideoModalBase] Save Learning Progress - Sending:', {
content_id: contentId,
watch_tm: Math.max(0, currentTime),
content_tm: Math.max(0, duration),
all_tm_increment: incrementalWatch,
is_watching: isWatching === 'N' ? 'N' : 'Y',
currentVideo: this.currentVideo.title || 'Unknown'
});
try {
const response = await fetch('/bbs/api/save_learning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: params.toString(),
keepalive: true
});
const result = await response.json();
console.log('[VideoModalBase] Save Learning Response:', result);
if (!result.success) {
console.error("[VideoModalBase] Save Learning Failed:", result.message, result);
} else {
// 서버에서 completed, completed_at 값을 currentVideo에 반영
if (typeof result.data === 'object' && result.data) {
this.currentVideo.completed = !!result.data.completed;
this.currentVideo.completed_at = result.data.completed_at || null;
}
console.log('[VideoModalBase] Save Learning Success:', result.data);
}
} catch (e) {
console.error("[VideoModalBase] API Error:", e);
}
}
/**
* 모달 컨텐츠 업데이트
* @param {HTMLElement} modalElement - 모달 요소
* @param {Object} videoData - 비디오 데이터
*/
updateModalContent(modalElement, videoData) {
const metaEm = modalElement.querySelector(".meta em");
const subcateRaw = String(videoData.subcate || '').trim();
const hasSubcate = subcateRaw !== "";
const categoryRaw = String(videoData.category || '').trim();
const categorySpan = modalElement.querySelector(".meta span");
if (categorySpan) {
categorySpan.textContent = categoryRaw;
if (this._looksLikeCode(categoryRaw)) {
this._resolveCodeName(categoryRaw).then((name) => {
if (!name) return;
if (!modalElement || !modalElement.isConnected) return;
categorySpan.textContent = name;
videoData.category = name;
});
}
}
const titleH3 = modalElement.querySelector(".tit-box h3");
if (titleH3) {
titleH3.textContent = videoData.title;
}
if (metaEm) {
if (hasSubcate) {
metaEm.textContent = subcateRaw;
metaEm.style.display = "";
if (this._looksLikeCode(subcateRaw)) {
this._resolveCodeName(subcateRaw).then((name) => {
if (!name) return;
if (!modalElement || !modalElement.isConnected) return;
metaEm.textContent = name;
metaEm.style.display = "";
videoData.subcate = name;
});
}
} else {
metaEm.textContent = "";
metaEm.style.display = "none";
}
}
// 영상 설명 업데이트
const descEl = modalElement.querySelector(".desc");
if (descEl) {
const descText = videoData.description || '';
descEl.innerHTML = descText.replace(/\n/g, '<br>');
}
// 북마크 상태 동기화
console.log('[북마크 추적 #3] updateModalContent → _syncBookmarkUI 호출 직전:', {
content_id: videoData.content_id,
id: videoData.id,
bookmark: videoData.bookmark,
is_bookmarked: videoData.is_bookmarked
});
this._syncBookmarkUI(modalElement, videoData);
}
/**
* 북마크 UI 동기화
*/
_syncBookmarkUI(modalElement, videoData) {
const bookmarkInput = modalElement.querySelector('.bookmark input[type="checkbox"]');
if (!bookmarkInput) {
console.warn('[VideoModalBase] _syncBookmarkUI: bookmark checkbox not found in modal');
return;
}
// 고유 ID 부여
const uniqueId = 'video-bookmark-' + Date.now();
bookmarkInput.id = uniqueId;
const bookmarkLabel = modalElement.querySelector('.bookmark');
if (bookmarkLabel) bookmarkLabel.setAttribute('for', uniqueId);
// content_id 저장
const contentId = videoData.content_id || videoData.id || '';
modalElement.dataset.bookmarkContentId = String(contentId);
// 초기 체크 상태
const isBookmarked = !!(videoData.bookmark || videoData.is_bookmarked || videoData.liked);
bookmarkInput.checked = isBookmarked;
console.log('[VideoModalBase] _syncBookmarkUI:', {
contentId: contentId,
isBookmarked: isBookmarked,
videoDataKeys: Object.keys(videoData),
bookmark: videoData.bookmark,
is_bookmarked: videoData.is_bookmarked,
liked: videoData.liked
});
}
/**
* 북마크 이벤트 바인딩
*/
setupBookmarkBox(modalElement) {
if (!modalElement || modalElement.dataset.bookmarkBoxBound === 'true') {
console.log('[VideoModalBase] setupBookmarkBox: skip (already bound or no modal)');
return;
}
const bookmarkInput = modalElement.querySelector('.bookmark input[type="checkbox"]');
if (!bookmarkInput) {
console.warn('[VideoModalBase] setupBookmarkBox: bookmark checkbox not found');
return;
}
const self = this;
console.log('[VideoModalBase] setupBookmarkBox: binding event, contentId =', modalElement.dataset.bookmarkContentId);
bookmarkInput.addEventListener('change', async function () {
const contentId = String(modalElement.dataset.bookmarkContentId || '').trim();
console.log('[VideoModalBase] 북마크 change 이벤트:', { contentId, checked: bookmarkInput.checked });
if (!contentId) {
bookmarkInput.checked = false;
console.error('[VideoModalBase] bookmarkContentId가 비어있음 - videoData에 content_id/id 없음');
alert('북마크 저장 대상 정보가 올바르지 않습니다.');
return;
}
const isActive = bookmarkInput.checked ? '1' : '0';
try {
bookmarkInput.disabled = true;
const body = new URLSearchParams({
content_id: String(contentId),
is_active: isActive,
});
console.log('[VideoModalBase] 북마크 API 호출:', { content_id: contentId, is_active: isActive });
const response = await fetch('/bbs/api/save_wishlist.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.toString(),
});
const responseText = await response.text();
console.log('[VideoModalBase] 북마크 API 응답 원문:', responseText);
let result;
try {
result = JSON.parse(responseText);
} catch (parseErr) {
console.error('[VideoModalBase] 북마크 API 응답 JSON 파싱 실패:', parseErr, responseText);
throw new Error('서버 응답을 처리할 수 없습니다.');
}
if (!response.ok || !result || result.success !== true) {
console.error('[VideoModalBase] 북마크 API 실패:', { status: response.status, result });
throw new Error(result?.message || '북마크 저장에 실패했습니다.');
}
console.log('[VideoModalBase] 북마크 저장 성공:', result);
// 메모리 내 비디오 데이터 동기화
if (self.currentVideo) {
self.currentVideo.bookmark = (isActive === '1');
self.currentVideo.is_bookmarked = (isActive === '1');
}
// config.videos 배열도 동기화
if (Array.isArray(self.config?.videos)) {
var vid = self.config.videos.find(function (v) {
return String(v.id) === String(contentId) || String(v.content_id) === String(contentId);
});
if (vid) {
vid.bookmark = (isActive === '1');
vid.is_bookmarked = (isActive === '1');
}
}
} catch (error) {
console.error('[VideoModalBase] 북마크 저장 실패:', error);
bookmarkInput.checked = !bookmarkInput.checked;
alert(error?.message || '북마크 저장 중 오류가 발생했습니다.');
} finally {
bookmarkInput.disabled = false;
}
});
modalElement.dataset.bookmarkBoxBound = 'true';
}
/**
* 추천 영상 로드 (goal_code 기반)
* @param {HTMLElement} modalElement - 모달 요소
* @param {Object} videoData - 현재 비디오 데이터
*/
async loadRecommendedVideos(modalElement, videoData) {
const videoListEl = modalElement.querySelector('.video-list');
if (!videoListEl) return;
const ulEl = videoListEl.querySelector('ul');
if (!ulEl) return;
const contentId = videoData.content_id || videoData.id;
if (!contentId) return;
// 로딩 중 표시
ulEl.innerHTML = '<li style="padding:20px; text-align:center; color:rgba(255,255,255,0.4); font-size:13px;">추천 영상 로딩 중...</li>';
try {
const path = (window && window.location && window.location.pathname) ? window.location.pathname : '';
const isMyclassContext =
videoData?.type === 'myclass' ||
videoData?.category_code === 'CA10001' ||
/\/myclass(?:_list)?\.php$/i.test(path);
const recommendApi = isMyclassContext
? '/bbs/api/get_recommend_videos_goal.php'
: '/bbs/api/get_recommend_videos.php';
const res = await fetch(recommendApi + '?content_id=' + encodeURIComponent(contentId));
const data = await res.json();
if (window && window.console && typeof window.console.debug === 'function') {
window.console.debug('[VideoModalBase] recommend payload', {
api: recommendApi,
mode: data.mode,
goal_code: data.goal_code,
content_id: contentId,
count: Array.isArray(data.videos) ? data.videos.length : 0
});
}
if (!data.success) {
ulEl.innerHTML = '<li style="padding:20px; text-align:center; color:rgba(255,255,255,0.4); font-size:13px;">관련 추천 영상이 없습니다.</li>';
return;
}
// 키워드 배지 업데이트
const badgeEl = modalElement.querySelector('.video-header .badge');
if (badgeEl) {
if (data.keywords && data.keywords.length > 0) {
badgeEl.textContent = data.keywords.join(', ');
badgeEl.style.display = '';
} else {
badgeEl.style.display = 'none';
}
}
// 영상 없으면 안내 메시지
if (!data.videos || data.videos.length === 0) {
ulEl.innerHTML = '<li style="padding:20px; text-align:center; color:rgba(255,255,255,0.4); font-size:13px;">관련 추천 영상이 없습니다.</li>';
return;
}
// 추천 영상 렌더링
const self = this;
ulEl.innerHTML = data.videos.map(function (v) {
const safeTitle = (v.title || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const safeCat = (v.category_name || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeSubcate = (v.subcate || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const safeCategory = (v.category || v.category_name || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const thumb = v.thumbnail || '/img/video/img_thumb_01.png';
const catClass = self._getCategoryClass(v.category_code);
return '<li>' +
'<a href="#" class="list" data-content-id="' + (v.content_id || '') + '" data-content-url="' + (v.content_url || '') + '" data-title="' + safeTitle + '" data-category="' + safeCategory + '" data-subcate="' + safeSubcate + '">' +
'<div class="thumb"><img src="' + thumb + '" alt="" /></div>' +
'<div class="txt-box">' +
'<div class="category ' + catClass + '">' + safeCat + '</div>' +
'<div class="title">' + safeTitle + '</div>' +
'</div>' +
'</a>' +
'</li>';
}).join('');
// 추천 영상 클릭 이벤트 (이벤트 위임)
ulEl.onclick = async (e) => {
const link = e.target.closest('a.list[data-content-id]');
if (!link) return;
e.preventDefault();
const newVideoData = {
id: link.getAttribute('data-content-id') || '',
content_id: link.getAttribute('data-content-id'),
url: link.getAttribute('data-content-url') || '',
content_url: link.getAttribute('data-content-url') || '',
title: link.getAttribute('data-title') || '',
category: link.getAttribute('data-category') || '',
subcate: link.getAttribute('data-subcate') || '',
type: videoData.type || 'main',
category_code: videoData.category_code || '',
};
if (!newVideoData.content_id) return;
// 현재 영상 정지 후 같은 모달 안에서 새 영상으로 교체
this._stopTracking();
if (this.ytPlayer) {
try { this.ytPlayer.destroy(); } catch (ex) { /* ignore */ }
this.ytPlayer = null;
}
this.lastSaveTime = 0;
// 상세 정보 보강(설명/북마크/시청시간)
try {
const detailRes = await fetch('/bbs/api/get_video_time.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content_id: newVideoData.content_id })
});
const detail = await detailRes.json();
if (detail && detail.success) {
if (detail.watch_tm !== undefined) newVideoData.watch_tm = detail.watch_tm;
if (detail.description) newVideoData.description = detail.description;
newVideoData.bookmark = !!detail.is_bookmarked;
newVideoData.is_bookmarked = !!detail.is_bookmarked;
}
} catch (_err) {
// 상세 API 실패 시에도 재생 전환은 계속 진행
}
this.currentVideo = newVideoData;
this.setupVideo(modalElement, newVideoData);
this.updateModalContent(modalElement, newVideoData);
this.loadRecommendedVideos(modalElement, newVideoData);
};
// 높이 재조정
this._scheduleHeightAdjust(modalElement);
} catch (err) {
console.warn('[VideoModalBase] 추천 영상 로드 실패:', err);
ulEl.innerHTML = '<li style="padding:20px; text-align:center; color:rgba(255,255,255,0.4); font-size:13px;">관련 추천 영상이 없습니다.</li>';
}
}
/**
* 카테고리 코드 → CSS 클래스 변환
*/
_getCategoryClass(categoryCode) {
const map = {
'CA10001': 'myclass',
'CA10002': 'onboarding',
'CA10003': 'legal',
'CA10004': 'leader',
'CA10005': 'insight',
'CA10006': 'biztrend',
};
return map[categoryCode] || '';
}
/**
* 모달 스크립트 실행
* @param {HTMLElement} modalElement - 모달 요소
*/
executeModalScripts(modalElement) {
const scripts = DOMUtils.$$("script", modalElement);
scripts.forEach((oldScript, index) => {
const newScript = document.createElement("script");
if (oldScript.type) {
newScript.type = oldScript.type;
}
if (oldScript.src) {
// 외부 스크립트
newScript.src = oldScript.src;
newScript.onload = () => {
console.log(`외부 스크립트 로드 완료: ${oldScript.src}`);
};
newScript.onerror = (e) => {
console.error(`외부 스크립트 로드 실패: ${oldScript.src}`, e);
};
} else {
// 인라인 스크립트
newScript.textContent = oldScript.textContent;
console.log(`인라인 스크립트 실행 #${index + 1}`);
}
// 기존 스크립트를 새 스크립트로 교체
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
/**
* 타입별 이벤트 설정
* @param {string} modalType - 모달 타입
* @param {HTMLElement} modalElement - 모달 요소
*/
setupModalEvents(modalType, modalElement) {
console.log("모달 이벤트 설정:", modalType);
// 닫기 이벤트 설정
this.setupModalCloseEvents(modalElement);
// 북마크는 모든 타입에서 공통 설정
this.setupBookmarkBox(modalElement);
console.log('[북마크 추적 #4] setupModalEvents 완료 - type:', modalType, ', bookmarkContentId:', modalElement.dataset.bookmarkContentId, ', bound:', modalElement.dataset.bookmarkBoxBound);
// 타입별 이벤트 설정
switch (modalType) {
case "main":
if (this.config.enableCommentResizer) {
this.setupCommentResizer(modalElement);
}
if (this.config.enableCommentBox) {
this.setupCommentBox(modalElement);
}
if (this.config.enableHeightAdjustment) {
this.initializeHeightAdjustment(modalElement);
}
break;
case "comment":
if (this.config.enableCommentBox) {
this.setupCommentBox(modalElement);
}
this.adjustCommentOnlyLayout(modalElement);
break;
case "onboarding":
if (this.config.enableCommentBox) {
this.setupCommentBox(modalElement);
}
break;
case "essential":
this.setupEssentialLayout(modalElement);
break;
case "learning":
this.setupLearningLayout(modalElement);
break;
default:
console.warn("알 수 없는 모달 타입:", modalType);
}
}
/**
* 모달 닫기 이벤트 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupModalCloseEvents(modalElement) {
const closeBtn = modalElement.querySelector(".close");
if (closeBtn) {
closeBtn.onclick = () => {
this.close();
};
}
modalElement.onclick = (e) => {
if (e.target === modalElement) {
this.close();
}
};
const escHandler = (e) => {
if (e.key === "Escape") {
this.close();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
/**
* 모달 닫기 (오버라이드)
* @returns {Promise}
*/
async close() {
if (this.currentModalElement) {
// 추적 중지 및 최종 저장
this._stopTracking();
if (this.ytPlayer) {
await this._saveLearningProgress(Math.floor(this.lastSaveTime), 'N');
this.lastSaveTime = 0;
try {
if (typeof this.ytPlayer.destroy === 'function') {
this.ytPlayer.destroy();
}
} catch (e) {
console.warn("[VideoModalBase] Player destroy error:", e);
}
this.ytPlayer = null;
}
// Observer들 정리
this.cleanupObservers();
// 페이드아웃 효과
await AnimationUtils.fade(this.currentModalElement, "out", 300);
// DOM에서 제거
if (this.currentModalElement && this.currentModalElement.parentNode) {
this.currentModalElement.parentNode.removeChild(this.currentModalElement);
}
}
this.currentModalElement = null;
this.currentVideo = null;
// body 스크롤 해제
if (typeof bodyUnlock === "function") {
bodyUnlock();
}
return this;
}
/**
* 모달 파괴 (오버라이드)
*/
async destroy() {
this.cleanupObservers();
await this.close();
}
/**
* Observer 정리
*/
cleanupObservers() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
if (this.heightAdjustTimer) {
clearTimeout(this.heightAdjustTimer);
this.heightAdjustTimer = null;
}
if (this.resizerCleanup) {
this.resizerCleanup();
this.resizerCleanup = null;
}
this._retryCount = 0;
}
// ========================================
// 레이아웃 관련 메서드 (기존 로직 유지)
// ========================================
/**
* 높이 조정 초기화 (리플로우 최적화)
* @param {HTMLElement} modalElement - 모달 요소
*/
initializeHeightAdjustment(modalElement) {
// 1단계: 즉시 시도 (첫 렌더링)
this._scheduleHeightAdjust(modalElement);
// 2단계: requestAnimationFrame (2프레임 대기)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this._scheduleHeightAdjust(modalElement);
});
});
// 3-5단계: 지연 시도 (배치 처리)
[50, 100, 200].forEach((delay) => {
setTimeout(() => {
this._scheduleHeightAdjust(modalElement);
}, delay);
});
// 6단계: ResizeObserver 설정
this.setupResizeObserver(modalElement);
// 7단계: MutationObserver 설정
this.setupMutationObserver(modalElement);
// 8단계: 이미지 로딩 대기
this.waitForImagesAndAdjust(modalElement);
}
/**
* 높이 조정 스케줄링 (리플로우 최소화를 위한 배치 처리)
* @private
* @param {HTMLElement} modalElement - 모달 요소
*/
_scheduleHeightAdjust(modalElement) {
if (this._pendingHeightAdjust) {
cancelAnimationFrame(this._pendingHeightAdjust);
}
this._pendingHeightAdjust = requestAnimationFrame(() => {
this.adjustVideoListHeight(modalElement);
this._pendingHeightAdjust = null;
});
}
/**
* ResizeObserver 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupResizeObserver(modalElement) {
const videoSide = modalElement?.querySelector(".video-side");
const commentWrap = modalElement?.querySelector(".comment-wrap");
if (!videoSide) return;
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
// throttled 높이 조정 함수 (리플로우 최소화)
const throttledAdjustHeight = Utils.throttle(() => {
this._scheduleHeightAdjust(modalElement);
}, 100); // 100ms throttle
this.resizeObserver = new ResizeObserver((entries) => {
throttledAdjustHeight();
});
this.resizeObserver.observe(videoSide);
if (commentWrap) {
this.resizeObserver.observe(commentWrap);
}
}
/**
* MutationObserver 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupMutationObserver(modalElement) {
const videoList = modalElement?.querySelector(".video-list");
if (!videoList) return;
if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
// throttled 높이 조정 함수 (리플로우 최소화)
const throttledAdjustHeight = Utils.throttle(() => {
this._scheduleHeightAdjust(modalElement);
}, 100); // 100ms throttle
this.mutationObserver = new MutationObserver((mutations) => {
throttledAdjustHeight();
});
this.mutationObserver.observe(videoList, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
}
/**
* 이미지 로딩 대기 후 높이 조정
* @param {HTMLElement} modalElement - 모달 요소
*/
async waitForImagesAndAdjust(modalElement) {
const videoSide = modalElement?.querySelector(".video-side");
if (!videoSide) return;
const images = videoSide.querySelectorAll("img");
if (images.length === 0) {
return;
}
console.log(`이미지 ${images.length}개 로딩 대기 중...`);
const imagePromises = Array.from(images).map((img) => {
if (img.complete) return Promise.resolve();
return new Promise((resolve) => {
img.onload = () => resolve();
img.onerror = () => resolve();
setTimeout(resolve, 10000); // 10초 타임아웃
});
});
await Promise.all(imagePromises);
console.log("모든 이미지 로딩 완료: 높이 재조정");
this.adjustVideoListHeight(modalElement);
}
/**
* 높이 조정 (main, learning, onboarding 타입 지원)
* @param {HTMLElement} modalElement - 모달 요소
*/
adjustVideoListHeight(modalElement) {
const videoSide = modalElement?.querySelector(".video-side");
const videoHeader = modalElement?.querySelector(".video-header");
const videoList = modalElement?.querySelector(".video-list");
const learningList = modalElement?.querySelector(".learning-list");
const commentWrap = modalElement?.querySelector(".comment-wrap");
// learning-list가 있으면 더 상세한 계산 사용, 없으면 video-list 사용
const targetList = learningList || videoList;
if (!videoSide || !videoHeader || !targetList) {
console.warn("[VideoModalBase] 필요한 요소를 찾을 수 없습니다");
return false;
}
// 높이 조정 중 플래그 설정
if (!this._isAdjustingHeight) {
this._isAdjustingHeight = true;
}
// 스크롤 위치 저장
const savedScrollTop = targetList.scrollTop;
// 전체 높이
const totalHeight = videoSide.clientHeight;
// 높이가 0이거나 비정상적으로 작으면 DOM이 아직 렌더링되지 않은 것
if (totalHeight < 100) {
console.warn(
`[VideoModalBase] videoSide 높이가 비정상적으로 작습니다: ${totalHeight}px. 재측정 예약...`
);
// 최대 3번까지만 재시도
if (!this._retry_count) this._retry_count = 0;
if (this._retry_count < 3) {
this._retry_count++;
this._isAdjustingHeight = false;
setTimeout(() => this.adjustVideoListHeight(modalElement), 100);
} else {
console.error("[VideoModalBase] 높이 측정 재시도 횟수 초과");
this._retry_count = 0;
this._isAdjustingHeight = false;
}
return false;
}
// 재시도 카운터 초기화
this._retry_count = 0;
// 헤더 높이
const headerHeight = videoHeader.offsetHeight;
// comment-wrap 높이
const commentWrapHeight = commentWrap ? commentWrap.offsetHeight : 0;
// learning-list가 있는 경우 더 상세한 계산
let availableHeight;
if (learningList) {
// video-list가 있으면 제목과 padding 고려 (learning-list는 video-list 내부에 있음)
let titleHeight = 0;
let paddingTop = 0;
let paddingBottom = 0;
if (videoList) {
// video-list의 제목 높이
const videoListTitle = videoList.querySelector("h5.tit");
titleHeight = videoListTitle ? videoListTitle.offsetHeight : 0;
// video-list의 padding 값 계산
const videoListStyle = window.getComputedStyle(videoList);
paddingTop = parseInt(videoListStyle.paddingTop) || 0;
paddingBottom = parseInt(videoListStyle.paddingBottom) || 0;
}
// learning-list에 사용 가능한 최대 높이
// learning-list는 video-list 내부에 있으므로 video-list의 제목과 padding을 고려해야 함
// comment-wrap이 없어도 (commentWrapHeight = 0) 정상 작동
// commentInfoOffset은 comment-wrap 내부 요소이므로 이미 commentWrapHeight에 포함됨
availableHeight =
totalHeight -
headerHeight -
commentWrapHeight -
titleHeight -
paddingTop -
paddingBottom -
10;
} else {
// video-list만 있는 경우 (기존 로직)
availableHeight = totalHeight - headerHeight - commentWrapHeight;
}
// 사용 가능한 높이가 음수이거나 너무 작으면 경고
if (availableHeight < 50) {
console.warn(
`[VideoModalBase] 사용 가능한 높이가 너무 작습니다: ${availableHeight}px`
);
this._isAdjustingHeight = false;
return false;
}
// 리스트의 실제 컨텐츠 높이 측정 (스타일 제거 후)
const originalHeight = targetList.style.height;
const originalOverflow = targetList.style.overflowY;
targetList.style.height = "auto";
targetList.style.overflowY = "visible";
const listContentHeight = targetList.scrollHeight;
targetList.style.height = originalHeight;
targetList.style.overflowY = originalOverflow;
// 컨텐츠가 적으면 컨텐츠 높이만큼, 많으면 사용 가능한 높이만큼
const listHeight = Math.min(listContentHeight, availableHeight);
// 최소 높이 보장
const finalHeight = Math.max(listHeight, 100);
// 현재 설정된 높이 확인
const currentHeight = targetList.style.height
? parseInt(targetList.style.height)
: targetList.offsetHeight;
// 스크롤 필요 여부 확인
const needsScroll = listContentHeight > availableHeight;
console.log("[VideoModalBase] 높이 측정 성공:", {
totalHeight,
headerHeight,
commentWrapHeight,
availableHeight,
listContentHeight,
listHeight,
finalHeight,
currentHeight,
needsScroll,
savedScrollTop,
hasLearningList: !!learningList,
});
// 높이가 실제로 변경되는 경우에만 스타일 업데이트 (리플로우 최소화)
const heightChanged = Math.abs(currentHeight - finalHeight) > 1;
// 캐시 키 생성
const cacheKey = `${targetList.className}-${modalElement.id || 'default'}`;
const lastHeight = this._lastHeightValues[cacheKey];
// 높이가 변경되지 않고, 스크롤 여부도 동일하면 스타일 업데이트 생략
if (!heightChanged && lastHeight === finalHeight &&
targetList.style.overflowY === (needsScroll ? "auto" : "hidden")) {
this._isAdjustingHeight = false;
return true; // 변경 없음, 조기 종료
}
// 스타일 업데이트를 requestAnimationFrame으로 배치
// learning-list의 height와 overflow-y는 CSS로 관리 (인라인 스타일 제거)
requestAnimationFrame(() => {
// learning-list가 아닌 경우에만 height 설정 (video-list만 있는 경우)
if (heightChanged && !learningList) {
targetList.style.height = finalHeight + "px";
}
// learning-list의 overflow-y는 CSS로 관리
if (!learningList) {
targetList.style.overflowY = needsScroll ? "hidden" : "hidden";
}
// 캐시 업데이트
this._lastHeightValues[cacheKey] = finalHeight;
});
// video-list가 별도로 있는 경우에도 스크롤 여부 체크 (리플로우 최소화)
// overflow-y: hidden 제거, CSS 기본값 사용
if (videoList && learningList) {
// learning-list가 있는 경우에도 video-list의 높이 설정
// video-list는 learning-list를 포함하므로, 전체 사용 가능한 높이에서 제목과 padding을 빼면 됨
const videoListTitle = videoList.querySelector("h5.tit");
const videoListTitleHeight = videoListTitle ? videoListTitle.offsetHeight : 0;
const videoListStyle = window.getComputedStyle(videoList);
const videoListPaddingTop = parseInt(videoListStyle.paddingTop) || 0;
const videoListPaddingBottom = parseInt(videoListStyle.paddingBottom) || 0;
// video-list에 사용 가능한 높이 (전체 높이에서 헤더, 댓글, 제목, padding 제외)
const videoListAvailableHeight =
totalHeight -
headerHeight -
commentWrapHeight -
videoListTitleHeight -
videoListPaddingTop -
videoListPaddingBottom -
10;
// video-list의 실제 컨텐츠 높이 측정 (learning-list 포함)
const videoListOriginalHeight = videoList.style.height;
const videoListOriginalOverflow = videoList.style.overflowY;
videoList.style.height = "auto";
videoList.style.overflowY = "visible";
const videoListContentHeight = videoList.scrollHeight;
videoList.style.height = videoListOriginalHeight;
videoList.style.overflowY = videoListOriginalOverflow;
// video-list의 높이 계산 (learning-list를 포함한 전체 높이)
const videoListHeight = Math.min(videoListContentHeight, videoListAvailableHeight);
const videoListFinalHeight = Math.max(videoListHeight, 100);
// video-list의 현재 높이 확인
const videoListCurrentHeight = videoList.style.height
? parseInt(videoList.style.height)
: videoList.offsetHeight;
const videoListHeightChanged = Math.abs(videoListCurrentHeight - videoListFinalHeight) > 1;
const videoListNeedsScroll = videoListContentHeight > videoListFinalHeight;
// video-list 높이 및 스크롤 설정
requestAnimationFrame(() => {
if (videoListHeightChanged) {
videoList.style.height = videoListFinalHeight + "px";
}
if (videoListNeedsScroll) {
videoList.style.overflowY = "auto";
} else {
videoList.style.overflowY = ""; // CSS 기본값 사용
}
});
} else if (videoList && !learningList) {
// video-list만 있는 경우 스크롤 여부 체크
const videoListContentHeight = videoList.scrollHeight;
const videoListAvailableHeight = videoList.clientHeight;
const videoListNeedsScroll = videoListContentHeight > videoListAvailableHeight;
// requestAnimationFrame으로 배치하여 리플로우 최소화
requestAnimationFrame(() => {
// 스크롤이 필요할 때만 auto 설정, 필요 없을 때는 CSS 기본값 사용
if (videoListNeedsScroll) {
videoList.style.overflowY = "auto";
} else {
videoList.style.overflowY = ""; // CSS 기본값 사용
}
});
}
// 컨텐츠 높이를 CSS 변수로 설정 (::before 요소에서 사용)
if (learningList) {
learningList.style.setProperty("--scroll-height", `${listContentHeight}px`);
}
// 스크롤 위치 복원 (높이 변경 여부와 관계없이)
requestAnimationFrame(() => {
targetList.scrollTop = savedScrollTop;
// 높이 조정 완료 후 플래그 해제
this._isAdjustingHeight = false;
});
return true;
}
/**
* 댓글 전용 레이아웃 조정
* @param {HTMLElement} modalElement - 모달 요소
*/
adjustCommentOnlyLayout(modalElement) {
const commentWrap = modalElement?.querySelector(".comment-wrap");
if (commentWrap) {
if (this.config.enableCommentResizer) {
this.setupCommentResizer(modalElement);
}
if (this.config.enableCommentBox) {
this.setupCommentBox(modalElement);
}
}
}
/**
* 필수 교육 레이아웃 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupEssentialLayout(modalElement) {
console.log("필수 교육 레이아웃 설정");
// 예: 진도율 표시, 완료 체크 등
}
/**
* 학습 레이아웃 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupLearningLayout(modalElement) {
console.log("학습 레이아웃 설정");
// 예: 퀴즈, 학습 노트 등
}
/**
* 댓글 리사이저 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupCommentResizer(modalElement) {
const resizer = modalElement?.querySelector(".comment-resizer");
const commentListWrap = modalElement?.querySelector(".comment-list-wrap");
const commentWrap = modalElement?.querySelector(".comment-wrap");
const videoSide = modalElement?.querySelector(".video-side");
if (!resizer || !commentListWrap || !commentWrap) {
console.warn("리사이저 요소를 찾을 수 없습니다");
return;
}
let isResizing = false;
let startY = 0;
let startHeight = 0;
const minHeight = 52;
const maxHeight = 600;
const commentList = commentListWrap.querySelector(".comment-list");
const hasComments = commentList && commentList.children.length > 0;
console.log(`댓글 리사이저 초기화: 댓글 ${hasComments ? "있음" : "없음"}`);
if (!hasComments) {
resizer.style.display = "none";
commentListWrap.style.height = "0px";
commentListWrap.style.flex = "";
} else {
resizer.style.display = "block";
this.adjustCommentListWrapHeight(commentListWrap);
const h = commentListWrap.offsetHeight;
commentListWrap.style.flex = "0 0 " + h + "px";
}
resizer.addEventListener("mousedown", (e) => {
isResizing = true;
startY = e.clientY;
startHeight = commentListWrap.offsetHeight;
resizer.classList.add("resizing");
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
e.preventDefault();
});
const onMouseMove = (e) => {
if (!isResizing) return;
const delta = startY - e.clientY;
const newHeight = Math.min(Math.max(startHeight + delta, minHeight), maxHeight);
commentListWrap.style.flex = "0 0 " + newHeight + "px";
commentListWrap.style.height = newHeight + "px";
if (videoSide && this.config.enableHeightAdjustment) {
requestAnimationFrame(() => {
this.adjustVideoListHeight(modalElement);
});
}
};
const onMouseUp = () => {
if (!isResizing) return;
isResizing = false;
resizer.classList.remove("resizing");
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
this.resizerCleanup = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
}
/**
* 댓글 입력 기능 설정
* @param {HTMLElement} modalElement - 모달 요소
*/
setupCommentBox(modalElement) {
const textarea = modalElement?.querySelector(".comment-box textarea");
const btnCancel = modalElement?.querySelector(".btn-cancel");
const btnSave = modalElement?.querySelector(".btn-save");
if (!textarea || !btnCancel || !btnSave) {
console.warn("댓글 박스 요소를 찾을 수 없습니다");
return;
}
console.log("[VideoModalBase] setupCommentBox - currentVideo:", this.currentVideo);
const categoryCode = String(this.currentVideo?.category_code || '').toUpperCase();
const isMyClass = categoryCode === 'MYCLASS' || categoryCode === 'CA10001';
const placeholderText = isMyClass ? "한줄 소감을 남겨주세요" : "댓글을 작성해주세요";
const btnSaveText = isMyClass ? "작성" : "등록";
const currentPath = String(window.location.pathname || "");
const isLengthLimitedPage = /\/skin\/(?:index|onboarding(?:_completed|_finish)?|leadership(?:_[^/]+)?)(?:\.php)?$/i.test(currentPath);
const commentMaxLength = 200;
let lastLengthAlertAt = 0;
const trimCommentLength = (textareaEl, showAlert) => {
if (!isLengthLimitedPage || !textareaEl || typeof textareaEl.value !== "string") return false;
if (textareaEl.value.length <= commentMaxLength) return false;
textareaEl.value = textareaEl.value.slice(0, commentMaxLength);
if (showAlert) {
const now = Date.now();
if (now - lastLengthAlertAt > 800) {
lastLengthAlertAt = now;
alert("댓글은 200자까지 입력할 수 있습니다.");
}
}
return true;
};
textarea.placeholder = placeholderText;
btnSave.textContent = btnSaveText;
if (isLengthLimitedPage) {
textarea.maxLength = commentMaxLength;
}
// 기존 데이터 로드 (첫 진입 시)
const videoContentId = this.currentVideo?.content_id || this.currentVideo?.id;
if (this.currentVideo && videoContentId) {
console.log("[VideoModalBase] Loading comments for:", videoContentId);
this.loadComments(modalElement, videoContentId);
} else {
console.warn("[VideoModalBase] currentVideo or content_id/id is missing:", {
currentVideo: this.currentVideo,
content_id: this.currentVideo?.content_id,
id: this.currentVideo?.id
});
}
const adjustTextareaHeight = (textareaEl) => {
// 높이 설정: 1줄=32px, 2줄=52px, 3줄 이상=72px
const singleLineHeight = 32;
const twoLineHeight = 52;
const maxHeight = 72; // 3줄 이상 최대 높이
// 입력값이 없으면 기본 32px로 설정
if (!textareaEl.value || textareaEl.value.trim().length === 0) {
textareaEl.style.height = singleLineHeight + "px";
textareaEl.style.overflowY = "hidden";
textareaEl.style.overflowX = "hidden";
return;
}
// 스크롤바가 보이지 않도록 먼저 overflow를 hidden으로 설정
textareaEl.style.overflowY = "hidden";
textareaEl.style.overflowX = "hidden";
// 높이를 auto로 설정하여 실제 컨텐츠 높이 측정
textareaEl.style.height = "auto";
// 강제 리플로우 (높이 계산을 위해)
void textareaEl.offsetHeight;
const scrollHeight = textareaEl.scrollHeight;
const computedStyle = window.getComputedStyle(textareaEl);
const lineHeight = parseFloat(computedStyle.lineHeight) || 20;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
// 실제 줄 수 계산을 위한 높이 기준
const actualSingleLineHeight = lineHeight + paddingTop + paddingBottom;
const actualTwoLineHeight = (lineHeight * 2) + paddingTop + paddingBottom;
const actualThreeLineHeight = (lineHeight * 3) + paddingTop + paddingBottom;
// 높이 설정: 1줄=32px, 2줄=52px, 3줄 이상=72px
if (scrollHeight <= actualSingleLineHeight) {
// 1줄: 32px
textareaEl.style.height = singleLineHeight + "px";
textareaEl.style.overflowY = "hidden";
} else if (scrollHeight <= actualTwoLineHeight) {
// 2줄: 52px
textareaEl.style.height = twoLineHeight + "px";
textareaEl.style.overflowY = "hidden";
} else if (scrollHeight <= actualThreeLineHeight) {
// 3줄: 72px
textareaEl.style.height = maxHeight + "px";
textareaEl.style.overflowY = "hidden";
} else {
// 3줄 초과: 72px 고정, 스크롤 활성화
textareaEl.style.height = maxHeight + "px";
textareaEl.style.overflowY = "auto";
}
textareaEl.style.overflowX = "hidden";
};
adjustTextareaHeight(textarea);
textarea.oninput = (e) => {
trimCommentLength(e.target, true);
const hasValue = e.target.value.trim().length > 0;
adjustTextareaHeight(e.target);
if (hasValue) {
btnCancel.removeAttribute("disabled");
btnSave.removeAttribute("disabled");
btnSave.classList.add("btn-active");
} else {
btnCancel.setAttribute("disabled", "disabled");
btnSave.setAttribute("disabled", "disabled");
btnSave.classList.remove("btn-active");
}
};
btnCancel.onclick = (e) => {
e.preventDefault();
textarea.value = "";
adjustTextareaHeight(textarea);
btnCancel.setAttribute("disabled", "disabled");
btnSave.setAttribute("disabled", "disabled");
btnSave.classList.remove("btn-active");
textarea.focus();
};
btnSave.onclick = async (e) => {
e.preventDefault();
if (btnSave.disabled || btnSave.dataset.saving === "true") {
return;
}
const comment = textarea.value.trim();
if (!comment) {
return;
}
if (isLengthLimitedPage && comment.length > commentMaxLength) {
trimCommentLength(textarea, true);
return;
}
btnSave.dataset.saving = "true";
btnSave.setAttribute("disabled", "disabled");
try {
const cid = this.currentVideo.content_id || this.currentVideo.id;
const success = await this.saveCommentFile(cid, comment);
if (success) {
this.loadComments(modalElement, cid);
textarea.value = "";
adjustTextareaHeight(textarea);
btnCancel.setAttribute("disabled", "disabled");
btnSave.classList.remove("btn-active");
}
} finally {
btnSave.dataset.saving = "false";
const hasValue = textarea.value.trim().length > 0;
if (hasValue) {
btnSave.removeAttribute("disabled");
} else {
btnSave.setAttribute("disabled", "disabled");
}
}
};
}
/**
* 댓글 목록 API 호출
* @param {HTMLElement} modalElement - 모달 요소
* @param {string} contentId - 영상 ID
*/
async loadComments(modalElement, contentId) {
try {
console.log("[VideoModalBase] loadComments called with:", { contentId });
const url = `/bbs/api/get_comments.php?content_id=${encodeURIComponent(String(contentId))}`;
console.log("[VideoModalBase] Fetching comments from:", url);
const response = await fetch(url);
console.log("[VideoModalBase] Get comments response status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error("[VideoModalBase] HTTP Error:", response.status, errorText);
return;
}
const result = await response.json();
console.log("[VideoModalBase] Get comments result:", result);
if (result.success) {
this.renderComments(modalElement, result.data, result.category);
} else {
console.warn("[VideoModalBase] Get comments failed:", result.message);
}
} catch (error) {
console.error("[VideoModalBase] Load Comments Error:", error);
}
}
/**
* 댓글 목록 렌더링
* @param {HTMLElement} modalElement - 모달 요소
* @param {Array} comments - 댓글 데이터 배열
* @param {string} category - 카테고리 (MYCLASS/NORMAL)
*/
renderComments(modalElement, comments, category) {
let listWrap = modalElement.querySelector(".comment-list-wrap");
let container = modalElement.querySelector(".comment-wrap");
// 만약 template에 list-wrap이 없으면 생성 (video.php 대비)
if (!listWrap && container) {
const resizer = document.createElement("div");
resizer.className = "comment-resizer";
resizer.innerHTML = '<div class="resizer-handle"></div>';
container.prepend(resizer);
listWrap = document.createElement("div");
listWrap.className = "comment-list-wrap";
listWrap.innerHTML = '<ul class="comment-list"></ul>';
// comment-box 앞에 삽입
const commentBox = container.querySelector(".comment-box");
if (commentBox) {
container.insertBefore(listWrap, commentBox);
} else {
container.appendChild(listWrap);
}
this.setupCommentResizer(modalElement);
}
const list = listWrap?.querySelector("ul.comment-list");
if (!list) return;
list.innerHTML = "";
const categoryCode = String(category || this.currentVideo?.category_code || '').toUpperCase();
const isMyClass = (categoryCode === 'MYCLASS' || categoryCode === 'CA10001');
if (comments.length === 0) {
const emptyMsg = document.createElement("li");
emptyMsg.className = "empty-comment";
emptyMsg.style.cssText = "padding:20px; text-align:center; color:rgba(255,255,255,0.4); font-size:13px;";
emptyMsg.textContent = isMyClass ? "작성된 한줄 소감이 없습니다." : "작성된 댓글이 없습니다.";
list.appendChild(emptyMsg);
} else {
comments.forEach((item) => {
const li = document.createElement("li");
li.setAttribute("data-id", item.id);
let actions = "";
if (item.is_author) {
actions = `
<div class="actions">
<button class="btn-edit-comment" onclick="event.preventDefault()">수정</button>
<button class="btn-del-comment" onclick="event.preventDefault()">삭제</button>
</div>
`;
}
li.innerHTML = `
<div class="comment-info">
<div class="photo">
<img src="${item.profile_image || '/img/ico/ico_user.svg'}" onerror="this.src='/img/ico/ico_user.svg'" />
</div>
<div class="user-comment">
<span class="user-name">${item.member_name || item.member_id}</span>
<textarea class="user-text" disabled>${item.comment}</textarea>
</div>
${actions}
</div>
`;
// 삭제 이벤트 연결
const delBtn = li.querySelector(".btn-del-comment");
if (delBtn) {
delBtn.onclick = async () => {
if (confirm("정말 삭제하시겠습니까?")) {
// 마이클래스: content_id 전달 (clear_comment.php)
// 기타: 댓글 PK id 전달 (delete_comment.php)
const deleteParam = isMyClass
? (this.currentVideo.content_id || this.currentVideo.id)
: item.id;
const success = await this.deleteComment(deleteParam);
if (success) {
this.loadComments(modalElement, this.currentVideo.content_id || this.currentVideo.id);
}
}
};
}
// 수정 이벤트 연결
const editBtn = li.querySelector(".btn-edit-comment");
if (editBtn) {
editBtn.onclick = () => {
const textarea = li.querySelector(".user-text");
if (editBtn.textContent === "수정") {
textarea.removeAttribute("disabled");
textarea.focus();
editBtn.textContent = "저장";
editBtn.style.color = "#00ffcc";
li.classList.add("editing");
} else {
const newComment = textarea.value.trim();
if (newComment) {
this.saveCommentFile(this.currentVideo.content_id || this.currentVideo.id, newComment, item.id).then(success => {
if (success) {
editBtn.textContent = "수정";
editBtn.style.color = "";
textarea.setAttribute("disabled", "disabled");
li.classList.remove("editing");
this.loadComments(modalElement, this.currentVideo.content_id || this.currentVideo.id);
}
});
}
}
};
}
list.appendChild(li);
});
}
// 높이 조정
this.showCommentSection(modalElement);
this.adjustCommentListWrapHeight(listWrap);
this.adjustUserTextHeights(modalElement);
// 마이클래스 리스트의 연필 아이콘 상태를 소감문 존재 여부와 동기화
if (isMyClass) {
const currentContentId = String(this.currentVideo?.content_id || this.currentVideo?.id || "").trim();
if (currentContentId !== "") {
const hasComment = Array.isArray(comments) && comments.some((item) => {
const text = String(item?.comment || "").trim();
return text.length > 0;
});
window.dispatchEvent(new CustomEvent("myclass:comment-status-changed", {
detail: {
contentId: currentContentId,
hasComment,
},
}));
}
}
}
/**
* 댓글 저장 API 호출
*/
async saveCommentFile(contentId, comment, commentId) {
try {
const payload = { content_id: contentId, comment: comment };
if (commentId) payload.id = commentId;
console.log("[VideoModalBase] Saving comment:", payload);
const response = await fetch("/bbs/api/save_comment.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
console.log("[VideoModalBase] Save response status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error("[VideoModalBase] HTTP Error:", response.status, errorText);
alert(`저장 실패 (${response.status}): ${errorText}`);
return false;
}
const result = await response.json();
console.log("[VideoModalBase] Save result:", result);
if (!result.success) {
alert(result.message || "저장에 실패했습니다.");
return false;
}
return true;
} catch (error) {
console.error("[VideoModalBase] Save Comment Error:", error);
alert("저장 중 오류가 발생했습니다: " + error.message);
return false;
}
}
/**
* 댓글 삭제 API 호출
*/
async deleteComment(contentId) {
try {
const categoryCode = String(this.currentVideo?.category_code || '').toUpperCase();
const isMyClass = (categoryCode === 'MYCLASS' || categoryCode === 'CA10001');
// 마이클래스: clear_comment.php (comment = NULL 처리)
// 기타: delete_comment.php (edu_comments 에서 DELETE)
const url = isMyClass
? "/bbs/api/clear_comment.php"
: "/bbs/api/delete_comment.php";
const body = isMyClass
? { content_id: contentId }
: { id: contentId };
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!result.success) {
alert(result.message || "삭제 실패하였습니다.");
return false;
}
return true;
} catch (error) {
console.error("[VideoModalBase] Delete Comment Error:", error);
return false;
}
}
/**
* 댓글 섹션 표시 (첫 댓글 작성 시)
* @param {HTMLElement} modalElement - 모달 요소
*/
showCommentSection(modalElement) {
const commentListWrap = modalElement?.querySelector(".comment-list-wrap");
const resizer = modalElement?.querySelector(".comment-resizer");
if (!commentListWrap || !resizer) return;
if (commentListWrap.offsetHeight === 0) {
resizer.style.display = "block";
// 댓글이 있으면 컨텐츠 높이만큼 설정
this.adjustCommentListWrapHeight(commentListWrap);
console.log("댓글 섹션 표시 (컨텐츠 높이)");
if (this.config.enableHeightAdjustment) {
requestAnimationFrame(() => {
this.adjustVideoListHeight(modalElement);
});
}
}
// user-text 높이 조정
this.adjustUserTextHeights(modalElement);
}
/**
* .comment-list-wrap의 높이를 컨텐츠에 맞게 조정
* @param {HTMLElement} commentListWrap - 댓글 리스트 래퍼 요소
*/
adjustCommentListWrapHeight(commentListWrap) {
if (!commentListWrap) return;
const modalElement = commentListWrap.closest(".modal");
if (!modalElement) return;
const commentList = commentListWrap.querySelector(".comment-list");
if (!commentList || commentList.children.length === 0) {
commentListWrap.style.height = "0px";
// 댓글이 없을 때도 video-list 스크롤 재측정
if (this.config.enableHeightAdjustment) {
requestAnimationFrame(() => {
this.adjustVideoListHeight(modalElement);
});
}
return;
}
// 높이를 auto로 설정하여 실제 컨텐츠 높이 측정
commentListWrap.style.height = "auto";
commentListWrap.style.overflowY = "hidden";
const scrollHeight = commentListWrap.scrollHeight;
const computedStyle = window.getComputedStyle(commentListWrap);
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
const contentHeight = scrollHeight + paddingTop + paddingBottom;
const minHeight = 52;
const finalHeight = Math.max(contentHeight, minHeight);
const maxHeight = parseFloat(computedStyle.maxHeight) || Infinity;
const adjustedHeight = Math.min(finalHeight, maxHeight);
commentListWrap.style.height = adjustedHeight + "px";
commentListWrap.style.overflowY = "";
if (this.config.enableHeightAdjustment) {
requestAnimationFrame(() => {
this.adjustVideoListHeight(modalElement);
});
}
}
/**
* .user-text 요소들의 높이를 컨텐츠에 맞게 조정
* @param {HTMLElement} modalElement - 모달 요소
*/
adjustUserTextHeights(modalElement) {
const userTexts = modalElement?.querySelectorAll(".user-text");
if (!userTexts || userTexts.length === 0) {
return;
}
userTexts.forEach((textarea) => {
textarea.style.setProperty("overflow-y", "hidden", "important");
textarea.style.setProperty("overflow-x", "hidden", "important");
textarea.style.setProperty("max-height", "none", "important");
textarea.style.setProperty("height", "0px", "important");
const h = textarea.scrollHeight;
textarea.style.setProperty("height", (h > 0 ? h : 40) + "px", "important");
});
requestAnimationFrame(() => {
userTexts.forEach((textarea) => {
const h = textarea.scrollHeight;
if (h > 0) {
textarea.style.setProperty("height", h + "px", "important");
}
});
});
}
}
// ES6 모듈 내보내기
if (typeof module !== "undefined" && module.exports) {
module.exports = { VideoModalBase };
}