diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 00000000..a90d9fdf --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,35 @@ +# Attribution & Credits + +## GIFs Gallery + +The default GIFs in the GIFs Gallery feature are sourced from: + +**[Cool-GIFs-For-GitHub](https://github.com/Anmol-Baranwal/Cool-GIFs-For-GitHub)** by [Anmol Baranwal](https://github.com/Anmol-Baranwal) + +- **License**: MIT License +- **Repository**: https://github.com/Anmol-Baranwal/Cool-GIFs-For-GitHub +- **Author**: [@Anmol_Codes](https://twitter.com/Anmol_Codes) + +### MIT License + +The MIT License allows: +- ✅ Commercial use +- ✅ Modification +- ✅ Distribution +- ✅ Private use + +We appreciate Anmol's incredible work in creating and maintaining this collection of high-quality GIFs specifically designed for GitHub profile READMEs! + +### Additional Resources + +Users of this generator can also: +- Browse 200+ more GIFs from the Cool-GIFs-For-GitHub repository +- Add custom GIF URLs from any licensed source +- Upload their own GIF files +- Use any public GIF URL they have rights to + +--- + +## Other Dependencies + +For a complete list of dependencies and their licenses, see [package.json](./package.json). diff --git a/package-lock.json b/package-lock.json index f94d5352..e56c6818 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@hookform/resolvers": "^5.2.2", "@next/third-parties": "^15.5.4", "@tailwindcss/typography": "^0.5.19", + "@types/jszip": "^3.4.0", "critters": "^0.0.23", "framer-motion": "^12.23.24", + "jszip": "^3.10.1", "lucide-react": "^0.545.0", "next": "15.5.4", "react": "19.1.0", @@ -2991,6 +2993,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4420,6 +4431,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/critters": { "version": "0.0.23", "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.23.tgz", @@ -6316,6 +6333,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6353,6 +6376,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -7017,6 +7046,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7061,6 +7102,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -8706,6 +8756,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9031,6 +9087,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9173,6 +9235,27 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -9481,6 +9564,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -9601,6 +9690,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", @@ -9817,6 +9912,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", diff --git a/package.json b/package.json index 54f72b54..b0b4c458 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "@hookform/resolvers": "^5.2.2", "@next/third-parties": "^15.5.4", "@tailwindcss/typography": "^0.5.19", + "@types/jszip": "^3.4.0", "critters": "^0.0.23", "framer-motion": "^12.23.24", + "jszip": "^3.10.1", "lucide-react": "^0.545.0", "next": "15.5.4", "react": "19.1.0", diff --git a/src/app/globals.css b/src/app/globals.css index 274cd761..fd19da6a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -415,3 +415,28 @@ select::placeholder { .text-large h3 { font-size: 1.75rem; } + +/* Theme-aware scrollbar styles */ +.gif-gallery-scroll { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--muted) / 0.5); +} + +.gif-gallery-scroll::-webkit-scrollbar { + width: 8px; +} + +.gif-gallery-scroll::-webkit-scrollbar-track { + background: hsl(var(--muted) / 0.5); + border-radius: 4px; +} + +.gif-gallery-scroll::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 4px; + transition: background 0.2s ease; +} + +.gif-gallery-scroll::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d9530fdc..528c2b9f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,15 +5,16 @@ 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 { DEFAULT_DATA, DEFAULT_LINK, DEFAULT_SOCIAL } from '@/constants/defaults'; +import { profileSchema, linksSchema, socialSchema, gifSchema } from '@/lib/validations'; +import { DEFAULT_DATA, DEFAULT_LINK, DEFAULT_SOCIAL, DEFAULT_GIF } from '@/constants/defaults'; import { initialSkillState } from '@/constants/skills'; import { BasicInfoSection } from '@/components/sections/basic-info-section'; import { LinksSection } from '@/components/sections/links-section'; import { SocialSection } from '@/components/sections/social-section'; import { generateMarkdown } from '@/lib/markdown-generator'; import { saveFormData, loadFormData, clearFormData } from '@/lib/storage'; -import type { ProfileFormData, LinksFormData, SocialFormData } from '@/lib/validations'; +import type { ProfileFormData, LinksFormData, SocialFormData, GifFormData } from '@/lib/validations'; +import type { GifItem } from '@/types/gifs'; import { DEFAULT_SUPPORT } from '@/constants/defaults'; import { Header } from '@/components/layout/header'; import { Footer } from '@/components/layout/footer'; @@ -37,7 +38,7 @@ const steps: { id: Step; title: string; description: string }[] = [ { id: 'basic', title: 'Basic Info', description: 'Tell us about yourself' }, { id: 'links', title: 'Links', description: 'Portfolio, blog, resume' }, { id: 'social', title: 'Social', description: 'Social media profiles' }, - { id: 'skills', title: 'Skills', description: 'Technologies you know' }, + { id: 'skills', title: 'Skills & GIFs', description: 'Technologies and animations' }, { id: 'preview', title: 'Preview', description: 'Review and generate' }, ]; @@ -110,10 +111,28 @@ export default function GeneratorPage() { mode: 'onChange', }); + const { + register: registerGifs, + formState: { errors: gifsErrors }, + watch: watchGifs, + setValue: setGifValue, + reset: resetGifs, + } = useForm({ + resolver: zodResolver(gifSchema), + defaultValues: savedData?.gifs ? { ...DEFAULT_GIF, ...savedData.gifs } : DEFAULT_GIF, + mode: 'onChange', + }); + + // State for selected GIFs (includes File objects which can't be in form) + const [selectedGifs, setSelectedGifs] = useState( + savedData?.gifs?.selectedGifs || [] + ); + // Watch all form values for live preview const profileData = watchProfile(); const linksData = watchLinks(); const socialData = watchSocial(); + const gifsData = watchGifs(); // Generate markdown with useMemo to prevent unnecessary recalculations const markdown = useMemo(() => { @@ -123,8 +142,9 @@ export default function GeneratorPage() { social: socialData, support: DEFAULT_SUPPORT, skills, + gifs: { ...gifsData, selectedGifs }, // Include actual selectedGifs with File objects }); - }, [profileData, linksData, socialData, skills]); + }, [profileData, linksData, socialData, skills, gifsData, selectedGifs]); // Mark as initialized after first render to enable auto-save useEffect(() => { @@ -157,6 +177,7 @@ export default function GeneratorPage() { console.log('📊 Auto-save - Links data:', linksData); console.log('📊 Auto-save - Social data:', socialData); console.log('📊 Auto-save - Skills selected:', Object.values(skills).filter(Boolean).length); + console.log('📊 Auto-save - GIFs data:', gifsData); setSaveStatus('saving'); const timer = setTimeout(() => { @@ -167,6 +188,7 @@ export default function GeneratorPage() { social: socialData, support: DEFAULT_SUPPORT, skills, + gifs: { ...gifsData, selectedGifs }, // Include selectedGifs lastSaved: now.toISOString(), }; @@ -193,12 +215,49 @@ export default function GeneratorPage() { JSON.stringify(linksData), JSON.stringify(socialData), JSON.stringify(skills), + JSON.stringify(gifsData), + JSON.stringify(selectedGifs.map(g => ({ id: g.id, url: g.url, name: g.name }))), ]); const handleSkillChange = (skill: string, checked: boolean) => { setSkills((prev) => ({ ...prev, [skill]: checked })); }; + // GIF handlers + const handleGifSelect = useCallback((gif: GifItem) => { + setSelectedGifs((prev) => [...prev, gif]); + setGifValue('selectedGifs', [...selectedGifs, gif]); + }, [selectedGifs, setGifValue]); + + const handleGifRemove = useCallback((gifId: string) => { + setSelectedGifs((prev) => { + const updated = prev.filter((g) => g.id !== gifId); + setGifValue('selectedGifs', updated); + return updated; + }); + }, [setGifValue]); + + const handleCustomGifAdd = useCallback((url: string, name: string) => { + const customGif: GifItem = { + id: `custom-${Date.now()}`, + url, + name, + category: 'custom', + isCustom: true, + }; + setSelectedGifs((prev) => [...prev, customGif]); + setGifValue('selectedGifs', [...selectedGifs, customGif]); + }, [selectedGifs, setGifValue]); + + const handleGifUpdate = useCallback((gifId: string, updates: Partial) => { + setSelectedGifs((prev) => { + const updated = prev.map((g) => (g.id === gifId ? { ...g, ...updates } : g)); + // Also update the form value to keep it in sync + setGifValue('selectedGifs', updated); + return updated; + }); + }, [setGifValue]); + const handleGitHubAutoFill = (data: { profile: Partial; links: Partial; @@ -261,7 +320,7 @@ export default function GeneratorPage() { showConfirm({ title: 'Clear All Data', message: - 'Are you sure you want to clear all data? This will reset all form fields, skills, and settings. This action cannot be undone.', + 'Are you sure you want to clear all data? This will reset all form fields, skills, GIFs, and settings. This action cannot be undone.', confirmText: 'Clear All', cancelText: 'Cancel', variant: 'warning', @@ -270,13 +329,32 @@ export default function GeneratorPage() { resetProfile(DEFAULT_DATA); resetLinks(DEFAULT_LINK); resetSocial(DEFAULT_SOCIAL); + resetGifs(DEFAULT_GIF); setSkills(initialSkillState); + setSelectedGifs([]); setLastSaved(null); setSaveStatus('idle'); showSuccess('All data cleared successfully', 'Form has been reset to default values'); }, }); - }, [showConfirm, resetProfile, resetLinks, resetSocial, setSkills, showSuccess]); + }, [showConfirm, resetProfile, resetLinks, resetSocial, resetGifs, setSkills, showSuccess]); + + const handleDownloadReadme = async () => { + // Standard README download + const blob = new Blob([markdown], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `README-${Date.now()}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showSuccess('README downloaded!', 'Your GitHub profile README is ready'); + trackReadmeGenerated(); + trackFileExported('download', 'markdown'); + }; const handleDownloadJSON = () => { const data = { @@ -560,6 +638,15 @@ export default function GeneratorPage() { selectedSkills={skills} onSkillChange={handleSkillChange} registerProfile={registerProfile} + registerGifs={registerGifs} + errorsGifs={gifsErrors} + watchGifs={watchGifs} + setGifValue={setGifValue} + selectedGifs={selectedGifs} + onGifSelect={handleGifSelect} + onGifRemove={handleGifRemove} + onCustomGifAdd={handleCustomGifAdd} + onGifUpdate={handleGifUpdate} /> )} @@ -595,7 +682,11 @@ export default function GeneratorPage() { } > - + )} diff --git a/src/components/sections/gifs-section.tsx b/src/components/sections/gifs-section.tsx new file mode 100644 index 00000000..b2e4e301 --- /dev/null +++ b/src/components/sections/gifs-section.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import { UseFormRegister, FieldErrors, UseFormWatch, UseFormSetValue } from 'react-hook-form'; +import { X, Check, Link as LinkIcon } from 'lucide-react'; +import { CollapsibleSection } from '@/components/ui/collapsible-section'; +import { FormCheckbox } from '@/components/forms/form-checkbox'; +import { FormSelect } from '@/components/forms/form-select'; +import type { GifFormData } from '@/lib/validations'; +import type { GifItem } from '@/types/gifs'; +import { DEFAULT_GIFS, GIF_CATEGORIES, MAX_GIFS } from '@/constants/gifs'; +import { useErrorToast, useSuccessToast } from '@/components/ui/toast'; + +interface GifsSectionProps { + register: UseFormRegister; + errors: FieldErrors; + watch: UseFormWatch; + setValue: UseFormSetValue; + selectedGifs: GifItem[]; + onGifSelect: (gif: GifItem) => void; + onGifRemove: (gifId: string) => void; + onCustomGifAdd: (url: string, name: string) => void; + onGifUpdate: (gifId: string, updates: Partial) => void; +} + +export function GifsSection({ + register, + errors, + watch, + setValue, + selectedGifs, + onGifSelect, + onGifRemove, + onCustomGifAdd, + onGifUpdate, +}: GifsSectionProps) { + const [activeCategory, setActiveCategory] = useState('all'); + const [customGifUrl, setCustomGifUrl] = useState(''); + const [customGifName, setCustomGifName] = useState(''); + const showError = useErrorToast(); + const showSuccess = useSuccessToast(); + + const enabled = watch('enabled'); + const alignment = watch('alignment'); + const size = watch('size'); + const position = watch('position'); + + const filteredGifs = + activeCategory === 'all' + ? DEFAULT_GIFS + : DEFAULT_GIFS.filter((gif) => gif.category === activeCategory); + + const handleAddCustomGif = () => { + if (!customGifUrl.trim()) { + showError('URL required', 'Please enter a GIF URL'); + return; + } + + if (!customGifName.trim()) { + showError('Name required', 'Please enter a name for the GIF'); + return; + } + + // Basic URL validation + try { + new URL(customGifUrl); + } catch { + showError('Invalid URL', 'Please enter a valid URL'); + return; + } + + if (selectedGifs.length >= MAX_GIFS) { + showError('Limit reached', `Maximum ${MAX_GIFS} GIFs allowed`); + return; + } + + onCustomGifAdd(customGifUrl.trim(), customGifName.trim()); + setCustomGifUrl(''); + setCustomGifName(''); + showSuccess('GIF added!', 'Custom GIF added successfully'); + }; + + const isGifSelected = (gifId: string) => selectedGifs.some((g) => g.id === gifId); + const canSelectMore = selectedGifs.length < MAX_GIFS; + + return ( +
+
+

