250 lines
8.1 KiB
JavaScript
250 lines
8.1 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
|
|
// Helper to get all days in the last 52 weeks, starting on Monday - UTC version
|
|
function getCalendarDays() {
|
|
const days = [];
|
|
|
|
// Work entirely in UTC to match Gitea
|
|
const today = new Date();
|
|
const todayUTC = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
|
|
|
|
// Find the Monday that starts our 52-week period
|
|
const startDate = new Date(todayUTC);
|
|
startDate.setUTCDate(todayUTC.getUTCDate() - (51 * 7 - 1));
|
|
|
|
// Adjust startDate to the Monday of that week (using UTC day)
|
|
const startDayOfWeek = startDate.getUTCDay(); // 0 = Sunday, 1 = Monday, etc.
|
|
const daysToMonday = startDayOfWeek === 0 ? -6 : 1 - startDayOfWeek;
|
|
startDate.setUTCDate(startDate.getUTCDate() + daysToMonday);
|
|
|
|
// Generate exactly 52 weeks (364 days) starting from that Monday
|
|
for (let i = 0; i < 7 * 52; i++) {
|
|
const d = new Date(startDate);
|
|
d.setUTCDate(startDate.getUTCDate() + i);
|
|
days.push(d);
|
|
}
|
|
|
|
return days;
|
|
}
|
|
|
|
// Get month labels for the top
|
|
function getMonthLabels(days) {
|
|
const labels = [];
|
|
let lastMonth = null;
|
|
for (let week = 0; week < 52; week++) {
|
|
const day = days[week * 7];
|
|
const month = day.toLocaleString('default', { month: 'short' });
|
|
if (month !== lastMonth) {
|
|
labels.push({ week, month });
|
|
lastMonth = month;
|
|
}
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
// Day labels for rows (Monday to Sunday)
|
|
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'];
|
|
|
|
// Color scales
|
|
const COLORS_LIGHT = [
|
|
'#ebedf0', // 0
|
|
'#c6e48b', // 1
|
|
'#7bc96f', // 2
|
|
'#239a3b', // 3
|
|
'#196127', // 4+
|
|
];
|
|
const COLORS_DARK = [
|
|
'#23272e', // 0
|
|
'#3c4d36', // 1
|
|
'#4e7c4e', // 2
|
|
'#399150', // 3
|
|
'#6ee7b7', // 4+
|
|
];
|
|
|
|
function getColor(count, isDark) {
|
|
const palette = isDark ? COLORS_DARK : COLORS_LIGHT;
|
|
if (!count) return palette[0];
|
|
if (count >= 4) return palette[4];
|
|
return palette[count];
|
|
}
|
|
|
|
export default function ContributionCalendar({ data }) {
|
|
const days = getCalendarDays();
|
|
const monthLabels = getMonthLabels(days);
|
|
const [isDark, setIsDark] = useState(false);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Detect dark mode by checking for 'dark' class on <html>
|
|
const checkDark = () => {
|
|
setIsDark(document.documentElement.classList.contains('dark'));
|
|
};
|
|
checkDark();
|
|
const observer = new MutationObserver(checkDark);
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Detect mobile screen size
|
|
const checkMobile = () => {
|
|
setIsMobile(window.innerWidth <= 640); // Tailwind's sm breakpoint
|
|
};
|
|
checkMobile();
|
|
window.addEventListener('resize', checkMobile);
|
|
return () => window.removeEventListener('resize', checkMobile);
|
|
}, []);
|
|
|
|
// Determine how many weeks to show
|
|
const weeksToShow = isMobile && !expanded ? 18 : 52;
|
|
const startWeek = 52 - weeksToShow;
|
|
const visibleDays = days.slice(startWeek * 7, 52 * 7);
|
|
const visibleMonthLabels = monthLabels.filter(l => l.week >= startWeek);
|
|
|
|
const calendarRef = React.useRef(null);
|
|
|
|
// When expanding to full year on mobile, scroll to the right (most recent weeks)
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
const el = calendarRef.current;
|
|
if (!el) return;
|
|
if (expanded) {
|
|
// Wait for the DOM to update, then scroll
|
|
setTimeout(() => {
|
|
el.scrollLeft = el.scrollWidth;
|
|
}, 100);
|
|
}
|
|
}, [expanded, isMobile]);
|
|
|
|
// Get max count for scaling (optional, for more dynamic color)
|
|
// const max = Math.max(...Object.values(data));
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
maxWidth: '100%',
|
|
margin: '0 auto',
|
|
padding: 0,
|
|
borderRadius: '1rem',
|
|
background: isDark ? 'rgba(30,41,59,0.85)' : 'rgba(255,255,255,0.7)',
|
|
boxShadow: isDark ? '0 4px 24px 0 rgba(0,0,0,0.32)' : '0 4px 24px 0 rgba(0,0,0,0.08)',
|
|
backdropFilter: 'blur(12px)',
|
|
WebkitBackdropFilter: 'blur(12px)',
|
|
border: isDark ? '1px solid rgba(51,65,85,0.5)' : '1px solid rgba(255,255,255,0.3)',
|
|
overflowX: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
minHeight: 160,
|
|
position: 'relative',
|
|
}}
|
|
className="contribution-calendar"
|
|
ref={calendarRef}
|
|
>
|
|
{/* Month labels */}
|
|
<div style={{ display: 'flex', marginBottom: 4, justifyContent: 'center' }}>
|
|
<div style={{ width: 44 }} />
|
|
{Array.from({ length: weeksToShow }).map((_, weekIdx) => {
|
|
const label = visibleMonthLabels.find((l) => l.week === weekIdx + startWeek);
|
|
return (
|
|
<div
|
|
key={weekIdx}
|
|
style={{
|
|
flex: '0 0 14px',
|
|
textAlign: 'center',
|
|
fontSize: 12,
|
|
color: isDark ? '#b6c2d1' : '#888',
|
|
fontWeight: 500,
|
|
minWidth: 14,
|
|
}}
|
|
>
|
|
{label ? label.month : ''}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
{/* Day labels */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 4, justifyContent: 'center' }}>
|
|
{DAY_LABELS.map((label, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
height: 14,
|
|
fontSize: 12,
|
|
color: isDark ? '#b6c2d1' : '#888',
|
|
textAlign: 'right',
|
|
lineHeight: '14px',
|
|
marginBottom: 1,
|
|
minWidth: 32,
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Weeks (columns) */}
|
|
<div style={{ display: 'flex' }}>
|
|
{Array.from({ length: weeksToShow }).map((_, weekIdx) => (
|
|
<div key={weekIdx} style={{ display: 'flex', flexDirection: 'column', flex: '0 0 14px' }}>
|
|
{/* Days (rows) - Using UTC for consistency with Gitea */}
|
|
{Array.from({ length: 7 }).map((_, dayIdx) => {
|
|
const day = visibleDays[weekIdx * 7 + dayIdx];
|
|
if (!day) return <div key={dayIdx} style={{ width: 12, height: 12, margin: 1 }} />;
|
|
|
|
const dateStr = day.toISOString().slice(0, 10);
|
|
const count = data[dateStr] || 0;
|
|
|
|
return (
|
|
<div
|
|
key={dateStr}
|
|
title={`${dateStr}: ${count} contribution${count === 1 ? '' : 's'}`}
|
|
style={{
|
|
width: 12,
|
|
height: 12,
|
|
margin: 1,
|
|
background: getColor(count, isDark),
|
|
borderRadius: 2,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: isDark ? '#b6c2d1' : '#888', marginTop: 4, textAlign: 'center' }}>
|
|
<span>Less</span>
|
|
{(isDark ? COLORS_DARK : COLORS_LIGHT).map((color, i) => (
|
|
<span key={i} style={{ display: 'inline-block', width: 12, height: 12, background: color, margin: '0 2px', borderRadius: 2 }} />
|
|
))}
|
|
<span>More</span>
|
|
</div>
|
|
{/* Toggle button for mobile */}
|
|
{isMobile && (
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
style={{
|
|
marginTop: 8,
|
|
background: isDark ? '#23272e' : '#f3f4f6',
|
|
color: isDark ? '#b6c2d1' : '#333',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
padding: '6px 16px',
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.16)' : '0 1px 4px rgba(0,0,0,0.06)',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
aria-expanded={expanded}
|
|
>
|
|
{expanded ? 'Show less' : 'Show full year'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
} |