From 8181944a0648eaefec55143647d6287ddd08a7b9 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Wed, 3 Sep 2025 10:07:23 +0530 Subject: [PATCH] feat: Add DevFolio - GitHub Profiles --- docusaurus.config.ts | 26 +- src/components/devfolio/AdminDashboard.tsx | 732 ++++++++++++++++++ src/components/devfolio/AdminPanel.tsx | 355 +++++++++ src/components/devfolio/AdvancedSearch.tsx | 336 ++++++++ .../devfolio/AnalyticsDashboard.tsx | 486 ++++++++++++ src/components/devfolio/DevfolioSection.tsx | 499 ++++++++++++ src/components/devfolio/ProfileCard.tsx | 194 +++++ src/components/devfolio/SubmissionModal.tsx | 528 +++++++++++++ src/components/ui/Toast.tsx | 131 ++++ src/lib/firebase.ts | 6 +- src/lib/firestore.ts | 437 +++++++++++ src/lib/mockFirestore.ts | 309 ++++++++ src/pages/devfolio/index.tsx | 140 ++++ src/services/github.ts | 280 +++++++ src/types/devfolio.ts | 46 ++ 15 files changed, 4489 insertions(+), 16 deletions(-) create mode 100644 src/components/devfolio/AdminDashboard.tsx create mode 100644 src/components/devfolio/AdminPanel.tsx create mode 100644 src/components/devfolio/AdvancedSearch.tsx create mode 100644 src/components/devfolio/AnalyticsDashboard.tsx create mode 100644 src/components/devfolio/DevfolioSection.tsx create mode 100644 src/components/devfolio/ProfileCard.tsx create mode 100644 src/components/devfolio/SubmissionModal.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/lib/firestore.ts create mode 100644 src/lib/mockFirestore.ts create mode 100644 src/pages/devfolio/index.tsx create mode 100644 src/services/github.ts create mode 100644 src/types/devfolio.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index c59f9d0a..2bfb1746 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -10,7 +10,6 @@ const config: Config = { title: "Recode Hive", tagline: "Dinosaurs are cool", favicon: "img/favicon.ico", - url: "https://your-docusaurus-site.example.com", baseUrl: "/", @@ -24,18 +23,18 @@ const config: Config = { // Google Analytics and Theme Scripts scripts: [ { - src: '/instant-theme.js', + src: "/instant-theme.js", async: false, }, { - src: 'https://www.googletagmanager.com/gtag/js?id=G-W02Z2VJYCR', + src: "https://www.googletagmanager.com/gtag/js?id=G-W02Z2VJYCR", async: true, }, { - src: '/gtag-init.js', + src: "/gtag-init.js", }, { - src: '/pinterest-init.js', + src: "/pinterest-init.js", }, ], @@ -46,7 +45,7 @@ const config: Config = { presets: [ [ - 'classic', + "classic", { docs: { sidebarPath: require.resolve("./sidebars.ts"), @@ -69,7 +68,7 @@ const config: Config = { customCss: require.resolve("./src/css/custom.css"), }, gtag: { - trackingID: 'G-W02Z2VJYCR', + trackingID: "G-W02Z2VJYCR", anonymizeIP: false, }, } satisfies Preset.Options, @@ -79,14 +78,13 @@ const config: Config = { themeConfig: { image: "img/docusaurus-social-card.jpg", colorMode: { - defaultMode: 'light', + defaultMode: "light", disableSwitch: false, respectPrefersColorScheme: false, // Let users manually control theme }, navbar: { - title:"Recode Hive", + title: "Recode Hive", logo: { - alt: "RecodeHive Logo", src: "img/logo.png", }, @@ -161,12 +159,12 @@ const config: Config = { items: [ { label: "πŸ’»GitHub Profiles", - to: "https://dev.recodehive.com/devfolio", + to: "/devfolio", }, { label: "πŸŽ–οΈ GitHub Badges", to: "/badges/github-badges/", - }, + }, ], }, { @@ -232,7 +230,7 @@ const config: Config = { // }, // ], // }, - + // { // href: "https://github.com/codeharborhub/codeharborhub", // position: "right", @@ -250,7 +248,7 @@ const config: Config = { // hideOnScroll: true, }, footer: { - style: 'dark', + style: "dark", links: [], copyright: `Copyright Β© ${new Date().getFullYear()} recodehive. Built with Docusaurus.`, }, diff --git a/src/components/devfolio/AdminDashboard.tsx b/src/components/devfolio/AdminDashboard.tsx new file mode 100644 index 00000000..d3163e1f --- /dev/null +++ b/src/components/devfolio/AdminDashboard.tsx @@ -0,0 +1,732 @@ +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + X, + Check, + Eye, + Clock, + User, + Calendar, + Github, + MapPin, + Building, + Search, + Filter, + Download, + Star, + Trash2, + Users, + Activity, + TrendingUp, + Heart, + GitBranch, + Settings, + Zap, +} from "lucide-react"; +import { + getPendingSubmissions, + approveSubmission, + rejectSubmission, + getApprovedProfiles, + getProfileStats, +} from "../../lib/firestore"; +import { useToast } from "../ui/Toast"; +import AdvancedSearch from "./AdvancedSearch"; +import AnalyticsDashboard from "./AnalyticsDashboard"; +import type { ProfileSubmission, DevfolioProfile } from "../../types/devfolio"; + +interface AdminDashboardProps { + isOpen: boolean; + onClose: () => void; +} + +interface DashboardStats { + totalSubmissions: number; + pendingReview: number; + approvedProfiles: number; + totalLikes: number; + todaySubmissions: number; + weeklyGrowth: number; +} + +const AdminDashboard: React.FC = ({ isOpen, onClose }) => { + const { addToast } = useToast(); + const [activeTab, setActiveTab] = useState< + "dashboard" | "submissions" | "profiles" | "analytics" + >("dashboard"); + const [submissions, setSubmissions] = useState([]); + const [profiles, setProfiles] = useState([]); + const [stats, setStats] = useState({ + totalSubmissions: 0, + pendingReview: 0, + approvedProfiles: 0, + totalLikes: 0, + todaySubmissions: 0, + weeklyGrowth: 0, + }); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterStatus, setFilterStatus] = useState< + "all" | "pending" | "approved" | "rejected" + >("all"); + const [selectedItems, setSelectedItems] = useState([]); + const [actionLoading, setActionLoading] = useState(null); + const [searchFilters, setSearchFilters] = useState({ + searchTerm: "", + sortBy: "followers" as "name" | "followers" | "repos" | "likes" | "date", + sortOrder: "desc" as "asc" | "desc", + minFollowers: 0, + minRepos: 0, + tags: [] as string[], + featured: null as boolean | null, + }); + + useEffect(() => { + if (isOpen) { + loadDashboardData(); + } + }, [isOpen]); + + const loadDashboardData = async () => { + try { + setLoading(true); + + const [pendingSubmissions, approvedProfilesList, profileStats] = + await Promise.all([ + getPendingSubmissions(), + getApprovedProfiles(), + getProfileStats(), + ]); + + setSubmissions(pendingSubmissions); + setProfiles(approvedProfilesList); + + // Calculate dashboard stats + const today = new Date().toDateString(); + const todaySubmissions = pendingSubmissions.filter( + (sub) => new Date(sub.submittedAt).toDateString() === today + ).length; + + setStats({ + totalSubmissions: + pendingSubmissions.length + approvedProfilesList.length, + pendingReview: pendingSubmissions.length, + approvedProfiles: approvedProfilesList.length, + totalLikes: profileStats.totalLikes, + todaySubmissions, + weeklyGrowth: Math.floor(Math.random() * 15) + 5, // Mock data + }); + } catch (error) { + console.error("Error loading dashboard data:", error); + addToast({ + type: "error", + title: "Dashboard Error", + message: "Failed to load dashboard data", + duration: 4000, + }); + } finally { + setLoading(false); + } + }; + + // Filter and sort profiles based on search criteria + const getFilteredProfiles = () => { + let filtered = [...profiles]; + + // Search by name, username, or bio + if (searchFilters.searchTerm) { + const term = searchFilters.searchTerm.toLowerCase(); + filtered = filtered.filter( + (profile) => + profile.githubData.name?.toLowerCase().includes(term) || + profile.githubData.username.toLowerCase().includes(term) || + profile.githubData.bio?.toLowerCase().includes(term) + ); + } + + // Filter by minimum followers + if (searchFilters.minFollowers > 0) { + filtered = filtered.filter( + (profile) => + (profile.githubData.followers || 0) >= searchFilters.minFollowers + ); + } + + // Filter by minimum repositories + if (searchFilters.minRepos > 0) { + filtered = filtered.filter( + (profile) => + (profile.githubData.public_repos || 0) >= searchFilters.minRepos + ); + } + + // Filter by featured status + if (searchFilters.featured !== null) { + filtered = filtered.filter( + (profile) => profile.featured === searchFilters.featured + ); + } + + // Sort profiles + filtered.sort((a, b) => { + let aValue: any; + let bValue: any; + + switch (searchFilters.sortBy) { + case "name": + aValue = (a.githubData.name || a.githubData.username).toLowerCase(); + bValue = (b.githubData.name || b.githubData.username).toLowerCase(); + break; + case "followers": + aValue = a.githubData.followers || 0; + bValue = b.githubData.followers || 0; + break; + case "repos": + aValue = a.githubData.public_repos || 0; + bValue = b.githubData.public_repos || 0; + break; + case "likes": + aValue = a.likes || 0; + bValue = b.likes || 0; + break; + case "date": + aValue = new Date(a.submittedAt).getTime(); + bValue = new Date(b.submittedAt).getTime(); + break; + default: + aValue = a.githubData.followers || 0; + bValue = b.githubData.followers || 0; + } + + if (searchFilters.sortOrder === "asc") { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + + return filtered; + }; + + const handleBulkAction = async (action: "approve" | "reject") => { + if (selectedItems.length === 0) return; + + setActionLoading("bulk"); + try { + const promises = selectedItems.map((id) => + action === "approve" ? approveSubmission(id) : rejectSubmission(id) + ); + + await Promise.all(promises); + + addToast({ + type: "success", + title: `Bulk ${action === "approve" ? "Approval" : "Rejection"}`, + message: `${selectedItems.length} submissions ${action}d successfully`, + duration: 5000, + }); + + setSelectedItems([]); + await loadDashboardData(); + } catch (error) { + console.error(`Error in bulk ${action}:`, error); + addToast({ + type: "error", + title: "Bulk Action Failed", + message: `Failed to ${action} selected submissions`, + duration: 4000, + }); + } finally { + setActionLoading(null); + } + }; + + const handleSingleAction = async ( + submissionId: string, + action: "approve" | "reject" + ) => { + setActionLoading(submissionId); + try { + if (action === "approve") { + await approveSubmission(submissionId); + } else { + await rejectSubmission(submissionId); + } + + addToast({ + type: "success", + title: `Submission ${action === "approve" ? "Approved" : "Rejected"}`, + message: "Action completed successfully", + duration: 4000, + }); + + await loadDashboardData(); + } catch (error) { + console.error(`Error ${action}ing submission:`, error); + addToast({ + type: "error", + title: "Action Failed", + message: `Failed to ${action} submission`, + duration: 4000, + }); + } finally { + setActionLoading(null); + } + }; + + const toggleSelection = (id: string) => { + setSelectedItems((prev) => + prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id] + ); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const filteredSubmissions = submissions.filter((submission) => { + const matchesSearch = + submission.githubData.username + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + submission.githubData.name + .toLowerCase() + .includes(searchTerm.toLowerCase()); + const matchesFilter = + filterStatus === "all" || submission.status === filterStatus; + return matchesSearch && matchesFilter; + }); + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

+ Admin Dashboard +

+

+ Manage profiles and submissions +

+
+
+ +
+ + {/* Tab Navigation */} +
+
+ {[ + { id: "dashboard", label: "Dashboard", icon: Activity }, + { id: "submissions", label: "Submissions", icon: Clock }, + { id: "profiles", label: "Profiles", icon: Users }, + { id: "analytics", label: "Analytics", icon: TrendingUp }, + ].map(({ id, label, icon: Icon }) => ( + + ))} +
+
+ + {/* Content */} +
+ {activeTab === "dashboard" && ( +
+
+ {/* Stats Cards */} + +
+
+

Total Profiles

+

+ {stats.approvedProfiles} +

+
+ +
+
+ + +
+
+

Pending Review

+

+ {stats.pendingReview} +

+
+ +
+
+ + +
+
+

Total Likes

+

{stats.totalLikes}

+
+ +
+
+ + +
+
+

+ Today's Submissions +

+

+ {stats.todaySubmissions} +

+
+ +
+
+
+ + {/* Recent Activity */} +
+

+ Recent Activity +

+
+ {submissions.slice(0, 5).map((submission, index) => ( +
+ {submission.githubData.name} +
+

+ New submission from{" "} + {submission.githubData.username} +

+

+ {formatDate(submission.submittedAt)} +

+
+ + Pending + +
+ ))} +
+
+
+ )} + + {activeTab === "submissions" && ( +
+ {/* Controls */} +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+ + + + {selectedItems.length > 0 && ( +
+ + +
+ )} +
+ + {/* Submissions List */} +
+ {filteredSubmissions.map((submission) => ( + +
+ toggleSelection(submission.id!)} + className="mt-1 w-4 h-4 text-blue-600 rounded focus:ring-blue-500" + /> + + {submission.githubData.name} + +
+
+

+ {submission.githubData.name || + submission.githubData.username} +

+ + @{submission.githubData.username} + +
+ + {submission.githubData.bio && ( +

+ {submission.githubData.bio} +

+ )} + +
+ + + + {submission.githubData.followers} followers + + + + + + {submission.githubData.public_repos} repos + + + + + {formatDate(submission.submittedAt)} + +
+
+ +
+ + + + + + + +
+
+
+ ))} + + {filteredSubmissions.length === 0 && ( +
+ +

+ No submissions found +

+

+ No submissions match your current filters +

+
+ )} +
+
+ )} + + {activeTab === "profiles" && ( +
+
+

+ Profile Management +

+ +
+ + {/* Profile Grid */} +
+ {getFilteredProfiles().map((profile) => ( + +
+ {profile.githubData.name} +
+

+ {profile.githubData.name || + profile.githubData.username} +

+

+ @{profile.githubData.username} +

+
+
+ + {profile.githubData.followers || 0} +
+
+ + {profile.githubData.public_repos || 0} +
+
+ + {profile.likes || 0} +
+
+
+
+ +
+
+ {profile.featured && ( + + + Featured + + )} +
+
+ + + +
+
+
+ ))} +
+
+ )} + + {activeTab === "analytics" && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default AdminDashboard; diff --git a/src/components/devfolio/AdminPanel.tsx b/src/components/devfolio/AdminPanel.tsx new file mode 100644 index 00000000..d1ee7079 --- /dev/null +++ b/src/components/devfolio/AdminPanel.tsx @@ -0,0 +1,355 @@ +import React, { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { + Check, + X, + Eye, + Clock, + User, + Calendar, + Github, + MapPin, + Building, +} from "lucide-react"; +import { + getPendingSubmissions, + approveSubmission, + rejectSubmission, +} from "../../lib/firestore"; +import { useToast } from "../ui/Toast"; +import type { ProfileSubmission } from "../../types/devfolio"; + +interface AdminPanelProps { + isOpen: boolean; + onClose: () => void; +} + +const AdminPanel: React.FC = ({ isOpen, onClose }) => { + const { addToast } = useToast(); + const [submissions, setSubmissions] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedSubmission, setSelectedSubmission] = + useState(null); + const [actionLoading, setActionLoading] = useState(null); + + useEffect(() => { + if (isOpen) { + loadSubmissions(); + } + }, [isOpen]); + + const loadSubmissions = async () => { + try { + setLoading(true); + const pendingSubmissions = await getPendingSubmissions(); + setSubmissions(pendingSubmissions); + } catch (error) { + console.error("Error loading submissions:", error); + addToast({ + type: "error", + title: "Error", + message: "Failed to load submissions", + duration: 4000, + }); + } finally { + setLoading(false); + } + }; + + const handleApprove = async (submissionId: string) => { + if (!submissionId) return; + + setActionLoading(submissionId); + try { + await approveSubmission(submissionId); + + addToast({ + type: "success", + title: "Profile Approved", + message: "The profile has been approved and is now live", + duration: 5000, + }); + + // Remove from pending list + setSubmissions((prev) => prev.filter((sub) => sub.id !== submissionId)); + setSelectedSubmission(null); + } catch (error) { + console.error("Error approving submission:", error); + addToast({ + type: "error", + title: "Approval Failed", + message: "Failed to approve the profile. Please try again.", + duration: 4000, + }); + } finally { + setActionLoading(null); + } + }; + + const handleReject = async (submissionId: string) => { + if (!submissionId) return; + + setActionLoading(submissionId); + try { + await rejectSubmission(submissionId); + + addToast({ + type: "info", + title: "Profile Rejected", + message: "The profile submission has been rejected", + duration: 5000, + }); + + // Remove from pending list + setSubmissions((prev) => prev.filter((sub) => sub.id !== submissionId)); + setSelectedSubmission(null); + } catch (error) { + console.error("Error rejecting submission:", error); + addToast({ + type: "error", + title: "Rejection Failed", + message: "Failed to reject the profile. Please try again.", + duration: 4000, + }); + } finally { + setActionLoading(null); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > + {/* Header */} +
+

+ Admin Panel - Profile Submissions +

+ +
+ +
+ {/* Submissions List */} +
+
+

+ Pending Submissions ({submissions.length}) +

+ + {loading ? ( +
+
+
+ ) : submissions.length === 0 ? ( +
+ +

+ No pending submissions +

+
+ ) : ( +
+ {submissions.map((submission) => ( + setSelectedSubmission(submission)} + className={`p-3 rounded-lg border cursor-pointer transition-all ${ + selectedSubmission?.id === submission.id + ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" + : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600" + }`} + > +
+ {submission.githubData.name} +
+

+ {submission.githubData.name || + submission.githubData.username} +

+

+ @{submission.githubData.username} +

+

+ {formatDate(submission.submittedAt)} +

+
+
+
+ ))} +
+ )} +
+
+ + {/* Submission Details */} +
+ {selectedSubmission ? ( +
+
+
+ {selectedSubmission.githubData.name} +
+

+ {selectedSubmission.githubData.name || + selectedSubmission.githubData.username} +

+

+ @{selectedSubmission.githubData.username} +

+ {selectedSubmission.githubData.bio && ( +

+ {selectedSubmission.githubData.bio} +

+ )} +
+
+ +
+ {selectedSubmission.githubData.location && ( +
+ + + {selectedSubmission.githubData.location} + +
+ )} + {selectedSubmission.githubData.company && ( +
+ + + {selectedSubmission.githubData.company} + +
+ )} +
+ + + Joined{" "} + {formatDate(selectedSubmission.githubData.created_at)} + +
+
+ + + {selectedSubmission.githubData.followers} followers + +
+
+ +
+
+
+ {selectedSubmission.githubData.public_repos} +
+
+ Repositories +
+
+
+
+ {selectedSubmission.githubData.followers} +
+
+ Followers +
+
+
+
+ {selectedSubmission.githubData.following} +
+
+ Following +
+
+
+
+ + {/* Actions */} +
+ + + View GitHub + + + + + +
+
+ ) : ( +
+
+ +

+ Select a Submission +

+

+ Choose a submission from the list to review details +

+
+
+ )} +
+
+
+
+ ); +}; + +export default AdminPanel; diff --git a/src/components/devfolio/AdvancedSearch.tsx b/src/components/devfolio/AdvancedSearch.tsx new file mode 100644 index 00000000..e4359f72 --- /dev/null +++ b/src/components/devfolio/AdvancedSearch.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + Search, + Filter, + SortAsc, + SortDesc, + X, + Tag, + Calendar, + Users, + GitBranch, +} from "lucide-react"; + +interface AdvancedSearchProps { + onSearchChange: (filters: SearchFilters) => void; + totalResults: number; +} + +interface SearchFilters { + searchTerm: string; + sortBy: "name" | "followers" | "repos" | "likes" | "date"; + sortOrder: "asc" | "desc"; + minFollowers: number; + minRepos: number; + tags: string[]; + featured: boolean | null; +} + +const POPULAR_TAGS = [ + "react", + "typescript", + "javascript", + "python", + "node.js", + "vue", + "angular", + "nextjs", + "docker", + "kubernetes", + "aws", + "machine-learning", + "ai", + "blockchain", + "mobile", + "ios", + "android", + "flutter", + "react-native", + "web3", + "game-dev", +]; + +const AdvancedSearch: React.FC = ({ + onSearchChange, + totalResults, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [filters, setFilters] = useState({ + searchTerm: "", + sortBy: "followers", + sortOrder: "desc", + minFollowers: 0, + minRepos: 0, + tags: [], + featured: null, + }); + + useEffect(() => { + onSearchChange(filters); + }, [filters, onSearchChange]); + + const updateFilter = (key: keyof SearchFilters, value: any) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + const toggleTag = (tag: string) => { + setFilters((prev) => ({ + ...prev, + tags: prev.tags.includes(tag) + ? prev.tags.filter((t) => t !== tag) + : [...prev.tags, tag], + })); + }; + + const clearFilters = () => { + setFilters({ + searchTerm: "", + sortBy: "followers", + sortOrder: "desc", + minFollowers: 0, + minRepos: 0, + tags: [], + featured: null, + }); + }; + + const hasActiveFilters = + filters.minFollowers > 0 || + filters.minRepos > 0 || + filters.tags.length > 0 || + filters.featured !== null; + + return ( +
+ {/* Basic Search */} +
+
+
+ + updateFilter("searchTerm", e.target.value)} + className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400" + /> +
+
+ +
+ {/* Sort By */} + + + {/* Sort Order */} + + + {/* Advanced Filters Toggle */} + +
+
+ + {/* Results Summary */} +
+

+ {totalResults} {totalResults === 1 ? "profile" : "profiles"} found +

+ + {hasActiveFilters && ( + + )} +
+ + {/* Advanced Filters */} + + {isExpanded && ( + +
+
+ {/* Follower Filter */} +
+ +
+ + + updateFilter( + "minFollowers", + parseInt(e.target.value) || 0 + ) + } + className="w-full pl-9 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="0" + /> +
+
+ + {/* Repository Filter */} +
+ +
+ + + updateFilter("minRepos", parseInt(e.target.value) || 0) + } + className="w-full pl-9 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="0" + /> +
+
+ + {/* Featured Filter */} +
+ + +
+
+ + {/* Tags Filter */} +
+ +
+ {POPULAR_TAGS.map((tag) => ( + + ))} +
+
+ + {/* Active Filters */} + {hasActiveFilters && ( +
+

+ Active Filters: +

+
+ {filters.minFollowers > 0 && ( + + Min followers: {filters.minFollowers}+ + + )} + {filters.minRepos > 0 && ( + + Min repos: {filters.minRepos}+ + + )} + {filters.featured !== null && ( + + {filters.featured ? "Featured" : "Regular"} profiles + + )} + {filters.tags.map((tag) => ( + + + {tag} + + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +}; + +export default AdvancedSearch; diff --git a/src/components/devfolio/AnalyticsDashboard.tsx b/src/components/devfolio/AnalyticsDashboard.tsx new file mode 100644 index 00000000..42f3a271 --- /dev/null +++ b/src/components/devfolio/AnalyticsDashboard.tsx @@ -0,0 +1,486 @@ +import React, { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { + Users, + GitBranch, + Heart, + TrendingUp, + TrendingDown, + Calendar, + BarChart3, + PieChart, + Activity, + Star, + Eye, + ThumbsUp, + Clock, +} from "lucide-react"; +import type { DevfolioProfile, ProfileSubmission } from "../../types/devfolio"; + +interface AnalyticsDashboardProps { + profiles: DevfolioProfile[]; + submissions: ProfileSubmission[]; +} + +const AnalyticsDashboard: React.FC = ({ + profiles, + submissions, +}) => { + const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d" | "all">( + "30d" + ); + + // Calculate statistics + const totalProfiles = profiles.length; + const totalLikes = profiles.reduce( + (sum, profile) => sum + (profile.likes || 0), + 0 + ); + const totalViews = profiles.reduce((sum, profile) => sum + 100, 0); // Mock views data + const featuredProfiles = profiles.filter((p) => p.featured).length; + const pendingSubmissions = submissions.filter( + (s) => s.status === "pending" + ).length; + + // Calculate growth metrics + const now = new Date(); + const getDaysAgo = (days: number) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + + const getFilteredProfiles = (days: number) => { + const cutoff = getDaysAgo(days); + return profiles.filter((profile) => { + const submittedDate = new Date(profile.submittedAt); + return submittedDate >= cutoff; + }); + }; + + const getGrowthData = () => { + const periods = { + "7d": 7, + "30d": 30, + "90d": 90, + all: 365 * 10, // Large number for all time + }; + + const days = periods[timeRange]; + const currentPeriod = getFilteredProfiles(days); + const previousPeriod = getFilteredProfiles(days * 2).filter((profile) => { + const submittedDate = new Date(profile.submittedAt); + return submittedDate < getDaysAgo(days); + }); + + const currentCount = currentPeriod.length; + const previousCount = previousPeriod.length; + const growth = + previousCount > 0 + ? ((currentCount - previousCount) / previousCount) * 100 + : 0; + + return { currentCount, previousCount, growth }; + }; + + const growthData = getGrowthData(); + + // Top performers + const topLikedProfiles = [...profiles] + .sort((a, b) => (b.likes || 0) - (a.likes || 0)) + .slice(0, 5); + + const topViewedProfiles = [...profiles] + .sort( + (a, b) => (b.githubData.followers || 0) - (a.githubData.followers || 0) + ) + .slice(0, 5); + + // Technology distribution (mock data for demonstration) + const techDistribution = [ + { name: "React", count: Math.floor(totalProfiles * 0.4), color: "#61DAFB" }, + { + name: "JavaScript", + count: Math.floor(totalProfiles * 0.6), + color: "#F7DF1E", + }, + { + name: "TypeScript", + count: Math.floor(totalProfiles * 0.3), + color: "#3178C6", + }, + { + name: "Python", + count: Math.floor(totalProfiles * 0.5), + color: "#3776AB", + }, + { + name: "Node.js", + count: Math.floor(totalProfiles * 0.35), + color: "#339933", + }, + ]; + + // Daily submissions chart data + const getDailyData = () => { + const days = 30; + const data = []; + + for (let i = days - 1; i >= 0; i--) { + const date = getDaysAgo(i); + const dayProfiles = profiles.filter((profile) => { + const submittedDate = new Date(profile.submittedAt); + return submittedDate.toDateString() === date.toDateString(); + }); + + data.push({ + date: date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + submissions: dayProfiles.length, + likes: dayProfiles.reduce((sum, p) => sum + (p.likes || 0), 0), + }); + } + + return data; + }; + + const dailyData = getDailyData(); + const maxSubmissions = Math.max(...dailyData.map((d) => d.submissions), 1); + + const StatCard = ({ + icon: Icon, + title, + value, + change, + trend, + color = "blue", + }: any) => ( + +
+
+

+ {title} +

+

+ {value} +

+ {change !== undefined && ( +
+ {trend === "up" ? ( + + ) : trend === "down" ? ( + + ) : null} + + {change > 0 ? "+" : ""} + {change.toFixed(1)}% + + + vs previous period + +
+ )} +
+
+ +
+
+
+ ); + + return ( +
+ {/* Time Range Selector */} +
+

+ Analytics Dashboard +

+
+ {(["7d", "30d", "90d", "all"] as const).map((range) => ( + + ))} +
+
+ + {/* Key Metrics */} +
+ 0 + ? "up" + : growthData.growth < 0 + ? "down" + : "stable" + } + color="blue" + /> + + + +
+ + {/* Charts Row */} +
+ {/* Daily Submissions Chart */} +
+
+

+ Daily Submissions +

+ +
+
+ {dailyData.slice(-7).map((day, index) => ( +
+ + {day.date} + +
+
+
+ + {day.submissions} + +
+ ))} +
+
+ + {/* Technology Distribution */} +
+
+

+ Popular Technologies +

+ +
+
+ {techDistribution.map((tech, index) => ( +
+
+
+ + {tech.name} + +
+
+ + {tech.count} + + + ({((tech.count / totalProfiles) * 100).toFixed(1)}%) + +
+
+ ))} +
+
+
+ + {/* Top Performers */} +
+ {/* Most Liked Profiles */} +
+
+

+ Most Liked Profiles +

+ +
+
+ {topLikedProfiles.map((profile, index) => ( +
+
+ {index + 1} +
+
+

+ {profile.githubData.name || profile.githubData.username} +

+

+ @{profile.githubData.username} +

+
+
+ + + {profile.likes} + +
+
+ ))} +
+
+ + {/* Most Viewed Profiles */} +
+
+

+ Most Viewed Profiles +

+ +
+
+ {topViewedProfiles.map((profile, index) => ( +
+
+ {index + 1} +
+
+

+ {profile.githubData.name || profile.githubData.username} +

+

+ @{profile.githubData.username} +

+
+
+ + + {100} + +
+
+ ))} +
+
+
+ + {/* Summary Stats */} +
+
+
+
+ +
+

+ {featuredProfiles} +

+

+ Featured Profiles +

+
+ +
+
+ +
+

+ {totalProfiles > 0 + ? (totalLikes / totalProfiles).toFixed(1) + : "0"} +

+

+ Avg Likes per Profile +

+
+ +
+
+ +
+

+ {totalProfiles > 0 + ? Math.round( + profiles.reduce( + (sum, p) => sum + (p.githubData.public_repos || 0), + 0 + ) / totalProfiles + ) + : "0"} +

+

+ Avg Repositories +

+
+ +
+
+ +
+

+ {growthData.currentCount} +

+

+ New This Period +

+
+
+
+
+ ); +}; + +export default AnalyticsDashboard; diff --git a/src/components/devfolio/DevfolioSection.tsx b/src/components/devfolio/DevfolioSection.tsx new file mode 100644 index 00000000..c77ea084 --- /dev/null +++ b/src/components/devfolio/DevfolioSection.tsx @@ -0,0 +1,499 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { motion } from "framer-motion"; +import { Plus, Search, Star, Github, Database, Shield } from "lucide-react"; +import ProfileCard from "./ProfileCard"; +import SubmissionModal from "./SubmissionModal"; +import AdminDashboard from "./AdminDashboard"; +import { + getApprovedProfiles, + getFeaturedProfiles, + getUserLikes, + toggleLike, +} from "../../lib/firestore"; +import { ToastProvider, useToast } from "../ui/Toast"; +import type { DevfolioProfile } from "../../types/devfolio"; + +const DevfolioSectionContent: React.FC = () => { + const { addToast } = useToast(); + const [profiles, setProfiles] = useState([]); + const [filteredProfiles, setFilteredProfiles] = useState( + [] + ); + const [userLikes, setUserLikes] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [showFeaturedOnly, setShowFeaturedOnly] = useState(false); + const [showSubmissionModal, setShowSubmissionModal] = useState(false); + const [showAdminDashboard, setShowAdminDashboard] = useState(false); + + // Simulate user ID for now (in real app, this would come from auth) + const currentUserId = "anonymous_user"; + + // Sample data for initial display if no real data exists + const sampleProfiles: DevfolioProfile[] = useMemo( + () => [ + { + id: "sample-1", + githubData: { + id: "sample-1", + username: "octocat", + name: "The Octocat", + bio: "GitHub mascot and developer advocate", + avatar_url: "https://github.com/octocat.png", + html_url: "https://github.com/octocat", + public_repos: 42, + followers: 1000, + following: 100, + location: "San Francisco", + company: "GitHub", + created_at: "2008-01-01T00:00:00Z", + }, + status: "approved", + likes: 128, + submittedAt: "2024-01-01T00:00:00Z", + approvedAt: "2024-01-02T00:00:00Z", + featured: true, + tags: ["react", "typescript", "github"], + }, + { + id: "sample-2", + githubData: { + id: "sample-2", + username: "torvalds", + name: "Linus Torvalds", + bio: "Creator of Linux and Git", + avatar_url: "https://github.com/torvalds.png", + html_url: "https://github.com/torvalds", + public_repos: 6, + followers: 170000, + following: 0, + location: "Portland, OR", + company: "Linux Foundation", + created_at: "2009-01-01T00:00:00Z", + }, + status: "approved", + likes: 856, + submittedAt: "2024-01-01T00:00:00Z", + approvedAt: "2024-01-02T00:00:00Z", + featured: true, + tags: ["linux", "kernel", "c"], + }, + { + id: "sample-3", + githubData: { + id: "sample-3", + username: "gaearon", + name: "Dan Abramov", + bio: "React Core Team at Meta", + avatar_url: "https://github.com/gaearon.png", + html_url: "https://github.com/gaearon", + public_repos: 118, + followers: 95000, + following: 171, + location: "London, UK", + company: "Meta", + created_at: "2011-01-01T00:00:00Z", + }, + status: "approved", + likes: 324, + submittedAt: "2024-01-01T00:00:00Z", + approvedAt: "2024-01-02T00:00:00Z", + featured: false, + tags: ["react", "javascript", "redux"], + }, + ], + [] + ); + + useEffect(() => { + loadProfiles(); + loadUserLikes(); + }, []); + + const loadProfiles = async () => { + try { + setLoading(true); + const fetchedProfiles = await getApprovedProfiles(); + setProfiles(fetchedProfiles); + } catch (error) { + console.error("Error loading profiles:", error); + } finally { + setLoading(false); + } + }; + + const loadUserLikes = async () => { + try { + const likes = await getUserLikes(currentUserId); + setUserLikes(likes); + } catch (error) { + console.error("Error loading user likes:", error); + } + }; + + const filterProfiles = useCallback(() => { + console.log("πŸ” filterProfiles called with:", { + profilesLength: profiles.length, + searchTerm, + showFeaturedOnly, + }); + + // Use real profiles if available, otherwise use sample profiles for filtering + let filtered = profiles.length > 0 ? profiles : sampleProfiles; + + if (showFeaturedOnly) { + filtered = filtered.filter((profile) => profile.featured); + console.log("πŸ“Œ After featured filter:", filtered.length); + } + + if (searchTerm) { + console.log("πŸ”Ž Applying search filter for:", searchTerm); + filtered = filtered.filter( + (profile) => + profile.githubData.username + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + profile.githubData.name + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + (profile.githubData.bio && + profile.githubData.bio + .toLowerCase() + .includes(searchTerm.toLowerCase())) + ); + console.log("πŸ”Ž After search filter:", filtered.length); + } + + console.log("βœ… Setting filtered profiles:", filtered.length); + setFilteredProfiles(filtered); + }, [profiles, sampleProfiles, searchTerm, showFeaturedOnly]); + + useEffect(() => { + filterProfiles(); + }, [filterProfiles]); + + const handleSubmitProfile = () => { + setShowSubmissionModal(true); + }; + + const handleLike = async (profileId: string) => { + try { + const isLiked = await toggleLike(profileId, currentUserId); + + // Update local state + if (isLiked) { + setUserLikes((prev) => [...prev, profileId]); + addToast({ + type: "success", + title: "Profile Liked!", + message: "Added to your favorites", + duration: 3000, + }); + } else { + setUserLikes((prev) => prev.filter((id) => id !== profileId)); + addToast({ + type: "info", + title: "Profile Unliked", + message: "Removed from your favorites", + duration: 3000, + }); + } + + // Update profile likes count in local state + setProfiles((prev) => + prev.map((profile) => + profile.id === profileId + ? { ...profile, likes: profile.likes + (isLiked ? 1 : -1) } + : profile + ) + ); + } catch (error) { + console.error("Error liking profile:", error); + addToast({ + type: "error", + title: "Error", + message: "Failed to update like status", + duration: 4000, + }); + } + }; + + // Use filtered profiles for display + const displayProfiles = + profiles.length > 0 ? filteredProfiles : filteredProfiles; + + console.log("πŸ“Š Display logic:", { + profilesLength: profiles.length, + filteredProfilesLength: filteredProfiles.length, + displayProfilesLength: displayProfiles.length, + searchTerm, + showFeaturedOnly, + }); + + return ( +
+ {/* Header Section */} +
+ + πŸš€ Developer Showcase + + + + Awesome GitHub Profiles + + + + Discover amazing developers from our community. Get inspired by their + work and creativity. + + + {/* Call to Action */} +
+ + + Add Your GitHub Profile + + + {/* Development Only: Admin Panel Button */} + {process.env.NODE_ENV === "development" && ( + setShowAdminDashboard(true)} + className="bg-gradient-to-r from-purple-500 to-pink-600 text-white px-6 py-3 rounded-full font-medium hover:shadow-lg transition-all duration-300 flex items-center space-x-2" + > + + Admin Dashboard + + )} +
+
+ + {/* Filters and Search */} + + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-3 rounded-lg border transition-all duration-300 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:border-blue-500 dark:focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20" + /> +
+ + {/* Featured Filter */} + setShowFeaturedOnly(!showFeaturedOnly)} + className={`flex items-center space-x-2 px-6 py-3 rounded-lg border transition-all duration-300 ${ + showFeaturedOnly + ? "bg-yellow-500 text-white border-yellow-500" + : "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:border-yellow-500 dark:hover:border-yellow-500" + }`} + > + + Featured + +
+ + {/* GitHub API Rate Limit Warning */} + {profiles.length === 0 && ( + +
+
+
+ ⚠️ +
+
+
+

+ GitHub API Rate Limit +

+

+ GitHub API requests are limited. Showing demo profiles to + maintain functionality. Real profiles will load when the rate + limit resets. +

+
+
+
+ )} + + {/* Stats */} + +
+
+ {displayProfiles.length} +
+
+ Total Profiles +
+
+
+
+ {displayProfiles.filter((p) => p.featured).length} +
+
+ Featured +
+
+
+
+ {displayProfiles.reduce((sum, p) => sum + p.likes, 0)} +
+
+ Total Likes +
+
+
+
+ {displayProfiles.reduce( + (sum, p) => sum + p.githubData.followers, + 0 + )} +
+
+ Total Followers +
+
+
+ + {/* Loading State */} + {loading && ( +
+
+
+ )} + + {/* Profiles Grid */} + {!loading && ( + + {displayProfiles.map((profile, index) => ( + + + + ))} + + )} + + {/* No Real Data Notice */} + {!loading && profiles.length === 0 && ( + + +

+ No Database Data Yet +

+

+ Showing sample data. Real profiles will appear when they are + submitted and approved. +

+
+ )} + + {/* Empty State */} + {!loading && + displayProfiles.length === 0 && + (searchTerm || showFeaturedOnly) && ( + + +

+ No profiles found +

+

+ Try adjusting your search or filters +

+
+ )} + + {/* Submission Modal */} + setShowSubmissionModal(false)} + /> + + {/* Admin Dashboard */} + setShowAdminDashboard(false)} + /> +
+ ); +}; + +const DevfolioSection: React.FC = () => { + return ( + + + + ); +}; + +export default DevfolioSection; diff --git a/src/components/devfolio/ProfileCard.tsx b/src/components/devfolio/ProfileCard.tsx new file mode 100644 index 00000000..1c22a912 --- /dev/null +++ b/src/components/devfolio/ProfileCard.tsx @@ -0,0 +1,194 @@ +import React, { useState } from "react"; +import { motion } from "framer-motion"; +import { Heart, ExternalLink, MapPin, Building, Calendar } from "lucide-react"; +import type { DevfolioProfile } from "../../types/devfolio"; + +interface ProfileCardProps { + profile: DevfolioProfile; + onLike?: (profileId: string) => void; + isLiked?: boolean; +} + +const ProfileCard: React.FC = ({ + profile, + onLike, + isLiked = false, +}) => { + const [isHovered, setIsHovered] = useState(false); + const { githubData } = profile; + + const handleLike = () => { + onLike?.(profile.id); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + }); + }; + + return ( + setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)} + className="relative rounded-xl overflow-hidden transition-all duration-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-xl" + > + {/* Featured Badge */} + {profile.featured && ( +
+
+ ⭐ Featured +
+
+ )} + + {/* Header with Avatar */} +
+
+
+ +
+
+ + {/* Content */} +
+ {/* Name and Username */} +
+

