Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import HomePage from './pages/HomePage';
import UserPage from './pages/UserPage';
import LoaderTestPage from './pages/LoaderTestPage';
import TokenWarningBanner from './components/TokenWarningBanner';
import { Analytics } from "@vercel/analytics/react"
import { Analytics } from "@vercel/analytics/react";

// This check determines if the warning banner should be displayed.
const isTokenMissing = !process.env.VITE_GITHUB_APP_TOKEN;
Expand Down
5 changes: 2 additions & 3 deletions components/Contributors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,12 @@ const Contributors: React.FC = () => {
}, []);

if (!contributors) {
return (
<div className="mt-8 flex items-center justify-center text-gray-400">Loading contributors…</div>
return ( <div className="flex items-center justify-center text-gray-400">Loading contributors…</div>
);
}

return (
<div className="mt-8 w-full max-w-2xl mx-auto text-center">
<div className="w-full max-w-2xl mx-auto text-center">
<h3 className="text-sm text-gray-300 font-semibold mb-3">Contributors</h3>
<div className="flex flex-wrap items-center justify-center gap-3">
{contributors.map((c) => (
Expand Down
41 changes: 41 additions & 0 deletions components/ExportCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import GitWrapCard from './GitWrapCard';
import type { UserStats, Theme } from '../types';
import { CardLayout } from '../types';

interface ExportCardProps {
userData: UserStats;
funMessage: string;
theme: Theme;
layout: CardLayout;
}

/**
* Export-only wrapper that renders a GitWrap card at a fixed 1080x1440 size.
* The component is meant to be mounted in a hidden container before converting to PNG.
*/
const ExportCard: React.FC<ExportCardProps> = ({ userData, funMessage, theme, layout }) => {
return (
<div
style={{
width: '1080px',
height: '1440px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
className={`${theme.classes.bg} ${theme.classes.textPrimary}`}
>
<GitWrapCard
userData={userData}
funMessage={funMessage}
theme={theme}
layout={layout}
isExport
/>
</div>
);
};

export default ExportCard;
5 changes: 3 additions & 2 deletions components/GitWrapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ interface GitWrapCardProps {
funMessage: string;
theme: Theme;
layout: CardLayout;
isExport?: boolean;
}

const GitWrapCard = forwardRef<HTMLDivElement, GitWrapCardProps>(({ userData, funMessage, theme, layout }, ref) => {
const props = { userData, funMessage, theme };
const GitWrapCard = forwardRef<HTMLDivElement, GitWrapCardProps>(({ userData, funMessage, theme, layout, isExport = false }, ref) => {
const props = { userData, funMessage, theme, isExport };

switch (layout) {
case CardLayout.Modern:
Expand Down
115 changes: 53 additions & 62 deletions components/layouts/ClassicLayout.tsx

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions components/layouts/CompactLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const CompactLayout = forwardRef<HTMLDivElement, LayoutProps>(({ userData, funMe
</div>
<div className="text-right">
<div className={`text-3xl font-black ${classes.accent} leading-none`}>2025</div>
<div className={`text-[10px] font-semibold ${classes.textSecondary} tracking-widest uppercase`}>GitWrap</div>
<div className={`text-[0.625rem] font-semibold ${classes.textSecondary} tracking-widest uppercase`}>GitWrap</div>
</div>
</header>

Expand All @@ -136,30 +136,30 @@ const CompactLayout = forwardRef<HTMLDivElement, LayoutProps>(({ userData, funMe
<div className={`p-3 rounded-lg bg-gradient-to-br from-purple-500/15 to-purple-600/5 border border-purple-400/20 text-center`}>
<CommitIcon className="w-4 h-4 mx-auto mb-1 text-purple-400" />
<div className={`text-lg font-bold ${classes.highlight} leading-none`}>{userData.totalCommits > 999 ? `${(userData.totalCommits / 1000).toFixed(1)}k` : userData.totalCommits}</div>
<div className={`text-[9px] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Commits</div>
<div className={`text-[0.5625rem] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Commits</div>
</div>
<div className={`p-3 rounded-lg bg-gradient-to-br from-blue-500/15 to-blue-600/5 border border-blue-400/20 text-center`}>
<PRIcon className="w-4 h-4 mx-auto mb-1 text-blue-400" />
<div className={`text-lg font-bold ${classes.highlight} leading-none`}>{userData.totalPRs}</div>
<div className={`text-[9px] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>PRs</div>
<div className={`text-[0.5625rem] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>PRs</div>
</div>
<div className={`p-3 rounded-lg bg-gradient-to-br from-green-500/15 to-green-600/5 border border-green-400/20 text-center`}>
<IssueIcon className="w-4 h-4 mx-auto mb-1 text-green-400" />
<div className={`text-lg font-bold ${classes.highlight} leading-none`}>{userData.totalIssues}</div>
<div className={`text-[9px] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Issues</div>
<div className={`text-[0.5625rem] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Issues</div>
</div>
<div className={`p-3 rounded-lg bg-gradient-to-br from-pink-500/15 to-pink-600/5 border border-pink-400/20 text-center`}>
<ReviewIcon className="w-4 h-4 mx-auto mb-1 text-pink-400" />
<div className={`text-lg font-bold ${classes.highlight} leading-none`}>{userData.totalPRReviews}</div>
<div className={`text-[9px] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Reviews</div>
<div className={`text-[0.5625rem] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Reviews</div>
</div>
</div>

{/* Activity Heatmap */}
<div className="p-4 rounded-xl bg-white/5 border border-white/10">
<h3 className={`text-xs font-bold uppercase tracking-wider mb-2 ${classes.accent}`}>2025 Activity</h3>
<MiniHeatmap weeks={heatmapWeeks} />
<div className="flex items-center justify-end gap-1.5 mt-2 text-[10px]">
<div className="flex items-center justify-end gap-1.5 mt-2 text-[0.625rem]">
<span className={classes.textSecondary}>Less</span>
<div className="flex items-center gap-0.5">
{[0, 1, 2, 3, 4].map((level) => (
Expand Down Expand Up @@ -209,17 +209,17 @@ const CompactLayout = forwardRef<HTMLDivElement, LayoutProps>(({ userData, funMe
<div className="space-y-3">
<div>
<div className={`text-2xl font-black ${classes.highlight} leading-none`}>{userData.longestStreakDays}</div>
<div className={`text-[10px] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Day Streak 🔥</div>
<div className={`text-[0.625rem] uppercase tracking-wider ${classes.textSecondary} mt-0.5`}>Day Streak 🔥</div>
</div>
<div className="h-px bg-white/10"></div>
<div className="flex justify-between items-center">
<div>
<div className={`text-sm font-bold ${classes.highlight}`}>{userData.bestDayOfWeek.slice(0, 3)}</div>
<div className={`text-[9px] ${classes.textSecondary}`}>Peak Day</div>
<div className={`text-[0.5625rem] ${classes.textSecondary}`}>Peak Day</div>
</div>
<div>
<div className={`text-sm font-bold ${classes.highlight}`}>{userData.githubAnniversary}Y</div>
<div className={`text-[9px] ${classes.textSecondary}`}>On GitHub</div>
<div className={`text-[0.5625rem] ${classes.textSecondary}`}>On GitHub</div>
</div>
</div>
</div>
Expand All @@ -236,7 +236,7 @@ const CompactLayout = forwardRef<HTMLDivElement, LayoutProps>(({ userData, funMe
</div>

{/* Footer */}
<footer className={`mt-4 text-center text-[10px] ${classes.textSecondary} tracking-wider`}>
<footer className={`mt-4 text-center text-[0.625rem] ${classes.textSecondary} tracking-wider`}>
<span className={classes.accent}>GitWrap</span> • Year in Review
</footer>
</div>
Expand Down
35 changes: 35 additions & 0 deletions index.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,38 @@ html, body, #root {
h1, h2, h3, h4, h5, h6, strong {
font-weight: 600;
}

/* Export-mode breakpoint overrides so sm: utilities behave consistently */
.export-mode .sm\:block {
display: block !important;
}

.export-mode .sm\:flex {
display: flex !important;
}

.export-mode .sm\:inline-flex {
display: inline-flex !important;
}

.export-mode .sm\:grid {
display: grid !important;
}

.export-mode .sm\:inline-block {
display: inline-block !important;
}

.export-mode .sm\:hidden {
display: none !important;
}

/* Utility to hide scrollbars without disabling scroll */
body.no-scrollbar {
scrollbar-width: none;
}

body.no-scrollbar::-webkit-scrollbar {
width: 0;
height: 0;
}
2 changes: 1 addition & 1 deletion pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@ const HomePage: React.FC = () => {
);
};

export default HomePage;
export default HomePage;
128 changes: 94 additions & 34 deletions pages/UserPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import { useParams, Link } from 'react-router-dom';
import type { UserStats, Theme } from '../types';
import { AspectRatio, CardLayout } from '../types';
import { fetchGitHubStats } from '../services/github';
import { generateFunMessage } from '../services/geminiService';
import { THEMES } from '../constants';
import GitWrapCard from '../components/GitWrapCard';
import ExportCard from '../components/ExportCard';
import ThemeSelector from '../components/ThemeSelector';
import SocialShare from '../components/SocialShare';
import { DownloadIcon } from '../components/icons/DownloadIcon';
Expand Down Expand Up @@ -67,32 +69,88 @@ const UserPage: React.FC = () => {
}, [fetchData]);

const handleDownload = async (aspectRatio: AspectRatio) => {
if (!cardRef.current) return;
if (!userData) return;
setIsDownloading(aspectRatio);

const cardElement = cardRef.current;
const originalStyle = cardElement.getAttribute('style');

// Define target dimensions based on aspect ratio
// For 3:4 (Social Post), use standardized mobile-friendly dimensions
let targetWidth: number;
let targetHeight: number;

if (aspectRatio === AspectRatio.Social) { // "3:4"
// Use 1080x1440 which is a standard mobile post size (maintains 3:4 ratio)
targetWidth = 1080;
targetHeight = 1440;
}
const targetWidth = 1080;
const targetHeight = 1440;

const exportContainer = document.createElement('div');
exportContainer.style.position = 'fixed';
exportContainer.style.left = '0';
exportContainer.style.top = '0';
exportContainer.style.width = `${targetWidth}px`;
exportContainer.style.height = `${targetHeight}px`;
exportContainer.style.opacity = '0';
exportContainer.style.pointerEvents = 'none';
exportContainer.style.overflow = 'hidden';
exportContainer.style.zIndex = '-1';
exportContainer.setAttribute('aria-hidden', 'true');

document.body.appendChild(exportContainer);

const root = createRoot(exportContainer);
root.render(
<ExportCard
userData={userData}
funMessage={funMessage}
theme={activeTheme}
layout={CardLayout.Classic}
/>
);

try {
// Temporarily set explicit dimensions on the card element to ensure proper rendering
cardElement.style.width = `${targetWidth}px`;
cardElement.style.height = `${targetHeight}px`;
// Allow React to flush and layout to settle
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
await new Promise((resolve) => setTimeout(resolve, 200));

// Ensure fonts (if available) are loaded before capture
const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
if (fontSet?.ready) {
await fontSet.ready.catch(() => undefined);
}

const dataUrl = await htmlToImage.toPng(cardElement, {
pixelRatio: 2, // Higher quality on download
// Wait for images to load
const images: HTMLImageElement[] = Array.from(exportContainer.querySelectorAll('img'));
if (images.length > 0) {
await Promise.race([
Promise.all(
images.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete) {
resolve();
} else {
img.addEventListener('load', () => resolve(), { once: true });
img.addEventListener('error', () => resolve(), { once: true });
}
})
)
),
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
]);
}

const cardElement = exportContainer.firstElementChild as HTMLElement | null;
const captureTarget = cardElement ?? exportContainer;
const computedStyles = getComputedStyle(captureTarget);
const backgroundColor = computedStyles.backgroundColor;

const dataUrl = await htmlToImage.toPng(captureTarget, {
pixelRatio: 1, // Force exact 1080x1440 output
cacheBust: true,
allowTaint: false,
width: targetWidth,
height: targetHeight,
style: {
opacity: '1',
visibility: 'visible',
transform: 'none',
filter: 'none',
},
backgroundColor:
backgroundColor && backgroundColor !== 'rgba(0, 0, 0, 0)'
? backgroundColor
: undefined,
});

const link = document.createElement('a');
Expand All @@ -101,17 +159,15 @@ const UserPage: React.FC = () => {
link.download = `gitwrap-2025-${safeUsername}-classic-${safeAspectRatio}.png`;
link.href = dataUrl;
link.click();
} catch(err) {
} catch (err) {
console.error('Failed to download image:', err);
const message = err instanceof Error ? err.message : String(err);
alert(`Sorry, there was an error creating the image. This can be caused by external resources (like avatars) failing to load. Please try again.\n\nError: ${message}`);
alert(
`Sorry, there was an error creating the image. This can be caused by external resources (like avatars) failing to load. Please try again.\n\nError: ${message}`
);
} finally {
// Restore original styles
if (originalStyle) {
cardElement.setAttribute('style', originalStyle);
} else {
cardElement.removeAttribute('style');
}
root.unmount();
document.body.removeChild(exportContainer);
setIsDownloading(null);
}
};
Expand All @@ -125,7 +181,7 @@ const UserPage: React.FC = () => {
<div className="w-24 h-24 mx-auto mb-6 relative">
<div className="absolute inset-0 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full opacity-20 animate-ping"></div>
<div className="absolute inset-0 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full opacity-40 blur-xl"></div>
<svg className="relative w-24 h-24 text-white opacity-90" fill="currentColor" viewBox="0 0 24 24">
<svg className="24 h-24 text-white opacity-90" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" clipRule="evenodd" />
</svg>
</div>
Expand Down Expand Up @@ -168,11 +224,15 @@ const UserPage: React.FC = () => {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 lg:gap-12">
<div className="lg:col-span-2">
{/* Mobile: Remove aspect ratio constraint, Desktop: Keep 3:4 ratio */}
<div className="w-full max-w-xl mx-auto lg:max-w-none overflow-hidden">
<div className="sm:aspect-[3/4] w-full">
<GitWrapCard ref={cardRef} userData={userData} funMessage={funMessage} theme={activeTheme} layout={CardLayout.Classic} />
</div>
{/* Let the card grow naturally so no content is clipped */}
<div className="w-full max-w-3xl mx-auto lg:max-w-none">
<GitWrapCard
ref={cardRef}
userData={userData}
funMessage={funMessage}
theme={activeTheme}
layout={CardLayout.Classic}
/>
</div>
</div>
<div className="space-y-8">
Expand Down