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
2 changes: 2 additions & 0 deletions app/generator/GeneratorClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { GeneratorState } from './types';
import type { ImportedData } from './utils/githubMapper';

const INITIAL_STATE: GeneratorState = {
layoutTemplate: 'classic',
name: '',
description: '',
selectedTechs: [],
Expand Down Expand Up @@ -103,6 +104,7 @@ export function GeneratorClient() {
<div className="w-full lg:w-[44%] xl:w-[42%] flex-shrink-0">
<EditorPanel
state={state}
onLayoutTemplateChange={(v) => setState((s) => ({ ...s, layoutTemplate: v }))}
onNameChange={(v) => setState((s) => ({ ...s, name: v }))}
onDescriptionChange={(v) => setState((s) => ({ ...s, description: v }))}
onShowHeroImageChange={(v) => setState((s) => ({ ...s, showHeroImage: v }))}
Expand Down
11 changes: 10 additions & 1 deletion app/generator/components/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { LayoutTemplateSection } from './sections/LayoutTemplateSection';
import { NameSection } from './sections/NameSection';
import { DescriptionSection } from './sections/DescriptionSection';
import { HeroImageSection } from './sections/HeroImageSection';
Expand All @@ -14,11 +15,12 @@ import { ArticlesSection } from './sections/ArticlesSection';
import { GitHubImportModal } from './GitHubImportModal';
import { FaGithub } from 'react-icons/fa';
import { PROFILE_PRESETS } from '../data/presets';
import type { GeneratorState, TechIconDisplay } from '../types';
import type { GeneratorState, TechIconDisplay, LayoutTemplate } from '../types';
import type { ImportedData } from '../utils/githubMapper';

export interface EditorPanelProps {
state: GeneratorState;
onLayoutTemplateChange?: (v: LayoutTemplate) => void;
onNameChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onShowHeroImageChange?: (v: boolean) => void;
Expand Down Expand Up @@ -54,6 +56,7 @@ export interface EditorPanelProps {

export function EditorPanel({
state,
onLayoutTemplateChange = () => {},
onNameChange,
onDescriptionChange,
onShowHeroImageChange = () => {},
Expand Down Expand Up @@ -147,6 +150,12 @@ export function EditorPanel({
onApply={onApplyImport}
/>

<LayoutTemplateSection
layoutTemplate={state.layoutTemplate ?? 'classic'}
onChange={onLayoutTemplateChange}
onReset={() => onLayoutTemplateChange('classic')}
/>

<NameSection value={state.name} onChange={onNameChange} onReset={() => onNameChange('')} />
<DescriptionSection
value={state.description}
Expand Down
3 changes: 2 additions & 1 deletion app/generator/components/EditorPanel.type-compiler.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { EditorPanelProps } from './EditorPanel';
import type { GeneratorState } from '../types';
import type { GeneratorState, LayoutTemplate } from '../types';
import type { ImportedData } from '../utils/githubMapper';

describe('EditorPanel Type Compiler Validation', () => {
it('Test 1: validates GeneratorState structure', () => {
expectTypeOf<GeneratorState>().toEqualTypeOf<{
layoutTemplate?: LayoutTemplate;
name: string;
description: string;
selectedTechs: string[];
Expand Down
120 changes: 120 additions & 0 deletions app/generator/components/sections/LayoutTemplateSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use client';

import { LayoutTemplate as LayoutIcon } from 'lucide-react';
import { SectionCard, FieldLabel } from '../SectionCard';
import type { LayoutTemplate } from '../../types';

interface LayoutTemplateSectionProps {
layoutTemplate?: LayoutTemplate;
onChange: (v: LayoutTemplate) => void;
onReset?: () => void;
}

interface TemplateOption {
id: LayoutTemplate;
name: string;
badge: string;
icon: string;
description: string;
orderPreview: string;
}

const TEMPLATE_OPTIONS: TemplateOption[] = [
{
id: 'classic',
name: 'Classic',
badge: 'Standard',
icon: '🏛️',
description: 'Traditional top-down layout starting with Header and Hero banner.',
orderPreview: 'Header → Hero → Tech → Socials → Streak → Spotlight',
},
{
id: 'minimalist',
name: 'Minimalist',
badge: 'Clean & Direct',
icon: '🪶',
description: 'Concise layout putting quick contact and key skills upfront.',
orderPreview: 'Header → Socials → Tech → Hero → Streak → Articles',
},
{
id: 'data-heavy',
name: 'Data Heavy',
badge: 'Stats First',
icon: '📊',
description: 'Metrics-driven structure emphasizing contribution graphs and streak stats early.',
orderPreview: 'Header → Streak → Spotlight → Graphs → Tech → Socials',
},
{
id: 'storyteller',
name: 'Storyteller',
badge: 'Narrative',
icon: '📖',
description:
'Content and visual narrative layout featuring articles and repository spotlights.',
orderPreview: 'Header → Hero → Articles → Spotlight → Tech → Streak',
},
];

export function LayoutTemplateSection({
layoutTemplate = 'classic',
onChange,
onReset,
}: LayoutTemplateSectionProps) {
return (
<SectionCard
title="Layout Template"
icon="📐"
description="Choose a structural arrangement for your README sections"
onReset={onReset}
>
<FieldLabel>Select Layout</FieldLabel>
<div
className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 mt-2"
role="radiogroup"
aria-label="Layout Templates"
>
{TEMPLATE_OPTIONS.map((tpl) => {
const isSelected = layoutTemplate === tpl.id;
return (
<button
key={tpl.id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onChange(tpl.id)}
className={`flex flex-col text-left p-3 rounded-xl border transition-all text-xs relative ${
isSelected
? 'border-emerald-500/80 bg-emerald-500/10 dark:bg-emerald-500/10 text-emerald-900 dark:text-emerald-100 shadow-sm ring-1 ring-emerald-500/50'
: 'border-gray-200/80 dark:border-white/10 bg-gray-50/50 dark:bg-white/[0.03] text-gray-700 dark:text-white/70 hover:border-emerald-500/30 hover:bg-emerald-500/5'
}`}
>
<div className="flex items-center justify-between w-full mb-1">
<div className="flex items-center gap-1.5 font-semibold text-gray-900 dark:text-white">
<span className="text-base select-none">{tpl.icon}</span>
<span>{tpl.name}</span>
</div>
<span
className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
isSelected
? 'bg-emerald-500 text-white dark:bg-emerald-400 dark:text-gray-950'
: 'bg-gray-200/70 dark:bg-white/10 text-gray-600 dark:text-white/60'
}`}
>
{tpl.badge}
</span>
</div>
<p className="text-[11px] text-gray-500 dark:text-white/50 leading-relaxed mb-2">
{tpl.description}
</p>
<div className="mt-auto pt-1.5 border-t border-gray-200/40 dark:border-white/5">
<span className="text-[9.5px] font-mono text-gray-400 dark:text-white/40 block truncate">
{tpl.orderPreview}
</span>
</div>
</button>
);
})}
</div>
</SectionCard>
);
}
4 changes: 4 additions & 0 deletions app/generator/data/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const PROFILE_PRESETS: ProfilePreset[] = [
showCommitPulse: true,
showSnakeGraph: true,
graphPlacement: 'bottom',
layoutTemplate: 'classic',
},
},
{
Expand Down Expand Up @@ -64,6 +65,7 @@ export const PROFILE_PRESETS: ProfilePreset[] = [
showRepoSpotlight: true,
showSnakeGraph: true,
graphPlacement: 'bottom',
layoutTemplate: 'storyteller',
},
},
{
Expand All @@ -90,6 +92,7 @@ export const PROFILE_PRESETS: ProfilePreset[] = [
showCommitPulse: true,
showPacmanGraph: true,
graphPlacement: 'bottom',
layoutTemplate: 'data-heavy',
},
},
{
Expand Down Expand Up @@ -117,6 +120,7 @@ export const PROFILE_PRESETS: ProfilePreset[] = [
showCommitPulse: true,
showSnakeGraph: true,
graphPlacement: 'bottom',
layoutTemplate: 'minimalist',
},
},
];
3 changes: 3 additions & 0 deletions app/generator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export interface Social {
siSlug?: string;
}

export type LayoutTemplate = 'classic' | 'minimalist' | 'data-heavy' | 'storyteller';

export interface GeneratorState {
layoutTemplate?: LayoutTemplate;
name: string;
description: string;
selectedTechs: string[];
Expand Down
121 changes: 121 additions & 0 deletions app/generator/utils/readmeGenerator.layout-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import { generateReadme } from './readmeGenerator';
import type { GeneratorState } from '../types';

const sampleState: GeneratorState = {
layoutTemplate: 'classic',
name: 'Alex Developer',
description: 'Full stack software architect',
showHeroImage: true,
heroImageUrl: 'https://example.com/hero.gif',
selectedTechs: ['typescript', 'react'],
selectedSocials: ['github'],
socialLinks: { github: 'alexdev' },
githubUsername: 'alexdev',
showCommitPulse: true,
commitPulseAccent: '00ff00',
showRepoSpotlight: true,
spotlightRepo: 'awesome-project',
showArticles: true,
articlesPlatform: 'devto',
articlesUsername: 'alexdev',
showSnakeGraph: true,
showPacmanGraph: false,
graphPlacement: 'bottom',
};

describe('readmeGenerator Layout Templates', () => {
it('generates sections in Classic order by default', () => {
const md = generateReadme({ ...sampleState, layoutTemplate: 'classic' });
const headerPos = md.indexOf("Hi, I'm Alex Developer");
const heroPos = md.indexOf('https://example.com/hero.gif');
const techPos = md.indexOf('Tech Stack');
const socialPos = md.indexOf('Connect With Me');
const streakPos = md.indexOf('GitHub Streak');
const spotlightPos = md.indexOf('Repository Spotlight');
const articlesPos = md.indexOf('Latest Articles');
const graphsPos = md.indexOf('Snake Contribution Graph');

expect(headerPos).toBeGreaterThan(-1);
expect(heroPos).toBeGreaterThan(headerPos);
expect(techPos).toBeGreaterThan(heroPos);
expect(socialPos).toBeGreaterThan(techPos);
expect(streakPos).toBeGreaterThan(socialPos);
expect(spotlightPos).toBeGreaterThan(streakPos);
expect(articlesPos).toBeGreaterThan(spotlightPos);
expect(graphsPos).toBeGreaterThan(articlesPos);
});

it('generates sections in Minimalist order', () => {
const md = generateReadme({ ...sampleState, layoutTemplate: 'minimalist' });
const headerPos = md.indexOf("Hi, I'm Alex Developer");
const socialPos = md.indexOf('Connect With Me');
const techPos = md.indexOf('Tech Stack');
const heroPos = md.indexOf('https://example.com/hero.gif');
const streakPos = md.indexOf('GitHub Streak');
const spotlightPos = md.indexOf('Repository Spotlight');
const articlesPos = md.indexOf('Latest Articles');
const graphsPos = md.indexOf('Snake Contribution Graph');

expect(headerPos).toBeLessThan(socialPos);
expect(socialPos).toBeLessThan(techPos);
expect(techPos).toBeLessThan(heroPos);
expect(heroPos).toBeLessThan(streakPos);
expect(streakPos).toBeLessThan(spotlightPos);
expect(spotlightPos).toBeLessThan(articlesPos);
expect(articlesPos).toBeLessThan(graphsPos);
});

it('generates sections in Data Heavy order', () => {
const md = generateReadme({ ...sampleState, layoutTemplate: 'data-heavy' });
const headerPos = md.indexOf("Hi, I'm Alex Developer");
const streakPos = md.indexOf('GitHub Streak');
const spotlightPos = md.indexOf('Repository Spotlight');
const graphsPos = md.indexOf('Snake Contribution Graph');
const techPos = md.indexOf('Tech Stack');
const socialPos = md.indexOf('Connect With Me');
const heroPos = md.indexOf('https://example.com/hero.gif');
const articlesPos = md.indexOf('Latest Articles');

expect(headerPos).toBeLessThan(streakPos);
expect(streakPos).toBeLessThan(spotlightPos);
expect(spotlightPos).toBeLessThan(graphsPos);
expect(graphsPos).toBeLessThan(techPos);
expect(techPos).toBeLessThan(socialPos);
expect(socialPos).toBeLessThan(heroPos);
expect(heroPos).toBeLessThan(articlesPos);
});

it('generates sections in Storyteller order', () => {
const md = generateReadme({ ...sampleState, layoutTemplate: 'storyteller' });
const headerPos = md.indexOf("Hi, I'm Alex Developer");
const heroPos = md.indexOf('https://example.com/hero.gif');
const articlesPos = md.indexOf('Latest Articles');
const spotlightPos = md.indexOf('Repository Spotlight');
const techPos = md.indexOf('Tech Stack');
const streakPos = md.indexOf('GitHub Streak');
const socialPos = md.indexOf('Connect With Me');
const graphsPos = md.indexOf('Snake Contribution Graph');

expect(headerPos).toBeLessThan(heroPos);
expect(heroPos).toBeLessThan(articlesPos);
expect(articlesPos).toBeLessThan(spotlightPos);
expect(spotlightPos).toBeLessThan(techPos);
expect(techPos).toBeLessThan(streakPos);
expect(streakPos).toBeLessThan(socialPos);
expect(socialPos).toBeLessThan(graphsPos);
});

it('falls back to Classic layout when layoutTemplate is missing', () => {
const stateWithoutLayout = { ...sampleState };
delete stateWithoutLayout.layoutTemplate;
const md = generateReadme(stateWithoutLayout);

const headerPos = md.indexOf("Hi, I'm Alex Developer");
const heroPos = md.indexOf('https://example.com/hero.gif');
const techPos = md.indexOf('Tech Stack');

expect(headerPos).toBeLessThan(heroPos);
expect(heroPos).toBeLessThan(techPos);
});
});
Loading
Loading