├── .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 | Enter 2 Github users
12 | Battle
13 | See the winners
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 |
57 | );
58 | }
59 | }
60 |
61 | function PlayerPreview({ username, onReset, label }) {
62 | return (
63 |
64 | {label}
65 |
66 |
78 |
79 | {close}
80 |
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 |
9 | "nav-link" + (isActive ? " active" : "")}
12 | >
13 | Github Battle
14 |
15 |
16 |
17 |
20 | "nav-link " + (isActive ? " active" : "")
21 | }
22 | >
23 | Popular
24 |
25 |
26 |
27 |
30 | "nav-link " + (isActive ? " active" : "")
31 | }
32 | >
33 | Battle
34 |
35 |
36 |
37 |
38 | {theme === "light" ? moonIcon : sunIcon}
39 |
40 |
41 |
42 |
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 | onUpdateLanguage(e.target.value)}
12 | selected={selected}
13 | >
14 | {languages.map((language) => (
15 |
16 | {language}
17 |
18 | ))}
19 |
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 |
27 |
{location || "unknown"}
28 |
29 |
34 |
35 |
36 |
37 | Name: {login || "n/a"}
38 |
39 |
40 | Company: {company || "n/a"}
41 |
42 |
43 | Followers: {followers}
44 |
45 |
46 | Following: {following}
47 |
48 |
49 | Repositories: {public_repos}
50 |
51 |
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 |
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 |
16 |
17 | By: {login}
18 |
19 | {language && (
20 |
21 | Language: {language}
22 |
23 | )}
24 |
25 | Created: {" "}
26 | {new Date(created_at).toLocaleDateString()}
27 |
28 |
29 | Updated: {" "}
30 | {new Date(updated_at).toLocaleDateString()}
31 |
32 |
33 | Watchers:
34 | {watchers.toLocaleString()}
35 |
36 | {forked_count && (
37 |
38 | Forked: {forked_count.toLocaleString()}
39 |
40 | )}
41 |
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 | {hashtag}
59 | Repository
60 | Stars
61 | Forks
62 | Open Issue
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 | {index + 1}
85 |
86 |
96 | }
97 | >
98 |
108 |
109 |
110 | {stargazers_count}
111 | {forks}
112 | {open_issues}
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 |
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 |
27 | }>
28 |
29 | } />
30 | } />
31 | } />
32 |
33 |
34 |
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 |
--------------------------------------------------------------------------------