blog-garden-widget
Version:
GitHub style blog activity visualization widget with RSS feed support, deployed proxy server integration, and enhanced customization options for multiple users
482 lines (411 loc) • 16.8 kB
JavaScript
/**
* Graden Widget - GitHub Style Blog Activity Visualization
* Version: 1.0.0
* Author: Blog Garden
* License: MIT
*/
(function(window, document) {
'use strict';
// CSS 스타일 동적 추가
function injectStyles() {
const styleId = 'graden-widget-styles';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.graden-widget {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
width: 250px;
max-width: 250px;
padding: 20px;
background: #555;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin: 20px 0;
box-sizing: border-box;
overflow: hidden;
}
.activity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.activity-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
margin: 0;
}
.activity-legend {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #e1e4e8;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-colors {
display: flex;
gap: 2px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.activity-grid {
display: flex;
gap: 4px;
margin-bottom: 16px;
overflow: hidden;
width: 210px;
max-width: 210px;
}
.week-column {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
min-width: 0;
width: 14px;
}
.day-cell {
width: 12px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: transform 0.1s ease;
position: relative;
flex-shrink: 0;
min-width: 0;
max-width: 12px;
}
.day-cell:hover {
transform: scale(1.2);
}
.day-cell.today {
outline: 2px solid #0969da;
outline-offset: 2px;
}
.day-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #24292f;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
margin-bottom: 8px;
pointer-events: none;
}
.day-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: #24292f;
}
.day-cell:hover .day-tooltip {
opacity: 1;
visibility: visible;
}
.activity-footer {
text-align: center;
font-size: 11px;
color: #e0e0e0;
}
.loading {
text-align: center;
padding: 40px 20px;
color: #e0e0e0;
}
.error {
text-align: center;
padding: 20px;
color: #cf222e;
background: #ffebe9;
border-radius: 6px;
border: 1px solid #ff8182;
}
(max-width: 768px) {
.day-cell {
width: 10px;
height: 10px;
}
.legend-color {
width: 10px;
height: 10px;
}
}
`;
document.head.appendChild(style);
}
// GradenWidget 클래스
class GradenWidget {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
// 기본 옵션 설정
const defaultOptions = {
rssUrl: 'https://pearlluck.tistory.com/rss',
title: '활동 기록',
updateInterval: 24 * 60 * 60 * 1000, // 24시간
showLegend: true,
showFooter: true,
proxyUrl: 'https://blog-graden.vercel.app', // 프록시 서버 URL
colors: {
0: '#ebedef',
1: '#9be9a8',
2: '#40c463',
3: '#30a14e',
4: '#216e39'
}
};
// 사용자 옵션과 기본 옵션 병합
this.options = Object.assign({}, defaultOptions, options);
// colors 옵션 별도 처리 (깊은 병합)
if (options.colors) {
this.options.colors = Object.assign({}, defaultOptions.colors, options.colors);
}
this.data = {};
this.weeks = [];
this.maxCount = 1;
this.intervalId = null;
if (!this.container) {
throw new Error('Container element not found');
}
this.init();
}
async init() {
try {
injectStyles();
this.render();
await this.fetchActivityData();
this.generateGrid();
this.startAutoUpdate();
} catch (error) {
this.showError('데이터를 불러올 수 없습니다.');
console.error('Graden Widget initialization error:', error);
}
}
render() {
this.container.innerHTML = `
<div class="graden-widget">
<div class="activity-header">
<h3 class="activity-title">${this.options.title}</h3>
${this.options.showLegend ? `
<div class="activity-legend">
<span class="legend-item">
<span>적음</span>
<div class="legend-colors">
<div class="legend-color" style="background-color: ${this.options.colors[0]};"></div>
<div class="legend-color" style="background-color: ${this.options.colors[1]};"></div>
<div class="legend-color" style="background-color: ${this.options.colors[2]};"></div>
<div class="legend-color" style="background-color: ${this.options.colors[3]};"></div>
<div class="legend-color" style="background-color: ${this.options.colors[4]};"></div>
</div>
<span>많음</span>
</span>
</div>
` : ''}
</div>
<div id="graden-widget-grid-${this.container.id || 'default'}" class="activity-grid">
<div class="loading">데이터를 불러오는 중...</div>
</div>
${this.options.showFooter ? `
<div class="activity-footer">
최근 3개월간의 활동을 보여줍니다
</div>
` : ''}
</div>
`;
}
async fetchActivityData() {
try {
const proxyUrl = this.options.proxyUrl || 'https://blog-graden.vercel.app';
const response = await fetch(`${proxyUrl}/analyze/rss?url=${encodeURIComponent(this.options.rssUrl)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const dateCounts = await response.json();
this.data = dateCounts;
this.maxCount = Math.max(...Object.values(this.data), 1);
console.log('RSS 데이터 로드 완료:', new Date().toLocaleString('ko-KR'));
} catch (error) {
console.error('RSS 데이터 로드 실패:', error);
console.error('에러 상세 정보:', {
message: error.message,
stack: error.stack,
rssUrl: this.options.rssUrl
});
throw error;
}
}
generateGrid() {
const gridContainer = document.getElementById(`graden-widget-grid-${this.container.id || 'default'}`);
if (!gridContainer) return;
gridContainer.innerHTML = '';
// 최근 3개월간의 날짜 생성 (약 12주)
const endDate = new Date();
const startDate = new Date(endDate.getTime() - (90 * 24 * 60 * 60 * 1000)); // 90일
const allDates = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
allDates.push(new Date(d));
}
// 주별로 그룹화
this.weeks = [];
let currentWeek = [];
allDates.forEach(date => {
currentWeek.push(date);
if (currentWeek.length === 7) {
this.weeks.push([...currentWeek]);
currentWeek = [];
}
});
if (currentWeek.length > 0) {
this.weeks.push(currentWeek);
}
// 그리드 렌더링
this.weeks.forEach((week, weekIndex) => {
const weekColumn = document.createElement('div');
weekColumn.className = 'week-column';
week.forEach((date, dayIndex) => {
const dayCell = document.createElement('div');
const count = this.getActivityCount(date);
const isToday = this.isToday(date);
dayCell.className = `day-cell ${isToday ? 'today' : ''}`;
dayCell.style.backgroundColor = this.getColorByCount(count);
// 마우스 이벤트 추가
dayCell.addEventListener('mouseenter', () => {
this.updateFooterText(date, count);
});
dayCell.addEventListener('mouseleave', () => {
this.resetFooterText();
});
weekColumn.appendChild(dayCell);
});
gridContainer.appendChild(weekColumn);
});
}
getActivityCount(date) {
const dateString = date.toISOString().split('T')[0];
return this.data[dateString] || 0;
}
getColorByCount(count) {
if (count === 0) return this.options.colors[0];
if (count <= this.maxCount * 0.25) return this.options.colors[1];
if (count <= this.maxCount * 0.5) return this.options.colors[2];
if (count <= this.maxCount * 0.75) return this.options.colors[3];
return this.options.colors[4];
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
getTooltipText(date, count) {
const formattedDate = date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
if (count === 0) {
return `${formattedDate}: 게시물 없음`;
}
return `${formattedDate}: ${count}개 게시물`;
}
updateFooterText(date, count) {
const footer = this.container.querySelector('.activity-footer');
if (footer) {
const formattedDate = date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
if (count === 0) {
footer.textContent = `${formattedDate}: 게시물 없음`;
} else {
footer.textContent = `${formattedDate}: ${count}개 게시물`;
}
}
}
resetFooterText() {
const footer = this.container.querySelector('.activity-footer');
if (footer) {
footer.textContent = '최근 3개월간의 활동을 보여줍니다';
}
}
startAutoUpdate() {
this.intervalId = setInterval(async () => {
try {
console.log('24시간 주기 RSS 데이터 업데이트 시작:', new Date().toLocaleString('ko-KR'));
await this.fetchActivityData();
this.generateGrid();
} catch (error) {
console.error('자동 업데이트 실패:', error);
}
}, this.options.updateInterval);
}
showError(message) {
const gridContainer = document.getElementById(`graden-widget-grid-${this.container.id || 'default'}`);
if (gridContainer) {
gridContainer.innerHTML = `<div class="error">${message}</div>`;
}
}
// 공개 메서드들
update() {
return this.fetchActivityData().then(() => {
this.generateGrid();
});
}
destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.container) {
this.container.innerHTML = '';
}
}
setOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
this.render();
this.generateGrid();
}
}
// 전역 객체에 노출
window.GradenWidget = GradenWidget;
// 자동 초기화 (data-graden-widget 속성이 있는 요소들)
function autoInit() {
const containers = document.querySelectorAll('[data-graden-widget]');
containers.forEach(container => {
const options = {};
// data-* 속성에서 옵션 파싱
if (container.dataset.rssUrl) options.rssUrl = container.dataset.rssUrl;
if (container.dataset.title) options.title = container.dataset.title;
if (container.dataset.updateInterval) options.updateInterval = parseInt(container.dataset.updateInterval);
if (container.dataset.showLegend !== undefined) options.showLegend = container.dataset.showLegend === 'true';
if (container.dataset.showFooter !== undefined) options.showFooter = container.dataset.showFooter === 'true';
if (container.dataset.proxyUrl) options.proxyUrl = container.dataset.proxyUrl; // 프록시 URL 추가
new GradenWidget(container, options);
});
}
// DOM 로드 완료 시 자동 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
}
})(window, document);