GIFs Gallery

+

+ Add animated GIFs to make your GitHub profile more engaging and expressive +

+
+ + {/* Enable GIFs Toggle */} +
+ +

+ Show animated GIFs in your GitHub profile README +

+
+ + {enabled && ( + <> + {/* GIF Settings */} + +

+ Set default settings for all GIFs. You can customize each GIF individually below. +

+
+ setValue('alignment', value as 'left' | 'center' | 'right')} + /> + + setValue('size', value as 'small' | 'medium' | 'large')} + /> + + setValue('position', value as 'top' | 'middle' | 'bottom')} + /> +
+
+ + {/* Selected GIFs with Individual Controls */} + {selectedGifs.length > 0 && ( +
+
+

+ Selected GIFs ({selectedGifs.length}/{MAX_GIFS}) +

+
+ +
+ {selectedGifs.map((gif) => ( +
+
+ {/* GIF Preview */} +
+ {gif.name} + +
+

{gif.name}

+
+
+ + {/* Individual Controls */} +
+

GIF Settings

+ +
+ {/* Position */} +
+ + +
+ + {/* Alignment */} +
+ + +
+ + {/* Size */} +
+ + +
+
+ + {/* Position Preview Badge */} +
+ Will appear: + + {gif.position === 'top' || (!gif.position && position === 'top') + ? '🔝 At the top of your README' + : gif.position === 'middle' || (!gif.position && position === 'middle') + ? '🎯 In the middle section' + : '⬇️ At the bottom of your README'} + +
+
+
+
+ ))} +
+
+ )} + + {/* Add GIF Methods */} +
+ {/* Built-in Gallery */} + +
+

