/** * 비디오 모달 기본 클래스 * 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/ 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= const vParam = parsed.searchParams.get('v'); if (vParam && /^[a-zA-Z0-9_-]{11}$/.test(vParam)) { return vParam; } // youtube.com/embed/ 또는 /shorts/ 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} */ 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, '
'); } // 북마크 상태 동기화 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 = '
  • 추천 영상 로딩 중...
  • '; 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 = '
  • 관련 추천 영상이 없습니다.
  • '; 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 = '
  • 관련 추천 영상이 없습니다.
  • '; return; } // 추천 영상 렌더링 const self = this; ulEl.innerHTML = data.videos.map(function (v) { const safeTitle = (v.title || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const safeCat = (v.category_name || '').replace(/&/g, '&').replace(//g, '>'); const safeSubcate = (v.subcate || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const safeCategory = (v.category || v.category_name || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const thumb = v.thumbnail || '/img/video/img_thumb_01.png'; const catClass = self._getCategoryClass(v.category_code); return '
  • ' + '' + '
    ' + '
    ' + '
    ' + safeCat + '
    ' + '
    ' + safeTitle + '
    ' + '
    ' + '
    ' + '
  • '; }).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 = '
  • 관련 추천 영상이 없습니다.
  • '; } } /** * 카테고리 코드 → 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 = '
    '; container.prepend(resizer); listWrap = document.createElement("div"); listWrap.className = "comment-list-wrap"; listWrap.innerHTML = '
      '; // 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 = `
      `; } li.innerHTML = `
      ${item.member_name || item.member_id}
      ${actions}
      `; // 삭제 이벤트 연결 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 }; }