Files
edu/js/biztrend_260408.js

1227 lines
40 KiB
JavaScript

/**
* 비즈트렌드 달력 페이지 전용 스크립트
* ===================================
* 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<Array<number|null>>}
*/
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 기사 콘텐츠 블록 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 =
'<div class="article-card-title-box">' +
'<h4 class="article-card-title">' + escapeHtml(item.title) + "</h4>";
if (item.badge) {
titleBoxHTML += '<span class="article-card-badge">' + escapeHtml(item.badge) + "</span>";
}
if (item.gauge != null) {
titleBoxHTML +=
'<div class="gauge-bar"><div class="gauge-fill" style="width:' + item.gauge + '%"></div></div>';
}
titleBoxHTML += "</div>";
// 리스트
var listHTML = "";
if (item.list && item.list.length) {
listHTML = '<ul class="article-card-list">';
item.list.forEach(function (li) {
listHTML += "<li>" + escapeHtml(li) + "</li>";
});
listHTML += "</ul>";
}
// 이미지 여부에 따라 내부 구조 분기
var bodyHTML = item.image
? '<div class="article-card-image">' +
'<div class="article-card-image-placeholder" aria-hidden="true">' +
'<img src="' + escapeHtml(item.image) + '" alt="' + escapeHtml(item.title) + '">' +
"</div>" +
listHTML +
"</div>"
: 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 (
'<div class="article-card-content"' + dkAttr + contentIdAttr + '>' +
'<a href="#" class="article-card-link" data-video-id="' + escapeHtml(item.videoId || "") + '">' +
titleBoxHTML +
bodyHTML +
"</a>" +
'<button type="button" class="article-card-like' + likedClass + '" aria-label="' + likedLabel + '"></button>' +
"</div>"
);
}
/**
* 날짜 카드 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 (
'<article class="' + classes.join(" ") + '">' +
'<span class="article-card-num" aria-hidden="true">' + day + "</span>" +
contentsHTML +
"</article>"
);
}
/**
* 빈 셀 HTML을 반환합니다.
*/
function createEmptyCardHTML() {
return '<article class="article-card is-empty" aria-hidden="true"></article>';
}
/**
* 월 구분선 HTML을 반환합니다.
*/
function createMonthSeparatorHTML(year, month) {
return (
'<div class="article-grid-month-sep" data-year="' + year + '" data-month="' + month + '">' +
'<span class="article-grid-month-label">' + year + "년 " + month + "월</span>" +
"</div>"
);
}
/**
* 특정 월의 모든 행 HTML을 반환합니다.
*/
function buildMonthHTML(year, month, addSeparator) {
var rows = buildMonthRows(year, month);
var html = addSeparator ? createMonthSeparatorHTML(year, month) : "";
rows.forEach(function (row) {
html += '<div class="article-grid-row">';
row.forEach(function (day) {
if (day === null) {
html += createEmptyCardHTML();
} else {
html += createCardHTML(day, year, month);
}
});
html += "</div>";
});
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",
'<div id="article-grid-sentinel" class="article-grid-sentinel"></div>'
);
// 새로 추가된 센티넬 관찰
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 (
'<div class="datepicker-wheel-item' +
isSelected +
'" data-value="' +
item.value +
'">' +
item.label +
"</div>"
);
})
.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 =
'<div class="datepicker-wheel-pad" aria-hidden="true" style="min-height:' +
pad +
"px;height:" +
pad +
'px;flex-shrink:0"></div>';
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();
}
})();