+ 💡 GIFs from Cool-GIFs-For-GitHub (MIT Licensed) +

+

+ Browse 200+ more GIFs at{' '} + + github.com/Anmol-Baranwal/Cool-GIFs-For-GitHub + +

+
+ + {/* Category Filter */} +
+ {GIF_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* GIF Gallery Grid */} +
+ {filteredGifs.map((gif) => { + const isSelected = isGifSelected(gif.id); + const canSelect = canSelectMore || isSelected; + + return ( +
canSelect && !isSelected && onGifSelect(gif)} + className={`relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all ${ + isSelected + ? 'border-primary ring-primary/20 ring-2' + : canSelect + ? 'border-border hover:border-primary/50' + : 'border-border cursor-not-allowed opacity-50' + }`} + > + {gif.name} +
+

{gif.name}

+
+ {isSelected && ( +
+ +
+ )} +
+ ); + })} +
+ + {!canSelectMore && ( +

+ Maximum {MAX_GIFS} GIFs selected. Remove a GIF to select another. +

+ )} +
+ + {/* Custom GIF URL */} +
+ +
+
+ + setCustomGifUrl(e.target.value)} + placeholder="https://user-images.githubusercontent.com/74038190/..." + className="border-border bg-background w-full rounded-lg border px-3 py-2 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +

