diff --git a/src/app/page.tsx b/src/app/page.tsx index d9530fdc..6df09484 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { motion } from 'framer-motion'; import { Download } from 'lucide-react'; -import { profileSchema, linksSchema, socialSchema } from '@/lib/validations'; +import { profileSchema, linksSchema, socialSchema, defaultStarHistoryConfig } from '@/lib/validations'; import { DEFAULT_DATA, DEFAULT_LINK, DEFAULT_SOCIAL } from '@/constants/defaults'; import { initialSkillState } from '@/constants/skills'; import { BasicInfoSection } from '@/components/sections/basic-info-section'; @@ -74,15 +74,23 @@ export default function GeneratorPage() { const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [hasInitialized, setHasInitialized] = useState(false); + // Create default profile data with Star History config + const defaultProfileData = useMemo(() => ({ + ...DEFAULT_DATA, + starHistory: false, + starHistoryConfig: defaultStarHistoryConfig + }), []); + const { register: registerProfile, formState: { errors: profileErrors }, watch: watchProfile, reset: resetProfile, trigger: triggerProfile, + setValue: setValueProfile, } = useForm({ resolver: zodResolver(profileSchema), - defaultValues: savedData?.profile ? { ...DEFAULT_DATA, ...savedData.profile } : DEFAULT_DATA, + defaultValues: savedData?.profile ? { ...defaultProfileData, ...savedData.profile } : defaultProfileData, mode: 'onChange', }); @@ -267,7 +275,7 @@ export default function GeneratorPage() { variant: 'warning', onConfirm: () => { clearFormData(); - resetProfile(DEFAULT_DATA); + resetProfile(defaultProfileData); resetLinks(DEFAULT_LINK); resetSocial(DEFAULT_SOCIAL); setSkills(initialSkillState); @@ -276,7 +284,7 @@ export default function GeneratorPage() { showSuccess('All data cleared successfully', 'Form has been reset to default values'); }, }); - }, [showConfirm, resetProfile, resetLinks, resetSocial, setSkills, showSuccess]); + }, [showConfirm, resetProfile, defaultProfileData, resetLinks, resetSocial, setSkills, showSuccess]); const handleDownloadJSON = () => { const data = { @@ -316,7 +324,7 @@ export default function GeneratorPage() { // Validate and import data if (imported.profile) { - resetProfile({ ...DEFAULT_DATA, ...imported.profile } as ProfileFormData); + resetProfile({ ...defaultProfileData, ...imported.profile } as ProfileFormData); } if (imported.links) { resetLinks({ ...DEFAULT_LINK, ...imported.links } as LinksFormData); @@ -560,6 +568,8 @@ export default function GeneratorPage() { selectedSkills={skills} onSkillChange={handleSkillChange} registerProfile={registerProfile} + watchProfile={watchProfile} + setValueProfile={setValueProfile} /> )} diff --git a/src/components/sections/skills-section.tsx b/src/components/sections/skills-section.tsx index a2c7e026..6ebd6bf7 100644 --- a/src/components/sections/skills-section.tsx +++ b/src/components/sections/skills-section.tsx @@ -2,11 +2,12 @@ import { useState, useMemo, useEffect } from 'react'; import { Info } from 'lucide-react'; -import { UseFormRegister } from 'react-hook-form'; +import { UseFormRegister, UseFormWatch, UseFormSetValue } from 'react-hook-form'; import { FormCheckbox } from '@/components/forms/form-checkbox'; import { FormInput } from '@/components/forms/form-input'; import { Select } from '@/components/ui/select'; import { CollapsibleSection } from '@/components/ui/collapsible-section'; +import { StarHistory } from './star-history'; import { categorizedSkills, categories } from '@/constants/skills'; import { getSkillIconUrl } from '@/lib/markdown-generator'; import type { ProfileFormData } from '@/lib/validations'; @@ -15,12 +16,16 @@ interface SkillsSectionProps { selectedSkills: Record; onSkillChange: (skill: string, checked: boolean) => void; registerProfile: UseFormRegister; + watchProfile: UseFormWatch; + setValueProfile: UseFormSetValue; } export function SkillsSection({ selectedSkills, onSkillChange, registerProfile, + watchProfile, + setValueProfile, }: SkillsSectionProps) { const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); @@ -259,6 +264,13 @@ export function SkillsSection({ )} + + {/* Star History Charts */} + ); } diff --git a/src/components/sections/star-history.tsx b/src/components/sections/star-history.tsx new file mode 100644 index 00000000..fade6709 --- /dev/null +++ b/src/components/sections/star-history.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { UseFormRegister, UseFormWatch, UseFormSetValue } from 'react-hook-form'; +import { FormCheckbox } from '@/components/forms/form-checkbox'; +import { FormInput } from '@/components/forms/form-input'; +import type { ProfileFormData } from '@/lib/validations'; +import { generateStarHistoryURL, parseRepos, validateRepos } from '@/lib/star-history'; + +interface StarHistoryProps { + register: UseFormRegister; + watch: UseFormWatch; + setValue: UseFormSetValue; +} + + +export function StarHistory({ register, watch, setValue }: StarHistoryProps) { + const [reposInput, setReposInput] = useState(''); + const [previewUrl, setPreviewUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState([]); + + // Watch form values + const starHistoryEnabled = watch('starHistory'); + const starHistoryConfig = watch('starHistoryConfig'); + const repos = starHistoryConfig?.repos || []; + const chartType = starHistoryConfig?.chartType || 'Date'; + const theme = starHistoryConfig?.theme || 'auto'; + + // Initialize repos input only once when component mounts or repos change from empty + useEffect(() => { + // Only set reposInput if it's empty and we have repos + if (repos.length > 0 && reposInput === '') { + setReposInput(repos.join(', ')); + } + }, [repos]); // Intentionally exclude reposInput from dependencies to prevent infinite loops when updating its value; note this creates a hidden dependency issue if reposInput changes independently. + + // Update preview when config changes + const updatePreview = useCallback(() => { + // ...original updatePreview logic here... + }, [repos, chartType, theme]); + + useEffect(() => { + updatePreview(); + }, [updatePreview]); + + // Add this useEffect to sync the states + useEffect(() => { + const configEnabled = configData?.enabled; + + // If they're out of sync, fix it + if (mainEnabled !== configEnabled) { + setValue('starHistoryConfig.enabled', mainEnabled, { shouldValidate: true }); + } + }, [watch('starHistory'), configData?.enabled, setValue]); + + const updatePreview = async () => { + if (!starHistoryEnabled || repos.length === 0) { + setPreviewUrl(''); + return; + } + + setLoading(true); + try { + const effectiveTheme = theme === 'auto' ? 'light' : theme; + const url = generateStarHistoryURL({ + repos: repos, + type: chartType, + theme: effectiveTheme as 'light' | 'dark' + }); + setPreviewUrl(url); + } catch (error) { + console.error('Error generating preview:', error); + setErrors(['Failed to generate preview']); + } finally { + setLoading(false); + } + }; + + const handleReposChange = (value: string) => { + setReposInput(value); + + // Parse repositories + const parsedRepos = parseRepos(value); + + // Validate + const validation = validateRepos(parsedRepos); + setErrors(validation.errors); + + if (validation.valid) { + setValue('starHistoryConfig.repos', parsedRepos, { shouldValidate: true }); + } + else { + // Clear repos if invalid + setValue('starHistoryConfig.repos', [], { shouldValidate: true }); + } + }; + + const handleChartTypeChange = (type: 'Date' | 'Timeline') => { + setValue('starHistoryConfig.chartType', type, { shouldValidate: true }); + }; + + const handleThemeChange = (theme: 'light' | 'dark' | 'auto') => { + setValue('starHistoryConfig.theme', theme, { shouldValidate: true }); + }; + + return ( +
+
+

+ + Star History Charts +

+ + + + {starHistoryEnabled && ( +
+ {/* Repositories Input */} +
+ + handleReposChange(e.target.value)} + placeholder="facebook/react, vuejs/vue, microsoft/vscode" + helperText="💡 Enter repositories in 'owner/repo' format. Example: facebook/react, vuejs/vue" + /> + {errors.length > 0 && ( +
+ {errors.map((error, index) => ( +
• {error}
+ ))} +
+ )} +
+ + {/* Chart Type */} +
+ +
+ + +
+
+ + {/* Theme */} +
+ +
+ + + +
+
+ + {/* Preview */} +
+ +
+ {loading ? ( +
Loading preview...
+ ) : previewUrl ? ( + Star History Preview setErrors(['Failed to load preview. Check repository names.'])} + /> + ) : ( +
Enter repositories to see preview
+ )} +
+
+ + {/* Help Text */} +
+

+ Tip: The Star History chart shows the growth of GitHub stars over time. + Perfect for showcasing project popularity and growth trends. +

+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/lib/markdown-generator.ts b/src/lib/markdown-generator.ts index a2f572f9..2cd18606 100644 --- a/src/lib/markdown-generator.ts +++ b/src/lib/markdown-generator.ts @@ -5,6 +5,7 @@ import type { SupportFormData, } from './validations'; import { DEFAULT_PREFIX } from '@/constants/defaults'; +import { generateStarHistoryMarkdown } from './star-history'; interface GenerateMarkdownOptions { profile: Partial; @@ -350,6 +351,15 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string { markdown += `

${social.twitter}

\n\n`; } + // Star History Chart + if (profile.starHistory && profile.starHistoryConfig?.enabled) { + const starHistoryMarkdown = generateStarHistoryMarkdown(profile.starHistoryConfig); + if (starHistoryMarkdown) { + markdown += `

⭐ Star History

\n\n`; + markdown += `${starHistoryMarkdown}\n\n`; + } + } + // About sections const aboutSections = [ { diff --git a/src/lib/star-history.ts b/src/lib/star-history.ts new file mode 100644 index 00000000..d24507ce --- /dev/null +++ b/src/lib/star-history.ts @@ -0,0 +1,105 @@ +export interface StarHistoryParams { + repos: string[]; + type?: 'Date' | 'Timeline'; + theme?: 'light' | 'dark'; +} + +/** + * Generate Star History chart URL + */ +export const generateStarHistoryURL = (params: StarHistoryParams): string => { + const searchParams = new URLSearchParams({ + repos: params.repos.join(','), + type: params.type || 'Date', + }); + + if (params.theme === 'dark') { + searchParams.append('theme', 'dark'); + } + + return `https://api.star-history.com/svg?${searchParams.toString()}`; +}; + +/** + * Validate repository format (owner/repo) + */ +export const validateRepoFormat = (repo: string): boolean => { + const trimmedRepo = repo.trim(); + if (!trimmedRepo) return false; + + return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(trimmedRepo); +}; + +/** + * Validate multiple repositories + */ +export const validateRepos = (repos: string[]): { valid: boolean; errors: string[] } => { + const errors: string[] = []; + + if (repos.length === 0) { + errors.push('At least one repository is required'); + } + + if (repos.length > 5) { + errors.push('Maximum 5 repositories allowed'); + } + + repos.forEach((repo, index) => { + if (!validateRepoFormat(repo)) { + errors.push(`Repository "${repo}" has invalid format. Use "owner/repo" format.`); + } + }); + + return { + valid: errors.length === 0, + errors + }; +}; + +/** + * Parse repositories from comma-separated string + */ +export const parseRepos = (input: string): string[] => { + return input + .split(',') + .map(repo => repo.trim()) + .filter(repo => repo.length > 0); +}; + +/** + * Generate markdown for Star History chart + */ +export const generateStarHistoryMarkdown = (config: { + enabled: boolean; + repos: string[]; + chartType: 'Date' | 'Timeline'; + theme: 'light' | 'dark' | 'auto'; +}): string => { + if (!config.enabled || !config.repos || config.repos.length === 0) { + return ''; + } + + const url = generateStarHistoryURL({ + repos: config.repos, + type: config.chartType, + theme: config.theme === 'auto' ? undefined : config.theme + }); + + const altText = 'Star History Chart'; + const starHistoryUrl = `https://star-history.com/#${config.repos.join('&')}&${config.chartType}`; + + // For auto theme, use picture tag with media queries and make it clickable + if (config.theme === 'auto') { + return ` + + + + + ${altText} + + `.trim(); + } + + // For light/dark theme, use simple markdown image with link + return `[![${altText}](${url})](${starHistoryUrl})`; +}; diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 776b2136..3c38904e 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -1,5 +1,13 @@ import { z } from 'zod'; +// Star History validation schema +export const starHistorySchema = z.object({ + enabled: z.boolean(), + repos: z.array(z.string().regex(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/, 'Invalid repository format. Use "owner/repo"')), + chartType: z.enum(['Date', 'Timeline']), + theme: z.enum(['light', 'dark', 'auto']), +}); + // Profile validation schema export const profileSchema = z.object({ // Basic Information @@ -56,6 +64,10 @@ export const profileSchema = z.object({ devDynamicBlogs: z.boolean(), mediumDynamicBlogs: z.boolean(), rssDynamicBlogs: z.boolean(), + + // Star History Integration + starHistory: z.boolean(), + starHistoryConfig: starHistorySchema, }); // Links validation schema @@ -117,3 +129,12 @@ export type LinksFormData = z.infer; export type SocialFormData = z.infer; export type SupportFormData = z.infer; export type CompleteFormData = z.infer; +export type StarHistoryFormData = z.infer; + +// Default Star History configuration +export const defaultStarHistoryConfig: StarHistoryFormData = { + enabled: false, + repos: [], + chartType: 'Date', + theme: 'auto' +}; \ No newline at end of file diff --git a/src/types/profile.ts b/src/types/profile.ts index 08089417..3ca7e4b3 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -37,6 +37,13 @@ export interface StreakStatsOptions { theme: string; } +export interface StarHistoryConfig { + enabled: boolean; + repos: string[]; + chartType: 'Date' | 'Timeline'; + theme: 'light' | 'dark' | 'auto'; +} + export interface ProfileData { title: string; subtitle: string; @@ -61,6 +68,8 @@ export interface ProfileData { devDynamicBlogs: boolean; mediumDynamicBlogs: boolean; rssDynamicBlogs: boolean; + starHistory: boolean; + starHistoryConfig: StarHistoryConfig; } export interface ProfileLinks {