Skip to content
Open
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
36 changes: 11 additions & 25 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// app/api/streak/route.ts
import { NextResponse } from 'next/server';
import { fetchGitHubContributions } from '../../../lib/github';
import { calculateStreak } from '../../../lib/calculate';
import { generateSVG } from '../../../lib/svg/generator';
Expand All @@ -10,17 +9,12 @@ import { themes } from '../../../lib/svg/themes';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const user = searchParams.get('user');

if (!user) {
return new NextResponse('Missing "user" parameter', { status: 400 });
}

const user = searchParams.get('user') || 'unknown';
const themeName = searchParams.get('theme') || 'dark';
const selectedTheme = themes[themeName] || themes.dark;

const rawSpeed = searchParams.get('speed') || '8s';
const speed = /^\d+(\.\d+)?s$/.test(rawSpeed) ? rawSpeed : '8s';
const speed = /^\\d+(\\.\\d+)?s$/.test(rawSpeed) ? rawSpeed : '8s';

const rawScale = searchParams.get('scale');
const scale = rawScale === 'log' ? 'log' : 'linear';
Expand Down Expand Up @@ -52,7 +46,7 @@ export async function GET(request: Request) {
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;

// 5. Return the Image Response
return new NextResponse(svg, {
return new Response(svg, {
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': cacheControl,
Expand All @@ -61,25 +55,17 @@ export async function GET(request: Request) {
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;",
},
});
} catch (error: unknown) {
} catch (error) {
console.error('Streak API Error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';

const errorSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
<rect width="100%" height="100%" fill="#2d0000" rx="8"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#ffcccc" font-family="sans-serif" font-size="14">
Error: ${message}
return new Response(`
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100">
<rect width="100%" height="100%" fill="#0a0a0a"/>
<text x="20" y="50" fill="#888" font-size="14">
No data available
</text>
</svg>
`;

return new NextResponse(errorSvg, {
status: 500,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache',
},
`.trim(), {
headers: { 'Content-Type': 'image/svg+xml' }
});
}
}
76 changes: 58 additions & 18 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,21 @@ const Icons = {

export default function LandingPage() {
const [username, setUsername] = useState('jhasourav07');
const [theme, setTheme] = useState('dark');
const [copied, setCopied] = useState(false);
const guideRef = useRef<HTMLDivElement>(null);
const themes = ['dark', 'neon', 'dracula', 'github', 'light'];

const badgeUrl = `/api/streak?user=${username}`;
const markdown = `![CommitPulse](https://commitpulse.vercel.app/api/streak?user=${username})`;
const badgeUrl = `/api/streak?user=${username}&theme=${theme}`;
const markdown = `![CommitPulse](https://commitpulse.vercel.app/api/streak?user=${username}&theme=${theme})`;

const copyToClipboard = () => {
navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => {
guideRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 80);
setTimeout(() => setCopied(false), 50000);
setTimeout(() => setCopied(false), 2000);
};

return (
Expand Down Expand Up @@ -130,17 +132,21 @@ export default function LandingPage() {
{copied ? (
<motion.div
key="check"
initial={{ y: 10 }}
animate={{ y: 0 }}
initial={{ y: 10, opacity: 0, scale: 0.98 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -8, opacity: 0, scale: 0.98 }}
transition={{ duration: 0.22 }}
className="flex items-center gap-2"
>
<Icons.Check /> Copied
<Icons.Check /> Copied!
</motion.div>
) : (
<motion.div
key="copy"
initial={{ y: -10 }}
animate={{ y: 0 }}
initial={{ y: -10, opacity: 0, scale: 0.98 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 8, opacity: 0, scale: 0.98 }}
transition={{ duration: 0.22 }}
className="flex items-center gap-2"
>
<Icons.Copy /> Copy Link
Expand All @@ -160,16 +166,50 @@ export default function LandingPage() {
<div className="group relative">
<div className="absolute -inset-1 rounded-[2rem] bg-white/5 opacity-50 blur-xl transition duration-1000 group-hover:opacity-100" />
<div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-xl border border-[rgba(255,255,255,0.06)] bg-black p-6">
<Image
src={badgeUrl}
alt="Preview"
width={900}
height={600}
unoptimized
loading="eager"
priority
className="h-auto max-w-full drop-shadow-[0_20px_50px_rgba(0,0,0,0.5)]"
/>
<AnimatePresence mode="wait">
<motion.div
key={badgeUrl}
initial={{ opacity: 0, y: 10, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.985 }}
transition={{ duration: 0.26, ease: 'easeOut' }}
className="w-full"
>
<Image
src={badgeUrl}
alt="Preview"
width={900}
height={600}
unoptimized
loading="eager"
priority
className="h-auto max-w-full drop-shadow-[0_20px_50px_rgba(0,0,0,0.5)]"
/>
</motion.div>
</AnimatePresence>
</div>
</div>

<div className="mt-6">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.15em] text-white/30">
Choose Theme
</p>
<div className="flex flex-wrap items-center gap-2.5">
{themes.map((themeName) => (
<motion.button
key={themeName}
onClick={() => setTheme(themeName)}
whileHover={{ y: -1.5, scale: 1.03 }}
whileTap={{ scale: 0.97 }}
className={`rounded-lg border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition-all duration-200 ${
theme === themeName
? 'border-white/35 bg-white text-black shadow-[0_0_20px_rgba(255,255,255,0.22)]'
: 'border-[rgba(255,255,255,0.12)] bg-[#111] text-white/70 hover:border-white/25 hover:bg-white/5 hover:text-white active:bg-white/10'
}`}
>
{themeName}
</motion.button>
))}
</div>
</div>
</div>
Expand Down
59 changes: 53 additions & 6 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,51 @@ export function clearGitHubApiCacheForTests(): void {
reposCache.clear();
}

const getHeaders = () => ({
Authorization: `bearer ${process.env.GITHUB_PAT || process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
});
const getHeaders = (includeContentType = false): Record<string, string> => {
const token = process.env.GITHUB_TOKEN;
const headers: {
Authorization?: string;
Accept: string;
'Content-Type'?: string;
} = {
Authorization: token ? `Bearer ${token}` : undefined,
Accept: 'application/vnd.github+json',
};

if (!headers.Authorization) {
delete headers.Authorization;
}

if (includeContentType) {
headers['Content-Type'] = 'application/json';
}

return headers;
};

function createFallbackContributionCalendar(): ContributionCalendar {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);

const days = Array.from({ length: 371 }, (_, idx) => {
const date = new Date(today);
date.setUTCDate(today.getUTCDate() - (370 - idx));
return {
contributionCount: 0,
date: date.toISOString().slice(0, 10),
color: '#ebedf0',
};
});

const weeks = Array.from({ length: Math.ceil(days.length / 7) }, (_, weekIndex) => ({
contributionDays: days.slice(weekIndex * 7, weekIndex * 7 + 7),
}));

return {
totalContributions: 0,
weeks,
};
}

export async function fetchGitHubContributions(
username: string,
Expand Down Expand Up @@ -93,13 +134,19 @@ export async function fetchGitHubContributions(

const res = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: getHeaders(),
headers: getHeaders(true),
body: JSON.stringify({ query, variables: { login: username } }),
cache: 'no-store', // Cache handled by our in-memory layer + API route headers
});

if (!res.ok) {
if (res.status === 401) throw new Error('GitHub PAT is invalid or missing');
if (res.status === 401 && !process.env.GITHUB_TOKEN) {
const fallbackCalendar = createFallbackContributionCalendar();
if (!options.bypassCache) {
contributionsCache.set(key, fallbackCalendar, GITHUB_CACHE_TTL_MS);
}
return fallbackCalendar;
}
throw new Error(`GitHub GraphQL API returned status ${res.status}`);
}

Expand Down
Loading
Loading