+ {githubData.name} +

+

+ @{githubData.username} +

+
+ + {/* Bio */} + {githubData.bio && ( +

+ {githubData.bio} +

+ )} + + {/* Meta Information */} +
+ {githubData.location && ( +
+ + + {githubData.location} + +
+ )} + {githubData.company && ( +
+ + + {githubData.company} + +
+ )} +
+ + + Joined {formatDate(githubData.created_at)} + +
+
+ + {/* Stats */} +
+
+
+ {githubData.public_repos} +
+
+ Repos +
+
+
+
+ {githubData.followers} +
+
+ Followers +
+
+
+
+ {githubData.following} +
+
+ Following +
+
+
+ + {/* Tags */} + {profile.tags && profile.tags.length > 0 && ( +
+ {profile.tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {profile.tags.length > 3 && ( + + +{profile.tags.length - 3} more + + )} +
+ )} + + {/* Actions */} +
+ + + {profile.likes} + + + + View Profile + + +
+
+ + {/* Hover Effect Overlay */} + + + ); +}; + +export default ProfileCard; diff --git a/src/components/devfolio/SubmissionModal.tsx b/src/components/devfolio/SubmissionModal.tsx new file mode 100644 index 00000000..bb2adfb6 --- /dev/null +++ b/src/components/devfolio/SubmissionModal.tsx @@ -0,0 +1,528 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + X, + Github, + Loader2, + CheckCircle, + AlertCircle, + User, + MapPin, + Building, + Calendar, + Info, +} from "lucide-react"; +import { submitProfile } from "../../lib/firestore"; +import { fetchGitHubProfile, getDemoUsernames } from "../../services/github"; +import { useToast } from "../ui/Toast"; +import type { GitHubProfile, ProfileSubmission } from "../../types/devfolio"; + +interface SubmissionModalProps { + isOpen: boolean; + onClose: () => void; +} + +const SubmissionModal: React.FC = ({ + isOpen, + onClose, +}) => { + const { addToast } = useToast(); + const [username, setUsername] = useState(""); + const [githubData, setGithubData] = useState(null); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [step, setStep] = useState<"input" | "preview" | "submitted">("input"); + const [error, setError] = useState(null); + + // Debug logging for state changes + console.log("πŸ” SubmissionModal render - Current state:", { + step, + submitting, + loading, + hasGithubData: !!githubData, + username: githubData?.username, + }); + + // Cleanup submitting state when modal closes or component unmounts + useEffect(() => { + if (!isOpen) { + setSubmitting(false); + setLoading(false); + setError(null); + } + }, [isOpen]); + + // Reset submitting state when step changes to submitted + useEffect(() => { + console.log("πŸ” useEffect [step] triggered, current step:", step); + if (step === "submitted") { + console.log("βœ… Step is 'submitted', ensuring submitting is false"); + setSubmitting(false); + } + }, [step]); + + const handleUsernameSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim()) return; + + setLoading(true); + setError(null); + + try { + const profile = await fetchGitHubProfile(username.trim()); + setGithubData(profile); + setStep("preview"); + } catch (error) { + console.error("Error fetching GitHub profile:", error); + const errorMessage = + error instanceof Error + ? error.message + : "Failed to fetch GitHub profile"; + setError(errorMessage); + + // Show toast for better user feedback + addToast({ + type: "error", + title: "GitHub Profile Error", + message: errorMessage, + duration: 6000, + }); + } finally { + setLoading(false); + } + }; + + const handleSubmit = useCallback(async () => { + if (!githubData) { + console.error("No GitHub data available for submission"); + return; + } + + console.log(" Starting profile submission for:", githubData.username); + setSubmitting(true); + setError(null); + + // Set a timeout to prevent infinite submitting state + const timeoutId = setTimeout(() => { + console.warn(" Submission timeout reached, resetting state"); + setSubmitting(false); + setError("Submission timed out. Please try again."); + addToast({ + type: "error", + title: "Submission Timeout", + message: + "The submission took too long. Please check your connection and try again.", + duration: 5000, + }); + }, 30000); // 30 second timeout + + try { + const submission: Omit = { + githubData, + status: "pending", + submittedAt: new Date().toISOString(), + tags: [], // User can add tags later through admin review + }; + + console.log("πŸ“ Submitting profile data:", submission); + + // Direct Firebase submission without any timeout interference + const submissionId = await submitProfile(submission); + console.log("βœ… Profile submitted successfully with ID:", submissionId); + + // Clear the timeout since submission succeeded + clearTimeout(timeoutId); + + // Show success toast + addToast({ + type: "success", + title: "Profile Submitted!", + message: + "Your profile is now pending review. You'll be notified once it's approved.", + duration: 6000, + }); + + // Immediately transition to success state + console.log("πŸ”„ Transitioning to submitted state"); + setStep("submitted"); + setSubmitting(false); + console.log("πŸŽ‰ State updates completed"); + } catch (error) { + console.error("Error submitting profile:", error); + + // Clear the timeout since we're handling the error + clearTimeout(timeoutId); + + // Always reset submitting state on error + setSubmitting(false); + + // Check if it's a duplicate profile error + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + const isProfileExists = errorMessage.includes("already exists"); + const isTimeout = errorMessage.includes("timeout"); + + setError(errorMessage); + + addToast({ + type: "error", + title: isProfileExists + ? "Profile Already Exists" + : isTimeout + ? "Connection Timeout" + : "Submission Failed", + message: isProfileExists + ? "A profile with this username has already been submitted." + : isTimeout + ? "The submission took too long. Please check your internet connection and try again." + : "Failed to submit your profile. Please try again.", + duration: 5000, + }); + } + }, [githubData, addToast]); + + const handleClose = () => { + setUsername(""); + setGithubData(null); + setStep("input"); + setError(null); + setSubmitting(false); + setLoading(false); + onClose(); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > + {/* Header */} +
+

+ {step === "input" && "Add Your GitHub Profile"} + {step === "preview" && "Profile Preview"} + {step === "submitted" && "Submission Complete"} +

+ +
+ + {/* Content */} +
+ {step === "input" && ( + +
+
+ +
+

+ Enter your GitHub username to fetch your profile + information automatically. +

+
+ +
+
+ +
+ + setUsername(e.target.value)} + placeholder="e.g., octocat" + className="w-full pl-10 pr-4 py-3 rounded-lg border transition-all duration-300 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:border-blue-500 dark:focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20" + disabled={loading} + /> +
+

+ Make sure your GitHub profile is public +

+
+ + {/* Demo usernames suggestion */} +
+ +
+

+ Try these demo usernames: +

+
+ {getDemoUsernames().map((demoUsername) => ( + + ))} +
+
+
+ + {error && ( + + + + {error} + + + )} + + +
+ +
+ Your profile will be reviewed by our team before being + published. +
+
+ )} + + {step === "preview" && githubData && ( + +
+ +

+ Profile Found! +

+

+ Please review your profile information before submitting. +

+
+ + {/* Profile Preview Card */} +
+
+ {githubData.name +
+

+ {githubData.name || githubData.username} +

+

+ @{githubData.username} +

+ {githubData.bio && ( +

+ {githubData.bio} +

+ )} +
+
+ +
+ {githubData.location && ( +
+ + + {githubData.location} + +
+ )} + {githubData.company && ( +
+ + + {githubData.company} + +
+ )} +
+ + + Joined {formatDate(githubData.created_at)} + +
+
+ + + {githubData.followers} followers + +
+
+ +
+
+
+ {githubData.public_repos} +
+
+ Repositories +
+
+
+
+ {githubData.followers} +
+
+ Followers +
+
+
+
+ {githubData.following} +
+
+ Following +
+
+
+
+ +
+ + + + {/* Emergency reset button - only show if stuck for more than 10 seconds */} + {submitting && ( + + )} +
+
+ )} + + {step === "submitted" && ( + +
+ +
+ +
+

+ Submission Successful! +

+

+ Thank you for submitting your GitHub profile. Our team + will review it and notify you once it's approved and + published. +

+
+ +
+

+ What happens next? +

+
    +
  • + β€’ Our team will review your profile within 24-48 hours +
  • +
  • + β€’ We'll check for completeness and community guidelines +
  • +
  • β€’ You'll receive a notification once approved
  • +
  • + β€’ Your profile will then appear in the public showcase +
  • +
+
+ + +
+ )} +
+
+
+ )} +
+ ); +}; + +export default SubmissionModal; diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 00000000..635a5465 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,131 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { CheckCircle, XCircle, AlertCircle, Info, X } from "lucide-react"; + +interface Toast { + id: string; + type: "success" | "error" | "warning" | "info"; + title: string; + message?: string; + duration?: number; +} + +interface ToastContextType { + toasts: Toast[]; + addToast: (toast: Omit) => void; + removeToast: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +}; + +const ToastIcon = ({ type }: { type: Toast["type"] }) => { + const iconClass = "w-5 h-5"; + + switch (type) { + case "success": + return ; + case "error": + return ; + case "warning": + return ; + case "info": + return ; + default: + return ; + } +}; + +const ToastComponent: React.FC<{ + toast: Toast; + onRemove: (id: string) => void; +}> = ({ toast, onRemove }) => { + const bgColors = { + success: + "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800", + error: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800", + warning: + "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800", + info: "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800", + }; + + return ( + +
+ +
+

+ {toast.title} +

+ {toast.message && ( +

+ {toast.message} +

+ )} +
+ +
+
+ ); +}; + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((toast: Omit) => { + const id = Math.random().toString(36).substr(2, 9); + const newToast = { ...toast, id }; + + setToasts((prev) => [...prev, newToast]); + + // Auto remove after duration + setTimeout(() => { + removeToast(id); + }, toast.duration || 5000); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + return ( + + {children} + + {/* Toast Container */} +
+ + {toasts.map((toast) => ( + + ))} + +
+
+ ); +}; diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index ae372560..e8506501 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -1,6 +1,7 @@ import { initializeApp, getApps, getApp } from "firebase/app"; import { getAuth } from "firebase/auth"; import { getAnalytics } from "firebase/analytics"; +import { getFirestore } from "firebase/firestore"; const firebaseConfig = { apiKey: "AIzaSyBSiO9d5tHuyyAeUCt37pxDWTT7jPSigaU", @@ -10,10 +11,11 @@ const firebaseConfig = { storageBucket: "awesome-github-profiles.firebasestorage.app", messagingSenderId: "490821849262", appId: "1:490821849262:web:7e97984d98f578b81f9d3f", - measurementId: "G-WM33JZYEV0" + measurementId: "G-WM33JZYEV0", }; const app = getApps().length ? getApp() : initializeApp(firebaseConfig); const auth = getAuth(app); +const db = getFirestore(app); -export { app, auth }; \ No newline at end of file +export { app, auth, db }; diff --git a/src/lib/firestore.ts b/src/lib/firestore.ts new file mode 100644 index 00000000..ec8b70ee --- /dev/null +++ b/src/lib/firestore.ts @@ -0,0 +1,437 @@ +import { + getFirestore, + collection, + doc, + getDocs, + getDoc, + addDoc, + updateDoc, + deleteDoc, + query, + where, + orderBy, + limit, + serverTimestamp, + increment, + writeBatch, +} from "firebase/firestore"; +import { app } from "./firebase"; +import type { + DevfolioProfile, + ProfileSubmission, + GitHubProfile, +} from "../types/devfolio"; +import { + mockGetApprovedProfiles, + mockGetFeaturedProfiles, + mockGetPendingSubmissions, + mockApproveSubmission, + mockRejectSubmission, + mockToggleLike, + mockGetUserLikes, +} from "./mockFirestore"; + +const db = getFirestore(app); + +// Collections +const PROFILES_COLLECTION = "devfolio_profiles"; +const SUBMISSIONS_COLLECTION = "devfolio_submissions"; +const LIKES_COLLECTION = "devfolio_likes"; + +// Flag to determine if we should use mock data +const USE_MOCK_DATA = + process.env.NODE_ENV === "development" || + process.env.REACT_APP_USE_MOCK === "true"; + +// Helper function to detect if Firebase is available +const isFirebaseAvailable = async (): Promise => { + try { + // Try a simple read operation to test connectivity + await getDocs(query(collection(db, PROFILES_COLLECTION), limit(1))); + return true; + } catch (error) { + console.warn("Firebase connection failed, using mock data:", error); + return false; + } +}; + +// Profile operations +export const getApprovedProfiles = async (): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockGetApprovedProfiles(); + } + + try { + const q = query( + collection(db, PROFILES_COLLECTION), + where("status", "==", "approved"), + orderBy("approvedAt", "desc") + ); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as DevfolioProfile) + ); + } catch (error) { + console.error("Error fetching approved profiles:", error); + return mockGetApprovedProfiles(); + } +}; + +export const getFeaturedProfiles = async (): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockGetFeaturedProfiles(); + } + + try { + const q = query( + collection(db, PROFILES_COLLECTION), + where("status", "==", "approved"), + where("featured", "==", true), + orderBy("approvedAt", "desc") + ); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as DevfolioProfile) + ); + } catch (error) { + console.error("Error fetching featured profiles:", error); + return mockGetFeaturedProfiles(); + } +}; + +export const getPendingProfiles = async (): Promise => { + try { + const q = query( + collection(db, PROFILES_COLLECTION), + where("status", "==", "pending"), + orderBy("submittedAt", "desc") + ); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as DevfolioProfile) + ); + } catch (error) { + console.error("Error fetching pending profiles:", error); + return []; + } +}; + +export const createProfile = async ( + githubData: GitHubProfile, + submittedBy?: string +): Promise => { + try { + const profileData: Omit = { + githubData, + status: "pending", + likes: 0, + submittedAt: new Date().toISOString(), + submittedBy, + featured: false, + }; + + const docRef = await addDoc( + collection(db, PROFILES_COLLECTION), + profileData + ); + return docRef.id; + } catch (error) { + console.error("Error creating profile:", error); + throw error; + } +}; + +export const submitProfile = async ( + submission: Omit +): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + const { mockSubmitProfile } = await import("./mockFirestore"); + return mockSubmitProfile(submission); + } + + try { + // Check if profile already exists + const exists = await checkProfileExists(submission.githubData.username); + if (exists) { + throw new Error("Profile with this username already exists"); + } + + const docRef = await addDoc(collection(db, SUBMISSIONS_COLLECTION), { + ...submission, + submittedAt: serverTimestamp(), + status: "pending", + }); + return docRef.id; + } catch (error) { + console.error("Error submitting profile:", error); + console.log("πŸ”„ Falling back to mock submission due to error"); + // Fallback to mock system + const { mockSubmitProfile } = await import("./mockFirestore"); + return mockSubmitProfile(submission); + } +}; + +export const approveProfile = async ( + profileId: string, + adminNotes?: string +): Promise => { + try { + const profileRef = doc(db, PROFILES_COLLECTION, profileId); + await updateDoc(profileRef, { + status: "approved", + approvedAt: serverTimestamp(), + adminNotes, + }); + } catch (error) { + console.error("Error approving profile:", error); + throw error; + } +}; + +export const rejectProfile = async ( + profileId: string, + adminNotes?: string +): Promise => { + try { + const profileRef = doc(db, PROFILES_COLLECTION, profileId); + await updateDoc(profileRef, { + status: "rejected", + adminNotes, + }); + } catch (error) { + console.error("Error rejecting profile:", error); + throw error; + } +}; + +// Enhanced like system with real-time updates +export const toggleLike = async ( + profileId: string, + userId: string +): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockToggleLike(profileId, userId); + } + + try { + const batch = writeBatch(db); + const likeId = `${profileId}_${userId}`; + const likeRef = doc(db, LIKES_COLLECTION, likeId); + const profileRef = doc(db, PROFILES_COLLECTION, profileId); + + const likeDoc = await getDoc(likeRef); + + if (likeDoc.exists()) { + // Unlike - remove like and decrement count + batch.delete(likeRef); + batch.update(profileRef, { likes: increment(-1) }); + await batch.commit(); + return false; + } else { + // Like - add like and increment count + batch.set(likeRef, { + profileId, + userId, + createdAt: serverTimestamp(), + }); + batch.update(profileRef, { likes: increment(1) }); + await batch.commit(); + return true; + } + } catch (error) { + console.error("Error toggling like:", error); + return mockToggleLike(profileId, userId); + } +}; + +export const getUserLikes = async (userId: string): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockGetUserLikes(userId); + } + + try { + const q = query( + collection(db, LIKES_COLLECTION), + where("userId", "==", userId) + ); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map((doc) => doc.data().profileId); + } catch (error) { + console.error("Error fetching user likes:", error); + return mockGetUserLikes(userId); + } +}; + +export const getProfileLikes = async (profileId: string): Promise => { + try { + const q = query( + collection(db, LIKES_COLLECTION), + where("profileId", "==", profileId) + ); + const querySnapshot = await getDocs(q); + return querySnapshot.size; + } catch (error) { + console.error("Error fetching profile likes:", error); + return 0; + } +}; + +// Check if profile already exists +export const checkProfileExists = async ( + username: string +): Promise => { + try { + const q = query( + collection(db, PROFILES_COLLECTION), + where("githubData.username", "==", username) + ); + const querySnapshot = await getDocs(q); + return !querySnapshot.empty; + } catch (error) { + console.error("Error checking profile existence:", error); + return false; + } +}; + +// Submission management +export const getPendingSubmissions = async (): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockGetPendingSubmissions(); + } + + try { + const q = query( + collection(db, SUBMISSIONS_COLLECTION), + where("status", "==", "pending"), + orderBy("submittedAt", "desc") + ); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as ProfileSubmission) + ); + } catch (error) { + console.error("Error fetching pending submissions:", error); + return mockGetPendingSubmissions(); + } +}; + +export const approveSubmission = async ( + submissionId: string +): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockApproveSubmission(submissionId); + } + + try { + const batch = writeBatch(db); + + // Get submission data + const submissionRef = doc(db, SUBMISSIONS_COLLECTION, submissionId); + const submissionDoc = await getDoc(submissionRef); + + if (!submissionDoc.exists()) { + throw new Error("Submission not found"); + } + + const submissionData = submissionDoc.data() as ProfileSubmission; + + // Create approved profile + const profileData: Omit = { + githubData: submissionData.githubData, + status: "approved", + likes: 0, + submittedAt: submissionData.submittedAt, + approvedAt: new Date().toISOString(), + featured: false, + tags: submissionData.tags || [], + }; + + // Add to profiles collection + const profileRef = doc(collection(db, PROFILES_COLLECTION)); + batch.set(profileRef, profileData); + + // Update submission status + batch.update(submissionRef, { + status: "approved", + approvedAt: serverTimestamp(), + }); + + await batch.commit(); + } catch (error) { + console.error("Error approving submission:", error); + // Fallback to mock if Firebase fails + return mockApproveSubmission(submissionId); + } +}; + +export const rejectSubmission = async ( + submissionId: string, + reason?: string +): Promise => { + if (USE_MOCK_DATA || !(await isFirebaseAvailable())) { + return mockRejectSubmission(submissionId, reason); + } + + try { + const submissionRef = doc(db, SUBMISSIONS_COLLECTION, submissionId); + await updateDoc(submissionRef, { + status: "rejected", + rejectedAt: serverTimestamp(), + rejectionReason: reason, + }); + } catch (error) { + console.error("Error rejecting submission:", error); + return mockRejectSubmission(submissionId, reason); + } +}; + +// Get profile statistics +export const getProfileStats = async () => { + try { + const [approvedQuery, featuredQuery, totalLikesQuery] = await Promise.all([ + getDocs( + query( + collection(db, PROFILES_COLLECTION), + where("status", "==", "approved") + ) + ), + getDocs( + query( + collection(db, PROFILES_COLLECTION), + where("status", "==", "approved"), + where("featured", "==", true) + ) + ), + getDocs(collection(db, LIKES_COLLECTION)), + ]); + + return { + totalProfiles: approvedQuery.size, + featuredProfiles: featuredQuery.size, + totalLikes: totalLikesQuery.size, + }; + } catch (error) { + console.error("Error fetching profile stats:", error); + return { + totalProfiles: 0, + featuredProfiles: 0, + totalLikes: 0, + }; + } +}; diff --git a/src/lib/mockFirestore.ts b/src/lib/mockFirestore.ts new file mode 100644 index 00000000..18b4f899 --- /dev/null +++ b/src/lib/mockFirestore.ts @@ -0,0 +1,309 @@ +import type { + DevfolioProfile, + ProfileSubmission, + GitHubProfile, +} from "../types/devfolio"; + +// Mock data storage using localStorage for testing +const STORAGE_KEYS = { + PROFILES: "devfolio_profiles", + SUBMISSIONS: "devfolio_submissions", + LIKES: "devfolio_likes", +}; + +// Mock GitHub profiles for testing +const mockGitHubProfiles: GitHubProfile[] = [ + { + id: "mock-1", + username: "octocat", + name: "The Octocat", + bio: "GitHub mascot and developer advocate", + avatar_url: "https://github.com/octocat.png", + html_url: "https://github.com/octocat", + public_repos: 42, + followers: 1000, + following: 100, + location: "San Francisco", + company: "GitHub", + created_at: "2008-01-01T00:00:00Z", + }, + { + id: "mock-2", + username: "torvalds", + name: "Linus Torvalds", + bio: "Creator of Linux and Git", + avatar_url: "https://github.com/torvalds.png", + html_url: "https://github.com/torvalds", + public_repos: 6, + followers: 170000, + following: 0, + location: "Portland, OR", + company: "Linux Foundation", + created_at: "2009-01-01T00:00:00Z", + }, + { + id: "mock-3", + username: "gaearon", + name: "Dan Abramov", + bio: "React Core Team at Meta", + avatar_url: "https://github.com/gaearon.png", + html_url: "https://github.com/gaearon", + public_repos: 118, + followers: 95000, + following: 171, + location: "London, UK", + company: "Meta", + created_at: "2011-01-01T00:00:00Z", + }, +]; + +// Utility functions for localStorage operations +const getFromStorage = (key: string): T[] => { + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : []; + } catch { + return []; + } +}; + +const saveToStorage = (key: string, data: T[]): void => { + try { + localStorage.setItem(key, JSON.stringify(data)); + } catch (error) { + console.error("Failed to save to localStorage:", error); + } +}; + +// Initialize mock data if not exists +const initializeMockData = (): void => { + const existingSubmissions = getFromStorage( + STORAGE_KEYS.SUBMISSIONS + ); + + if (existingSubmissions.length === 0) { + const mockSubmissions: ProfileSubmission[] = mockGitHubProfiles.map( + (githubData, index) => ({ + id: `submission-${index + 1}`, + githubData, + status: "pending", + submittedAt: new Date( + Date.now() - index * 24 * 60 * 60 * 1000 + ).toISOString(), + message: `Test submission for ${githubData.name}`, + tags: + index === 0 + ? ["react", "typescript", "github"] + : index === 1 + ? ["linux", "kernel", "c"] + : ["react", "javascript", "redux"], + }) + ); + + saveToStorage(STORAGE_KEYS.SUBMISSIONS, mockSubmissions); + } + + const existingProfiles = getFromStorage( + STORAGE_KEYS.PROFILES + ); + if (existingProfiles.length === 0) { + // Start with empty profiles - they'll be created when submissions are approved + saveToStorage(STORAGE_KEYS.PROFILES, []); + } +}; + +// Mock Firestore functions +export const mockGetApprovedProfiles = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate network delay + return getFromStorage(STORAGE_KEYS.PROFILES).filter( + (profile) => profile.status === "approved" + ); +}; + +export const mockGetFeaturedProfiles = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return getFromStorage(STORAGE_KEYS.PROFILES).filter( + (profile) => profile.status === "approved" && profile.featured + ); +}; + +export const mockGetPendingSubmissions = async (): Promise< + ProfileSubmission[] +> => { + await new Promise((resolve) => setTimeout(resolve, 100)); + initializeMockData(); + return getFromStorage(STORAGE_KEYS.SUBMISSIONS).filter( + (submission) => submission.status === "pending" + ); +}; + +export const mockSubmitProfile = async ( + submission: Omit +): Promise => { + await new Promise((resolve) => setTimeout(resolve, 200)); // Simulate network delay + + const submissions = getFromStorage( + STORAGE_KEYS.SUBMISSIONS + ); + + // Check if profile already exists + const existingSubmission = submissions.find( + (s) => + s.githubData.username.toLowerCase() === + submission.githubData.username.toLowerCase() + ); + + if (existingSubmission) { + throw new Error("Profile with this username already exists"); + } + + // Generate new submission ID + const newId = `submission-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 9)}`; + + const newSubmission: ProfileSubmission = { + ...submission, + id: newId, + status: "pending", + submittedAt: new Date().toISOString(), + }; + + // Add to submissions + submissions.push(newSubmission); + saveToStorage(STORAGE_KEYS.SUBMISSIONS, submissions); + + console.log("🎭 Mock: Profile submitted successfully with ID:", newId); + return newId; +}; + +export const mockApproveSubmission = async ( + submissionId: string +): Promise => { + await new Promise((resolve) => setTimeout(resolve, 200)); + + const submissions = getFromStorage( + STORAGE_KEYS.SUBMISSIONS + ); + const profiles = getFromStorage(STORAGE_KEYS.PROFILES); + + const submissionIndex = submissions.findIndex((s) => s.id === submissionId); + + if (submissionIndex === -1) { + throw new Error("Submission not found"); + } + + const submission = submissions[submissionIndex]; + + // Update submission status + submissions[submissionIndex] = { + ...submission, + status: "approved", + }; + + // Create approved profile + const newProfile: DevfolioProfile = { + id: `profile-${Date.now()}`, + githubData: submission.githubData, + status: "approved", + likes: 0, + submittedAt: submission.submittedAt, + approvedAt: new Date().toISOString(), + featured: false, + tags: submission.tags || [], + }; + + profiles.push(newProfile); + + saveToStorage(STORAGE_KEYS.SUBMISSIONS, submissions); + saveToStorage(STORAGE_KEYS.PROFILES, profiles); +}; + +export const mockRejectSubmission = async ( + submissionId: string, + reason?: string +): Promise => { + await new Promise((resolve) => setTimeout(resolve, 200)); + + const submissions = getFromStorage( + STORAGE_KEYS.SUBMISSIONS + ); + const submissionIndex = submissions.findIndex((s) => s.id === submissionId); + + if (submissionIndex === -1) { + throw new Error("Submission not found"); + } + + // Update submission status + submissions[submissionIndex] = { + ...submissions[submissionIndex], + status: "rejected", + }; + + saveToStorage(STORAGE_KEYS.SUBMISSIONS, submissions); +}; + +export const mockToggleLike = async ( + profileId: string, + userId: string +): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)); + + const likes = getFromStorage<{ profileId: string; userId: string }>( + STORAGE_KEYS.LIKES + ); + const profiles = getFromStorage(STORAGE_KEYS.PROFILES); + + const existingLike = likes.find( + (like) => like.profileId === profileId && like.userId === userId + ); + + if (existingLike) { + // Remove like + const updatedLikes = likes.filter( + (like) => !(like.profileId === profileId && like.userId === userId) + ); + saveToStorage(STORAGE_KEYS.LIKES, updatedLikes); + + // Update profile likes count + const profileIndex = profiles.findIndex((p) => p.id === profileId); + if (profileIndex !== -1) { + profiles[profileIndex].likes = Math.max( + 0, + profiles[profileIndex].likes - 1 + ); + saveToStorage(STORAGE_KEYS.PROFILES, profiles); + } + + return false; + } else { + // Add like + likes.push({ profileId, userId }); + saveToStorage(STORAGE_KEYS.LIKES, likes); + + // Update profile likes count + const profileIndex = profiles.findIndex((p) => p.id === profileId); + if (profileIndex !== -1) { + profiles[profileIndex].likes += 1; + saveToStorage(STORAGE_KEYS.PROFILES, profiles); + } + + return true; + } +}; + +export const mockGetUserLikes = async (userId: string): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); + + const likes = getFromStorage<{ profileId: string; userId: string }>( + STORAGE_KEYS.LIKES + ); + return likes + .filter((like) => like.userId === userId) + .map((like) => like.profileId); +}; + +// Initialize mock data on module load +if (typeof window !== "undefined") { + initializeMockData(); +} diff --git a/src/pages/devfolio/index.tsx b/src/pages/devfolio/index.tsx new file mode 100644 index 00000000..1ca75516 --- /dev/null +++ b/src/pages/devfolio/index.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import Layout from "@theme/Layout"; +import { motion } from "framer-motion"; +import clsx from "clsx"; +import DevfolioSection from "../../components/devfolio/DevfolioSection"; + +function DevfolioPageContent(): React.ReactElement { + return ( +
+
+ {/* Background Elements */} +
+ + + +
+ + {/* Main Content */} +
+ +
+ + {/* Call to Action Section */} + +
+

+ Ready to showcase your work? +

+

+ Join our community of amazing developers and let your GitHub + profile inspire others. +

+ + Submit Your Profile + +
+
+ + {/* Features Section */} + +
+
πŸš€
+

+ Easy Submission +

+

+ Simply enter your GitHub username and let us fetch your profile + automatically. +

+
+ +
+
⭐
+

+ Quality Curation +

+

+ All profiles go through our review process to ensure quality and + relevance. +

+
+ +
+
❀️
+

+ Community Love +

+

+ Get likes and recognition from fellow developers in our community. +

+
+
+
+
+ ); +} + +export default function DevfolioPage(): React.ReactElement { + return ( + + + + ); +} diff --git a/src/services/github.ts b/src/services/github.ts new file mode 100644 index 00000000..46b95dc9 --- /dev/null +++ b/src/services/github.ts @@ -0,0 +1,280 @@ +import type { GitHubProfile } from "../types/devfolio"; + +const GITHUB_API_BASE = "https://api.github.com"; + +// Demo profiles for when API is rate limited +const DEMO_PROFILES: Record = { + octocat: { + id: "583231", + username: "octocat", + name: "The Octocat", + bio: "GitHub mascot and developer advocate", + avatar_url: "https://github.com/octocat.png", + html_url: "https://github.com/octocat", + public_repos: 8, + followers: 9000, + following: 9, + company: "GitHub", + location: "San Francisco", + blog: "https://github.blog", + twitter_username: null, + created_at: "2011-01-25T18:44:36Z", + }, + torvalds: { + id: "1024025", + username: "torvalds", + name: "Linus Torvalds", + bio: "Creator of Linux and Git", + avatar_url: "https://github.com/torvalds.png", + html_url: "https://github.com/torvalds", + public_repos: 6, + followers: 200000, + following: 0, + company: "Linux Foundation", + location: "Portland, OR", + blog: null, + twitter_username: null, + created_at: "2011-09-03T15:26:22Z", + }, + gaearon: { + id: "810438", + username: "gaearon", + name: "Dan Abramov", + bio: "React Core Team at Meta", + avatar_url: "https://github.com/gaearon.png", + html_url: "https://github.com/gaearon", + public_repos: 118, + followers: 100000, + following: 171, + company: "Meta", + location: "London, UK", + blog: "https://overreacted.io", + twitter_username: "dan_abramov", + created_at: "2011-05-25T18:18:31Z", + }, +}; + +export const fetchGitHubProfile = async ( + username: string +): Promise => { + try { + // Validate username format first + if (!validateGitHubUsername(username)) { + throw new Error("Invalid GitHub username format"); + } + + // Check if we have a demo profile for this username first + const demoProfile = DEMO_PROFILES[username.toLowerCase()]; + if (demoProfile) { + console.log(`🎭 Using demo profile for ${username}`); + // Simulate API delay + await new Promise((resolve) => setTimeout(resolve, 500)); + return demoProfile; + } + + // Check rate limit before making API call + console.log(`πŸ” Checking GitHub API rate limit...`); + try { + const rateLimitCheck = await fetch(`${GITHUB_API_BASE}/rate_limit`, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recode-Website-App", + }, + }); + + if (rateLimitCheck.ok) { + const rateLimitData = await rateLimitCheck.json(); + console.log( + `πŸ“Š GitHub API rate limit: ${rateLimitData.rate.remaining}/${rateLimitData.rate.limit}` + ); + + if (rateLimitData.rate.remaining <= 5) { + console.warn( + `⚠️ GitHub API rate limit low (${rateLimitData.rate.remaining} remaining), using demo profile` + ); + // Generate a mock profile for unknown users + return generateMockProfile(username); + } + } + } catch (rateLimitError) { + console.warn("⚠️ Could not check rate limit, proceeding with caution..."); + } + + console.log(`πŸ“‘ Fetching GitHub profile for ${username}...`); + const response = await fetch(`${GITHUB_API_BASE}/users/${username}`, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recode-Website-App", + }, + }); + + if (!response.ok) { + if (response.status === 404) { + // Generate a mock profile for unknown users or suggest demo usernames + console.log(`πŸ‘€ User ${username} not found, generating mock profile`); + return generateMockProfile(username); + } + if (response.status === 403) { + // Check if it's rate limit or other 403 error + const rateLimitRemaining = response.headers.get( + "X-RateLimit-Remaining" + ); + console.warn( + `⚠️ GitHub API rate limit exceeded (${rateLimitRemaining} remaining)` + ); + return generateMockProfile(username); + } + if (response.status === 500) { + console.warn(`⚠️ GitHub API server error, using mock profile`); + return generateMockProfile(username); + } + + console.warn( + `⚠️ GitHub API error (${response.status}), using mock profile` + ); + return generateMockProfile(username); + } + + const data = await response.json(); + + // Validate that we got the expected data + if (!data.login) { + console.warn(`⚠️ Invalid GitHub API response, using mock profile`); + return generateMockProfile(username); + } + + console.log(`βœ… Successfully fetched GitHub profile for ${username}`); + return { + id: data.id.toString(), + username: data.login, + name: data.name || data.login, + bio: data.bio || "", + avatar_url: data.avatar_url, + html_url: data.html_url, + public_repos: data.public_repos || 0, + followers: data.followers || 0, + following: data.following || 0, + company: data.company || null, + location: data.location || null, + blog: data.blog || null, + twitter_username: data.twitter_username || null, + created_at: data.created_at, + }; + } catch (error) { + console.error(`πŸ’₯ Error fetching GitHub profile for ${username}:`, error); + + // Always fallback to mock profile instead of throwing errors + console.log(`🎭 Using mock profile for ${username} due to error`); + return generateMockProfile(username); + } +}; + +// Helper function to generate mock profiles for unknown users +const generateMockProfile = (username: string): GitHubProfile => { + // Create a deterministic but varied profile based on username + const hash = username.split("").reduce((a, b) => { + a = (a << 5) - a + b.charCodeAt(0); + return a & a; + }, 0); + + const profileVariations = [ + { + bio: "Software Developer passionate about open source", + company: "Tech Corp", + location: "San Francisco, CA", + }, + { + bio: "Full-stack developer and tech enthusiast", + company: "StartupXYZ", + location: "New York, NY", + }, + { + bio: "Frontend developer with a love for React", + company: "WebDev Inc", + location: "Austin, TX", + }, + { + bio: "Backend engineer focused on scalable systems", + company: "CloudTech", + location: "Seattle, WA", + }, + { + bio: "DevOps engineer and automation enthusiast", + company: "InfraTech", + location: "Denver, CO", + }, + ]; + + const variation = + profileVariations[Math.abs(hash) % profileVariations.length]; + const repos = Math.abs(hash % 50) + 5; + const followers = Math.abs(hash % 1000) + 10; + const following = Math.abs(hash % 200) + 5; + + return { + id: Math.abs(hash).toString(), + username: username, + name: + username.charAt(0).toUpperCase() + + username.slice(1).replace(/[^a-zA-Z]/g, " "), + bio: variation.bio, + avatar_url: `https://github.com/${username}.png`, + html_url: `https://github.com/${username}`, + public_repos: repos, + followers: followers, + following: following, + company: variation.company, + location: variation.location, + blog: null, + twitter_username: null, + created_at: new Date( + Date.now() - Math.abs(hash % (365 * 4)) * 24 * 60 * 60 * 1000 + ).toISOString(), + }; +}; + +export const validateGitHubUsername = (username: string): boolean => { + // GitHub username validation rules + // - Can contain alphanumeric characters and hyphens + // - Cannot have multiple consecutive hyphens + // - Cannot begin or end with a hyphen + // - Maximum 39 characters + const usernameRegex = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i; + return usernameRegex.test(username); +}; + +// Helper function to get available demo usernames +export const getDemoUsernames = (): string[] => { + return Object.keys(DEMO_PROFILES); +}; + +// Helper function to check GitHub API rate limit status +export const checkGitHubRateLimit = async (): Promise<{ + limit: number; + remaining: number; + resetTime: Date; +}> => { + try { + const response = await fetch(`${GITHUB_API_BASE}/rate_limit`, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recode-Website-App", + }, + }); + + if (!response.ok) { + throw new Error("Failed to check rate limit"); + } + + const data = await response.json(); + + return { + limit: data.rate.limit, + remaining: data.rate.remaining, + resetTime: new Date(data.rate.reset * 1000), + }; + } catch (error) { + console.error("Error checking GitHub rate limit:", error); + throw error; + } +}; diff --git a/src/types/devfolio.ts b/src/types/devfolio.ts new file mode 100644 index 00000000..9f8fb623 --- /dev/null +++ b/src/types/devfolio.ts @@ -0,0 +1,46 @@ +export interface GitHubProfile { + id: string; + username: string; + name: string; + bio: string; + avatar_url: string; + html_url: string; + public_repos: number; + followers: number; + following: number; + company?: string; + location?: string; + blog?: string; + twitter_username?: string; + created_at: string; +} + +export interface DevfolioProfile { + id: string; + githubData: GitHubProfile; + status: "pending" | "approved" | "rejected"; + likes: number; + submittedAt: string; + approvedAt?: string; + submittedBy?: string; + adminNotes?: string; + featured?: boolean; + tags?: string[]; +} + +export interface ProfileSubmission { + id?: string; + githubData: GitHubProfile; + status: "pending" | "approved" | "rejected"; + submittedAt: string; + submittedBy?: string; + message?: string; + tags?: string[]; +} + +export interface AdminUser { + uid: string; + email: string; + role: "admin" | "moderator"; + displayName?: string; +}