Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5a9ecf1
feat: Automatically sort SOCIALS list alphabetically
pisum-sativum Jul 22, 2026
e6f1ded
Merge remote-tracking branch 'origin/main' into fix-socials-sort
pisum-sativum Jul 22, 2026
6978cd3
fix: resolve TS2322 type error in SOCIALS sort by casting array as So…
pisum-sativum Jul 22, 2026
dff1d44
fix: resolve GraphQL request deduplication and sanitize PAT errors (#…
pisum-sativum Jul 22, 2026
4d4e10f
fix: resolve garbage characters extraction in resume parser
pisum-sativum Jul 22, 2026
aaa38b7
fix(resume-parser): propagate buffer.toString errors instead of swall…
pisum-sativum Jul 22, 2026
9dace07
chore: revert unrelated changes from PR
pisum-sativum Jul 23, 2026
8172a7f
fix(BurnoutRiskTable): add missing Image import to fix TS2322 and pre…
pisum-sativum Jul 23, 2026
126c4da
Merge remote-tracking branch 'origin/main' into fix-resume-parser-gar…
pisum-sativum Jul 23, 2026
32f1c03
style: fix prettier formatting in BurnoutRiskTable, InteractiveViewer…
pisum-sativum Jul 23, 2026
6b41c1d
style: fix UTF-8 encoding and prettier formatting; restore unrelated …
pisum-sativum Jul 23, 2026
5e844ac
style: restore InteractiveViewer and achievements to origin/main exac…
pisum-sativum Jul 23, 2026
2e2730a
style: fix prettier formatting for remaining files
pisum-sativum Jul 23, 2026
aff9f34
revert: revert all unrelated file changes so PR only targets lib/resu…
pisum-sativum Jul 23, 2026
e81ebaa
Fix typescript error in BurnoutRiskTable and format code
pisum-sativum Jul 24, 2026
58897b7
Fix EditorPanel tests by mocking useGitHubUserExists to prevent netwo…
pisum-sativum Jul 24, 2026
006bffb
chore: resolve formatting and build errors by reverting unrelated files
pisum-sativum Jul 25, 2026
51cb9b9
merge: sync with origin/main to pull latest formatting and build fixes
pisum-sativum Jul 25, 2026
d89756a
style: format BurnoutRiskTable with prettier after merging origin/main
pisum-sativum Jul 25, 2026
f858896
fix(resume-parser): resolve unused variable warning in catch block
pisum-sativum Jul 25, 2026
de2df9a
style: revert InteractiveViewer, BurnoutRiskTable, and achievements t…
pisum-sativum Jul 27, 2026
357f081
style: format all files with prettier to fix CI checks
pisum-sativum Jul 27, 2026
39dde80
feat: Add LeetCode Stats Card (#7812)
pisum-sativum Jul 28, 2026
9fff024
feat: Custom Color Palette Picker for SVGs (#7807)
pisum-sativum Jul 29, 2026
a31c7f8
fix: resolve merge conflicts with main and fix formatting issues
pisum-sativum Jul 31, 2026
497c245
style: fix prettier formatting for CI compliance
pisum-sativum Jul 31, 2026
6bf430d
Merge remote-tracking branch 'origin/main' into feat/custom-color-pal…
pisum-sativum Aug 6, 2026
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
85 changes: 85 additions & 0 deletions app/api/leetcode/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { GET } from './route';
import * as api from '@/services/leetcode/api';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('@/services/leetcode/api', () => ({
getLeetCodeStats: vi.fn(),
}));

describe('GET /api/leetcode', () => {
beforeEach(() => {
vi.resetAllMocks();
});

afterEach(() => {
vi.clearAllMocks();
});

it('returns 400 for missing username parameter', async () => {
const req = new Request('http://localhost/api/leetcode');
const res = await GET(req);

expect(res.status).toBe(400);
expect(res.headers.get('Content-Type')).toContain('image/svg+xml');

const text = await res.text();
expect(text).toContain('Username is required');
});

it('returns 400 for invalid bg color parameter', async () => {
const req = new Request('http://localhost/api/leetcode?username=user&bg=invalid');
const res = await GET(req);

expect(res.status).toBe(400);
expect(res.headers.get('Content-Type')).toContain('image/svg+xml');

const text = await res.text();
expect(text).toContain('bg must be a valid hex color');
});

it('returns 200 SVG with valid user stats', async () => {
vi.mocked(api.getLeetCodeStats).mockResolvedValue({
username: 'leetcode_dev',
totalSolved: 300,
easySolved: 100,
mediumSolved: 150,
hardSolved: 50,
ranking: 5000,
});

const req = new Request('http://localhost/api/leetcode?username=leetcode_dev&theme=dark');
const res = await GET(req);

expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8');

const text = await res.text();
expect(text).toContain("leetcode_dev's LeetCode Stats");

Check failure on line 57 in app/api/leetcode/route.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

app/api/leetcode/route.test.ts > GET /api/leetcode > returns 200 SVG with valid user stats

AssertionError: expected '<svg width="450" height="200" viewBox…' to contain 'leetcode_dev\'s LeetCode Stats' - Expected + Received - leetcode_dev's LeetCode Stats + <svg width="450" height="200" viewBox="0 0 450 200" fill="none" xmlns="http://www.w3.org/2000/svg"><style>@font-face{font-family: 'Space Grotesk';font-style: normal;font-weight: 400;font-display: swap;src: url(data:font/woff2;base64,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
expect(text).toContain('Rank #5,000');
expect(text).toContain('300');
});

it('returns 304 if ETag matches', async () => {
vi.mocked(api.getLeetCodeStats).mockResolvedValue({
username: 'leetcode_dev',
totalSolved: 300,
easySolved: 100,
mediumSolved: 150,
hardSolved: 50,
ranking: 5000,
});

const req1 = new Request('http://localhost/api/leetcode?username=leetcode_dev');
const res1 = await GET(req1);
const etag = res1.headers.get('ETag');
expect(etag).toBeTruthy();

const req2 = new Request('http://localhost/api/leetcode?username=leetcode_dev', {
headers: {
'If-None-Match': etag as string,
},
});
const res2 = await GET(req2);
expect(res2.status).toBe(304);
});
});
83 changes: 83 additions & 0 deletions app/api/leetcode/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server';
import { getLeetCodeStats } from '@/services/leetcode/api';
import { generateLeetCodeSVG } from '@/lib/svg/leetcode';
import { buildInlineErrorSVG } from '@/lib/svg/generator';
import { resolveErrorTheme } from '@/lib/svg/themes';
import { leetcodeParamsSchema, coerceQueryParams } from '@/lib/validations';
import { optimizeSVG } from '@/lib/svg/optimizer';
import crypto from 'crypto';

const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com; img-src data:;";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);

const parseResult = leetcodeParamsSchema.safeParse(coerceQueryParams(searchParams));

if (!parseResult.success) {
const fieldErrors = parseResult.error.flatten();
const firstError =
Object.values(fieldErrors.fieldErrors).flat()[0] ??
fieldErrors.formErrors[0] ??
'Invalid parameters';

const errTheme = resolveErrorTheme(searchParams);
const errorSvg = buildInlineErrorSVG(firstError, {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
});

return new NextResponse(errorSvg, {
status: 400,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
'Content-Security-Policy': SVG_CSP_HEADER,
},
});
}

const params = parseResult.data;

const stats = await getLeetCodeStats(params.username);

let svg = generateLeetCodeSVG(stats, params);

if (params.minify) {
svg = optimizeSVG(svg);
}

const isRefreshRequested = params.refresh || params.bypassCache;
const cacheControl = isRefreshRequested
? 'no-cache, no-store, must-revalidate'
: 'public, max-age=30, s-maxage=30, stale-while-revalidate=30';

const etag = crypto.createHash('sha256').update(svg).digest('hex');
const weakEtag = `W/"${etag}"`;
const ifNoneMatch = request.headers.get('if-none-match');

if (ifNoneMatch) {
const etags = ifNoneMatch.split(',').map((e) => e.trim());
if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) {
return new NextResponse(null, {
status: 304,
headers: {
'Cache-Control': cacheControl,
ETag: weakEtag,
},
});
}
}

return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': cacheControl,
'Content-Security-Policy': SVG_CSP_HEADER,
ETag: weakEtag,
},
});
}
7 changes: 3 additions & 4 deletions app/contact/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import ContactPage from './page';
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
motion: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
div: ({
children,
initial,
Expand All @@ -24,7 +23,7 @@ vi.mock('framer-motion', () => ({
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars

form: ({
children,
onSubmit,
Expand All @@ -38,7 +37,7 @@ vi.mock('framer-motion', () => ({
{children}
</form>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars

p: ({
children,
initial,
Expand All @@ -49,7 +48,7 @@ vi.mock('framer-motion', () => ({
}: React.HTMLAttributes<HTMLParagraphElement> & Record<string, unknown>) => (
<p {...props}>{children}</p>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars

button: ({
children,
whileTap,
Expand Down
8 changes: 8 additions & 0 deletions app/generator/GeneratorClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const INITIAL_STATE: GeneratorState = {
showArticles: false,
articlesPlatform: 'devto',
articlesUsername: '',
themeBg: '',
themeText: '',
themeBorder: '',
themeIcon: '',
showHeroImage: false,
heroImageUrl: '',
heroImageWidth: '450',
Expand Down Expand Up @@ -136,6 +140,10 @@ export function GeneratorClient() {
onShowArticlesChange={(v) => setState((s) => ({ ...s, showArticles: v }))}
onArticlesPlatformChange={(v) => setState((s) => ({ ...s, articlesPlatform: v }))}
onArticlesUsernameChange={(v) => setState((s) => ({ ...s, articlesUsername: v }))}
onThemeBgChange={(v) => setState((s) => ({ ...s, themeBg: v }))}
onThemeTextChange={(v) => setState((s) => ({ ...s, themeText: v }))}
onThemeBorderChange={(v) => setState((s) => ({ ...s, themeBorder: v }))}
onThemeIconChange={(v) => setState((s) => ({ ...s, themeIcon: v }))}
onApplyImport={handleApplyImport}
onApplyPreset={handleApplyPreset}
/>
Expand Down
25 changes: 25 additions & 0 deletions app/generator/components/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CommitPulseSection } from './sections/CommitPulseSection';
import { RepoSpotlightSection } from './sections/RepoSpotlightSection';
import { ContributionGraphSection } from './sections/ContributionGraphSection';
import { ArticlesSection } from './sections/ArticlesSection';
import { ThemeSection } from './sections/ThemeSection';
import { GitHubImportModal } from './GitHubImportModal';
import { FaGithub } from 'react-icons/fa';
import { PROFILE_PRESETS } from '../data/presets';
Expand Down Expand Up @@ -48,6 +49,10 @@ export interface EditorPanelProps {
onShowArticlesChange?: (v: boolean) => void;
onArticlesPlatformChange?: (v: 'devto' | 'hashnode') => void;
onArticlesUsernameChange?: (v: string) => void;
onThemeBgChange?: (v: string) => void;
onThemeTextChange?: (v: string) => void;
onThemeBorderChange?: (v: string) => void;
onThemeIconChange?: (v: string) => void;
onApplyImport: (data: ImportedData) => void;
onApplyPreset?: (presetState: Partial<GeneratorState>) => void;
}
Expand Down Expand Up @@ -78,6 +83,10 @@ export function EditorPanel({
onShowArticlesChange = () => {},
onArticlesPlatformChange = () => {},
onArticlesUsernameChange = () => {},
onThemeBgChange = () => {},
onThemeTextChange = () => {},
onThemeBorderChange = () => {},
onThemeIconChange = () => {},
onApplyImport,
onApplyPreset,
}: EditorPanelProps) {
Expand Down Expand Up @@ -242,6 +251,22 @@ export function EditorPanel({
onArticlesUsernameChange('');
}}
/>
<ThemeSection
themeBg={state.themeBg}
themeText={state.themeText}
themeBorder={state.themeBorder}
themeIcon={state.themeIcon}
onThemeBgChange={onThemeBgChange}
onThemeTextChange={onThemeTextChange}
onThemeBorderChange={onThemeBorderChange}
onThemeIconChange={onThemeIconChange}
onReset={() => {
onThemeBgChange('');
onThemeTextChange('');
onThemeBorderChange('');
onThemeIconChange('');
}}
/>
</form>
);
}
4 changes: 4 additions & 0 deletions app/generator/components/EditorPanel.type-compiler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ describe('EditorPanel Type Compiler Validation', () => {
showArticles?: boolean;
articlesPlatform?: 'devto' | 'hashnode';
articlesUsername?: string;
themeBg?: string;
themeText?: string;
themeBorder?: string;
themeIcon?: string;
showHeroImage?: boolean;
heroImageUrl?: string;
heroImageWidth?: string;
Expand Down
104 changes: 104 additions & 0 deletions app/generator/components/sections/ThemeSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use client';

import { SectionCard, FieldLabel } from '../SectionCard';

export interface ThemeSectionProps {
themeBg?: string;
themeText?: string;
themeBorder?: string;
themeIcon?: string;
onThemeBgChange: (v: string) => void;
onThemeTextChange: (v: string) => void;
onThemeBorderChange: (v: string) => void;
onThemeIconChange: (v: string) => void;
onReset?: () => void;
}

export function ThemeSection({
themeBg = '',
themeText = '',
themeBorder = '',
themeIcon = '',
onThemeBgChange,
onThemeTextChange,
onThemeBorderChange,
onThemeIconChange,
onReset,
}: ThemeSectionProps) {
const renderColorInput = (
id: string,
label: string,
value: string,
onChange: (v: string) => void
) => {
const isValid = value === '' || /^[0-9a-fA-F]{6}$/.test(value);

return (
<div key={id}>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<div className="flex items-center gap-3">
<div className="relative flex items-center">
<span className="absolute left-3 text-xs text-gray-400 dark:text-white/30 select-none">
#
</span>
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value.replace(/^#/, ''))}
placeholder="10b981"
maxLength={6}
spellCheck={false}
className="w-32 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 pl-7 pr-3 py-2.5 text-sm font-mono text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500/40 transition-colors"
/>
</div>
<div
className="w-8 h-8 rounded-lg border border-gray-200 dark:border-white/10 flex-shrink-0 transition-colors"
style={{
background: isValid && value ? `#${value}` : 'transparent',
}}
/>
{value && !isValid && <p className="text-[11px] text-amber-500">Invalid hex</p>}
{value && isValid && (
<button
type="button"
onClick={() => onChange('')}
className="text-[11px] text-gray-400 dark:text-white/30 hover:text-gray-700 dark:hover:text-white/60 transition-colors"
>
Clear
</button>
)}
</div>
</div>
);
};

const activeCount = [themeBg, themeText, themeBorder, themeIcon].filter(Boolean).length;

return (
<SectionCard
title="Custom Color Palette Picker"
description="Customize the colors of the generated SVGs"
defaultOpen={false}
badge={activeCount}
onReset={onReset}
>
<div className="flex flex-col gap-4">
{renderColorInput('theme-bg', 'Background Colour (bg)', themeBg, onThemeBgChange)}
{renderColorInput('theme-text', 'Text Colour (text)', themeText, onThemeTextChange)}
{renderColorInput(
'theme-icon',
'Icon/Accent Colour (accent)',
themeIcon,
onThemeIconChange
)}
{renderColorInput(
'theme-border',
'Border Colour (border)',
themeBorder,
onThemeBorderChange
)}
</div>
</SectionCard>
);
}
Loading
Loading