Enhance ContributionCalendar component for mobile responsiveness and user experience
- Added mobile detection and state management to handle screen size changes. - Implemented a scroll indicator for mobile users to enhance navigation. - Introduced a toggle button for expanding the calendar view to show the full year. - Adjusted the rendering logic to display a limited number of weeks based on the mobile state.
This commit is contained in:
@@ -65,6 +65,8 @@ 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>
|
||||
@@ -77,6 +79,54 @@ export default function ContributionCalendar({ data }) {
|
||||
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 ? 20 : 52;
|
||||
const startWeek = 52 - weeksToShow;
|
||||
const visibleDays = days.slice(startWeek * 7, 52 * 7);
|
||||
const visibleMonthLabels = monthLabels.filter(l => l.week >= startWeek);
|
||||
|
||||
// Scroll indicator for mobile
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
const calendarRef = React.useRef(null);
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const el = calendarRef.current;
|
||||
if (!el) return;
|
||||
const checkScroll = () => {
|
||||
setShowScrollHint(el.scrollWidth > el.clientWidth && el.scrollLeft < 16);
|
||||
};
|
||||
checkScroll();
|
||||
el.addEventListener('scroll', checkScroll);
|
||||
window.addEventListener('resize', checkScroll);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
};
|
||||
}, [isMobile, expanded]);
|
||||
|
||||
// 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));
|
||||
|
||||
@@ -99,14 +149,33 @@ export default function ContributionCalendar({ data }) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 160,
|
||||
position: 'relative',
|
||||
}}
|
||||
className="contribution-calendar"
|
||||
ref={calendarRef}
|
||||
>
|
||||
{/* Scroll indicator for mobile */}
|
||||
{isMobile && showScrollHint && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
background: isDark ? 'rgba(30,41,59,0.85)' : 'rgba(255,255,255,0.7)',
|
||||
borderRadius: 8,
|
||||
padding: '2px 8px',
|
||||
fontSize: 12,
|
||||
color: isDark ? '#b6c2d1' : '#555',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<span role="img" aria-label="scroll">⬅️➡️</span> Scroll
|
||||
</div>
|
||||
)}
|
||||
{/* Month labels */}
|
||||
<div style={{ display: 'flex', marginBottom: 4, justifyContent: 'center' }}>
|
||||
<div style={{ width: 44 }} />
|
||||
{Array.from({ length: 52 }).map((_, weekIdx) => {
|
||||
const label = monthLabels.find((l) => l.week === weekIdx);
|
||||
{Array.from({ length: weeksToShow }).map((_, weekIdx) => {
|
||||
const label = visibleMonthLabels.find((l) => l.week === weekIdx + startWeek);
|
||||
return (
|
||||
<div
|
||||
key={weekIdx}
|
||||
@@ -146,13 +215,14 @@ export default function ContributionCalendar({ data }) {
|
||||
</div>
|
||||
{/* Weeks (columns) */}
|
||||
<div style={{ display: 'flex' }}>
|
||||
{Array.from({ length: 52 }).map((_, weekIdx) => (
|
||||
{Array.from({ length: weeksToShow }).map((_, weekIdx) => (
|
||||
<div key={weekIdx} style={{ display: 'flex', flexDirection: 'column', flex: '0 0 14px' }}>
|
||||
{/* Days (rows) */}
|
||||
{Array.from({ length: 7 }).map((_, dayIdx) => {
|
||||
// Shift the dayIdx so that Monday is the first row and Sunday is the last
|
||||
const shiftedDayIdx = (dayIdx + 1) % 7;
|
||||
const day = days[weekIdx * 7 + shiftedDayIdx];
|
||||
const day = visibleDays[weekIdx * 7 + shiftedDayIdx];
|
||||
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 (
|
||||
@@ -180,6 +250,28 @@ export default function ContributionCalendar({ data }) {
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
@@ -16,53 +16,80 @@ const metadata = {
|
||||
const lang = Astro.params.lang || 'en';
|
||||
const t = getTranslation(lang);
|
||||
|
||||
// Cache Gitea commits across all language builds
|
||||
let cachedCommits = null;
|
||||
async function getCachedCommits() {
|
||||
if (cachedCommits) return cachedCommits;
|
||||
const GITEA_COMMITS_URL = 'https://git.365devnet.eu/api/v1/repos/365DevNet/365devnet/commits?sha=main&stat=true&verification=true&files=true';
|
||||
let commits = [];
|
||||
// Fetch Gitea user heatmap at build time (cached)
|
||||
async function getUserHeatmap(username) {
|
||||
const url = `https://git.365devnet.eu/api/v1/users/${username}/heatmap`;
|
||||
let heatmap = [];
|
||||
try {
|
||||
const headers = { accept: 'application/json' };
|
||||
if (import.meta.env.GITEA_TOKEN) {
|
||||
headers['Authorization'] = `token ${import.meta.env.GITEA_TOKEN}`;
|
||||
}
|
||||
const commitsResponse = await fetch(GITEA_COMMITS_URL, { headers });
|
||||
if (commitsResponse.ok) {
|
||||
commits = await commitsResponse.json();
|
||||
const response = await fetch(url, { headers });
|
||||
if (response.ok) {
|
||||
heatmap = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching commits:', error);
|
||||
console.error('Error fetching user heatmap:', error);
|
||||
}
|
||||
cachedCommits = commits;
|
||||
return commits;
|
||||
}
|
||||
|
||||
// Fetch Git repository data at build time (cached)
|
||||
const commits = await getCachedCommits();
|
||||
|
||||
// Format date to a readable format
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.toLocaleString(lang, { day: '2-digit' });
|
||||
const month = date.toLocaleString(lang, { month: 'short' }).toLowerCase().replace('.', '');
|
||||
const year = date.getFullYear();
|
||||
return `${day}-${month}-${year}`;
|
||||
};
|
||||
|
||||
// Helper: Group commits by date (YYYY-MM-DD)
|
||||
function getContributionData(commits) {
|
||||
// Aggregate by date
|
||||
const contributions = {};
|
||||
for (const commit of commits) {
|
||||
const date = commit.commit?.author?.date?.slice(0, 10);
|
||||
if (date) {
|
||||
contributions[date] = (contributions[date] || 0) + 1;
|
||||
}
|
||||
for (const entry of heatmap) {
|
||||
const date = new Date(entry.timestamp * 1000).toISOString().slice(0, 10);
|
||||
contributions[date] = (contributions[date] || 0) + entry.contributions;
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
|
||||
const contributionData = getContributionData(commits);
|
||||
const contributionData = await getUserHeatmap('Richard');
|
||||
|
||||
// Fetch latest user commits from activities/feeds
|
||||
async function getUserCommits(username) {
|
||||
const url = `https://git.365devnet.eu/api/v1/users/${username}/activities/feeds`;
|
||||
let feeds = [];
|
||||
try {
|
||||
const headers = { accept: 'application/json' };
|
||||
if (import.meta.env.GITEA_TOKEN) {
|
||||
headers['Authorization'] = `token ${import.meta.env.GITEA_TOKEN}`;
|
||||
}
|
||||
const response = await fetch(url, { headers });
|
||||
if (response.ok) {
|
||||
feeds = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user feeds:', error);
|
||||
}
|
||||
// Only keep commit_repo events
|
||||
const commits = [];
|
||||
for (const feed of feeds) {
|
||||
if (feed.op_type === 'commit_repo' && feed.content) {
|
||||
let content;
|
||||
try {
|
||||
content = JSON.parse(feed.content);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
// Each feed may have multiple commits
|
||||
if (content.Commits && Array.isArray(content.Commits)) {
|
||||
for (const commit of content.Commits) {
|
||||
commits.push({
|
||||
sha: commit.Sha1,
|
||||
message: commit.Message,
|
||||
author: commit.AuthorName,
|
||||
date: commit.Timestamp,
|
||||
repo: feed.repo ? feed.repo.full_name : '',
|
||||
repo_url: feed.repo ? feed.repo.html_url : '',
|
||||
compare_url: content.CompareURL ? `https://git.365devnet.eu/${content.CompareURL}` : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (commits.length >= 10) break;
|
||||
}
|
||||
return commits.slice(0, 10);
|
||||
}
|
||||
|
||||
const userCommits = await getUserCommits('Richard');
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return [
|
||||
@@ -72,6 +99,15 @@ export async function getStaticPaths() {
|
||||
{ params: { lang: 'fr' } },
|
||||
];
|
||||
}
|
||||
|
||||
// Format date to a readable format
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.toLocaleString(lang, { day: '2-digit' });
|
||||
const month = date.toLocaleString(lang, { month: 'short' }).toLowerCase().replace('.', '');
|
||||
const year = date.getFullYear();
|
||||
return `${day}-${month}-${year}`;
|
||||
};
|
||||
---
|
||||
|
||||
<Layout metadata={metadata}>
|
||||
@@ -86,21 +122,22 @@ export async function getStaticPaths() {
|
||||
<div class="space-y-8">
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">{t.development.latestCommits || 'Latest Commits'}</h2>
|
||||
{Array.isArray(commits) && commits.length > 0 ? (
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Showing contributions for user <b>Richard</b> (all repositories).</p>
|
||||
{Array.isArray(userCommits) && userCommits.length > 0 ? (
|
||||
<div class="space-y-4">
|
||||
{commits.slice(0, 10).map((commit) => (
|
||||
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg flex flex-col md:flex-row md:items-stretch md:gap-8">
|
||||
{userCommits.map((commit) => (
|
||||
<div class="bg-white/90 dark:bg-slate-900/90 p-6 rounded-2xl shadow-lg hover:shadow-xl transition-shadow duration-200 border border-gray-100 dark:border-slate-800 flex flex-col gap-4 md:flex-row md:items-stretch md:gap-8 mb-6">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium">{commit.commit?.message?.split('\n')[0] || 'No message'}</h3>
|
||||
<h3 class="font-semibold text-lg md:text-xl text-gray-900 dark:text-white mb-1">{commit.message.split('\n')[0] || 'No message'}</h3>
|
||||
{/* Format commit description with bullet points on new lines */}
|
||||
{commit.commit?.message ? (
|
||||
{commit.message ? (
|
||||
(() => {
|
||||
const lines = commit.commit.message.split('\n');
|
||||
const lines = commit.message.split('\n');
|
||||
const bullets = lines.filter(line => line.trim().startsWith('- '));
|
||||
if (bullets.length > 0) {
|
||||
// Render as a list if there are bullet points
|
||||
return (
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 list-disc ml-5">
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-300 list-disc ml-5">
|
||||
{lines.map((line, idx) =>
|
||||
line.trim().startsWith('- ')
|
||||
? <li key={idx}>{line.trim().slice(2)}</li>
|
||||
@@ -110,25 +147,55 @@ export async function getStaticPaths() {
|
||||
);
|
||||
} else {
|
||||
// Render as a paragraph if no bullet points
|
||||
return <p class="text-sm text-gray-600 dark:text-gray-400">{commit.commit.message}</p>;
|
||||
return <p class="text-sm text-gray-600 dark:text-gray-300">{commit.message}</p>;
|
||||
}
|
||||
})()
|
||||
) : null}
|
||||
</div>
|
||||
<div class="flex flex-col items-end min-w-[140px] bg-gray-100 dark:bg-gray-700 rounded-lg px-4 py-2 mt-4 md:mt-0 md:ml-8 shadow-sm border border-gray-200 dark:border-gray-600">
|
||||
<span class="text-base font-semibold text-gray-800 dark:text-gray-200 mb-1">{commit.commit?.author?.date ? formatDate(commit.commit.author.date) : ''}</span>
|
||||
{commit.commit?.author?.name && (
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mb-2">{commit.commit.author.name}</span>
|
||||
{/* Desktop commit info card */}
|
||||
<div class="hidden md:flex flex-col items-end min-w-[180px] bg-gray-50 dark:bg-slate-800 rounded-xl px-5 py-3 mt-4 md:mt-0 md:ml-8 shadow border border-gray-200 dark:border-gray-700">
|
||||
<span class="text-base font-semibold text-gray-800 dark:text-gray-200 mb-1">{commit.date ? formatDate(commit.date) : ''}</span>
|
||||
{commit.author && (
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mb-2">{commit.author === 'becarta' ? 'Richard Bergsma' : commit.author}</span>
|
||||
)}
|
||||
{commit.repo && (
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<a href={commit.repo_url} target="_blank" rel="noopener">{commit.repo}</a>
|
||||
</span>
|
||||
)}
|
||||
{commit.sha && (
|
||||
<a href={commit.html_url} target="_blank" rel="noopener" class="block mt-1 text-right">
|
||||
<a href={commit.compare_url} target="_blank" rel="noopener" class="block mt-1 text-right">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-300 block">Commit:</span>
|
||||
<code class="mt-1 px-2 py-1 rounded bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 font-mono text-xs text-blue-700 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-gray-900 transition-colors cursor-pointer block">
|
||||
<code class="px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 font-mono text-xs text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-800 transition-colors cursor-pointer block">
|
||||
{commit.sha.slice(0, 7)}
|
||||
</code>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile commit info area, visually separated and two rows with left/right alignment */}
|
||||
<div class="md:hidden border-t border-gray-200 dark:border-gray-700 pt-3 px-1 bg-gray-50 dark:bg-slate-800 rounded-b-xl text-xs text-gray-700 dark:text-gray-300">
|
||||
<div class="flex flex-row justify-between items-center mb-1">
|
||||
<span class="font-semibold">{commit.date ? formatDate(commit.date) : ''}</span>
|
||||
{commit.author && (
|
||||
<span>{commit.author === 'becarta' ? 'Richard Bergsma' : commit.author}</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
{commit.repo && (
|
||||
<span>
|
||||
<a href={commit.repo_url} target="_blank" rel="noopener">{commit.repo}</a>
|
||||
</span>
|
||||
)}
|
||||
{commit.sha && (
|
||||
<a href={commit.compare_url} target="_blank" rel="noopener">
|
||||
<span class="sr-only">Commit:</span>
|
||||
<code class="px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 font-mono text-xs text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-800 transition-colors cursor-pointer">
|
||||
{commit.sha.slice(0, 7)}
|
||||
</code>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user