/** * 비즈트렌드 달력 페이지 전용 스크립트 * =================================== * biztrend.html (성공예감&별책부록)에서 사용합니다. * - 연·월 선택 날짜피커 (휠 UI) * - 현재 달 기준 동적 달력 그리드 생성 * - 스크롤 바닥 감지 시 다음 달 자동 추가 (무한 스크롤) */ (function () { "use strict"; // ------------------------------ // 설정 상수 // ------------------------------ var YEAR_START = 2023; var YEAR_END = 2030; var ITEM_HEIGHT = 36; // 휠 한 항목 높이(px) // ------------------------------ // 무한 스크롤 상태 // ------------------------------ var loadedMonths = []; // [{year, month}] var scrollObserver = null; // 하단 무한 스크롤 (IntersectionObserver) var topObserver = null; // 상단 무한 스크롤 (IntersectionObserver) var labelObserver = null; // 라벨 업데이트 var articleData = {}; // 날짜별 기사 데이터 var videoModal = null; // 공통 비디오 모달 (VideoModalBase) var topLoadReady = false; // 상단 스크롤 활성화 플래그 // ------------------------------ // 날짜 값 읽기/쓰기 // ------------------------------ function getDateValue() { var input = document.querySelector(".biztrend .date-select-value"); if (!input) return { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }; var match = (input.value || "").match(/^(\d{4})-(\d{2})$/); if (match) { return { year: parseInt(match[1], 10), month: parseInt(match[2], 10), }; } var now = new Date(); return { year: now.getFullYear(), month: now.getMonth() + 1, }; } function setDateValue(year, month) { var input = document.querySelector(".biztrend .date-select-value"); var label = document.querySelector(".biztrend .date-select-label"); if (input) { input.value = year + "-" + String(month).padStart(2, "0"); } if (label) { var yearEl = label.querySelector(".date-select-year"); var monthEl = label.querySelector(".date-select-month"); if (yearEl) yearEl.textContent = year; if (monthEl) monthEl.textContent = month; } } // ------------------------------ // 달력 그리드 동적 생성 // ------------------------------ /** * 해당 월의 총 일수를 반환합니다. */ function getDaysInMonth(year, month) { return new Date(year, month, 0).getDate(); } /** * 월의 날짜를 월~토 6열 기준 행 배열로 반환합니다. * 일요일은 제외하며, 빈 셀은 null로 채웁니다. * @returns {Array>} */ function buildMonthRows(year, month) { var daysInMonth = getDaysInMonth(year, month); var rows = []; var currentRow = []; for (var d = 1; d <= daysInMonth; d++) { var jsDay = new Date(year, month - 1, d).getDay(); // 0=일, 1=월, ..., 6=토 if (jsDay === 0) continue; // 일요일 제외 // 월요일이면 새 행 시작 (이전 행이 있으면 6칸 채우고 저장) if (jsDay === 1 && currentRow.length > 0) { while (currentRow.length < 6) currentRow.push(null); rows.push(currentRow); currentRow = []; } // 1일이 월요일이 아닐 때 앞 빈 칸 추가 if (d === 1 && jsDay !== 1) { var emptyCount = jsDay - 1; // 월=0, 화=1, ..., 토=5 for (var i = 0; i < emptyCount; i++) { currentRow.push(null); } } currentRow.push(d); // 토요일이면 행 완료 if (jsDay === 6) { rows.push(currentRow); currentRow = []; } } // 마지막 행 처리 if (currentRow.length > 0) { while (currentRow.length < 6) currentRow.push(null); rows.push(currentRow); } return rows; } // ------------------------------ // 기사 데이터 로드 // ------------------------------ /** HTML의 #biztrend-data JSON을 읽어 articleData에 저장 */ function loadArticleData() { var el = document.getElementById("biztrend-data"); if (!el) return; try { articleData = JSON.parse(el.textContent); } catch (e) { articleData = {}; } } /** XSS 방지용 HTML 이스케이프 */ function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * 기사 콘텐츠 블록 HTML을 반환합니다. * @param {Object} item - articleData의 항목 하나 * @param {string} dateKey - 날짜 키 (예: "2026-02-01") * @param {number} idx - 해당 날짜 내 인덱스 */ function createContentHTML(item, dateKey, idx) { // 제목 + 게이지 + 배지 var titleBoxHTML = '
' + '

' + escapeHtml(item.title) + "

"; if (item.badge) { titleBoxHTML += '' + escapeHtml(item.badge) + ""; } if (item.gauge != null) { titleBoxHTML += '
'; } titleBoxHTML += "
"; // 리스트 var listHTML = ""; if (item.list && item.list.length) { listHTML = '"; } // 이미지 여부에 따라 내부 구조 분기 var bodyHTML = item.image ? '
' + '" + listHTML + "
" : listHTML; var likedClass = item.liked ? " is-active" : ""; var likedLabel = item.liked ? "좋아요 취소" : "좋아요"; var dkAttr = dateKey ? ' data-date-key="' + escapeHtml(dateKey) + '" data-item-idx="' + idx + '"' : ""; return ( '
' + '' + titleBoxHTML + bodyHTML + "" + '' + "
" ); } /** * 날짜 카드 HTML을 반환합니다. */ function createCardHTML(day, year, month) { var today = new Date(); today.setHours(0, 0, 0, 0); var cardDate = new Date(year, month - 1, day); cardDate.setHours(0, 0, 0, 0); var jsDay = new Date(year, month - 1, day).getDay(); var isWeekend = jsDay === 6; // 토요일 var isToday = cardDate.getTime() === today.getTime(); var isFuture = cardDate > today; var classes = ["article-card"]; if (isWeekend) classes.push("weekend"); if (isToday) classes.push("today"); if (isFuture) classes.push("is-future"); // 날짜 키로 기사 데이터 조회 (예: "2026-02-01") var dateKey = year + "-" + String(month).padStart(2, "0") + "-" + String(day).padStart(2, "0"); var items = articleData[dateKey] || []; var contentsHTML = items.map(function (item, idx) { return createContentHTML(item, dateKey, idx); }).join(""); return ( '
' + '" + contentsHTML + "
" ); } /** * 빈 셀 HTML을 반환합니다. */ function createEmptyCardHTML() { return ''; } /** * 월 구분선 HTML을 반환합니다. */ function createMonthSeparatorHTML(year, month) { return ( '
' + '' + year + "년 " + month + "월" + "
" ); } /** * 특정 월의 모든 행 HTML을 반환합니다. */ function buildMonthHTML(year, month, addSeparator) { var rows = buildMonthRows(year, month); var html = addSeparator ? createMonthSeparatorHTML(year, month) : ""; rows.forEach(function (row) { html += '
'; row.forEach(function (day) { if (day === null) { html += createEmptyCardHTML(); } else { html += createCardHTML(day, year, month); } }); html += "
"; }); return html; } /** * 다음 연·월을 반환합니다. */ function getNextMonth(year, month) { if (month === 12) return { year: year + 1, month: 1 }; return { year: year, month: month + 1 }; } /** * 이전 연·월을 반환합니다. */ function getPrevMonth(year, month) { if (month === 1) return { year: year - 1, month: 12 }; return { year: year, month: month - 1 }; } /** * 그리드 상단에 특정 월을 추가합니다. (스크롤 위치 유지) */ function prependMonth(year, month) { var gridBody = document.querySelector(".biztrend .article-grid-body"); if (!gridBody) return; // 이미 로드된 월 체크 var alreadyLoaded = loadedMonths.some(function (m) { return m.year === year && m.month === month; }); if (alreadyLoaded) return; // YEAR_START 이전은 추가하지 않음 if (year < YEAR_START || (year === YEAR_START && month < 1)) return; // 기존 첫 달 정보 (새 달 삽입 후 구분선 필요) var oldFirst = loadedMonths[0]; loadedMonths.unshift({ year: year, month: month }); // 삽입 전 스크롤 높이 저장 var prevScrollY = window.scrollY; var prevScrollHeight = document.documentElement.scrollHeight; // 새 달 HTML(구분선 없음) + 기존 첫 달 구분선 var html = buildMonthHTML(year, month, false) + createMonthSeparatorHTML(oldFirst.year, oldFirst.month); gridBody.insertAdjacentHTML("afterbegin", html); // 삽입된 높이만큼 스크롤 위치 보정 (화면 안 튀게) var addedHeight = document.documentElement.scrollHeight - prevScrollHeight; window.scrollTo(0, prevScrollY + addedHeight); // 새 구분선 라벨 옵저버 등록 observeMonthSeparators(); // 상단 센티넬을 맨 앞으로 복원 후 재관찰 gridBody.insertAdjacentHTML( "afterbegin", '
' ); if (topObserver) { var newTop = document.getElementById("article-grid-sentinel-top"); if (newTop) topObserver.observe(newTop); } // 새로 추가된 달이 화면 중앙에 올 때까지 상단 로드 비활성화 topLoadReady = false; var newSep = gridBody.querySelector( '.article-grid-month-sep[data-year="' + year + '"][data-month="' + month + '"]' ); if (newSep) { var centerObserver = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { topLoadReady = true; centerObserver.disconnect(); } }); }, { root: null, rootMargin: "0px", threshold: 0 } ); centerObserver.observe(newSep); } } /** * 그리드에 특정 월을 추가합니다. */ function appendMonth(year, month) { var gridBody = document.querySelector(".biztrend .article-grid-body"); if (!gridBody) return; // 이미 로드된 월 체크 var alreadyLoaded = loadedMonths.some(function (m) { return m.year === year && m.month === month; }); if (alreadyLoaded) return; loadedMonths.push({ year: year, month: month }); var isFirst = loadedMonths.length === 1; var html = buildMonthHTML(year, month, !isFirst); // 센티넬 제거 → 콘텐츠 추가 → 센티넬 재추가 var sentinel = document.getElementById("article-grid-sentinel"); if (sentinel) sentinel.remove(); gridBody.insertAdjacentHTML("beforeend", html); gridBody.insertAdjacentHTML( "beforeend", '
' ); // 새로 추가된 센티넬 관찰 if (scrollObserver) { var newSentinel = document.getElementById("article-grid-sentinel"); if (newSentinel) scrollObserver.observe(newSentinel); } // 새로 추가된 월 구분선을 라벨 옵저버에 등록 observeMonthSeparators(); } /** * 그리드를 초기화하고 지정 월부터 렌더링합니다. */ function renderCalendar(year, month) { var gridBody = document.querySelector(".biztrend .article-grid-body"); if (!gridBody) return; // 기존 옵저버 해제 if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; } if (topObserver) { topObserver.disconnect(); topObserver = null; } // 라벨 옵저버 초기화 initLabelObserver(); // 상태 초기화 loadedMonths = []; topLoadReady = false; gridBody.innerHTML = ""; // 첫 달 렌더링 appendMonth(year, month); // 하단·상단 무한 스크롤 초기화 initInfiniteScroll(); initTopScroll(); } // ------------------------------ // 월 구분선 진입 시 라벨 업데이트 (IntersectionObserver) // ------------------------------ /** * article-grid-month-sep가 뷰포트 상단 영역에 진입·이탈할 때 라벨을 갱신합니다. * - 진입(isIntersecting): 해당 월로 라벨 변경 * - 이탈(위쪽으로 빠져나감): 이미 지나간 상태이므로 유지 * - 이탈(아래쪽으로 빠져나감, 스크롤 업): 이전 달로 라벨 복원 */ function initLabelObserver() { if (labelObserver) labelObserver.disconnect(); labelObserver = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { var year = parseInt(entry.target.dataset.year, 10); var month = parseInt(entry.target.dataset.month, 10); if (entry.isIntersecting) { // 구분선이 뷰포트 상단 영역에 진입 → 해당 월로 업데이트 setDateValue(year, month); } else if (entry.boundingClientRect.top > 0) { // 구분선이 아래쪽으로 빠져나감 (위로 스크롤) → 이전 달로 복원 var idx = loadedMonths.findIndex(function (m) { return m.year === year && m.month === month; }); if (idx > 0) { var prev = loadedMonths[idx - 1]; setDateValue(prev.year, prev.month); } } }); }, { root: null, rootMargin: "0px 0px 0px 0px", // 구분선이 뷰포트에 진입하는 즉시 감지 threshold: 0, } ); } /** * 새로 추가된 월 구분선을 라벨 옵저버에 등록합니다. */ function observeMonthSeparators() { if (!labelObserver) return; document.querySelectorAll(".biztrend .article-grid-month-sep").forEach(function (sep) { labelObserver.observe(sep); }); } // ------------------------------ // 무한 스크롤 (IntersectionObserver) // ------------------------------ /** 하단: 다음 달 추가 */ function initInfiniteScroll() { var sentinel = document.getElementById("article-grid-sentinel"); if (!sentinel) return; scrollObserver = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting && loadedMonths.length > 0) { var last = loadedMonths[loadedMonths.length - 1]; var next = getNextMonth(last.year, last.month); // 현재 달 이후는 로드하지 않음 var now = new Date(); var nowYear = now.getFullYear(); var nowMonth = now.getMonth() + 1; if (next.year > nowYear || (next.year === nowYear && next.month > nowMonth)) return; appendMonth(next.year, next.month); } }); }, { root: null, rootMargin: "200px", threshold: 0 } ); scrollObserver.observe(sentinel); } /** 상단: 이전 달 추가 (IntersectionObserver — 로드 직후 즉시 발화는 setTimeout으로 방지) * 이전 달이 로드된 후에는 해당 달이 화면 중앙에 올 때까지 추가 로드 비활성화 */ function initTopScroll() { var gridBody = document.querySelector(".biztrend .article-grid-body"); if (!gridBody) return; // 상단 센티넬 생성 gridBody.insertAdjacentHTML( "afterbegin", '
' ); if (topObserver) topObserver.disconnect(); topObserver = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting || !topLoadReady || loadedMonths.length === 0) return; var first = loadedMonths[0]; var prev = getPrevMonth(first.year, first.month); if (prev.year < YEAR_START) return; prependMonth(prev.year, prev.month); }); }, { root: null, rootMargin: "0px", threshold: 0 } ); var topSentinel = document.getElementById("article-grid-sentinel-top"); if (topSentinel) topObserver.observe(topSentinel); // 페이지가 안정화된 후 활성화 (로드 시 즉시 발화 방지) setTimeout(function () { topLoadReady = true; }, 300); } // ------------------------------ // 모달 팝업 // ------------------------------ /** * 공통 비디오 모달 인스턴스를 준비합니다. */ function initModal() { if (videoModal) return; if (typeof VideoModalBase === "undefined") { console.warn("[biztrend] VideoModalBase를 찾을 수 없습니다."); return; } videoModal = new VideoModalBase({ modalPath: "./_modal/video.php", modalPathTemplate: "./_modal/video-{type}.php", }); } /** * 비디오 팝업을 열고 item 데이터를 채웁니다. (공통 비디오 모달 사용) */ function openModal(dateKey, idx) { var items = articleData[dateKey]; if (!items || !items[idx]) return; var item = items[idx]; if (!item.videoId) { if (typeof alert === "function") alert("방송 영상을 준비 중입니다."); return; } initModal(); if (!videoModal) return; var descText = ""; if (item.list && item.list.length) { descText = item.list.join("\n"); } var videoData = { id: item.contentId, content_id: item.contentId, url: item.videoId, title: item.title || "", category: "비즈트렌드", subcate: "성공예감&별책부록", type: "main", watch_tm: item.watchTm || 0, bookmark: !!item.liked, _biztrendDesc: descText, }; videoModal.openVideo(videoData).then(function (instance) { var modalEl = instance && instance.currentModalElement ? instance.currentModalElement : null; if (!modalEl) return; var descEl = modalEl.querySelector(".video-info .desc"); if (!descEl) return; if (videoData._biztrendDesc) { descEl.innerHTML = escapeHtml(videoData._biztrendDesc).replace(/\n/g, "
"); } else { descEl.textContent = ""; } }).catch(function () { // openVideo 내부에서 에러 처리(알림)하므로 여기서는 조용히 무시 }); } /** * 그리드 클릭 이벤트 위임 — 주말 카드 제외, article-card-content 클릭 시 비디오 팝업 오픈 */ function initModalEvents() { var grid = document.querySelector(".biztrend .article-grid"); if (!grid) return; grid.addEventListener("click", function (e) { // 링크 기본 동작 막기 var link = e.target.closest(".article-card-link"); if (link) e.preventDefault(); // 좋아요(북마크) 버튼 클릭 var likeBtn = e.target.closest(".article-card-like"); if (likeBtn) { e.preventDefault(); e.stopPropagation(); handleLikeClick(likeBtn); return; } var content = e.target.closest(".article-card-content"); if (!content) return; // 주말(별책부록) 카드는 팝업 없음 var card = e.target.closest(".article-card"); if (card && card.classList.contains("weekend")) return; var dateKey = content.dataset.dateKey; var idx = parseInt(content.dataset.itemIdx, 10); if (!dateKey || isNaN(idx)) return; openModal(dateKey, idx); }); } /** * 좋아요(북마크) 버튼 클릭 핸들러 */ function handleLikeClick(btn) { var contentEl = btn.closest(".article-card-content"); if (!contentEl) return; var dateKey = contentEl.dataset.dateKey; var idx = parseInt(contentEl.dataset.itemIdx, 10); if (!dateKey || isNaN(idx)) return; var items = articleData[dateKey]; if (!items || !items[idx]) return; var item = items[idx]; if (!item.contentId) return; // 현재 상태 토글 var isCurrentlyLiked = btn.classList.contains("is-active"); var nextActive = isCurrentlyLiked ? "0" : "1"; // UI 즉시 반영 (낙관적 업데이트) btn.disabled = true; btn.classList.toggle("is-active"); btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요" : "좋아요 취소"); var params = new URLSearchParams({ content_id: String(item.contentId), is_active: nextActive }); fetch("/bbs/api/save_wishlist.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: params.toString() }) .then(function (res) { return res.json(); }) .then(function (data) { if (!data || !data.success) { // 실패 시 롤백 btn.classList.toggle("is-active"); btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요"); console.warn("[biztrend] 북마크 저장 실패", data); return; } // articleData 동기화 item.liked = !isCurrentlyLiked; }) .catch(function (err) { // 에러 시 롤백 btn.classList.toggle("is-active"); btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요"); console.warn("[biztrend] 북마크 요청 오류", err); }) .finally(function () { btn.disabled = false; }); } // ------------------------------ // 날짜피커 휠 (연·월) // ------------------------------ function buildWheelItems() { var years = []; for (var y = YEAR_START; y <= YEAR_END; y++) { years.push({ value: y, label: y + "년" }); } var months = []; for (var m = 1; m <= 12; m++) { months.push({ value: m, label: m + "월" }); } return { years: years, months: months }; } function renderWheel(trackEl, items, selectedValue) { var idx = items.findIndex(function (i) { return i.value === selectedValue; }); if (idx < 0) idx = 0; var html = items .map(function (item, i) { var isSelected = i === idx ? " is-selected" : ""; return ( '
' + item.label + "
" ); }) .join(""); trackEl.innerHTML = html; trackEl.scrollTop = idx * ITEM_HEIGHT; } function getSelectedFromWheel(trackEl) { var idx = Math.round(trackEl.scrollTop / ITEM_HEIGHT); var items = trackEl.querySelectorAll(".datepicker-wheel-item"); idx = Math.max(0, Math.min(idx, items.length - 1)); var item = items[idx]; return item ? parseInt(item.dataset.value, 10) : null; } function scrollToSelected(trackEl, value) { var item = trackEl.querySelector( '.datepicker-wheel-item[data-value="' + value + '"]' ); if (!item) return; var allItems = trackEl.querySelectorAll(".datepicker-wheel-item"); var idx = Array.from(allItems).indexOf(item); if (idx < 0) return; trackEl.scrollTop = idx * ITEM_HEIGHT; allItems.forEach(function (el) { el.classList.toggle( "is-selected", parseInt(el.dataset.value, 10) === value ); }); } // ------------------------------ // 날짜피커 드롭다운 초기화 // ------------------------------ function initDatepickerDropdown() { var trigger = document.getElementById("datepicker-trigger"); var dropdown = document.getElementById("datepicker-dropdown"); if (!trigger || !dropdown) return; var yearTrack = dropdown.querySelector('[data-wheel="year"]'); var monthTrack = dropdown.querySelector('[data-wheel="month"]'); var items = buildWheelItems(); function openDropdown() { var v = getDateValue(); renderWheel(yearTrack, items.years, v.year); renderWheel(monthTrack, items.months, v.month); dropdown.classList.add("is-open"); dropdown.setAttribute("aria-hidden", "false"); trigger.setAttribute("aria-expanded", "true"); document.addEventListener("keydown", onEscape); setTimeout(function () { document.addEventListener("click", onDocumentClick); }, 0); } function closeDropdown() { dropdown.classList.remove("is-open"); dropdown.setAttribute("aria-hidden", "true"); trigger.setAttribute("aria-expanded", "false"); document.removeEventListener("keydown", onEscape); document.removeEventListener("click", onDocumentClick); } function onEscape(e) { if (e.key === "Escape") closeDropdown(); } function onDocumentClick(e) { if (!dropdown.contains(e.target) && e.target !== trigger) { closeDropdown(); } } function onConfirm() { var year = getSelectedFromWheel(yearTrack); var month = getSelectedFromWheel(monthTrack); if (year != null && month != null) { setDateValue(year, month); renderCalendar(year, month); // 선택한 달로 그리드 재생성 } closeDropdown(); } trigger.addEventListener("click", function (e) { e.stopPropagation(); if (dropdown.classList.contains("is-open")) { closeDropdown(); } else { openDropdown(); } }); dropdown .querySelector(".btn-datepicker-cancel") .addEventListener("click", function (e) { e.stopPropagation(); closeDropdown(); }); dropdown .querySelector(".btn-datepicker-confirm") .addEventListener("click", function (e) { e.stopPropagation(); onConfirm(); }); function updateSelectedClass(trackEl) { var val = getSelectedFromWheel(trackEl); if (val != null) { trackEl.querySelectorAll(".datepicker-wheel-item").forEach(function (el) { el.classList.toggle( "is-selected", parseInt(el.dataset.value, 10) === val ); }); } } yearTrack.addEventListener("scroll", function () { updateSelectedClass(yearTrack); }); monthTrack.addEventListener("scroll", function () { updateSelectedClass(monthTrack); }); yearTrack.addEventListener("click", function (e) { var item = e.target.closest(".datepicker-wheel-item"); if (item) { scrollToSelected(yearTrack, parseInt(item.dataset.value, 10)); } }); monthTrack.addEventListener("click", function (e) { var item = e.target.closest(".datepicker-wheel-item"); if (item) { scrollToSelected(monthTrack, parseInt(item.dataset.value, 10)); } }); } // ------------------------------ // 페이지 초기화 // ------------------------------ function initBiztrendCalendar() { if (!document.querySelector(".wrap.biztrend")) return; // 기사 데이터 로드 (#biztrend-data JSON) loadArticleData(); // 현재 날짜로 날짜피커 라벨 초기화 var now = new Date(); var initYear = now.getFullYear(); var initMonth = now.getMonth() + 1; setDateValue(initYear, initMonth); // 현재 달 기준 동적 그리드 렌더링 renderCalendar(initYear, initMonth); // 그리드 스크롤을 54px 아래에서 시작 (위로 스크롤 시 이전 달 추가 가능하도록) var grid = document.querySelector(".biztrend .article-grid"); if (grid) grid.scrollTop = 54; initDatepickerDropdown(); initModal(); initModalEvents(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initBiztrendCalendar); } else { initBiztrendCalendar(); } })();