├── .gitignore ├── README.md ├── app ├── components │ ├── Battle.jsx │ ├── Hover.jsx │ ├── Loading.jsx │ ├── Nav.jsx │ ├── Popular.jsx │ ├── Results.jsx │ ├── Table.jsx │ ├── Tooltip.jsx │ ├── icons.jsx │ └── withSearchParams.jsx ├── index.css ├── index.html ├── index.jsx └── utils │ └── api.js ├── package-lock.json ├── package.json ├── vercel.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Classic React course project - Github Battle App 2 | 3 | This is the repository for ui.dev's "Classic React" course project. 4 | 5 | For more information on the course, visit **[ui.dev/classic-react](https://ui.dev/classic-react/)**. 6 | 7 | ### Project 8 | 9 | This project is a "Github Battle" app. You'll be able to see the most popular repos for a variety of languages as well as battle two Github users to see who has the better profile. 10 | 11 | You can view the final project at **[battle.ui.dev](http://battle.ui.dev/)** 12 | 13 | ### Branches 14 | 15 | Every (Project) video in the course coincides with a branch. If you get stuck, you can checkout the branch for that video. 16 | -------------------------------------------------------------------------------- /app/components/Battle.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { close } from "./icons"; 4 | import { Link } from "react-router-dom"; 5 | 6 | function Instructions() { 7 | return ( 8 |
9 |

Instructions

10 |
    11 |
  1. Enter 2 Github users
  2. 12 |
  3. Battle
  4. 13 |
  5. See the winners
  6. 14 |
15 |
16 | ); 17 | } 18 | 19 | class PlayerInput extends React.Component { 20 | state = { 21 | username: "", 22 | }; 23 | handleSubmit = (event) => { 24 | event.preventDefault(); 25 | 26 | this.props.onSubmit(this.state.username); 27 | }; 28 | handleChange = (event) => { 29 | this.setState({ 30 | username: event.target.value, 31 | }); 32 | }; 33 | render() { 34 | return ( 35 |
36 | 39 |
40 | 48 | 55 |
56 |
57 | ); 58 | } 59 | } 60 | 61 | function PlayerPreview({ username, onReset, label }) { 62 | return ( 63 |
64 |

{label}

65 |
66 |
67 | {`Avatar 74 | 75 | {username} 76 | 77 |
78 | 81 |
82 |
83 | ); 84 | } 85 | 86 | PlayerPreview.propTypes = { 87 | username: PropTypes.string.isRequired, 88 | onReset: PropTypes.func.isRequired, 89 | label: PropTypes.string.isRequired, 90 | }; 91 | 92 | export default class Battle extends React.Component { 93 | state = { 94 | playerOne: null, 95 | playerTwo: null, 96 | }; 97 | handleSubmit = (id, player) => { 98 | this.setState({ 99 | [id]: player, 100 | }); 101 | }; 102 | handleReset = (id) => { 103 | this.setState({ 104 | [id]: null, 105 | }); 106 | }; 107 | render() { 108 | const { playerOne, playerTwo } = this.state; 109 | const disabled = !playerOne || !playerTwo; 110 | 111 | return ( 112 |
113 |
114 |

Players

115 | 122 | Battle 123 | 124 |
125 |
126 | {playerOne === null ? ( 127 | this.handleSubmit("playerOne", player)} 130 | /> 131 | ) : ( 132 | this.handleReset("playerOne")} 136 | /> 137 | )} 138 | {playerTwo === null ? ( 139 | this.handleSubmit("playerTwo", player)} 142 | /> 143 | ) : ( 144 | this.handleReset("playerTwo")} 148 | /> 149 | )} 150 |
151 | 152 |
153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/components/Hover.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export default class Hover extends React.Component { 4 | state = { 5 | hovering: false, 6 | }; 7 | mouseOver = () => { 8 | this.setState({ hovering: true }); 9 | }; 10 | mouseOut = () => { 11 | this.setState({ hovering: false }); 12 | }; 13 | render() { 14 | return ( 15 |
16 | {this.props.children(this.state.hovering)} 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const styles = { 5 | fontSize: "14px", 6 | position: "absolute", 7 | left: "0", 8 | right: "0", 9 | marginTop: "20px", 10 | textAlign: "center", 11 | }; 12 | 13 | class Delayed extends React.Component { 14 | state = { 15 | show: false, 16 | }; 17 | componentDidMount() { 18 | this.timeout = window.setTimeout(() => { 19 | this.setState({ show: true }); 20 | }, this.props.wait); 21 | } 22 | componentWillUnmount() { 23 | window.clearTimeout(this.timeout); 24 | } 25 | render() { 26 | return this.state.show === true ? this.props.children : null; 27 | } 28 | } 29 | 30 | Delayed.defaultProps = { 31 | wait: 300, 32 | }; 33 | 34 | Delayed.propTypes = { 35 | children: PropTypes.node.isRequired, 36 | wait: PropTypes.number, 37 | }; 38 | 39 | export default class Loading extends React.Component { 40 | state = { 41 | content: this.props.text, 42 | }; 43 | componentDidMount() { 44 | const { speed, text } = this.props; 45 | 46 | this.interval = window.setInterval(() => { 47 | this.state.content === text + "..." 48 | ? this.setState({ content: text }) 49 | : this.setState(({ content }) => ({ content: content + "." })); 50 | }, speed); 51 | } 52 | componentWillUnmount() { 53 | window.clearInterval(this.interval); 54 | } 55 | render() { 56 | return ( 57 | 58 |

{this.state.content}

59 |
60 | ); 61 | } 62 | } 63 | 64 | Loading.propTypes = { 65 | text: PropTypes.string.isRequired, 66 | speed: PropTypes.number.isRequired, 67 | }; 68 | 69 | Loading.defaultProps = { 70 | text: "Loading", 71 | speed: 300, 72 | }; 73 | -------------------------------------------------------------------------------- /app/components/Nav.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | import PropTypes from "prop-types"; 4 | import { sunIcon, moonIcon } from "./icons"; 5 | 6 | export default function Nav({ theme, toggleTheme }) { 7 | return ( 8 | 43 | ); 44 | } 45 | 46 | Nav.propTypes = { 47 | theme: PropTypes.string.isRequired, 48 | toggleTheme: PropTypes.func.isRequired, 49 | }; 50 | -------------------------------------------------------------------------------- /app/components/Popular.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { fetchPopularRepos } from "../utils/api"; 4 | import Table from "./Table"; 5 | 6 | function LanguagesNav({ selected, onUpdateLanguage }) { 7 | const languages = ["All", "JavaScript", "Ruby", "Java", "CSS", "Python"]; 8 | 9 | return ( 10 | 20 | ); 21 | } 22 | 23 | LanguagesNav.propTypes = { 24 | selected: PropTypes.string.isRequired, 25 | onUpdateLanguage: PropTypes.func.isRequired, 26 | }; 27 | 28 | export default class Popular extends React.Component { 29 | state = { 30 | selectedLanguage: "All", 31 | repos: null, 32 | error: null, 33 | }; 34 | componentDidMount() { 35 | this.updateLanguage(this.state.selectedLanguage); 36 | } 37 | updateLanguage = (selectedLanguage) => { 38 | this.setState({ 39 | selectedLanguage, 40 | error: null, 41 | }); 42 | 43 | fetchPopularRepos(selectedLanguage) 44 | .then((repos) => 45 | this.setState({ 46 | repos, 47 | error: null, 48 | }) 49 | ) 50 | .catch((error) => { 51 | console.warn("Error fetching repos: ", error); 52 | 53 | this.setState({ 54 | error: `There was an error fetching the repositories`, 55 | }); 56 | }); 57 | }; 58 | render() { 59 | const { selectedLanguage, repos, error } = this.state; 60 | 61 | return ( 62 |
63 |
64 |

Popular

65 | 69 |
70 | 71 | {error &&

{error}

} 72 | 73 | {repos && } 74 | 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/components/Results.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { battle } from "../utils/api"; 3 | import PropTypes from "prop-types"; 4 | import Loading from "./Loading"; 5 | import withSearchParams from "./withSearchParams"; 6 | import { Link } from "react-router-dom"; 7 | 8 | function Card({ profile }) { 9 | const { 10 | login, 11 | avatar_url, 12 | html_url, 13 | followers, 14 | following, 15 | public_repos, 16 | location, 17 | company, 18 | } = profile; 19 | 20 | return ( 21 |
22 |
23 |
24 |

25 | {login} 26 |

27 |

{location || "unknown"}

28 |
29 | {`Avatar 34 |
35 | 52 |
53 | ); 54 | } 55 | 56 | Card.propTypes = { 57 | profile: PropTypes.shape({ 58 | login: PropTypes.string.isRequired, 59 | avatar_url: PropTypes.string.isRequired, 60 | html_url: PropTypes.string.isRequired, 61 | followers: PropTypes.number.isRequired, 62 | following: PropTypes.number.isRequired, 63 | repositories: PropTypes.number, 64 | location: PropTypes.string, 65 | company: PropTypes.string, 66 | }).isRequired, 67 | }; 68 | 69 | class Results extends React.Component { 70 | state = { 71 | winner: null, 72 | loser: null, 73 | error: null, 74 | loading: true, 75 | }; 76 | componentDidMount() { 77 | const sp = this.props.router.searchParams; 78 | const playerOne = sp.get("playerOne"); 79 | const playerTwo = sp.get("playerTwo"); 80 | 81 | battle([playerOne, playerTwo]) 82 | .then((players) => { 83 | this.setState({ 84 | winner: players[0], 85 | loser: players[1], 86 | error: null, 87 | loading: false, 88 | }); 89 | }) 90 | .catch(({ message }) => { 91 | this.setState({ 92 | error: message, 93 | loading: false, 94 | }); 95 | }); 96 | } 97 | render() { 98 | const { winner, loser, error, loading } = this.state; 99 | 100 | if (loading === true) { 101 | return ; 102 | } 103 | 104 | if (error) { 105 | return

{error}

; 106 | } 107 | 108 | return ( 109 |
110 |
111 |

Results

112 | 113 | Reset 114 | 115 |
116 |
117 |
118 | 119 |

120 | 121 | {winner.score === loser.score ? "Tie" : "Winner"}{" "} 122 | {winner.score.toLocaleString()} 123 | 124 | {winner.score !== loser.score && ( 125 | Certificate 130 | )} 131 |

132 |
133 |
134 | 135 |

136 | 137 | {winner.score === loser.score ? "Tie" : "Loser"}{" "} 138 | {loser.score.toLocaleString()} 139 | 140 |

141 |
142 |
143 |
144 | ); 145 | } 146 | } 147 | 148 | export default withSearchParams(Results); 149 | -------------------------------------------------------------------------------- /app/components/Table.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { hashtag } from "./icons"; 4 | import Tooltip from "./Tooltip"; 5 | 6 | function MoreInfo({ 7 | created_at, 8 | forked_count, 9 | language, 10 | updated_at, 11 | watchers, 12 | login, 13 | }) { 14 | return ( 15 | 42 | ); 43 | } 44 | 45 | MoreInfo.propTypes = { 46 | created_at: PropTypes.string.isRequired, 47 | language: PropTypes.string, 48 | updated_at: PropTypes.string.isRequired, 49 | watchers: PropTypes.number.isRequired, 50 | type: PropTypes.string.isRequired, 51 | login: PropTypes.string.isRequired, 52 | }; 53 | 54 | function TableHead() { 55 | return ( 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | function TableRow({ 69 | index, 70 | owner, 71 | stargazers_count, 72 | forks, 73 | open_issues, 74 | name, 75 | created_at, 76 | updated_at, 77 | language, 78 | watchers, 79 | }) { 80 | const { login, avatar_url, type } = owner; 81 | 82 | return ( 83 | 84 | 85 | 110 | 111 | 112 | 113 | 114 | ); 115 | } 116 | 117 | TableRow.propTypes = { 118 | index: PropTypes.number.isRequired, 119 | owner: PropTypes.object.isRequired, 120 | stargazers_count: PropTypes.number.isRequired, 121 | forks: PropTypes.number.isRequired, 122 | open_issues: PropTypes.number.isRequired, 123 | name: PropTypes.string.isRequired, 124 | }; 125 | 126 | export default function Table({ repos }) { 127 | return ( 128 |
{hashtag}RepositoryStarsForksOpen Issue
{index + 1} 86 | 96 | } 97 | > 98 |
99 | {`Avatar 106 | {name} 107 |
108 |
109 |
{stargazers_count}{forks}{open_issues}
129 | 130 | 131 | {repos.map((repo, index) => { 132 | return ; 133 | })} 134 | 135 |
136 | ); 137 | } 138 | 139 | Table.propTypes = { 140 | repos: PropTypes.array.isRequired, 141 | }; 142 | -------------------------------------------------------------------------------- /app/components/Tooltip.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Hover from "./Hover"; 4 | 5 | const container = { 6 | position: "relative", 7 | display: "flex", 8 | }; 9 | 10 | export default function Tooltip({ children, element }) { 11 | return ( 12 | 13 | {(hovering) => { 14 | return ( 15 |
16 | {hovering === true && element} 17 | {children} 18 |
19 | ); 20 | }} 21 |
22 | ); 23 | } 24 | 25 | Tooltip.propTypes = { 26 | children: PropTypes.node.isRequired, 27 | element: PropTypes.node.isRequired, 28 | }; 29 | -------------------------------------------------------------------------------- /app/components/icons.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const hashtag = ( 4 | 5 | 11 | 12 | ); 13 | 14 | export const close = ( 15 | 16 | 22 | 23 | ); 24 | 25 | export const sunIcon = ( 26 | 27 | 33 | 34 | ); 35 | 36 | export const moonIcon = ( 37 | 38 | 44 | 45 | ); 46 | -------------------------------------------------------------------------------- /app/components/withSearchParams.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSearchParams } from "react-router-dom"; 3 | 4 | export default function withSearchParams(Component) { 5 | return function ComponentWithSearchParams(props) { 6 | const [searchParams] = useSearchParams(); 7 | 8 | return ; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /app/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --coal: #0f0d0e; 3 | --charcoal: #231f20; 4 | --charcoal-10: rgba(35, 31, 32, 0.1); 5 | --charcoal-50: rgba(35, 31, 32, 0.5); 6 | --gray: #262522; 7 | --yellow: #fcba28; 8 | --pink: #f38ba3; 9 | --green: #0ba95b; 10 | --purple: #7b5ea7; 11 | --biege-body: #fffbe3; 12 | --biege: #f9f4da; 13 | --biege-10: rgba(249, 244, 218, 0.1); 14 | --biege-50: rgba(249, 244, 218, 0.5); 15 | --blue: #12b5e5; 16 | --orange: #fc7428; 17 | --red: #ed203d; 18 | } 19 | 20 | html, 21 | body, 22 | #app { 23 | margin: 0; 24 | padding: 0; 25 | width: 100%; 26 | height: 100%; 27 | } 28 | 29 | body { 30 | font-family: "Outfit", sans-serif; 31 | background: var(--biege-body); 32 | color: var(--charcoal); 33 | } 34 | 35 | ul { 36 | padding: 0; 37 | } 38 | 39 | ul li { 40 | display: inline-block; 41 | } 42 | 43 | a { 44 | color: inherit; 45 | text-decoration: none; 46 | } 47 | 48 | .dark { 49 | background: var(--coal); 50 | color: var(--biege); 51 | } 52 | 53 | .dark { 54 | --bg: var(--coal); 55 | --bg-alt: var(--charcoal); 56 | --color: var(--biege); 57 | --color-muted: var(--biege-50); 58 | --color-inverse: var(--charcoal); 59 | --border-color: var(--biege-10); 60 | --accent: var(--yellow); 61 | } 62 | 63 | .light { 64 | --bg: var(--biege); 65 | --bg-alt: var(--biege); 66 | --color: var(--charcoal); 67 | --color-muted: var(--charcoal-50); 68 | --color-inverse: var(--biege); 69 | --border-color: var(--charcoal-10); 70 | --accent: var(--purple); 71 | } 72 | 73 | .dark, 74 | .light { 75 | min-height: 100%; 76 | } 77 | 78 | /* Elements */ 79 | 80 | h1 { 81 | font-family: "Paytone One", sans-serif; 82 | text-transform: uppercase; 83 | font-size: 24px; 84 | } 85 | 86 | h2 { 87 | font-size: 20px; 88 | font-weight: 400; 89 | margin: 0; 90 | margin-bottom: 24px; 91 | } 92 | 93 | h3 { 94 | font-size: 18px; 95 | font-weight: 400; 96 | margin: 0; 97 | margin-bottom: 24px; 98 | } 99 | 100 | h4 { 101 | font-size: 16px; 102 | text-transform: uppercase; 103 | font-weight: 700; 104 | margin: 0; 105 | } 106 | 107 | /* Components */ 108 | 109 | select { 110 | background: var(--bg-alt); 111 | color: var(--accent); 112 | border: none; 113 | border-radius: 8px; 114 | display: flex; 115 | justify-content: space-between; 116 | border: 10px solid var(--bg-alt); 117 | } 118 | 119 | select:focus { 120 | outline: 1px solid var(--border-color); 121 | } 122 | 123 | button, 124 | .btn { 125 | appearance: none; 126 | display: inline-flex; 127 | text-align: center; 128 | justify-content: center; 129 | padding: 10px 16px; 130 | border-radius: 8px; 131 | border: none; 132 | font-weight: 500; 133 | font-size: 14px; 134 | transition: all 200ms ease-in-out; 135 | cursor: pointer; 136 | } 137 | 138 | button, 139 | .btn:hover { 140 | opacity: 0.8; 141 | } 142 | 143 | button.primary, 144 | .btn.primary { 145 | background: var(--accent); 146 | color: var(--bg-alt); 147 | text-transform: uppercase; 148 | font-weight: 900; 149 | font-size: 12px; 150 | } 151 | 152 | button.secondary, 153 | .btn.secondary { 154 | background: var(--bg-alt); 155 | color: var(--accent); 156 | } 157 | 158 | button.link, 159 | .btn.link { 160 | appearance: none; 161 | background: transparent; 162 | color: var(--accent); 163 | padding: 10px; 164 | text-decoration: underline; 165 | display: inline-block; 166 | } 167 | 168 | button.large, 169 | .btn.large { 170 | font-size: 14px; 171 | padding: 16px; 172 | } 173 | 174 | /* Form */ 175 | 176 | label { 177 | font-size: 14px; 178 | margin-bottom: 8px; 179 | display: block; 180 | } 181 | 182 | input { 183 | appearance: none; 184 | border: 1px solid var(--border-color); 185 | background-color: transparent; 186 | border-radius: 4px; 187 | padding: 10px; 188 | color: var(--color); 189 | } 190 | 191 | input::placeholder { 192 | color: var(--color-muted); 193 | } 194 | 195 | input:focus { 196 | border-color: var(--color); 197 | outline: none; 198 | } 199 | 200 | /* Components */ 201 | 202 | table { 203 | width: 100%; 204 | border-collapse: collapse; 205 | } 206 | 207 | thead tr { 208 | font-size: 12px; 209 | text-transform: uppercase; 210 | font-weight: bold; 211 | } 212 | 213 | tr { 214 | padding: 4px; 215 | } 216 | 217 | th { 218 | height: 40px; 219 | position: sticky; 220 | top: 0; 221 | 222 | color: var(--color); 223 | z-index: 1; 224 | border-bottom: 1px solid var(--border-color); 225 | } 226 | 227 | td { 228 | height: 48px; 229 | text-align: center; 230 | border-bottom: 1px solid var(--border-color); 231 | } 232 | 233 | th:first-child, 234 | th:nth-child(2), 235 | td:first-child, 236 | td:nth-child(2) { 237 | text-align: left; 238 | } 239 | 240 | th:last-child, 241 | td:last-child { 242 | text-align: right; 243 | } 244 | 245 | td a { 246 | color: inherit; 247 | } 248 | 249 | tbody td { 250 | font-size: 14px; 251 | padding: 4px; 252 | } 253 | 254 | tbody td:first-child { 255 | opacity: 0.5; 256 | } 257 | 258 | /* Layout */ 259 | 260 | .container { 261 | padding: 5ch; 262 | margin: 0 auto; 263 | max-width: 90ch; 264 | padding-left: 16px; 265 | padding-right: 16px; 266 | } 267 | 268 | .left-center { 269 | display: flex; 270 | gap: 16px; 271 | align-items: center; 272 | } 273 | 274 | .split { 275 | display: flex; 276 | justify-content: space-between; 277 | align-items: center; 278 | } 279 | 280 | .text-center { 281 | text-align: center; 282 | } 283 | 284 | .stack { 285 | --space: 8px; 286 | display: grid; 287 | gap: var(--space); 288 | } 289 | 290 | .row { 291 | --space: 32px; 292 | display: flex; 293 | align-items: center; 294 | gap: var(--space); 295 | } 296 | 297 | .gap-md { 298 | --space: 16px; 299 | } 300 | 301 | .grid { 302 | display: grid; 303 | grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); 304 | gap: 32px; 305 | } 306 | 307 | .avatar { 308 | --size: 32px; 309 | --radius: 4px; 310 | aspect-ratio: 1/1; 311 | width: var(--size); 312 | height: var(--size); 313 | border-radius: var(--radius); 314 | } 315 | 316 | .avatar.large { 317 | --size: 48px; 318 | } 319 | 320 | .disabled { 321 | pointer-events: none; 322 | cursor: not-allowed; 323 | opacity: 0.5; 324 | } 325 | 326 | .animate-in { 327 | animation: fade-in 0.5s ease-in-out; 328 | } 329 | 330 | @keyframes fade-in { 331 | from { 332 | transform: translateY(40px); 333 | opacity: 0; 334 | } 335 | to { 336 | transform: translateY(0px); 337 | opacity: 1; 338 | } 339 | } 340 | 341 | /* Project Specific Styles */ 342 | 343 | .nav-link { 344 | opacity: 1; 345 | transition: color 0.2s ease-in-out; 346 | } 347 | 348 | .nav-link.active { 349 | color: var(--accent); 350 | } 351 | 352 | .nav-link:hover { 353 | color: var(--accent); 354 | } 355 | 356 | .row.split { 357 | padding: 8px; 358 | border-radius: 8px; 359 | } 360 | 361 | .btn.icon { 362 | --btn-color: var(--color); 363 | color: var(--btn-color); 364 | display: grid; 365 | place-items: center; 366 | aspect-ratio: 1/1; 367 | border: 1px solid var(--border-color); 368 | cursor: pointer; 369 | padding: 10px; 370 | transition: background-color 0.2s ease-in-out; 371 | } 372 | 373 | .btn.icon:hover { 374 | --btn-color: var(--accent); 375 | } 376 | 377 | .input-row { 378 | position: relative; 379 | display: flex; 380 | width: 100%; 381 | } 382 | 383 | .input-row input { 384 | flex: 1; 385 | } 386 | 387 | .input-row button { 388 | position: absolute; 389 | top: 50%; 390 | right: 10px; 391 | transform: translateY(-50%); 392 | } 393 | 394 | .main-stack { 395 | --space: 40px; 396 | } 397 | 398 | .player-label { 399 | font-size: 14px; 400 | font-weight: 400; 401 | display: block; 402 | margin: 0; 403 | margin-bottom: 8px; 404 | } 405 | 406 | .instructions-container { 407 | border: 1px solid var(--border-color); 408 | padding: 24px; 409 | border-radius: 16px; 410 | } 411 | 412 | .instructions-container ol { 413 | display: grid; 414 | gap: 8px; 415 | list-style-type: decimal; 416 | list-style-position: inside; 417 | margin: 0; 418 | padding: 0; 419 | font-size: 14px; 420 | opacity: 0.75; 421 | } 422 | 423 | .tooltip { 424 | position: relative; 425 | box-sizing: border-box; 426 | position: absolute; 427 | width: 240px; 428 | bottom: 100%; 429 | border-radius: 3px; 430 | padding: 12px; 431 | margin-bottom: 5px; 432 | text-align: center; 433 | font-size: 14px; 434 | z-index: 2; 435 | background: var(--bg-alt); 436 | color: var(--color-muted); 437 | } 438 | 439 | .tooltip span:nth-child(2) { 440 | color: var(--color); 441 | } 442 | 443 | .tooltip:after { 444 | content: ""; 445 | background-color: var(--bg-alt); 446 | display: block; 447 | width: 12px; 448 | height: 12px; 449 | position: absolute; 450 | left: 50%; 451 | transform: translateX(-50%) rotate(45deg); 452 | bottom: -6px; 453 | } 454 | 455 | .results { 456 | display: flex; 457 | text-align: center; 458 | justify-content: center; 459 | align-items: start; 460 | gap: 16px; 461 | } 462 | 463 | .results img { 464 | position: relative; 465 | top: -32px; 466 | } 467 | 468 | .card { 469 | padding: 40px; 470 | border-radius: 16px; 471 | font-size: 14px; 472 | background-color: var(--bg-alt); 473 | } 474 | 475 | .card p { 476 | margin: 0; 477 | opacity: 0.75; 478 | position: relative; 479 | } 480 | 481 | .card header { 482 | padding-bottom: 16px; 483 | margin-bottom: 16px; 484 | border-bottom: 1px solid var(--border-color); 485 | } 486 | 487 | .card li > *:not(:first-child) { 488 | opacity: 0.75; 489 | } 490 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Github Battle 5 | 11 | 12 | 13 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /app/index.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; 5 | import Nav from "./components/Nav"; 6 | import Loading from "./components/Loading"; 7 | 8 | const Results = React.lazy(() => import("./components/Results")); 9 | const Popular = React.lazy(() => import("./components/Popular")); 10 | const Battle = React.lazy(() => import("./components/Battle")); 11 | 12 | class App extends React.Component { 13 | state = { 14 | theme: "light", 15 | }; 16 | toggleTheme = () => { 17 | this.setState(({ theme }) => ({ 18 | theme: theme === "light" ? "dark" : "light", 19 | })); 20 | }; 21 | render() { 22 | return ( 23 | 24 |
25 |
26 |
35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | const rootElement = document.getElementById("app"); 42 | const root = ReactDOM.createRoot(rootElement); 43 | root.render(); 44 | -------------------------------------------------------------------------------- /app/utils/api.js: -------------------------------------------------------------------------------- 1 | const id = "YOUR_CLIENT_ID"; 2 | const sec = "YOUR_SECRET_ID"; 3 | const params = `?client_id=${id}&client_secret=${sec}`; 4 | 5 | export function fetchPopularRepos(language) { 6 | const endpoint = window.encodeURI( 7 | `https://api.github.com/search/repositories?q=stars:>1+language:${language}&sort=stars&order=desc&type=Repositories` 8 | ); 9 | 10 | return fetch(endpoint) 11 | .then((res) => res.json()) 12 | .then((data) => { 13 | if (!data.items) { 14 | throw new Error(data.message); 15 | } 16 | 17 | return data.items; 18 | }); 19 | } 20 | 21 | function getErrorMsg(message, username) { 22 | if (message === "Not Found") { 23 | return `${username} doesn't exist`; 24 | } 25 | 26 | return message; 27 | } 28 | 29 | function getProfile(username) { 30 | return fetch(`https://api.github.com/users/${username}${params}`) 31 | .then((res) => res.json()) 32 | .then((profile) => { 33 | if (profile.message) { 34 | throw new Error(getErrorMsg(profile.message, username)); 35 | } 36 | 37 | return profile; 38 | }); 39 | } 40 | 41 | function getRepos(username) { 42 | return fetch( 43 | `https://api.github.com/users/${username}/repos${params}&per_page=100` 44 | ) 45 | .then((res) => res.json()) 46 | .then((repos) => { 47 | if (repos.message) { 48 | throw new Error(getErrorMsg(repos.message, username)); 49 | } 50 | 51 | return repos; 52 | }); 53 | } 54 | 55 | function getStarCount(repos) { 56 | return repos.reduce((count, { stargazers_count }) => { 57 | return count + stargazers_count; 58 | }, 0); 59 | } 60 | 61 | function calculateScore(followers, repos) { 62 | return followers * 3 + getStarCount(repos); 63 | } 64 | 65 | function getUserData(player) { 66 | return Promise.all([getProfile(player), getRepos(player)]).then( 67 | ([profile, repos]) => ({ 68 | profile, 69 | score: calculateScore(profile.followers, repos), 70 | }) 71 | ); 72 | } 73 | 74 | function sortPlayers(players) { 75 | return players.sort((a, b) => b.score - a.score); 76 | } 77 | 78 | export function battle(players) { 79 | return Promise.all([getUserData(players[0]), getUserData(players[1])]).then( 80 | sortPlayers 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-battle", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack serve", 8 | "build": "NODE_ENV='production' webpack", 9 | "build-for-window": "set NODE_ENV=production&&webpack" 10 | }, 11 | "babel": { 12 | "presets": [ 13 | "@babel/preset-env", 14 | "@babel/preset-react" 15 | ] 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "prop-types": "^15.8.1", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-router-dom": "^6.3.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.18.10", 28 | "@babel/preset-env": "^7.18.10", 29 | "@babel/preset-react": "^7.18.6", 30 | "babel-loader": "^8.2.5", 31 | "css-loader": "^6.7.1", 32 | "html-webpack-plugin": "^5.5.0", 33 | "style-loader": "^3.3.1", 34 | "webpack": "^5.74.0", 35 | "webpack-cli": "^4.10.0", 36 | "webpack-dev-server": "^4.9.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: "./app/index.jsx", 6 | output: { 7 | path: path.resolve(__dirname, "dist"), 8 | filename: "index_bundle.js", 9 | publicPath: "/", 10 | }, 11 | module: { 12 | rules: [ 13 | { test: /\.(js|jsx)$/, use: "babel-loader" }, 14 | { test: /\.css$/, use: ["style-loader", "css-loader"] }, 15 | ], 16 | }, 17 | resolve: { 18 | extensions: [".jsx", "..."], 19 | }, 20 | mode: process.env.NODE_ENV === "production" ? "production" : "development", 21 | plugins: [ 22 | new HtmlWebpackPlugin({ 23 | template: "app/index.html", 24 | }), 25 | ], 26 | devServer: { 27 | historyApiFallback: true, 28 | }, 29 | }; 30 | --------------------------------------------------------------------------------