Initial commit: 교육 프로젝트 배포

This commit is contained in:
송대일
2026-07-01 18:32:42 +09:00
commit be6dccd120
1483 changed files with 5082202 additions and 0 deletions
+742
View File
@@ -0,0 +1,742 @@
// ============================================
// 게이지 차트 모듈 (이미지 아이콘 버전)
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils)
// ============================================
class GaugeChart {
// 상수 정의
static CONSTANTS = {
MIN_PERCENT: 0.14, // 최소 표시 퍼센트 (14%)
ANIMATION_DURATION: 1400, // 애니메이션 시간 (ms)
PHASE2_DURATION: 800, // 초과 시 2단계 애니메이션 시간
ICON_HIDE_THRESHOLD: 0.7, // 아이콘 숨김 임계값 (70%)
};
static ICON_CONFIG = {
rocket: { width: 18, height: 48, offsetX: 9, offsetY: 24, outerOffset: 30 },
snail: {
width: 75, // 50 → 75 (1.5배)
height: 64.5, // 43 → 64.5 (1.5배)
offsetX: 37.5, // 25 → 37.5 (1.5배)
offsetY: 32.25, // 21.5 → 32.25 (1.5배)
outerOffset: 30, // 유지
},
};
constructor(config, dependencies = {}) {
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
try {
if (!config || typeof config !== 'object') {
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
config = {};
}
this.config = {
size: config.size || 832,
strokeWidth: config.strokeWidth || 31,
maxValue: config.maxValue || 50,
padding: config.padding || 20,
outerTextOffset: config.outerTextOffset || 6,
innerTextOffset: config.innerTextOffset || 35,
dotRadius: config.dotRadius || 7,
// 아이콘 이미지 경로 설정
rocketIconPath:
config.rocketIconPath || "/img/ico/icon-rocket.svg",
snailIconPath:
config.snailIconPath || "/img/ico/icon-snail.svg",
};
this.svg = null;
this.center = 0;
this.radius = 0;
this.animated = false;
} catch (error) {
this._handleError(error, 'constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `GaugeChart.${context}`,
component: 'GaugeChart',
...additionalInfo
}, false);
} else {
console.error(`[GaugeChart] ${context}:`, error, additionalInfo);
}
}
// ============================================
// 초기화
// ============================================
init() {
try {
this.svg = this.domUtils?.$("#gauge") || document.getElementById("gauge");
if (!this.svg) {
this._handleError(new Error('게이지 SVG 요소를 찾을 수 없습니다.'), 'init');
return;
}
this._calculateDimensions();
this._setupViewBox();
} catch (error) {
this._handleError(error, 'init');
}
}
_calculateDimensions() {
try {
const { size, strokeWidth, padding } = this.config;
if (typeof size !== 'number' || typeof strokeWidth !== 'number' || typeof padding !== 'number') {
this._handleError(new Error('config 값이 유효하지 않습니다.'), '_calculateDimensions');
return;
}
this.center = size / 2;
this.radius = size / 2 - strokeWidth / 2 - padding;
} catch (error) {
this._handleError(error, '_calculateDimensions');
}
}
_setupViewBox() {
try {
if (!this.svg) {
this._handleError(new Error('svg 요소가 없습니다.'), '_setupViewBox');
return;
}
const { size } = this.config;
if (typeof size !== 'number') {
this._handleError(new Error('size가 유효하지 않습니다.'), '_setupViewBox');
return;
}
const extraSpace = 100;
this.svg.setAttribute(
"viewBox",
`-50 -50 ${size + 100} ${size + extraSpace}`
);
this.svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
} catch (error) {
this._handleError(error, '_setupViewBox');
}
}
// ============================================
// 게이지 업데이트
// ============================================
update(value, labelValue = value) {
try {
if (!this.svg) {
console.warn("[GaugeChart] svg 요소가 없습니다.");
return;
}
// 입력값 유효성 검증
if (typeof value !== 'number' || isNaN(value) || value < 0) {
this._handleError(new Error(`유효하지 않은 value: ${value}`), 'update');
return;
}
const shouldAnimate = !this.animated;
this.svg.innerHTML = "";
const { percent, angle } = this._calculateAngle(value);
const isOverMax = value > this.config.maxValue;
this._createTextPaths();
this._createBackgroundArc(isOverMax);
this._createFilledArc(angle, isOverMax, shouldAnimate);
this._addMyLearningText(value, labelValue, percent, angle, shouldAnimate);
this._addEndIcon(angle, value, shouldAnimate);
this._addAverageLearningText(value, percent);
if (shouldAnimate) {
this.animated = true;
}
} catch (error) {
this._handleError(error, 'update', { value });
}
}
_calculateAngle(value) {
const { maxValue } = this.config;
const { MIN_PERCENT } = GaugeChart.CONSTANTS;
let percent;
if (value <= maxValue) {
percent = value / maxValue;
percent = Math.max(percent, MIN_PERCENT);
} else {
percent = 2 - value / maxValue;
percent = Math.max(percent, 0.1);
}
const angle = -180 + percent * 180;
return { percent, angle };
}
// ============================================
// SVG 생성 메서드
// ============================================
_createTextPaths() {
const defs = this._createSVGElement("defs");
// 외곽 텍스트 경로
const outerArc = this._createPathElement(
"outerArc",
this._createArcPath(this.radius - this.config.outerTextOffset, -180, 0)
);
defs.appendChild(outerArc);
// 내부 텍스트 경로
const innerArc = this._createPathElement(
"innerArc",
this._createArcPath(this.radius - this.config.innerTextOffset, -180, 0)
);
defs.appendChild(innerArc);
this.svg.appendChild(defs);
}
_createBackgroundArc(isOverMax) {
const bg = this._createPathElement(
null,
this._createArcPath(this.radius, -180, 0)
);
if (isOverMax) {
bg.setAttribute("stroke", "#D8823B");
} else {
bg.setAttribute("class", "bg-arc");
bg.setAttribute("stroke", "#72451F");
bg.setAttribute("stroke-opacity", "0.1");
}
bg.setAttribute("stroke-width", this.config.strokeWidth);
bg.setAttribute("stroke-linecap", "round");
bg.setAttribute("fill", "none");
this.svg.appendChild(bg);
}
_createFilledArc(angle, isOverMax, shouldAnimate) {
const gradient = this._createGradient(angle);
const fg = this._createPathElement(null, "");
fg.setAttribute("stroke", isOverMax ? "url(#arcSweepGradient)" : "#e89555");
fg.setAttribute("stroke-width", this.config.strokeWidth);
fg.setAttribute("stroke-linecap", "round");
fg.setAttribute("fill", "none");
this.svg.appendChild(fg);
if (shouldAnimate) {
this._animateFilledArc(fg, angle, isOverMax);
} else {
fg.setAttribute("d", this._createArcPath(this.radius, -180, angle));
}
}
_createGradient(angle) {
try {
if (!this.svg) {
this._handleError(new Error('svg 요소가 없습니다.'), '_createGradient');
return null;
}
const defs = this.domUtils?.$("defs", this.svg) || this.svg.querySelector("defs");
if (!defs) {
this._handleError(new Error('defs 요소를 찾을 수 없습니다.'), '_createGradient');
return null;
}
let gradient = this.domUtils?.$("#arcSweepGradient", defs) || defs.querySelector("#arcSweepGradient");
if (!gradient) {
gradient = this._createSVGElement("linearGradient");
gradient.setAttribute("id", "arcSweepGradient");
gradient.setAttribute("x1", "0%");
gradient.setAttribute("y1", "0%");
gradient.setAttribute("x2", "100%");
gradient.setAttribute("y2", "0%");
const colors = [
{ offset: "0%", color: "#72451F" },
{ offset: "100%", color: "#D8823B" },
];
colors.forEach(({ offset, color }) => {
const stop = this._createSVGElement("stop");
stop.setAttribute("offset", offset);
stop.setAttribute("stop-color", color);
gradient.appendChild(stop);
});
defs.appendChild(gradient);
}
gradient.setAttribute(
"gradientTransform",
`rotate(${angle - 90}, 0.5, 0.5)`
);
return gradient;
} catch (error) {
this._handleError(error, '_createGradient', { angle });
return null;
}
}
// ============================================
// 애니메이션
// ============================================
_animateFilledArc(element, targetAngle, isOverMax) {
const { MIN_PERCENT, ANIMATION_DURATION, PHASE2_DURATION } =
GaugeChart.CONSTANTS;
const startAngle = -180 + MIN_PERCENT * 180;
const fullAngle = 0;
element.setAttribute(
"d",
this._createArcPath(this.radius, -180, startAngle)
);
if (isOverMax) {
this._animateTwoPhase(
element,
startAngle,
fullAngle,
targetAngle,
ANIMATION_DURATION,
PHASE2_DURATION
);
} else {
this._animateSinglePhase(
element,
startAngle,
targetAngle,
ANIMATION_DURATION
);
}
}
_animateSinglePhase(element, startAngle, endAngle, duration) {
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = this._easeOutCubic(progress);
const currentAngle = startAngle + (endAngle - startAngle) * easeOut;
element.setAttribute(
"d",
this._createArcPath(this.radius, -180, currentAngle)
);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
_animateTwoPhase(
element,
startAngle,
midAngle,
endAngle,
phase1Duration,
phase2Duration
) {
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
if (elapsed < phase1Duration) {
// Phase 1: 15% → 100%
const progress = elapsed / phase1Duration;
const easeOut = this._easeOutCubic(progress);
const currentAngle = startAngle + (midAngle - startAngle) * easeOut;
element.setAttribute(
"d",
this._createArcPath(this.radius, -180, currentAngle)
);
requestAnimationFrame(animate);
} else if (elapsed < phase1Duration + phase2Duration) {
// Phase 2: 100% → 최종값
const progress = (elapsed - phase1Duration) / phase2Duration;
const easeOut = this._easeOutCubic(progress);
const currentAngle = midAngle + (endAngle - midAngle) * easeOut;
element.setAttribute(
"d",
this._createArcPath(this.radius, -180, currentAngle)
);
requestAnimationFrame(animate);
} else {
// 완료
element.setAttribute(
"d",
this._createArcPath(this.radius, -180, endAngle)
);
}
};
requestAnimationFrame(animate);
}
// ============================================
// 텍스트 및 아이콘
// ============================================
_addMyLearningText(value, labelValue, percent, angle, shouldAnimate) {
try {
if (!this.svg) {
this._handleError(new Error('svg 요소가 없습니다.'), '_addMyLearningText');
return;
}
const text = this._createTextElement(
"나의 학습",
labelValue,
percent,
this.config.maxValue
);
if (!text) {
this._handleError(new Error('text 요소를 생성할 수 없습니다.'), '_addMyLearningText');
return;
}
this.svg.appendChild(text);
const textPath = this.domUtils?.$("textPath", text) || text.querySelector("textPath");
if (!textPath) {
this._handleError(new Error('textPath 요소를 찾을 수 없습니다.'), '_addMyLearningText');
return;
}
const finalOffset = this._calculateTextOffset(value, percent);
if (shouldAnimate) {
this._animateText(
textPath,
15,
finalOffset,
GaugeChart.CONSTANTS.ANIMATION_DURATION
);
} else {
textPath.setAttribute("startOffset", `${finalOffset}%`);
}
// 클릭 이벤트 추가: mypage.html로 이동
if (this.domUtils) {
this.domUtils.setStyles(text, { cursor: "pointer" });
} else {
text.style.cursor = "pointer";
}
const clickHandler = () => {
try {
window.location.href = "mypage.php";
} catch (error) {
this._handleError(error, '_addMyLearningText.clickHandler');
}
};
if (this.eventManager) {
const listenerId = this.eventManager.on(text, "click", clickHandler);
this.listenerIds.push({ element: text, id: listenerId, type: 'click' });
} else {
text.addEventListener("click", clickHandler);
}
} catch (error) {
this._handleError(error, '_addMyLearningText', { value, labelValue, percent, angle, shouldAnimate });
}
}
_createTextElement(label, value, percent, maxValue) {
const text = this._createSVGElement("text");
text.setAttribute("class", "my-learning-text");
text.setAttribute("font-size", "16");
text.setAttribute("font-weight", "700");
text.setAttribute("fill", "#FFF");
text.setAttribute("stroke", "rgba(0, 0, 0, 0.30)");
text.setAttribute("stroke-width", "2");
text.setAttribute("paint-order", "stroke fill");
const textPath = this._createSVGElement("textPath");
textPath.setAttributeNS(
"http://www.w3.org/1999/xlink",
"xlink:href",
"#outerArc"
);
textPath.setAttribute("text-anchor", "middle");
textPath.textContent = `${label} : ${value}`;
text.appendChild(textPath);
return text;
}
_calculateTextOffset(value, percent) {
if (value > this.config.maxValue) {
return 95;
}
return Math.max(10, percent * 100 - 4.5);
}
_animateText(element, startOffset, endOffset, duration) {
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = this._easeOutCubic(progress);
const currentOffset = startOffset + (endOffset - startOffset) * easeOut;
element.setAttribute("startOffset", `${currentOffset}%`);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
_addEndIcon(angle, value, shouldAnimate) {
const { ICON_HIDE_THRESHOLD } = GaugeChart.CONSTANTS;
// 70%~100% 사이에는 아이콘 표시하지 않음
if (
value >= this.config.maxValue * ICON_HIDE_THRESHOLD &&
value <= this.config.maxValue
) {
return;
}
const isOverMax = value > this.config.maxValue;
const iconType = isOverMax ? "rocket" : "snail";
const iconConfig = GaugeChart.ICON_CONFIG[iconType];
const icon = this._createIconElement(iconType);
this.svg.appendChild(icon);
if (shouldAnimate) {
this._animateIcon(icon, angle, iconConfig, isOverMax);
} else {
this._positionIcon(icon, angle, iconConfig, 0);
}
}
_createIconElement(type) {
const icon = this._createSVGElement("g");
// image 요소 생성
const image = this._createSVGElement("image");
const iconConfig = GaugeChart.ICON_CONFIG[type];
// 이미지 경로 설정
const imagePath =
type === "rocket"
? this.config.rocketIconPath
: this.config.snailIconPath;
image.setAttributeNS(
"http://www.w3.org/1999/xlink",
"xlink:href",
imagePath
);
image.setAttribute("width", iconConfig.width);
image.setAttribute("height", iconConfig.height);
icon.appendChild(image);
return icon;
}
_animateIcon(icon, targetAngle, iconConfig, isOverMax) {
const { MIN_PERCENT, ANIMATION_DURATION } = GaugeChart.CONSTANTS;
const startAngle = -180 + MIN_PERCENT * 180;
const fullAngle = 0;
const angleOffset = -3;
this._positionIcon(icon, startAngle, iconConfig, angleOffset);
if (isOverMax) {
// 초과 시: 100%까지만
this._animateIconPosition(
icon,
startAngle,
fullAngle,
iconConfig,
angleOffset,
ANIMATION_DURATION
);
} else {
// 정상 범위: 최종 각도까지
this._animateIconPosition(
icon,
startAngle,
targetAngle,
iconConfig,
angleOffset,
ANIMATION_DURATION
);
}
}
_animateIconPosition(
icon,
startAngle,
endAngle,
iconConfig,
angleOffset,
duration
) {
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = this._easeOutCubic(progress);
const currentAngle = startAngle + (endAngle - startAngle) * easeOut;
this._positionIcon(icon, currentAngle, iconConfig, angleOffset);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
_positionIcon(icon, angle, iconConfig, angleOffset = 0) {
const { offsetX, offsetY, outerOffset } = iconConfig;
const position = this._polarToCartesian(
this.center,
this.center,
this.radius + outerOffset,
angle + angleOffset
);
const rotation =
angle + (iconConfig === GaugeChart.ICON_CONFIG.snail ? 90 : 0);
icon.setAttribute(
"transform",
`translate(${position.x - offsetX}, ${position.y - offsetY}) rotate(${rotation}, ${offsetX}, ${offsetY})`
);
}
_addAverageLearningText(value, percent) {
const text = this._createSVGElement("text");
text.setAttribute("font-size", "13");
text.setAttribute("font-weight", "400");
text.setAttribute("fill", "#4A4947");
const textPath = this._createSVGElement("textPath");
textPath.setAttributeNS(
"http://www.w3.org/1999/xlink",
"xlink:href",
"#innerArc"
);
const offset =
value > this.config.maxValue ? Math.max(10, percent * 100) : 100;
const avgMinutes = Math.max(0, Math.round(Number(this.config.maxValue) || 0));
textPath.setAttribute("startOffset", `${offset}%`);
textPath.setAttribute("text-anchor", "end");
textPath.textContent = `전체 평균 학습 : ${avgMinutes.toLocaleString("ko-KR")}`;
text.appendChild(textPath);
this.svg.appendChild(text);
}
// ============================================
// 유틸리티 메서드
// ============================================
_createSVGElement(type) {
return document.createElementNS("http://www.w3.org/2000/svg", type);
}
_createPathElement(id, d) {
const path = this._createSVGElement("path");
if (id) path.setAttribute("id", id);
if (d) path.setAttribute("d", d);
return path;
}
_polarToCartesian(cx, cy, r, angle) {
const rad = (angle * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
}
_createArcPath(r, startAngle, endAngle) {
const start = this._polarToCartesian(
this.center,
this.center,
r,
startAngle
);
const end = this._polarToCartesian(this.center, this.center, r, endAngle);
return `M ${start.x} ${start.y} A ${r} ${r} 0 0 1 ${end.x} ${end.y}`;
}
_easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
/**
* 리소스 정리 (이벤트 리스너 제거)
*/
destroy() {
try {
// 이벤트 리스너 제거
if (this.eventManager && this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, id }) => {
this.eventManager.off(element, id);
});
this.listenerIds = [];
}
// 참조 정리
this.svg = null;
this.config = null;
this.center = 0;
this.radius = 0;
this.animated = false;
} catch (error) {
this._handleError(error, 'destroy');
}
}
}
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
// ============================================
// 비디오 카드 렌더링 모듈
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils, AnimationUtils)
// ============================================
class VideoCardRenderer {
constructor(config, dependencies = {}) {
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
try {
// 입력값 유효성 검증
if (!config || typeof config !== 'object') {
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
config = {};
}
this.config = config;
this.animationDelay = config.animationDelay || 50;
} catch (error) {
this._handleError(error, 'constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `VideoCardRenderer.${context}`,
component: 'VideoCardRenderer',
...additionalInfo
}, false);
} else {
console.error(`[VideoCardRenderer] ${context}:`, error, additionalInfo);
}
}
// 비디오 카드들 렌더링 (AnimationUtils 활용)
async renderCards(videos, containerId = "videoCardsContainer") {
try {
// 입력값 유효성 검증
if (!videos || !Array.isArray(videos)) {
this._handleError(new Error('videos가 배열이 아닙니다.'), 'renderCards', { containerId });
return;
}
if (!containerId || typeof containerId !== 'string') {
this._handleError(new Error('containerId가 유효하지 않습니다.'), 'renderCards', { videos });
return;
}
const container = this.domUtils?.$(`#${containerId}`) || document.querySelector(`#${containerId}`);
if (!container) {
this._handleError(new Error(`컨테이너를 찾을 수 없습니다: #${containerId}`), 'renderCards', { containerId });
return;
}
if (this.domUtils) {
this.domUtils.empty(container);
} else {
container.innerHTML = '';
}
const cards = [];
videos.forEach((video, index) => {
try {
const card = this.createVideoCard(video);
if (card) {
cards.push(card);
container.appendChild(card);
}
} catch (error) {
this._handleError(error, 'renderCards.createCard', { index, video });
}
});
// 순차적 애니메이션 (AnimationUtils 활용)
if (this.animationUtils && cards.length > 0) {
await this.animationUtils.sequentialAnimate(cards, "show", this.animationDelay);
} else if (typeof AnimationUtils !== 'undefined' && cards.length > 0) {
await AnimationUtils.sequentialAnimate(cards, "show", this.animationDelay);
}
} catch (error) {
this._handleError(error, 'renderCards', { videos, containerId });
}
}
// 비디오 카드 생성 (DOMUtils, VideoBase 활용)
createVideoCard(video) {
try {
// 입력값 유효성 검증
if (!video || typeof video !== 'object') {
this._handleError(new Error('video가 유효하지 않습니다.'), 'createVideoCard');
return null;
}
// VideoModel 사용 (있는 경우)
let videoModel;
if (typeof VideoModel !== 'undefined' && video instanceof VideoModel) {
videoModel = video;
} else if (typeof VideoModel !== 'undefined') {
try {
videoModel = new VideoModel(video);
} catch (error) {
this._handleError(error, 'createVideoCard.VideoModel', { video });
// VideoModel 생성 실패 시 원본 video 사용
videoModel = video;
}
} else {
videoModel = video;
}
const keywords = videoModel.keywords || [];
const keywordTags = Array.isArray(keywords)
? keywords.map((kw) => {
const escapedKw = this._escapeHtml(String(kw || ''));
return `<span class="key-badge">${escapedKw}</span>`;
}).join(" ")
: "";
const categoryClass = this.getCategoryClass(videoModel.category);
const pickerName = videoModel.picker && String(videoModel.picker).trim();
const pickBadge = pickerName
? `<div class="pick"><i class="ico-pick"></i>${this._escapeHtml(pickerName)}님<em>Pick!</em></div>`
: "";
const gauge = videoModel.gauge;
const gaugeBar = (typeof gauge === 'number' && gauge >= 0 && gauge <= 100)
? `<div class="gauge-bar"><div class="gauge-fill" style="width: ${gauge}%"></div></div>`
: "";
// VideoBase를 사용하여 썸네일 URL 생성
let thumbnailUrl = '';
try {
if (videoModel.getThumbnailUrl && typeof videoModel.getThumbnailUrl === 'function') {
thumbnailUrl = videoModel.getThumbnailUrl("sd");
} else if (typeof VideoBase !== 'undefined' && VideoBase.getYouTubeThumbnail) {
const videoId = videoModel.url || videoModel.getVideoId?.() || videoModel.id;
thumbnailUrl = VideoBase.getYouTubeThumbnail(videoId, "sd");
} else {
// 기본 썸네일 URL 생성
const videoId = videoModel.url || videoModel.id;
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/sddefault.jpg`;
}
} catch (error) {
this._handleError(error, 'createVideoCard.thumbnailUrl', { videoModel });
thumbnailUrl = '';
}
const videoId = videoModel.id || videoModel.url || '';
const title = this._escapeHtml(String(videoModel.title || ''));
const category = this._escapeHtml(String(videoModel.category || ''));
const isBookmarked = !!videoModel.bookmark;
const checkboxChecked = isBookmarked ? ' checked' : '';
const cardContent = `
<a href="#" class="card" data-video-id="${this._escapeHtml(String(videoId))}">
<div class="thumb">
<img src="${this._escapeHtml(thumbnailUrl)}" alt="${title}" loading="lazy" />
</div>
<div class="txt-box">
<label class="bookmark" for="like_chk${this._escapeHtml(String(videoId))}" onclick="event.stopPropagation();">
<input type="checkbox" id="like_chk${this._escapeHtml(String(videoId))}"${checkboxChecked}>
</label>
<div class="category ${categoryClass}">${category}</div>
<div class="title">${title}</div>
<div class="author">${keywordTags}</div>
</div>
${pickBadge}
</a>
${gaugeBar}
`;
// DOMUtils.createElement 사용
if (this.domUtils && this.domUtils.createElement) {
return this.domUtils.createElement("div", { class: "video-card" }, cardContent);
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.createElement) {
return DOMUtils.createElement("div", { class: "video-card" }, cardContent);
} else {
// 폴백: 직접 DOM 요소 생성
const div = document.createElement("div");
div.className = "video-card";
div.innerHTML = cardContent;
return div;
}
} catch (error) {
this._handleError(error, 'createVideoCard', { video });
return null;
}
}
/**
* HTML 이스케이프 (XSS 방지)
* @private
*/
_escapeHtml(text) {
if (typeof text !== 'string') {
text = String(text || '');
}
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
// 유틸리티: 카테고리 클래스
getCategoryClass(category) {
try {
if (!category || typeof category !== 'string') {
return "default";
}
const map = {
리더십: "leader",
인사이트: "insight",
비즈트렌드: "biz",
};
return map[category] || "default";
} catch (error) {
this._handleError(error, 'getCategoryClass', { category });
return "default";
}
}
/**
* 리소스 정리 (이벤트 리스너 제거)
*/
destroy() {
try {
// 이벤트 리스너 제거
if (this.eventManager && this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, id }) => {
this.eventManager.off(element, id);
});
this.listenerIds = [];
}
// 참조 정리
this.config = null;
this.animationDelay = 50;
} catch (error) {
this._handleError(error, 'destroy');
}
}
}
+302
View File
@@ -0,0 +1,302 @@
// ============================================
// 비디오 슬라이드 모듈 (페이지네이션)
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils, AnimationUtils)
// ============================================
class VideoSlider {
constructor(config, dependencies = {}) {
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
try {
// 입력값 유효성 검증
if (!config || typeof config !== 'object') {
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
config = {};
}
this.config = config;
this.videos = Array.isArray(config.videos) ? config.videos : [];
this.currentPage = 0;
this.videosPerPage = config.videosPerPage || 6;
// config에 videosPerPage가 없으면 설정
if (!this.config.videosPerPage) {
this.config.videosPerPage = this.videosPerPage;
}
} catch (error) {
this._handleError(error, 'constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `VideoSlider.${context}`,
component: 'VideoSlider',
...additionalInfo
}, false);
} else {
console.error(`[VideoSlider] ${context}:`, error, additionalInfo);
}
}
// 초기화
init() {
try {
this.setupEventListeners();
this.updatePagination();
} catch (error) {
this._handleError(error, 'init');
}
}
// 이벤트 리스너 설정 (EventManager 활용)
setupEventListeners() {
try {
const prevBtn = this.domUtils?.$("#prevBtn") ||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#prevBtn") : null) ||
document.querySelector("#prevBtn");
const nextBtn = this.domUtils?.$("#nextBtn") ||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#nextBtn") : null) ||
document.querySelector("#nextBtn");
if (!prevBtn || !nextBtn) {
console.warn("[VideoSlider] 이전/다음 버튼을 찾을 수 없습니다.");
return;
}
// 이전 버튼 클릭 핸들러
const prevHandler = async (e) => {
try {
e.preventDefault();
e.stopPropagation();
if (this.currentPage > 0) {
this.currentPage--;
await this.changePage();
}
} catch (error) {
this._handleError(error, 'setupEventListeners.prevHandler');
}
};
// 다음 버튼 클릭 핸들러
const nextHandler = async (e) => {
try {
e.preventDefault();
e.stopPropagation();
const totalPages = this.getTotalPages();
if (this.currentPage < totalPages - 1) {
this.currentPage++;
await this.changePage();
}
} catch (error) {
this._handleError(error, 'setupEventListeners.nextHandler');
}
};
// EventManager 사용 (있는 경우)
if (this.eventManager) {
const prevId = this.eventManager.on(prevBtn, "click", prevHandler);
const nextId = this.eventManager.on(nextBtn, "click", nextHandler);
this.listenerIds.push(
{ element: prevBtn, id: prevId, type: 'click' },
{ element: nextBtn, id: nextId, type: 'click' }
);
} else {
// 폴백: 직접 이벤트 리스너 등록
prevBtn.addEventListener("click", prevHandler);
nextBtn.addEventListener("click", nextHandler);
}
} catch (error) {
this._handleError(error, 'setupEventListeners');
}
}
// 현재 페이지 영상 가져오기
getCurrentPageVideos() {
try {
if (!Array.isArray(this.videos)) {
this._handleError(new Error('videos가 배열이 아닙니다.'), 'getCurrentPageVideos');
return [];
}
const videosPerPage = this.config.videosPerPage || this.videosPerPage || 6;
const start = this.currentPage * videosPerPage;
const end = start + videosPerPage;
return this.videos.slice(start, end);
} catch (error) {
this._handleError(error, 'getCurrentPageVideos');
return [];
}
}
// 페이지 변경 (AnimationUtils 활용)
async changePage() {
try {
const container = this.domUtils?.$("#videoCardsContainer") ||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#videoCardsContainer") : null) ||
document.querySelector("#videoCardsContainer");
if (!container) {
this._handleError(new Error('videoCardsContainer를 찾을 수 없습니다.'), 'changePage');
return;
}
// 페이드 아웃 효과
if (this.animationUtils && this.animationUtils.fade) {
await this.animationUtils.fade(container, "out", 400);
} else if (typeof AnimationUtils !== 'undefined' && AnimationUtils.fade) {
await AnimationUtils.fade(container, "out", 400);
} else {
// 폴백: 직접 페이드 효과
container.style.opacity = '0';
await (this.utils?.delay(400) || new Promise(resolve => setTimeout(resolve, 400)));
}
// 외부 렌더링 함수 호출
if (this.config.onPageChange && typeof this.config.onPageChange === 'function') {
try {
this.config.onPageChange(this.getCurrentPageVideos());
} catch (error) {
this._handleError(error, 'changePage.onPageChange');
}
}
// 페이지네이션 업데이트
this.updatePagination();
// 페이드 인 효과
if (this.animationUtils && this.animationUtils.fade) {
await this.animationUtils.fade(container, "in", 400);
} else if (typeof AnimationUtils !== 'undefined' && AnimationUtils.fade) {
await AnimationUtils.fade(container, "in", 400);
} else {
// 폴백: 직접 페이드 효과
container.style.opacity = '1';
}
} catch (error) {
this._handleError(error, 'changePage');
}
}
// 페이지네이션 업데이트
updatePagination() {
try {
const pagination = this.domUtils?.$("#pagination") ||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#pagination") : null) ||
document.querySelector("#pagination");
const prevBtn = this.domUtils?.$("#prevBtn") ||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#prevBtn") : null) ||
document.querySelector("#prevBtn");
const nextBtn = this.domUtils?.$("#nextBtn") ||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#nextBtn") : null) ||
document.querySelector("#nextBtn");
if (!pagination) {
console.warn("[VideoSlider] pagination 요소를 찾을 수 없습니다.");
return;
}
const totalPages = this.getTotalPages();
const currentPageNum = Math.max(0, Math.min(this.currentPage + 1, totalPages));
// XSS 방지를 위해 텍스트로 설정
pagination.innerHTML = '';
const currentSpan = document.createElement('span');
currentSpan.className = 'current';
currentSpan.textContent = currentPageNum;
pagination.appendChild(currentSpan);
pagination.appendChild(document.createTextNode(` / ${totalPages}`));
// 이전 버튼 상태
if (prevBtn) {
const isDisabled = this.currentPage === 0;
if (this.domUtils && this.domUtils.toggleClass) {
this.domUtils.toggleClass(prevBtn, "disabled", isDisabled);
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.toggleClass) {
DOMUtils.toggleClass(prevBtn, "disabled", isDisabled);
} else {
// 폴백
if (isDisabled) {
prevBtn.classList.add("disabled");
} else {
prevBtn.classList.remove("disabled");
}
}
}
// 다음 버튼 상태
if (nextBtn) {
const isDisabled = this.currentPage >= totalPages - 1;
if (this.domUtils && this.domUtils.toggleClass) {
this.domUtils.toggleClass(nextBtn, "disabled", isDisabled);
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.toggleClass) {
DOMUtils.toggleClass(nextBtn, "disabled", isDisabled);
} else {
// 폴백
if (isDisabled) {
nextBtn.classList.add("disabled");
} else {
nextBtn.classList.remove("disabled");
}
}
}
} catch (error) {
this._handleError(error, 'updatePagination');
}
}
// 전체 페이지 수
getTotalPages() {
try {
if (!Array.isArray(this.videos)) {
return 0;
}
const videosPerPage = this.config.videosPerPage || this.videosPerPage || 6;
if (videosPerPage <= 0) {
this._handleError(new Error('videosPerPage가 0 이하입니다.'), 'getTotalPages');
return 1;
}
return Math.max(1, Math.ceil(this.videos.length / videosPerPage));
} catch (error) {
this._handleError(error, 'getTotalPages');
return 1;
}
}
/**
* 리소스 정리 (이벤트 리스너 제거)
*/
destroy() {
try {
// 이벤트 리스너 제거
if (this.eventManager && this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, id }) => {
this.eventManager.off(element, id);
});
this.listenerIds = [];
}
// 참조 정리
this.config = null;
this.videos = [];
this.currentPage = 0;
} catch (error) {
this._handleError(error, 'destroy');
}
}
}
+361
View File
@@ -0,0 +1,361 @@
// ============================================
// 비디오 모달 관리 모듈 (Videomodalmanager.js)
// ============================================
//
// [역할] 비디오 카드 클릭 시 모달을 열고, YouTube 영상을 재생합니다.
//
// [파일명 참고] "Videomodalmanager"는 legacy 표기입니다.
// 클래스명은 VideoModalManager (PascalCase)를 사용합니다.
//
// [의존성] VideoModalBase(부모), DOMUtils, EventManager, ErrorHandler, Utils
//
// [사용 예] index.html 등에서:
// const modalManager = new VideoModalManager({ videos: [...] });
// modalManager.init();
//
// ============================================
class VideoModalManager extends VideoModalBase {
constructor(config, dependencies = {}) {
// 입력값 유효성 검증 (super 호출 전에 가능한 작업만)
if (!config || typeof config !== 'object') {
config = {};
}
// VideoModalBase에 전달할 config 준비
const baseConfig = {
videos: config.videos || [],
modalPath: config.modalPath || "./_modal/video.php",
modalPathTemplate: config.modalPathTemplate || "./_modal/video-{type}.php",
enableHeightAdjustment: config.enableHeightAdjustment !== false,
enableCommentResizer: config.enableCommentResizer !== false,
enableCommentBox: config.enableCommentBox !== false,
...config,
};
// 부모 클래스 생성자 호출 (반드시 먼저 호출)
super(baseConfig);
// 의존성 주입 (폴백 포함) - super() 호출 후
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
this._isInitialized = false;
this._openVideoPromise = null;
try {
// 추가 초기화 작업
} catch (error) {
this._handleError(error, 'constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `VideoModalManager.${context}`,
component: 'VideoModalManager',
...additionalInfo
}, false);
} else {
console.error(`[VideoModalManager] ${context}:`, error, additionalInfo);
}
}
// 초기화
init() {
try {
if (this._isInitialized) {
return;
}
this.setupCardClickEvents();
this._isInitialized = true;
} catch (error) {
this._handleError(error, 'init');
}
}
async openVideo(videoIdOrData) {
if (this._openVideoPromise) {
return this._openVideoPromise;
}
this._openVideoPromise = super.openVideo(videoIdOrData)
.finally(() => {
this._openVideoPromise = null;
});
return this._openVideoPromise;
}
// 카드 클릭 이벤트 설정 (DOMUtils, EventManager 활용)
setupCardClickEvents() {
try {
const containers = Array.from(document.querySelectorAll('[data-video-cards-container="true"], .js-video-cards-container, #videoCardsContainer'));
if (containers.length === 0) {
console.warn("[VideoModalManager] videoCardsContainer를 찾을 수 없습니다.");
return;
}
// VideoModalManager 인스턴스를 참조하기 위해 변수에 저장
const self = this;
const clickHandler = function (e) {
try {
// 북마크 클릭은 카드 오픈 로직에서 제외해야 체크 토글/change 저장이 정상 동작한다.
if (e.target && e.target.closest && e.target.closest('.bookmark')) {
return;
}
const trigger = e.target && e.target.closest
? e.target.closest('.book-info-btn[data-video-id], .books-item[data-video-id], .card[data-video-id], [data-video-id]')
: null;
if (!trigger) {
return;
}
e.preventDefault();
e.stopPropagation();
const videoIdAttr = trigger.getAttribute("data-video-id");
if (!videoIdAttr) {
console.warn("[VideoModalManager] data-video-id 속성이 없습니다.");
return;
}
const videoId = videoIdAttr;
if (!videoId) {
self._handleError(new Error(`유효하지 않은 videoId: ${videoIdAttr}`), 'setupCardClickEvents.clickHandler');
return;
}
self.openVideo(videoId);
} catch (error) {
self._handleError(error, 'setupCardClickEvents.clickHandler');
}
};
containers.forEach((container) => {
const nativeHandler = function (e) {
clickHandler(e);
};
container.addEventListener("click", nativeHandler);
self.listenerIds.push({ element: container, handler: nativeHandler, type: 'native' });
});
} catch (error) {
this._handleError(error, 'setupCardClickEvents');
}
}
// 기존 메서드 호환성을 위한 래퍼
async loadVideoModal(videoId) {
try {
// 입력값 유효성 검증
if (!videoId || (typeof videoId !== 'number' && typeof videoId !== 'string')) {
this._handleError(new Error(`유효하지 않은 videoId: ${videoId}`), 'loadVideoModal');
return null;
}
const id = videoId;
if (!id) {
this._handleError(new Error(`유효하지 않은 videoId: ${videoId}`), 'loadVideoModal');
return null;
}
return await this.openVideo(id);
} catch (error) {
this._handleError(error, 'loadVideoModal', { videoId });
return null;
}
}
// 기존 코드 호환성을 위한 래퍼 메서드들
get currentModal() {
try {
return this.currentModalElement;
} catch (error) {
this._handleError(error, 'currentModal.get');
return null;
}
}
set currentModal(value) {
try {
this.currentModalElement = value;
} catch (error) {
this._handleError(error, 'currentModal.set', { value });
}
}
// 기존 메서드 호환성 유지 (VideoModalBase의 메서드 사용)
adjustVideoListHeight() {
try {
if (this.currentModalElement) {
super.adjustVideoListHeight(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'adjustVideoListHeight');
}
}
setupCommentResizer() {
try {
if (this.currentModalElement) {
super.setupCommentResizer(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'setupCommentResizer');
}
}
setupCommentBox() {
try {
if (this.currentModalElement) {
super.setupCommentBox(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'setupCommentBox');
}
}
showCommentSection() {
try {
if (this.currentModalElement) {
super.showCommentSection(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'showCommentSection');
}
}
adjustCommentOnlyLayout() {
try {
if (this.currentModalElement) {
super.adjustCommentOnlyLayout(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'adjustCommentOnlyLayout');
}
}
setupEssentialLayout() {
try {
if (this.currentModalElement) {
super.setupEssentialLayout(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'setupEssentialLayout');
}
}
setupLearningLayout() {
try {
if (this.currentModalElement) {
super.setupLearningLayout(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'setupLearningLayout');
}
}
initializeHeightAdjustment() {
try {
if (this.currentModalElement) {
super.initializeHeightAdjustment(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'initializeHeightAdjustment');
}
}
setupResizeObserver() {
try {
if (this.currentModalElement) {
super.setupResizeObserver(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'setupResizeObserver');
}
}
setupMutationObserver() {
try {
if (this.currentModalElement) {
super.setupMutationObserver(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'setupMutationObserver');
}
}
async waitForImagesAndAdjust() {
try {
if (this.currentModalElement) {
await super.waitForImagesAndAdjust(this.currentModalElement);
}
} catch (error) {
this._handleError(error, 'waitForImagesAndAdjust');
}
}
destroyModal() {
try {
// 이벤트 리스너 제거
if (this.eventManager && this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, id, type }) => {
if (type === 'delegate') {
this.eventManager.undelegate(element, id);
} else if (type === 'native') {
element.removeEventListener('click', id || arguments[0]);
} else {
this.eventManager.off(element, id);
}
});
this.listenerIds = [];
} else if (this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, handler, type }) => {
if (type === 'native' && element && handler) {
element.removeEventListener('click', handler);
}
});
this.listenerIds = [];
}
// 부모 클래스의 destroy 호출
if (super.destroy && typeof super.destroy === 'function') {
super.destroy();
} else if (this.destroy && typeof this.destroy === 'function') {
this.destroy();
}
this._isInitialized = false;
} catch (error) {
this._handleError(error, 'destroyModal');
}
}
}
if (typeof window !== 'undefined') {
window.VideoModalManager = VideoModalManager;
}
if (typeof globalThis !== 'undefined') {
globalThis.VideoModalManager = VideoModalManager;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { VideoModalManager };
}