+ Get more GIF URLs from{' '} + + Cool-GIFs-For-GitHub + + {' '}(MIT Licensed) +

+
+ +
+ + setCustomGifName(e.target.value)} + placeholder="My Awesome GIF" + className="border-border bg-background w-full rounded-lg border px-3 py-2 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ + +
+
+
+
+ + {/* Error Messages */} + {errors.selectedGifs && ( +
+

{errors.selectedGifs.message}

+
+ )} + + )} +
+ ); +} diff --git a/src/components/sections/skills-section.tsx b/src/components/sections/skills-section.tsx index a2c7e026..67d6bacd 100644 --- a/src/components/sections/skills-section.tsx +++ b/src/components/sections/skills-section.tsx @@ -1,30 +1,65 @@ 'use client'; import { useState, useMemo, useEffect } from 'react'; -import { Info } from 'lucide-react'; -import { UseFormRegister } from 'react-hook-form'; +import { Info, X, Check, Link as LinkIcon } from 'lucide-react'; +import { UseFormRegister, FieldErrors, UseFormWatch, UseFormSetValue } from 'react-hook-form'; import { FormCheckbox } from '@/components/forms/form-checkbox'; import { FormInput } from '@/components/forms/form-input'; +import { FormSelect } from '@/components/forms/form-select'; import { Select } from '@/components/ui/select'; import { CollapsibleSection } from '@/components/ui/collapsible-section'; import { categorizedSkills, categories } from '@/constants/skills'; import { getSkillIconUrl } from '@/lib/markdown-generator'; -import type { ProfileFormData } from '@/lib/validations'; +import { DEFAULT_GIFS, GIF_CATEGORIES, MAX_GIFS } from '@/constants/gifs'; +import { useErrorToast, useSuccessToast } from '@/components/ui/toast'; +import type { ProfileFormData, GifFormData } from '@/lib/validations'; +import type { GifItem } from '@/types/gifs'; interface SkillsSectionProps { selectedSkills: Record; onSkillChange: (skill: string, checked: boolean) => void; registerProfile: UseFormRegister; + // GIF-related props + registerGifs: UseFormRegister; + errorsGifs: FieldErrors; + watchGifs: UseFormWatch; + setGifValue: UseFormSetValue; + selectedGifs: GifItem[]; + onGifSelect: (gif: GifItem) => void; + onGifRemove: (gifId: string) => void; + onCustomGifAdd: (url: string, name: string) => void; + onGifUpdate: (gifId: string, updates: Partial) => void; } export function SkillsSection({ selectedSkills, onSkillChange, registerProfile, + registerGifs, + errorsGifs, + watchGifs, + setGifValue, + selectedGifs, + onGifSelect, + onGifRemove, + onCustomGifAdd, + onGifUpdate, }: SkillsSectionProps) { const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); const [isMobile, setIsMobile] = useState(false); + + // GIF state + const [activeGifCategory, setActiveGifCategory] = useState('all'); + const [customGifUrl, setCustomGifUrl] = useState(''); + const [customGifName, setCustomGifName] = useState(''); + const showError = useErrorToast(); + const showSuccess = useSuccessToast(); + + const gifsEnabled = watchGifs('enabled'); + const gifAlignment = watchGifs('alignment'); + const gifSize = watchGifs('size'); + const gifPosition = watchGifs('position'); // Check if we're on mobile for responsive behavior useEffect(() => { @@ -52,6 +87,44 @@ export function SkillsSection({ return skills.filter((skill) => skill.toLowerCase().includes(searchQuery.toLowerCase())); }; + // GIF handlers + const filteredGifs = + activeGifCategory === 'all' + ? DEFAULT_GIFS + : DEFAULT_GIFS.filter((gif) => gif.category === activeGifCategory); + + const handleAddCustomGif = () => { + if (!customGifUrl.trim()) { + showError('URL required', 'Please enter a GIF URL'); + return; + } + + if (!customGifName.trim()) { + showError('Name required', 'Please enter a name for the GIF'); + return; + } + + try { + new URL(customGifUrl); + } catch { + showError('Invalid URL', 'Please enter a valid URL'); + return; + } + + if (selectedGifs.length >= MAX_GIFS) { + showError('Limit reached', `Maximum ${MAX_GIFS} GIFs allowed`); + return; + } + + onCustomGifAdd(customGifUrl.trim(), customGifName.trim()); + setCustomGifUrl(''); + setCustomGifName(''); + showSuccess('GIF added!', 'Custom GIF added successfully'); + }; + + const isGifSelected = (gifId: string) => selectedGifs.some((g) => g.id === gifId); + const canSelectMore = selectedGifs.length < MAX_GIFS; + // Create options for the select component const categoryOptions = [ { value: 'all', label: 'All Categories' }, @@ -259,6 +332,378 @@ export function SkillsSection({ )} + + {/* Animated GIFs Section */} +
+
+
+

+ 🎬 + Animated GIFs +

+

+ Add personality to your profile with animated GIFs +

+
+ + {/* Enable GIFs Toggle */} +
+ +

+ Show animated GIFs in your GitHub profile README +

+
+ + {gifsEnabled && ( + <> + {/* GIF Settings */} + +

+ Set default settings for all GIFs. You can customize each GIF individually below. +

+
+ setGifValue('alignment', value as 'left' | 'center' | 'right')} + /> + + setGifValue('size', value as 'small' | 'medium' | 'large')} + /> + + setGifValue('position', value as 'top' | 'middle' | 'bottom')} + /> +
+
+ + {/* Selected GIFs Preview */} + {selectedGifs.length > 0 && ( +
+
+

+ Selected GIFs ({selectedGifs.length}/{MAX_GIFS}) +

+
+ +
+ {selectedGifs.map((gif) => ( +
+
+ {/* GIF Preview */} +
+ {gif.name} + +

{gif.name}

+
+ + {/* Individual Controls */} +
+

Customize This GIF

+ +
+ {/* Position */} +
+ + +
+ + {/* Alignment */} +
+ + +
+ + {/* Size */} +
+ + +
+
+ + {/* Custom Size Inputs - Show only if size is custom */} + {(gif.size === 'custom' || (!gif.size && gifSize === 'custom')) && ( +
+
+ + { + const value = e.target.value; + if (value === '') { + onGifUpdate(gif.id, { customWidth: null }); + } else { + const numValue = parseInt(value); + if (!isNaN(numValue)) { + onGifUpdate(gif.id, { customWidth: numValue }); + } + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '' || isNaN(parseInt(value))) { + onGifUpdate(gif.id, { customWidth: 300 }); + } else { + const numValue = parseInt(value); + if (numValue < 50) { + onGifUpdate(gif.id, { customWidth: 50 }); + } else if (numValue > 1000) { + onGifUpdate(gif.id, { customWidth: 1000 }); + } + } + }} + className="border-border bg-background w-full rounded-lg border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + placeholder="300" + /> +
+
+ + { + const value = e.target.value; + if (value === '') { + onGifUpdate(gif.id, { customHeight: null }); + } else { + const numValue = parseInt(value); + if (!isNaN(numValue)) { + onGifUpdate(gif.id, { customHeight: numValue }); + } + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '' || isNaN(parseInt(value))) { + onGifUpdate(gif.id, { customHeight: 225 }); + } else { + const numValue = parseInt(value); + if (numValue < 50) { + onGifUpdate(gif.id, { customHeight: 50 }); + } else if (numValue > 1000) { + onGifUpdate(gif.id, { customHeight: 1000 }); + } + } + }} + className="border-border bg-background w-full rounded-lg border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + placeholder="225" + /> +
+

+ 💡 Range: 50-1000px +

+
+ )} + + {/* Position Preview Badge */} +
+ Will appear: + + {gif.position === 'top' || (!gif.position && gifPosition === 'top') + ? '🔝 At the top of your README' + : gif.position === 'middle' || (!gif.position && gifPosition === 'middle') + ? '🎯 In the middle section' + : '⬇️ At the bottom of your README'} + +
+
+
+
+ ))} +
+
+ )} + + {/* GIF Gallery & Custom URL */} +
+ {/* Built-in Gallery */} + + {/* Category Filter */} +
+ {GIF_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* GIF Gallery Grid - Scrollable with theme-aware scrollbar */} +
+
+ {filteredGifs.map((gif) => { + const isSelected = isGifSelected(gif.id); + const canSelect = canSelectMore || isSelected; + + return ( +
canSelect && !isSelected && onGifSelect(gif)} + className={`relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all ${ + isSelected + ? 'border-primary ring-primary/20 ring-2' + : canSelect + ? 'border-border hover:border-primary/50' + : 'border-border cursor-not-allowed opacity-50' + }`} + > + {gif.name} +
+

{gif.name}

+
+ {isSelected && ( +
+ +
+ )} +
+ ); + })} +
+
+ + {!canSelectMore && ( +

+ Maximum {MAX_GIFS} GIFs selected. Remove a GIF to select another. +

+ )} +
+ + {/* Custom GIF URL */} + +
+
+ + setCustomGifUrl(e.target.value)} + placeholder="https://user-images.githubusercontent.com/74038190/..." + className="border-border bg-background w-full rounded-lg border px-3 py-2 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ +
+ + setCustomGifName(e.target.value)} + placeholder="My Awesome GIF" + className="border-border bg-background w-full rounded-lg border px-3 py-2 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ + +
+
+
+ + {/* Error Messages */} + {errorsGifs.selectedGifs && ( +
+

{errorsGifs.selectedGifs.message}

+
+ )} + + )} +
+
); } diff --git a/src/components/ui/markdown-preview.tsx b/src/components/ui/markdown-preview.tsx index 74adfbb0..a91cd118 100644 --- a/src/components/ui/markdown-preview.tsx +++ b/src/components/ui/markdown-preview.tsx @@ -10,11 +10,13 @@ import { trackFileExported } from '@/lib/analytics'; interface MarkdownPreviewProps { markdown: string; title?: string; + onDownload?: () => void | Promise; } export const MarkdownPreview = memo(function MarkdownPreview({ markdown, title = 'Preview', + onDownload, }: MarkdownPreviewProps) { const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview'); const [copied, setCopied] = useState(false); @@ -33,6 +35,13 @@ export const MarkdownPreview = memo(function MarkdownPreview({ }; const handleDownload = () => { + // Use custom download handler if provided, otherwise use default + if (onDownload) { + onDownload(); + return; + } + + // Default download behavior const blob = new Blob([markdown], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -51,18 +60,31 @@ export const MarkdownPreview = memo(function MarkdownPreview({ const markdownComponents = useMemo( () => ({ // Custom rendering to maintain alignment and styling + div: ({ children, style, ...props }: any) => { + // Preserve inline styles (used for GIF alignment) + return
{children}
; + }, p: ({ children }: any) =>

{children}

, h1: ({ children }: any) =>

{children}

, h3: ({ children }: any) =>

{children}

, - img: ({ src, alt, width }: any) => { + img: ({ src, alt, width, height, style }: any) => { // Skill icons have width=40, resize them responsively if (width === '40' || width === 40) { return ( {alt} ); } - // Other images (badges, stats, etc.) keep their original size - return {alt}; + // GIFs and other images - preserve their width, height, and inline styles + return ( + {alt} + ); }, a: ({ href, children }: any) => ( = { + small: { width: 200, height: 150 }, + medium: { width: 300, height: 225 }, + large: { width: 400, height: 300 }, + custom: { width: 300, height: 225 }, // Default for custom, will be overridden +}; + +// Default GIF data +export const DEFAULT_GIF_DATA: GifData = { + enabled: false, + selectedGifs: [], + alignment: 'center', + size: 'medium', + position: 'top', +}; + +// Maximum number of GIFs allowed +export const MAX_GIFS = 5; + +// Maximum file size for uploads (5MB) +export const MAX_GIF_SIZE = 5 * 1024 * 1024; diff --git a/src/lib/markdown-generator.ts b/src/lib/markdown-generator.ts index a2f572f9..7d255443 100644 --- a/src/lib/markdown-generator.ts +++ b/src/lib/markdown-generator.ts @@ -3,8 +3,10 @@ import type { LinksFormData, SocialFormData, SupportFormData, + GifFormData, } from './validations'; import { DEFAULT_PREFIX } from '@/constants/defaults'; +import { GIF_SIZES } from '@/constants/gifs'; interface GenerateMarkdownOptions { profile: Partial; @@ -12,6 +14,7 @@ interface GenerateMarkdownOptions { social: Partial; support: Partial; skills: Record; + gifs?: Partial; } const socialPlatformUrls: Record string> = { @@ -322,8 +325,62 @@ export function getSkillIconUrl(skill: string): string { return `https://skillicons.dev/icons?i=${iconName}`; } +// Generate GIF markdown +function generateGifMarkdown(gifs: Partial, position: 'top' | 'middle' | 'bottom'): string { + if (!gifs.enabled || !gifs.selectedGifs || gifs.selectedGifs.length === 0) { + return ''; + } + + // Filter GIFs for this position (use individual position or fall back to global) + const gifsForPosition = gifs.selectedGifs.filter(gif => + (gif.position || gifs.position) === position + ); + + if (gifsForPosition.length === 0) { + return ''; + } + + let markdown = ''; + + gifsForPosition.forEach((gif) => { + // Use individual settings or fall back to global settings + const alignment = gif.alignment || gifs.alignment || 'center'; + const sizeKey = gif.size || gifs.size || 'medium'; + + // Handle custom size with custom width/height + let width: number; + let height: number; + + if (sizeKey === 'custom') { + // Handle null values during editing (fallback to defaults) + width = (gif.customWidth !== null && gif.customWidth !== undefined) ? gif.customWidth : 300; + height = (gif.customHeight !== null && gif.customHeight !== undefined) ? gif.customHeight : 225; + } else { + const size = GIF_SIZES[sizeKey] || GIF_SIZES.medium; + width = size.width; + height = size.height; + } + + // Use modern CSS for alignment - combine text-align with display block for images + let alignmentStyle = 'text-align: center;'; + const imgStyle = 'display: inline-block;'; + + if (alignment === 'left') { + alignmentStyle = 'text-align: left;'; + } else if (alignment === 'right') { + alignmentStyle = 'text-align: right;'; + } + + markdown += `
\n`; + markdown += ` ${gif.name}\n`; + markdown += `
\n\n`; + }); + + return markdown; +} + export function generateMarkdown(options: GenerateMarkdownOptions): string { - const { profile, links, social, support, skills } = options; + const { profile, links, social, support, skills, gifs } = options; let markdown = ''; // Title and Subtitle @@ -335,6 +392,11 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string { markdown += `### ${profile.subtitle}\n\n`; } + // GIFs at top position + if (gifs?.position === 'top' || gifs?.selectedGifs?.some(g => (g.position || gifs.position) === 'top')) { + markdown += generateGifMarkdown(gifs, 'top'); + } + // Visitor Badge if (profile.visitorsBadge && social.github) { markdown += `

${social.github}

\n\n`; @@ -401,6 +463,11 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string { markdown += `- ${DEFAULT_PREFIX.resume} **[${links.resume}](${links.resume})**\n\n`; } + // GIFs at middle position + if (gifs?.position === 'middle' || gifs?.selectedGifs?.some(g => (g.position || gifs.position) === 'middle')) { + markdown += generateGifMarkdown(gifs, 'middle'); + } + // Social Connect const socialLinks = Object.entries(social).filter( ([key, value]) => @@ -452,6 +519,11 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string { markdown += `

${social.github}

\n\n`; } + // GIFs at bottom position + if (gifs?.position === 'bottom' || gifs?.selectedGifs?.some(g => (g.position || gifs.position) === 'bottom')) { + markdown += generateGifMarkdown(gifs, 'bottom'); + } + return markdown; } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index a4d51e24..5ab106c4 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,4 +1,4 @@ -import type { ProfileFormData, LinksFormData, SocialFormData, SupportFormData } from './validations'; +import type { ProfileFormData, LinksFormData, SocialFormData, SupportFormData, GifFormData } from './validations'; export interface SavedFormData { profile: Partial; @@ -6,15 +6,39 @@ export interface SavedFormData { social: Partial; support: Partial; skills: Record; + gifs?: Partial; lastSaved: string; } const STORAGE_KEY = 'github-profile-generator'; +// Serialize GIF data for storage +function serializeGifData(gifs?: Partial): Partial | undefined { + if (!gifs) return undefined; + + return { + ...gifs, + selectedGifs: gifs.selectedGifs?.map(gif => ({ + id: gif.id, + url: gif.url, + name: gif.name, + category: gif.category, + isCustom: gif.isCustom, + // Save individual GIF settings + alignment: gif.alignment, + size: gif.size, + position: gif.position, + customWidth: gif.customWidth, + customHeight: gif.customHeight, + })), + }; +} + export function saveFormData(data: SavedFormData): void { try { const dataToSave = { ...data, + gifs: serializeGifData(data.gifs), lastSaved: new Date().toISOString(), }; console.log('💾 storage.ts - saveFormData called with:', dataToSave); diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 776b2136..5f53c26a 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -103,6 +103,32 @@ export const supportSchema = z.object({ buyMeACoffee: z.string().max(100), }); +// GIF validation schema +export const gifSchema = z.object({ + enabled: z.boolean(), + selectedGifs: z + .array( + z.object({ + id: z.string(), + url: z.string().url('Invalid GIF URL').or(z.string().startsWith('blob:')), + name: z.string().min(1, 'GIF name is required').max(100, 'Name is too long'), + category: z.enum(['greeting', 'coding', 'celebration', 'thinking', 'tech-logos', 'social', 'characters', 'custom']).optional(), + isCustom: z.boolean(), + isUploaded: z.boolean().optional(), + position: z.enum(['top', 'middle', 'bottom']).optional(), + alignment: z.enum(['left', 'center', 'right']).optional(), + size: z.enum(['small', 'medium', 'large', 'custom']).optional(), + customWidth: z.number().min(50).max(1000).optional().nullable(), + customHeight: z.number().min(50).max(1000).optional().nullable(), + // File and preview URL are not validated as they're transient + }) + ) + .max(5, 'Maximum 5 GIFs allowed'), + alignment: z.enum(['left', 'center', 'right']), + size: z.enum(['small', 'medium', 'large', 'custom']), + position: z.enum(['top', 'middle', 'bottom']), +}); + // Complete form schema export const completeFormSchema = z.object({ profile: profileSchema, @@ -110,10 +136,12 @@ export const completeFormSchema = z.object({ social: socialSchema, support: supportSchema, skills: z.record(z.string(), z.boolean()), + gifs: gifSchema.optional(), }); export type ProfileFormData = z.infer; export type LinksFormData = z.infer; export type SocialFormData = z.infer; export type SupportFormData = z.infer; +export type GifFormData = z.infer; export type CompleteFormData = z.infer; diff --git a/src/types/gifs.ts b/src/types/gifs.ts new file mode 100644 index 00000000..b0ae3706 --- /dev/null +++ b/src/types/gifs.ts @@ -0,0 +1,32 @@ +// GIF-related types +export interface GifItem { + id: string; + url: string; + name: string; + category?: 'greeting' | 'coding' | 'celebration' | 'thinking' | 'tech-logos' | 'social' | 'characters' | 'custom'; + isCustom: boolean; + position?: 'top' | 'middle' | 'bottom'; // Individual position for this GIF + alignment?: 'left' | 'center' | 'right'; // Individual alignment for this GIF + size?: 'small' | 'medium' | 'large' | 'custom'; // Individual size for this GIF + customWidth?: number | null; // Custom width when size is 'custom' (null when being edited) + customHeight?: number | null; // Custom height when size is 'custom' (null when being edited) +} + +export interface GifData { + enabled: boolean; + selectedGifs: GifItem[]; + alignment: 'left' | 'center' | 'right'; + size: 'small' | 'medium' | 'large' | 'custom'; + position: 'top' | 'middle' | 'bottom'; // Where in README to place GIFs +} + +export interface GifCategory { + id: string; + label: string; + icon?: string; +} + +export interface GifSize { + width: number; + height: number; +} diff --git a/src/types/profile.ts b/src/types/profile.ts index 08089417..9c127d30 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -106,3 +106,6 @@ export interface SupportLinks { export interface Skills { [key: string]: boolean; } + +// Re-export GIF types for convenience +export type { GifItem, GifData, GifCategory, GifSize } from './gifs';