/** * 비즈트렌드 달력 페이지 전용 스크립트 * =================================== * biztrend.html (성공예감&별책부록)에서 사용합니다. * - 연·월 선택 날짜피커 (휠 UI) * - 현재 달 기준 동적 달력 그리드 생성 * - 스크롤 바닥 감지 시 다음 달 자동 추가 (무한 스크롤) */ (function () { "use strict"; // ------------------------------ // 설정 상수 // ------------------------------ var YEAR_START = 2023; var YEAR_END = 2026; /** 그리드 최상단에서 살짝 내린 시작 위치(이전 달 프리로드를 위해 scrollTop>0 유지) */ var GRID_SCROLL_TOP_OFFSET = 54; // ------------------------------ // 무한 스크롤 상태 // ------------------------------ var loadedMonths = []; // [{year, month}] var scrollObserver = null; // 하단 무한 스크롤 (IntersectionObserver) var labelObserver = null; // 라벨 업데이트 var articleData = {}; // 날짜별 기사 데이터 var videoModal = null; // 공통 비디오 모달 (VideoModalBase) var topLoadReady = false; // 상단 스크롤 활성화 플래그 /** 날짜피커 확정 직후, 구분선 IntersectionObserver가 라벨을 덮어쓰지 않도록 잠시 무시 */ var labelObserverQuietUntil = 0; var lastLabelYear = null; var lastLabelMonth = null; // ------------------------------ // 날짜 값 읽기/쓰기 // ------------------------------ 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; } lastLabelYear = year; lastLabelMonth = month; } /** 달력·그리드에서 허용하는 최대 연·월(실시간 오늘 기준) */ function getMaxCalendarYearMonth() { var now = new Date(); return { year: now.getFullYear(), month: now.getMonth() + 1 }; } /** (year, month)가 오늘 이후면 오늘 연·월로 제한 */ function clampYearMonthToNow(year, month) { var max = getMaxCalendarYearMonth(); if (year > max.year || (year === max.year && month > max.month)) { return { year: max.year, month: max.month }; } return { year: year, month: month }; } /** 날짜피커 연 휠: YEAR_START ~ min(YEAR_END, 올해) */ function buildYearWheelItems() { var max = getMaxCalendarYearMonth(); var hi = Math.min(YEAR_END, max.year); var years = []; for (var y = YEAR_START; y <= hi; y++) { years.push({ value: y, label: y + "년" }); } if (!years.length) { var y = Math.min(YEAR_END, Math.max(YEAR_START, max.year)); years.push({ value: y, label: y + "년" }); } return years; } /** 날짜피커 월 휠: 과거 연은 1~12월, 올해는 1~이번 달만 */ function buildMonthWheelItemsForYear(year) { var max = getMaxCalendarYearMonth(); var endM = 12; if (year > max.year) { endM = 0; } else if (year === max.year) { endM = max.month; } var months = []; for (var m = 1; m <= endM; m++) { months.push({ value: m, label: m + "월" }); } if (!months.length) { months.push({ value: 1, label: "1월" }); } return months; } // ------------------------------ // 달력 그리드 동적 생성 // ------------------------------ /** * 해당 월의 총 일수를 반환합니다. */ 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 = {}; } } function syncLikedStateByContentId(contentId, isBookmarked) { var cid = String(contentId || ""); if (!cid) return; Object.keys(articleData).forEach(function (dateKey) { var items = articleData[dateKey]; if (!Array.isArray(items)) return; items.forEach(function (item) { if (String(item && item.contentId || "") !== cid) return; item.liked = !!isBookmarked; }); }); } function syncLikeButtonsByContentId(contentId, isBookmarked) { var cid = String(contentId || ""); if (!cid) return; var selector = '.article-card-content[data-content-id="' + cssEscapeValue(cid) + '"] .article-card-like'; var buttons = document.querySelectorAll(selector); buttons.forEach(function (btn) { btn.classList.toggle("is-active", !!isBookmarked); btn.setAttribute("aria-label", isBookmarked ? "좋아요 취소" : "좋아요"); }); // data-content-id가 없는 구버전 DOM 대비 fallback if (buttons.length) return; Object.keys(articleData).forEach(function (dateKey) { var items = articleData[dateKey]; if (!Array.isArray(items)) return; items.forEach(function (item, idx) { if (String(item && item.contentId || "") !== cid) return; var contentEl = document.querySelector( '.article-card-content[data-date-key="' + cssEscapeValue(dateKey) + '"][data-item-idx="' + idx + '"]' ); if (!contentEl) return; var btn = contentEl.querySelector('.article-card-like'); if (!btn) return; btn.classList.toggle("is-active", !!isBookmarked); btn.setAttribute("aria-label", isBookmarked ? "좋아요 취소" : "좋아요"); }); }); } function cssEscapeValue(value) { if (window.CSS && typeof window.CSS.escape === "function") { return window.CSS.escape(String(value)); } return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** 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 contentId = String(item && (item.contentId || item.content_id) || ""); // 제목 + 게이지 + 배지 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 + '"' : ""; var contentIdAttr = contentId ? ' data-content-id="' + escapeHtml(contentId) + '" data-content_id="' + escapeHtml(contentId) + '"' : ""; 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; loadedMonths.unshift({ year: year, month: month }); var grid = document.querySelector(".biztrend .article-grid"); var prevScrollTop = grid ? grid.scrollTop : 0; var prevScrollHeight = grid ? grid.scrollHeight : 0; // 새 달만 삽입 — appendMonth가 첫 달도 구분선을 포함하도록 맞춤 var html = buildMonthHTML(year, month, true); gridBody.insertAdjacentHTML("afterbegin", html); // 그리드 스크롤 컨테이너 기준: 삽입 높이만큼 scrollTop 보정 if (grid) { var addedHeight = grid.scrollHeight - prevScrollHeight; grid.scrollTop = prevScrollTop + addedHeight; } // 새 구분선 라벨 옵저버 등록 observeMonthSeparators(); // 새로 추가된 달이 화면 중앙에 올 때까지 상단 로드 비활성화 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); } else { topLoadReady = true; } } /** * 그리드에 특정 월을 추가합니다. */ function appendMonth(year, month) { var gridBody = document.querySelector(".biztrend .article-grid-body"); if (!gridBody) return; var capNow = clampYearMonthToNow(year, month); if (capNow.year !== year || capNow.month !== month) return; // 이미 로드된 월 체크 var alreadyLoaded = loadedMonths.some(function (m) { return m.year === year && m.month === month; }); if (alreadyLoaded) return; loadedMonths.push({ year: year, month: month }); // 첫 달도 구분선 포함 — labelObserver가 스크롤 복귀 시 첫 달을 감지할 수 있게 함 var html = buildMonthHTML(year, month, true); // 센티넬 제거 → 콘텐츠 추가 → 센티넬 재추가 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(); } /** * 선택한 연·월의 구분선이 그리드 뷰 상단 근처에 오도록 스크롤합니다. * (날짜피커로 점프했을 때 이전 scrollTop이 남아 달 상단이 안 보이는 문제 방지) */ function scrollGridToMonthSeparator(year, month) { var grid = document.querySelector(".biztrend .article-grid"); if (!grid) return; var sep = grid.querySelector( '.article-grid-month-sep[data-year="' + year + '"][data-month="' + month + '"]' ); if (!sep) return; var gridRect = grid.getBoundingClientRect(); var sepRect = sep.getBoundingClientRect(); var nextTop = grid.scrollTop + (sepRect.top - gridRect.top) - GRID_SCROLL_TOP_OFFSET; // scrollTop===0이면 상단 무한 스크롤이 바로 동작할 수 있어, 기존과 같이 최소 오프셋 유지 grid.scrollTop = Math.max(GRID_SCROLL_TOP_OFFSET, Math.max(0, nextTop)); } /** * 그리드를 초기화하고 지정 월부터 렌더링합니다. */ function renderCalendar(year, month) { var gridBody = document.querySelector(".biztrend .article-grid-body"); if (!gridBody) return; // 휠/데이터 범위(YEAR_START~YEAR_END) 보정 후, 오늘 이후 연·월은 생성하지 않음 month = Math.max(1, Math.min(12, month)); if (year < YEAR_START) { year = YEAR_START; month = 1; } else if (year > YEAR_END) { year = YEAR_END; month = 12; } var cap = clampYearMonthToNow(year, month); year = cap.year; month = cap.month; //console.log("renderCalendar:"); setDateValue(year, month); // 기존 옵저버 해제 if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; } // 상단 스크롤 리스너 정리 (중복 바인딩 방지) if (initTopScroll._handler && initTopScroll._target) { initTopScroll._target.removeEventListener("scroll", initTopScroll._handler); initTopScroll._handler = null; initTopScroll._target = null; } // 라벨 옵저버 초기화 initLabelObserver(); // 상태 초기화 loadedMonths = []; topLoadReady = false; gridBody.innerHTML = ""; // 첫 달 렌더링 appendMonth(year, month); // 하단·상단 무한 스크롤 초기화 initInfiniteScroll(); initTopScroll(); // 레이아웃 반영 후: 항상 해당 달 상단(월 구분선)이 보이도록 스크롤 초기화 requestAnimationFrame(function () { requestAnimationFrame(function () { scrollGridToMonthSeparator(year, month); }); }); } // ------------------------------ // 월 구분선 진입 시 라벨 업데이트 (IntersectionObserver) // ------------------------------ /** * article-grid-month-sep가 뷰포트 상단 영역에 진입·이탈할 때 라벨을 갱신합니다. * - 진입(isIntersecting): 해당 월로 라벨 변경 * - 이탈(위쪽으로 빠져나감): 이미 지나간 상태이므로 유지 * - 이탈(아래쪽으로 빠져나감, 스크롤 업): 이전 달로 라벨 복원 */ function initLabelObserver() { if (labelObserver) labelObserver.disconnect(); var grid = document.querySelector(".biztrend .article-grid"); var l_chk = 0; var l_first = 0; var l_seps_length = 2; labelObserver = new IntersectionObserver( function (entries) { if (Date.now() < labelObserverQuietUntil) return; // 같은 콜백 안에서 여러 entry가 섞여 들어오면(진입/이탈 이벤트 동시 포함), // 기존 로직은 setDateValue가 여러 번 호출되며 라벨이 되돌아갈 수 있습니다. // 그래서 콜백마다 "활성 월 1개"만 결정해 1번만 업데이트합니다. var candidates = []; entries.forEach(function (entry) { if (!entry.isIntersecting) return; var target = entry.target; if (!target || !target.dataset) return; if (!target.dataset.year || !target.dataset.month) return; candidates.push(entry); }); var chosen = null; if (candidates.length) { // root(스케롤 컨테이너) 기준 상단에 가장 가까운 구분선 선택 candidates.sort(function (a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; }); chosen = candidates[0].target; //console.log("if_chosen:"+chosen.dataset.month); // console.log("l_chk:"+l_chk); // console.log("l_first:"+l_first); if (l_first == 1){ l_chk = 1; } l_first = 1; } else { // 빠른 스크롤로 intersecting entry가 비는 경우를 대비한 보정 if (grid) { var rootRect = grid.getBoundingClientRect(); var seps = grid.querySelectorAll(".article-grid-month-sep"); var bestSep = null; var bestDist = Infinity; seps.forEach(function (sep) { var r = sep.getBoundingClientRect(); var dist = r.top - rootRect.top; // root 상단 근처(약간 위 포함)에서 가장 가까운 것을 선택 if (dist >= -2 && dist < bestDist) { bestDist = dist; bestSep = sep; } }); chosen = bestSep; //console.log("seps.length:"+seps.length); if(l_seps_length == seps.length){ l_chk = l_chk + 1; } l_seps_length = seps.length; } } if (!chosen || !chosen.dataset) return; var year = parseInt(chosen.dataset.year, 10); var month = parseInt(chosen.dataset.month, 10); if (isNaN(year) || isNaN(month)) return; //console.log("l_chk:"+l_chk); if(l_chk == 2){ month = month -1; if(month == 0){ year = year - 1; month = 12; } l_chk = 0; } // 변경이 있을 때만 업데이트(과도한 setDateValue 호출 방지) if (lastLabelYear === year && lastLabelMonth === month)return; //console.log("initLabelObserver"); //console.log("month:"+month); setDateValue(year, month); }, { // 실제 스크롤 컨테이너 기준으로 교차 판정해야 튐이 줄어듭니다. root: grid || 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); } /** 상단: .article-grid 스크롤 최상단(scrollTop === 0)에서 이전 달 추가 */ function initTopScroll() { if (!document.querySelector(".biztrend .article-grid-body")) return; var grid = document.querySelector(".biztrend .article-grid"); if (!grid) return; topLoadReady = false; if (initTopScroll._handler && initTopScroll._target) { initTopScroll._target.removeEventListener("scroll", initTopScroll._handler); } initTopScroll._target = grid; initTopScroll._handler = function () { if (!topLoadReady || loadedMonths.length === 0) return; if (grid.scrollTop > 0) return; var first = loadedMonths[0]; var prev = getPrevMonth(first.year, first.month); if (prev.year < YEAR_START) return; var lenBefore = loadedMonths.length; topLoadReady = false; prependMonth(prev.year, prev.month); if (loadedMonths.length === lenBefore) { topLoadReady = true; } }; grid.addEventListener("scroll", initTopScroll._handler, { passive: true }); setTimeout(function () { topLoadReady = true; }, 300); } // ------------------------------ // 모달 팝업 // ------------------------------ /** * 공통 비디오 모달 인스턴스를 준비합니다. */ function initModal() { if (videoModal) return; if (typeof VideoModalBase === "undefined") { console.warn("[biztrend] VideoModalBase를 찾을 수 없습니다."); return; } videoModal = new VideoModalBase({ modalPath: "./_modal/video.php", modalPathTemplate: "./_modal/video-{type}.php", }); } /** * 비디오 팝업을 열고 item 데이터를 채웁니다. (공통 비디오 모달 사용) */ function openModal(dateKey, idx) { var items = articleData[dateKey]; if (!items || !items[idx]) return; var item = items[idx]; if (!item.videoId) { if (typeof alert === "function") alert("방송 영상을 준비 중입니다."); return; } // 인라인 모달(#biztrendVideoModal)을 통해 열기 if (typeof window._biztrendOpenModal === "function") { window._biztrendOpenModal({ contentId: item.contentId, videoUrl: item.contentUrl || item.videoId, title: item.title || "", description: item.description || "", categoryName: "비즈트렌드", subCategory: item.badge || "성공예감&별책부록", watchTm: item.watchTm || 0, contentTm: item.contentTm || 0, bookmark: !!item.liked }); } else { console.warn("[biztrend] _biztrendOpenModal을 찾을 수 없습니다."); } } /** * 그리드 클릭 이벤트 위임 — 주말 카드 제외, article-card-content 클릭 시 비디오 팝업 오픈 */ function initModalEvents() { var grid = document.querySelector(".biztrend .article-grid"); if (!grid) return; grid.addEventListener("click", function (e) { // 링크 기본 동작 막기 var link = e.target.closest(".article-card-link"); if (link) e.preventDefault(); // 좋아요(북마크) 버튼 클릭 var likeBtn = e.target.closest(".article-card-like"); if (likeBtn) { e.preventDefault(); e.stopPropagation(); handleLikeClick(likeBtn); return; } var content = e.target.closest(".article-card-content"); if (!content) return; var dateKey = content.dataset.dateKey; var idx = parseInt(content.dataset.itemIdx, 10); if (!dateKey || isNaN(idx)) return; openModal(dateKey, idx); }); } /** * 좋아요(북마크) 버튼 클릭 핸들러 */ function handleLikeClick(btn) { var contentEl = btn.closest(".article-card-content"); if (!contentEl) return; var dateKey = contentEl.dataset.dateKey; var idx = parseInt(contentEl.dataset.itemIdx, 10); if (!dateKey || isNaN(idx)) return; var items = articleData[dateKey]; if (!items || !items[idx]) return; var item = items[idx]; if (!item.contentId) return; // 현재 상태 토글 var isCurrentlyLiked = btn.classList.contains("is-active"); var nextActive = isCurrentlyLiked ? "0" : "1"; // UI 즉시 반영 (낙관적 업데이트) btn.disabled = true; btn.classList.toggle("is-active"); btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요" : "좋아요 취소"); var params = new URLSearchParams({ content_id: String(item.contentId), is_active: nextActive }); fetch("/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; }); } // ------------------------------ // 날짜피커 휠 (연·월) // ------------------------------ /** * 휠 트랙에 padding/가운데 정렬 마스크가 있으면 scrollTop ≈ index×높이 공식이 깨집니다. * 선택 항목 중심을 트랙 가운데에 맞춥니다. */ function setWheelScrollToIndex(trackEl, index) { var nodeList = trackEl.querySelectorAll(".datepicker-wheel-item"); var len = nodeList.length; if (!len) return; var idx = Math.max(0, Math.min(index, len - 1)); var item = nodeList[idx]; trackEl.style.scrollBehavior = "auto"; var maxScroll = Math.max(0, trackEl.scrollHeight - trackEl.clientHeight); var guard = 0; while (guard++ < 12) { var trackRect = trackEl.getBoundingClientRect(); var itemRect = item.getBoundingClientRect(); var trackMid = trackRect.top + trackRect.height / 2; var itemMid = itemRect.top + itemRect.height / 2; var delta = itemMid - trackMid; if (Math.abs(delta) < 0.6) break; var nextTop = trackEl.scrollTop + delta; nextTop = Math.max(0, Math.min(nextTop, maxScroll)); if (nextTop === trackEl.scrollTop) break; trackEl.scrollTop = nextTop; } trackEl.style.removeProperty("scroll-behavior"); } function renderWheel(trackEl, items, selectedValue) { var idx = items.findIndex(function (i) { return i.value === selectedValue; }); if (idx < 0) idx = 0; var itemsHtml = items .map(function (item, i) { var isSelected = i === idx ? " is-selected" : ""; return ( '
' + item.label + "
" ); }) .join(""); /* * 마지막 항목(11·12월 등)도 선택 가이드(트랙 중앙)에 올리려면 * 스크롤 가능 영역이 아래로 충분히 있어야 합니다. 상·하 패딩 스페이서로 보충합니다. */ function layoutWheelWithPads() { trackEl.innerHTML = itemsHtml; var first = trackEl.querySelector(".datepicker-wheel-item"); var ih = first && first.offsetHeight ? first.offsetHeight : 36; var ch = trackEl.clientHeight; var pad = ch > 0 ? Math.max(0, Math.floor(ch / 2 - ih / 2)) : Math.max(54, Math.floor(ih * 1.5)); var padHtml = ''; trackEl.innerHTML = padHtml + itemsHtml + padHtml; setWheelScrollToIndex(trackEl, idx); syncWheelSelectedClass(trackEl); } requestAnimationFrame(function () { requestAnimationFrame(function () { layoutWheelWithPads(); }); }); } /** 트랙 시야 중앙에 가장 가까운 항목 값 */ function getSelectedFromWheel(trackEl) { var items = trackEl.querySelectorAll(".datepicker-wheel-item"); if (!items.length) return null; var trackRect = trackEl.getBoundingClientRect(); var trackMid = trackRect.top + trackRect.height / 2; var bestIdx = 0; var bestDist = Infinity; for (var i = 0; i < items.length; i++) { var r = items[i].getBoundingClientRect(); var itemMid = r.top + r.height / 2; var d = Math.abs(itemMid - trackMid); if (d < bestDist) { bestDist = d; bestIdx = i; } } return parseInt(items[bestIdx].dataset.value, 10); } /** 현재 scrollTop 기준으로 해당 트랙의 is-selected만 맞춤 */ function syncWheelSelectedClass(trackEl) { var val = getSelectedFromWheel(trackEl); if (val == null) return; trackEl.querySelectorAll(".datepicker-wheel-item").forEach(function (el) { el.classList.toggle( "is-selected", parseInt(el.dataset.value, 10) === val ); }); } 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.prototype.indexOf.call(allItems, item); if (idx < 0) return; setWheelScrollToIndex(trackEl, idx); 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 lastYearForMonthWheel = null; function refreshMonthWheelFromYear() { var y = getSelectedFromWheel(yearTrack); if (y == null) return; if (lastYearForMonthWheel === y) return; lastYearForMonthWheel = y; var months = buildMonthWheelItemsForYear(y); var curM = getSelectedFromWheel(monthTrack); var maxM = months[months.length - 1].value; var minM = months[0].value; if (curM == null || isNaN(curM)) curM = minM; curM = Math.max(minM, Math.min(curM, maxM)); renderWheel(monthTrack, months, curM); } function openDropdown() { dropdown.classList.add("is-open"); dropdown.setAttribute("aria-hidden", "false"); trigger.setAttribute("aria-expanded", "true"); var raw = getDateValue(); var v = clampYearMonthToNow(raw.year, raw.month); var yearItems = buildYearWheelItems(); if (yearItems.length && v.year < yearItems[0].value) { v.year = yearItems[0].value; } if (yearItems.length && v.year > yearItems[yearItems.length - 1].value) { v.year = yearItems[yearItems.length - 1].value; } var monthItems = buildMonthWheelItemsForYear(v.year); var maxM = monthItems[monthItems.length - 1].value; var minM = monthItems[0].value; v.month = Math.max(minM, Math.min(v.month, maxM)); lastYearForMonthWheel = v.year; renderWheel(yearTrack, yearItems, v.year); renderWheel(monthTrack, monthItems, v.month); 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() { // 모바일 관성 스크롤 직후 scrollTop 반영을 한 프레임 양보 requestAnimationFrame(function () { requestAnimationFrame(function () { var year = getSelectedFromWheel(yearTrack); var month = getSelectedFromWheel(monthTrack); if (year != null && month != null) { var picked = clampYearMonthToNow(year, month); labelObserverQuietUntil = Date.now() + 600; setDateValue(picked.year, picked.month); renderCalendar(picked.year, picked.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(); }); yearTrack.addEventListener("scroll", function () { syncWheelSelectedClass(yearTrack); refreshMonthWheelFromYear(); }); monthTrack.addEventListener("scroll", function () { syncWheelSelectedClass(monthTrack); }); yearTrack.addEventListener("click", function (e) { var item = e.target.closest(".datepicker-wheel-item"); if (item) { scrollToSelected(yearTrack, parseInt(item.dataset.value, 10)); requestAnimationFrame(function () { requestAnimationFrame(refreshMonthWheelFromYear); }); } }); 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; // 인라인 모달 스크립트에서 발생시키는 북마크 변경 이벤트를 수신해 // 페이지의 articleData를 동기화한다. function onBookmarkChanged(event) { var detail = event && event.detail ? event.detail : null; if (!detail) return; var cid = detail.contentId || detail.content_id; if (!cid) return; var isBookmarked = !!(detail.isBookmarked || detail.is_bookmarked || detail.isActive === "1"); syncLikedStateByContentId(cid, isBookmarked); syncLikeButtonsByContentId(cid, isBookmarked); } window.addEventListener("biztrend:bookmark-changed", onBookmarkChanged); window.addEventListener("bookmark-changed", onBookmarkChanged); window.addEventListener("wishlist:changed", onBookmarkChanged); // 기사 데이터 로드 (#biztrend-data JSON) loadArticleData(); // 현재 날짜로 날짜피커 라벨 초기화 var now = new Date(); var initYear = now.getFullYear(); var initMonth = now.getMonth() + 1; //console.log("initBiztrendCalendar"); setDateValue(initYear, initMonth); // 현재 달 기준 동적 그리드 렌더링 (renderCalendar 끝에서 스크롤 위치를 맞춤) renderCalendar(initYear, initMonth); initDatepickerDropdown(); initModal(); initModalEvents(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initBiztrendCalendar); } else { initBiztrendCalendar(); } })();