├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── deploy.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── [user].tsx ├── _app.js ├── api │ ├── clear.ts │ ├── progress.ts │ ├── render.ts │ └── retry.ts ├── faq.tsx └── index.tsx ├── public ├── fav.png ├── flash.png ├── promo1.png ├── promo2.png ├── promo3.png └── vercel.svg ├── remotion ├── Contrib.tsx ├── Decoration.tsx ├── DecorativeLines.tsx ├── EndCard.tsx ├── EndCard2.tsx ├── Flashcard.tsx ├── Green.tsx ├── IDidALot.tsx ├── Issues.tsx ├── Lang.tsx ├── Main.tsx ├── ManyLanguages.tsx ├── TitleCard.tsx ├── TopWeekday.tsx ├── TotalContributions.tsx ├── Transition.tsx ├── TransitionDemo.tsx ├── Video.tsx ├── all.ts ├── font.ts ├── icons │ ├── C.tsx │ ├── Dart.tsx │ ├── Elixir.tsx │ ├── Erlang.tsx │ ├── Flutter.tsx │ ├── Go.tsx │ ├── Haskell.tsx │ ├── Javascript.tsx │ ├── Php.tsx │ ├── Python.tsx │ ├── Ruby.tsx │ ├── Rust.tsx │ ├── Scala.tsx │ ├── Swift.tsx │ ├── Typescript.tsx │ └── Zig.tsx ├── index.tsx ├── letters │ ├── github.tsx │ ├── one.tsx │ ├── two.tsx │ └── zero.tsx └── map-response-to-stats.ts ├── src ├── components │ ├── Download.tsx │ ├── Footer.tsx │ ├── Rerender.tsx │ ├── button.ts │ └── spinner.tsx ├── config.ts ├── db │ ├── cache.ts │ ├── mongo.ts │ └── renders.ts ├── get-account-count.ts ├── get-all.ts ├── get-random-aws-account.ts ├── get-render-or-make.ts ├── get-render-progress-with-finality.ts ├── get-stats-or-fetch.ts ├── palette.ts ├── regions.ts ├── set-env-for-key.ts └── use-window-size.ts ├── styles └── globals.css ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | AWS_KEY_1= 2 | AWS_SECRET_1= 3 | AWS_KEY_2= 4 | AWS_SECRET_2= 5 | GITHUB_TOKEN= 6 | MONGO_URL= 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | .env 36 | tsconfig.tsbuildinfo 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | This is the repo for the 2021 version. Go to the 2022 version! 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | **Try it out live:** https://2021.githubunwrapped.com 12 | 13 | A platform that generates a year in review video for each GitHub user. Built with Next.JS, Remotion and AWS Lambda. 14 | 15 | ## Scaling strategy 16 | 17 | To allow hundreds of people to render their video at the same time, we applied multiple strategies for scaling: 18 | 19 | - Caching the video whenever possible. Before each render, a database lock is created to avoid multiple renders for the same GitHub user to be accidentally created. 20 | - A random region will be selected for each render to distribute renders to avoid hitting the [concurrency limit](https://www.remotion.dev/docs/lambda/troubleshooting/rate-limit). 21 | - Two AWS sub-accounts are used for rendering which each have their own concurrency limit of 1000 functions in parallel per region. In hindsight, it would have been easier to ask AWS for an increase. 22 | 23 | ## Setup 24 | 25 | 1. Run `yarn` to install dependencies. 26 | 2. Rename `.env.example` to `.env` 27 | 3. Set up your AWS account according to the [Remotion Lambda Setup guide](https://remotion.dev/docs/lambda/setup). We use multiple accounts for load-balancing: 28 | - Use `AWS_KEY_1` instead of `REMOTION_AWS_ACCESS_KEY_ID` and `AWS_SECRET_1` instead of `REMOTION_AWS_SECRET_ACCESS_KEY`. 29 | - You can use `AWS_KEY_2` and `AWS_SECRET_2` to load-balance between two accounts, or paste the same credentials as before to use the same account. 30 | - In `src/set-env-for-key.ts`, we rotate the environment variables. 31 | 4. Deploy the functions into your AWS account(s): 32 | ``` 33 | yarn deploy 34 | ``` 35 | 5. For caching the videos and GitHub API responses, set up a MongoDB (I use a free MongoDB Atlas Cloud instance) to save the videos. Set the connection string as `MONGO_URL` 36 | 6. For fetching data from GitHub, create a personal access token in your user settings and set it as `GITHUB_TOKEN`. 37 | 38 | You now have all environment variables. 39 | 40 | Run the web app: 41 | 42 | ```console 43 | npm run dev 44 | ``` 45 | 46 | Edit the template in the Remotion preview: 47 | 48 | ```console 49 | npm run preview 50 | ``` 51 | 52 | To deploy, connect your repository to Vercel or Heroku. 53 | 54 | Don't forget to also set the environment variables there too. 55 | 56 | ## License 57 | 58 | The code in this repository: Licensed under MIT. 59 | The Remotion library: Notice that for some entities a company license is needed. Read the terms [here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md#company-license). 60 | -------------------------------------------------------------------------------- /deploy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deployFunction, 3 | deploySite, 4 | getOrCreateBucket, 5 | } from "@remotion/lambda"; 6 | import dotenv from "dotenv"; 7 | import path from "path"; 8 | import { SITE_ID } from "./src/config"; 9 | import { getAccountCount } from "./src/get-account-count"; 10 | import { usedRegions } from "./src/regions"; 11 | import { setEnvForKey } from "./src/set-env-for-key"; 12 | dotenv.config(); 13 | 14 | const count = getAccountCount(); 15 | console.log(`Found ${count} accounts. Deploying...`); 16 | 17 | const execute = async () => { 18 | for (let i = 1; i <= count; i++) { 19 | for (const region of usedRegions) { 20 | setEnvForKey(i); 21 | const { functionName, alreadyExisted } = await deployFunction({ 22 | architecture: "arm64", 23 | createCloudWatchLogGroup: true, 24 | memorySizeInMb: 2048, 25 | timeoutInSeconds: 240, 26 | region, 27 | }); 28 | console.log( 29 | `${ 30 | alreadyExisted ? "Ensured" : "Deployed" 31 | } function "${functionName}" to ${region} in account ${i}` 32 | ); 33 | const { bucketName } = await getOrCreateBucket({ region }); 34 | const { serveUrl } = await deploySite({ 35 | siteName: SITE_ID, 36 | bucketName, 37 | entryPoint: path.join(process.cwd(), "remotion/index.tsx"), 38 | region, 39 | }); 40 | console.log( 41 | `Deployed site to ${region} in account ${i} under ${serveUrl}` 42 | ); 43 | } 44 | } 45 | }; 46 | 47 | execute() 48 | .then(() => process.exit(0)) 49 | .catch((err) => { 50 | console.error(err); 51 | process.exit(1); 52 | }); 53 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: false, 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-unwrapped", 3 | "private": true, 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "preview": "npx remotion preview remotion/index.tsx", 11 | "render": "npx remotion render remotion/index.tsx main out/unwrapped.mp4", 12 | "deploy": "ts-node deploy.ts" 13 | }, 14 | "license": "MIT", 15 | "dependencies": { 16 | "@remotion/bundler": "3.2.9", 17 | "@remotion/cli": "3.2.9", 18 | "@remotion/lambda": "3.2.9", 19 | "@remotion/player": "3.2.9", 20 | "@remotion/renderer": "3.2.9", 21 | "@types/lodash.chunk": "^4.2.6", 22 | "@types/lodash.groupby": "^4.6.6", 23 | "dotenv": "^10.0.0", 24 | "lodash.chunk": "^4.2.0", 25 | "lodash.groupby": "^4.6.0", 26 | "make-color-more-chill": "^0.2.2", 27 | "mongodb": "^4.2.1", 28 | "next": "12.3.1", 29 | "polished": "^4.1.3", 30 | "prettier": "^2.5.1", 31 | "prettier-plugin-organize-imports": "^2.3.4", 32 | "react": "17.0.2", 33 | "react-dom": "17.0.2", 34 | "remotion": "3.2.9", 35 | "simplex-noise": "3.0.0", 36 | "ts-node": "^10.9.1" 37 | }, 38 | "devDependencies": { 39 | "@types/react": "^17.0.37", 40 | "eslint": "8.4.1", 41 | "eslint-config-next": "12.3.1", 42 | "typescript": "^4.5.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { AbsoluteFill } from "remotion"; 3 | import { getFont } from "../remotion/font"; 4 | import { backButton } from "../src/components/button"; 5 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 6 | 7 | getFont(); 8 | 9 | const Spinner: React.FC = () => { 10 | return ( 11 | 20 |

User not found!

21 |

This doesn{"'"}t seem to be a GitHub user. Probably just a typo!

22 | 23 | 24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default Spinner; 31 | -------------------------------------------------------------------------------- /pages/[user].tsx: -------------------------------------------------------------------------------- 1 | import { Player, PlayerRef } from "@remotion/player"; 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | import { useRouter } from "next/router"; 5 | import { transparentize } from "polished"; 6 | import React, { useCallback, useEffect, useRef, useState } from "react"; 7 | import { AbsoluteFill } from "remotion"; 8 | import { getFont } from "../remotion/font"; 9 | import { Main } from "../remotion/Main"; 10 | import { CompactStats } from "../remotion/map-response-to-stats"; 11 | import { backButton } from "../src/components/button"; 12 | import Download from "../src/components/Download"; 13 | import { Footer, FOOTER_HEIGHT } from "../src/components/Footer"; 14 | import Rerender from "../src/components/Rerender"; 15 | import Spinner from "../src/components/spinner"; 16 | import { getStatsOrFetch } from "../src/get-stats-or-fetch"; 17 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 18 | import { RenderProgressOrFinality } from "./api/progress"; 19 | 20 | export async function getStaticPaths() { 21 | return { paths: [], fallback: true }; 22 | } 23 | 24 | const SafeHydrate: React.FC = ({ children }) => { 25 | return ( 26 |
27 | {typeof window === "undefined" ? null : children} 28 |
29 | ); 30 | }; 31 | 32 | const iosSafari = () => { 33 | if (typeof window === "undefined") { 34 | return false; 35 | } 36 | const ua = window.navigator.userAgent; 37 | const iOS = !!ua.match(/iPad/i) || !!ua.match(/iPhone/i); 38 | const webkit = !!ua.match(/WebKit/i); 39 | return iOS && webkit; 40 | }; 41 | 42 | export const getStaticProps = async ({ 43 | params, 44 | }: { 45 | params: { user: string }; 46 | }) => { 47 | const { user } = params; 48 | 49 | if (user.length > 40) { 50 | console.log("Username too long"); 51 | return { notFound: true }; 52 | } 53 | 54 | try { 55 | const compact = await getStatsOrFetch(user); 56 | if (!compact) { 57 | return { notFound: true }; 58 | } 59 | return { 60 | props: { 61 | user: compact, 62 | }, 63 | }; 64 | } catch (error) { 65 | console.error(error); 66 | return { notFound: true }; 67 | } 68 | }; 69 | 70 | const style: React.CSSProperties = { 71 | display: "flex", 72 | flexDirection: "column", 73 | maxWidth: 800, 74 | margin: "auto", 75 | paddingLeft: 20, 76 | paddingRight: 20, 77 | }; 78 | 79 | const abs: React.CSSProperties = { 80 | backgroundColor: BACKGROUND_COLOR, 81 | width: "100%", 82 | position: "relative", 83 | }; 84 | 85 | const container: React.CSSProperties = { 86 | minHeight: `calc(100vh - ${FOOTER_HEIGHT}px)`, 87 | width: "100%", 88 | position: "relative", 89 | }; 90 | 91 | const title: React.CSSProperties = { 92 | fontFamily: "Jelle", 93 | textAlign: "center", 94 | color: BASE_COLOR, 95 | marginBottom: 0, 96 | }; 97 | 98 | const subtitle: React.CSSProperties = { 99 | fontFamily: "Jelle", 100 | textAlign: "center", 101 | fontSize: 20, 102 | color: "red", 103 | marginTop: 14, 104 | marginBottom: 0, 105 | }; 106 | 107 | const layout: React.CSSProperties = { 108 | margin: "auto", 109 | width: "100%", 110 | display: "flex", 111 | flexDirection: "column", 112 | }; 113 | 114 | getFont(); 115 | 116 | export default function User(props: { user: CompactStats | null }) { 117 | const [ready, setReady] = useState(false); 118 | const [playing, setPlaying] = useState(false); 119 | const player = useRef(null); 120 | const ref = useRef(null); 121 | const { user } = props; 122 | 123 | const router = useRouter(); 124 | const username = ([] as string[]).concat(router.query.user ?? "")[0]; 125 | 126 | useEffect(() => { 127 | if (!ready || !user || !player.current) { 128 | return; 129 | } 130 | player.current.addEventListener("pause", () => { 131 | setPlaying(false); 132 | }); 133 | player.current.addEventListener("ended", () => { 134 | setPlaying(false); 135 | }); 136 | player.current.addEventListener("play", () => { 137 | setPlaying(true); 138 | }); 139 | }, [ready, user]); 140 | 141 | useEffect(() => { 142 | setReady(true); 143 | }, []); 144 | 145 | const [downloadProgress, setDownloadProgress] = 146 | useState(null); 147 | const [retrying, setRetrying] = useState(false); 148 | 149 | const pollProgress = useCallback(async () => { 150 | const poll = async () => { 151 | const progress = await fetch("/api/progress", { 152 | method: "POST", 153 | body: JSON.stringify({ 154 | username, 155 | }), 156 | }); 157 | const progressJson = (await progress.json()) as RenderProgressOrFinality; 158 | setDownloadProgress(progressJson); 159 | if (progressJson.type !== "finality") { 160 | setTimeout(poll, 1000); 161 | } 162 | }; 163 | 164 | setTimeout(() => { 165 | poll(); 166 | }, 1000); 167 | }, [username]); 168 | 169 | const render = useCallback(async () => { 170 | if (!username) { 171 | return; 172 | } 173 | const res = await fetch("/api/render", { 174 | method: "POST", 175 | body: JSON.stringify({ 176 | username, 177 | }), 178 | }); 179 | const prog = (await res.json()) as RenderProgressOrFinality; 180 | setDownloadProgress(prog); 181 | }, [username]); 182 | 183 | const retry = useCallback(async () => { 184 | setRetrying(true); 185 | const res = await fetch("/api/retry", { 186 | method: "POST", 187 | body: JSON.stringify({ 188 | username, 189 | }), 190 | }); 191 | const prog = (await res.json()) as RenderProgressOrFinality; 192 | setDownloadProgress(prog); 193 | setRetrying(false); 194 | }, [username]); 195 | 196 | const type = downloadProgress?.type ?? null; 197 | 198 | useEffect(() => { 199 | if (type === "progress") { 200 | pollProgress(); 201 | } 202 | }, [type, pollProgress]); 203 | 204 | useEffect(() => { 205 | if (downloadProgress === null) { 206 | render(); 207 | } 208 | }, [downloadProgress, render]); 209 | 210 | if (!user) { 211 | return ( 212 |
213 | 214 |
215 | ); 216 | } 217 | 218 | return ( 219 | 220 |
221 | 222 | 223 | {username} 224 | {"'"}s #GitHubUnwrapped 225 | 226 | 231 | 232 | 236 | 237 | 238 |
239 |
240 |
241 |

242 |

243 |

Here is your #GitHubUnwrapped!

244 |

@{username}

245 |
250 | {user ? ( 251 |
256 | 274 | { 283 | // @ts-expect-error 284 | player.current.toggle(e); 285 | }} 286 | > 287 | {playing ? null : ( 288 |
302 | 309 | 313 | 314 |
315 |
323 | Click to play 324 |
325 |
326 | )} 327 |
328 |
329 | ) : null} 330 |
335 |
336 |

343 | Download your video and tweet it using{" "} 344 | 349 | #GitHubUnwrapped 350 | {" "} 351 | hashtag! 352 |

353 | 359 | {iosSafari() ? ( 360 |

368 | Tip for iOS Safari: Long press the {'"'}Download button{'"'} 369 | , then press {'"'}Download Linked File{'"'} to save the 370 | video locally. 371 |

372 | ) : null} 373 |
378 | 379 | 380 | 381 |
386 | 391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 |
399 |
400 |
401 | ); 402 | } 403 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import "../styles/globals.css"; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ( 6 | <> 7 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export default MyApp; 24 | -------------------------------------------------------------------------------- /pages/api/clear.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { deleteCache } from "../../src/db/cache"; 3 | import { deleteRender, getRender } from "../../src/db/renders"; 4 | 5 | type RequestData = { 6 | username: string; 7 | }; 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse<{}> 12 | ) { 13 | const body = JSON.parse(req.body) as RequestData; 14 | await deleteCache(body.username); 15 | const render = await getRender(body.username); 16 | if (!render) { 17 | throw new Error("Could not get progress for " + body.username); 18 | } 19 | await deleteRender(render); 20 | res.status(200).json({}); 21 | } 22 | -------------------------------------------------------------------------------- /pages/api/progress.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { Finality, getRender } from "../../src/db/renders"; 3 | import { getRenderProgressWithFinality } from "../../src/get-render-progress-with-finality"; 4 | 5 | type RequestData = { 6 | username: string; 7 | }; 8 | 9 | export type RenderProgressOrFinality = 10 | | { 11 | type: "progress"; 12 | progress: { 13 | percent: number; 14 | }; 15 | } 16 | | { 17 | type: "finality"; 18 | finality: Finality; 19 | }; 20 | 21 | export default async function handler( 22 | req: NextApiRequest, 23 | res: NextApiResponse 24 | ) { 25 | const body = JSON.parse(req.body) as RequestData; 26 | const render = await getRender(body.username); 27 | if (!render) { 28 | throw new Error("Could not get progress for "); 29 | } 30 | 31 | const prog = await getRenderProgressWithFinality(render, render.account ?? 1); 32 | 33 | res.status(200).json(prog); 34 | return; 35 | } 36 | -------------------------------------------------------------------------------- /pages/api/render.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { getRenderOrMake } from "../../src/get-render-or-make"; 3 | import { getStatsOrFetch } from "../../src/get-stats-or-fetch"; 4 | import { RenderProgressOrFinality } from "./progress"; 5 | 6 | type RequestData = { 7 | username: string; 8 | }; 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | const body = JSON.parse(req.body) as RequestData; 15 | const stats = await getStatsOrFetch(body.username); 16 | if (!stats) { 17 | throw new Error("Could not get stats for" + body.username); 18 | } 19 | const prog = await getRenderOrMake(body.username, stats); 20 | res.status(200).json(prog); 21 | } 22 | -------------------------------------------------------------------------------- /pages/api/retry.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { deleteRender, getRender } from "../../src/db/renders"; 3 | import { getRenderOrMake } from "../../src/get-render-or-make"; 4 | import { getStatsOrFetch } from "../../src/get-stats-or-fetch"; 5 | import { RenderProgressOrFinality } from "./progress"; 6 | 7 | type RequestData = { 8 | username: string; 9 | }; 10 | 11 | export default async function handler( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | const body = JSON.parse(req.body) as RequestData; 16 | const render = await getRender(body.username); 17 | const stats = await getStatsOrFetch(body.username); 18 | if (!render) { 19 | throw new Error("Could not get progress for " + body.username); 20 | } 21 | if (!stats) { 22 | throw new Error("Could not get stats for" + body.username); 23 | } 24 | await deleteRender(render); 25 | const prog = await getRenderOrMake(body.username, stats); 26 | res.status(200).json(prog); 27 | } 28 | -------------------------------------------------------------------------------- /pages/faq.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { getFont } from "../remotion/font"; 3 | import { button } from "../src/components/button"; 4 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 5 | 6 | getFont(); 7 | 8 | const Faq: React.FC = () => { 9 | return ( 10 |
21 |
29 |
30 |
31 |
32 | 33 | 34 | 35 |
36 |
37 |

FAQ

38 | 39 |

How does it work?

40 |

41 | We call GitHub{"'"}s GraphQL API to fetch and calculate your 2021 42 | statistics. The video is written in React using Remotion. 43 |

44 |

45 | To display the video, the{" "} 46 | 54 | @remotion/player 55 | {" "} 56 | library is being used. When a username is entered for the first time, 57 | we render the video to an MP4 in an AWS Lambda function using{" "} 58 | 66 | @remotion/lambda 67 | {" "} 68 | and cache it in an S3 bucket. 69 |

70 |

71 | Want to make your own programmatic video solution? Check out{" "} 72 | 80 | Remotion! 81 | 82 |

83 | 84 |

Is the project open source?

85 |

86 | Yes, the source code is available on{" "} 87 | 95 | GitHub 96 | 97 | ! The source code of the video is {'"'}open source{'"'}, while 98 | Remotion, the framework for making videos is {'"'}source-available 99 | {'"'} and requires companies to obtain a license to use it. 100 |

101 |

Who is behind GitHub Unwrapped?

102 |

103 | This is a hackathon project by{" "} 104 | 112 | Jonny Burger 113 | 114 | . No affiliation with GitHub.{" "} 115 |

116 |

What is the song?

117 |

118 | 126 | {'"'}The Librarian{'"'} by Adi Goldstein. 127 | 128 |

129 |

Contact

130 |

131 | 139 | hi@remotion.dev 140 | 141 |

142 |
143 |
144 | ); 145 | }; 146 | 147 | export default Faq; 148 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerInternals } from "@remotion/player"; 2 | import Head from "next/head"; 3 | import { useRouter } from "next/router"; 4 | import { lighten } from "polished"; 5 | import React, { useCallback, useRef, useState } from "react"; 6 | import { Decoration } from "../remotion/Decoration"; 7 | import { getFont } from "../remotion/font"; 8 | import { button } from "../src/components/button"; 9 | import { Footer, FOOTER_HEIGHT } from "../src/components/Footer"; 10 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 11 | 12 | const input = (): React.CSSProperties => ({ 13 | padding: 14, 14 | borderRadius: 8, 15 | fontSize: 22, 16 | fontFamily: "Jelle", 17 | textAlign: "center", 18 | }); 19 | 20 | const container: React.CSSProperties = { 21 | height: "100%", 22 | width: "100%", 23 | position: "absolute", 24 | display: "flex", 25 | flexDirection: "column", 26 | paddingTop: "10vh", 27 | }; 28 | 29 | const abs: React.CSSProperties = { 30 | minHeight: `calc(100vh - ${FOOTER_HEIGHT}px)`, 31 | width: "100%", 32 | display: "flex", 33 | flexDirection: "column", 34 | overflow: "auto", 35 | backgroundColor: BACKGROUND_COLOR, 36 | position: "relative", 37 | }; 38 | 39 | const headerStyle: React.CSSProperties = { 40 | maxWidth: 800, 41 | paddingLeft: 20, 42 | paddingRight: 20, 43 | textAlign: "center", 44 | margin: "auto", 45 | }; 46 | 47 | const h1: React.CSSProperties = { 48 | fontWeight: "bold", 49 | fontSize: 40, 50 | color: BASE_COLOR, 51 | fontFamily: "Jelle", 52 | }; 53 | 54 | const paragraph: React.CSSProperties = { 55 | color: BASE_COLOR, 56 | lineHeight: 1.5, 57 | fontSize: 15, 58 | fontFamily: "Jelle", 59 | }; 60 | 61 | getFont(); 62 | 63 | const buttonStyle = (disabled: boolean): React.CSSProperties => 64 | disabled 65 | ? { 66 | ...button, 67 | backgroundColor: lighten(0.6, BASE_COLOR), 68 | borderBottomColor: lighten(0.4, BASE_COLOR), 69 | } 70 | : button; 71 | 72 | export default function Home() { 73 | const router = useRouter(); 74 | const [username, setUsername] = useState(""); 75 | const [loading, setLoading] = useState(false); 76 | 77 | const ref = useRef(null); 78 | 79 | const size = PlayerInternals.useElementSize(ref, { 80 | triggerOnWindowResize: true, 81 | shouldApplyCssTransforms: false, 82 | }); 83 | 84 | const onSubmit: React.FormEventHandler = useCallback( 85 | (e) => { 86 | e.preventDefault(); 87 | if (username.trim() === "") { 88 | return; 89 | } 90 | setLoading(true); 91 | router.push(`/${username}`); 92 | }, 93 | [router, username] 94 | ); 95 | 96 | const onChange: React.ChangeEventHandler = useCallback( 97 | (e) => { 98 | e.preventDefault(); 99 | setUsername(e.target.value); 100 | }, 101 | [] 102 | ); 103 | 104 | return ( 105 | <> 106 | 107 | #GitHubUnwrapped 2021 108 | 112 | 113 | 114 |
115 | {size ? ( 116 | <> 117 | 125 | 133 | 134 | ) : null} 135 |
136 |
137 |
Your coding year in review
138 |

139 | Get a personalized video of your GitHub activity in 2021. Type 140 | your username to get started! 141 |

142 |
143 | 144 |
145 | 156 |
157 |
158 | 165 |
166 |
167 |
168 |
169 |
170 | 171 | ); 172 | } 173 | -------------------------------------------------------------------------------- /public/fav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotion-dev/github-unwrapped-2021/94c470c049aef2f6b24dd8dbf2168ae2ee7dd80a/public/fav.png -------------------------------------------------------------------------------- /public/flash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotion-dev/github-unwrapped-2021/94c470c049aef2f6b24dd8dbf2168ae2ee7dd80a/public/flash.png -------------------------------------------------------------------------------- /public/promo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotion-dev/github-unwrapped-2021/94c470c049aef2f6b24dd8dbf2168ae2ee7dd80a/public/promo1.png -------------------------------------------------------------------------------- /public/promo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotion-dev/github-unwrapped-2021/94c470c049aef2f6b24dd8dbf2168ae2ee7dd80a/public/promo2.png -------------------------------------------------------------------------------- /public/promo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotion-dev/github-unwrapped-2021/94c470c049aef2f6b24dd8dbf2168ae2ee7dd80a/public/promo3.png -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /remotion/Contrib.tsx: -------------------------------------------------------------------------------- 1 | import chunk from "lodash.chunk"; 2 | import { lighten } from "polished"; 3 | import React from "react"; 4 | import { AbsoluteFill, Series } from "remotion"; 5 | import { BACKGROUND_COLOR } from "../src/palette"; 6 | import { Green } from "./Green"; 7 | import { IDidALot } from "./IDidALot"; 8 | import { CompactStats } from "./map-response-to-stats"; 9 | import { TotalContributions } from "./TotalContributions"; 10 | 11 | export const Contributions: React.FC<{ 12 | stats: CompactStats; 13 | }> = ({ stats }) => { 14 | return ( 15 | 20 |
28 | 29 | 30 | 31 | 32 | {Object.keys(stats.contributions) 33 | .sort() 34 | .map((m, i) => { 35 | const val = stats.contributions[m]; 36 | const chunked = chunk(val, 7); 37 | return ( 38 | 43 | 44 | 45 | ); 46 | })} 47 | 48 | 51 | 52 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /remotion/Decoration.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps, useEffect, useRef } from "react"; 2 | import { AbsoluteFill, interpolate, random } from "remotion"; 3 | import SimplexNoise from "simplex-noise"; 4 | import { BACKGROUND_COLOR, LINE_COLOR } from "../src/palette"; 5 | import { One, OneSize } from "./letters/one"; 6 | import { Two, TwoSize } from "./letters/two"; 7 | import { Zero, ZeroSize } from "./letters/zero"; 8 | 9 | const noiseX = new SimplexNoise("seedx2"); 10 | const noiseY = new SimplexNoise("seedY2"); 11 | 12 | const items: { 13 | size: readonly [number, number]; 14 | Component: React.FC & { fill: string }>; 15 | }[] = [ 16 | { 17 | size: TwoSize, 18 | Component: Two, 19 | }, 20 | { 21 | size: ZeroSize, 22 | Component: Zero, 23 | }, 24 | 25 | { 26 | size: OneSize, 27 | Component: One, 28 | }, 29 | ]; 30 | 31 | const getEffectiveSize = (width: number, height: number, size: number) => { 32 | const heightScale = size / width; 33 | const widthScale = size / height; 34 | 35 | const smaller = Math.min(heightScale, widthScale); 36 | 37 | return smaller; 38 | }; 39 | 40 | export const Decoration: React.FC<{ 41 | start: readonly [number, number]; 42 | end: readonly [number, number]; 43 | width: number; 44 | height: number; 45 | progress: number; 46 | curliness: number; 47 | }> = ({ width, height, progress, start, end, curliness }) => { 48 | const ref = useRef(null); 49 | const scale = Math.sqrt(width * height) / 700; 50 | 51 | useEffect(() => { 52 | if (!ref.current) { 53 | return; 54 | } 55 | 56 | const pointSize = Math.sqrt(width * height) * 0.15; 57 | 58 | const ctx = ref.current.getContext("2d"); 59 | if (!ctx) { 60 | return; 61 | } 62 | ctx.clearRect(0, 0, width, height); 63 | 64 | for (let i = -0.8; i < 1.8 * progress; i += 0.005) { 65 | const pointX = 66 | interpolate(i, [0, 1], [start[0], end[0]]) + 67 | noiseX.noise2D(0, (i / 2) * curliness) * 0.1; 68 | const pointY = 69 | interpolate(i, [0, 1], [start[1], end[1]]) + 70 | noiseY.noise2D(0, (i / 2) * curliness) * 0.1; 71 | 72 | ctx.fillStyle = LINE_COLOR; 73 | ctx.fillRect(pointX * width, pointY * height, pointSize, pointSize); 74 | } 75 | }, [curliness, end, height, progress, start, width]); 76 | return ( 77 | 86 | 96 | {items.map(({ Component, size }, i) => { 97 | const pointX = interpolate( 98 | i, 99 | [-0.2, items.length - 1 + 0.2], 100 | [start[0], end[0]] 101 | ); 102 | const pointY = interpolate( 103 | i, 104 | [-0.2, items.length - 1 + 0.2], 105 | [start[1], end[1]] 106 | ); 107 | 108 | const effHeight = getEffectiveSize(size[0], size[1], 160) * size[1]; 109 | const effWidth = getEffectiveSize(size[0], size[1], 160) * size[0]; 110 | 111 | return ( 112 | 120 | 121 | 131 | 132 | 133 | ); 134 | })} 135 | 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /remotion/DecorativeLines.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AbsoluteFill, useVideoConfig } from "remotion"; 3 | import { Decoration } from "./Decoration"; 4 | 5 | const start = [1, 0.5] as const; 6 | const end = [0.7, 0] as const; 7 | 8 | const start2 = [0, 0.75] as const; 9 | const end2 = [0.5, 1] as const; 10 | 11 | export const DecorativeLines: React.FC = () => { 12 | const { width, height } = useVideoConfig(); 13 | 14 | return ( 15 | 16 | 24 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /remotion/EndCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AbsoluteFill, 4 | spring, 5 | useCurrentFrame, 6 | useVideoConfig, 7 | } from "remotion"; 8 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 9 | import { Decoration } from "./Decoration"; 10 | import { CompactStats } from "./map-response-to-stats"; 11 | 12 | const title: React.CSSProperties = { 13 | textAlign: "center", 14 | fontSize: 70, 15 | fontFamily: "Jelle", 16 | color: BASE_COLOR, 17 | fontWeight: "bold", 18 | }; 19 | 20 | export const EndCard: React.FC<{ 21 | stats: CompactStats; 22 | enableDecoration: boolean; 23 | }> = ({ stats, enableDecoration }) => { 24 | const { width, height, fps } = useVideoConfig(); 25 | const frame = useCurrentFrame(); 26 | const line = spring({ 27 | fps, 28 | frame: frame - 10, 29 | config: { 30 | mass: 4, 31 | damping: 200, 32 | }, 33 | }); 34 | 35 | const zeroCommits = stats.contributionCount === 0; 36 | 37 | return ( 38 | 43 | {enableDecoration ? ( 44 | <> 45 | 53 | 61 | 62 | ) : null} 63 | 69 |
70 | {zeroCommits 71 | ? "Actually, everything is on GitLab." 72 | : `Wonder how you'll compare?`} 73 |
74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /remotion/EndCard2.tsx: -------------------------------------------------------------------------------- 1 | import { lighten } from "polished"; 2 | import React from "react"; 3 | import { 4 | AbsoluteFill, 5 | interpolate, 6 | spring, 7 | useCurrentFrame, 8 | useVideoConfig, 9 | } from "remotion"; 10 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 11 | 12 | const subtitle: React.CSSProperties = { 13 | textAlign: "center", 14 | fontSize: 80, 15 | fontFamily: "Jelle", 16 | color: BASE_COLOR, 17 | fontWeight: "bold", 18 | marginTop: 12, 19 | }; 20 | 21 | export const EndCard2: React.FC = () => { 22 | const frame = useCurrentFrame(); 23 | const { fps } = useVideoConfig(); 24 | const chars = "githubunwrapped"; 25 | const off = chars.length * 4 + 14; 26 | const bigspr = spring({ 27 | frame: frame - off, 28 | fps, 29 | config: { 30 | mass: 0.3, 31 | damping: 200, 32 | }, 33 | }); 34 | 35 | return ( 36 | 43 | 48 |
Get yours at
49 |
55 | {chars.split("").map((char, i) => { 56 | const spr = spring({ 57 | frame: frame - i * 4, 58 | fps, 59 | config: { 60 | mass: 0.1, 61 | damping: 200, 62 | }, 63 | }); 64 | return ( 65 | 76 | {char} 77 | 78 | ); 79 | })} 80 | 86 | .com 87 | 88 |
89 |
94 |
95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /remotion/Flashcard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AbsoluteFill, useVideoConfig } from "remotion"; 3 | import { Decoration } from "./Decoration"; 4 | 5 | export const Flashcard: React.FC = () => { 6 | const { width, height } = useVideoConfig(); 7 | return ( 8 | 13 | 21 | 22 | 29 | 33 | 34 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /remotion/Green.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; 3 | import { BASE_COLOR } from "../src/palette"; 4 | import { SpaceSavingContribution } from "./map-response-to-stats"; 5 | 6 | const title: React.CSSProperties = { 7 | textAlign: "center", 8 | fontSize: 70, 9 | fontFamily: "Jelle", 10 | color: BASE_COLOR, 11 | }; 12 | 13 | export const Green: React.FC<{ 14 | chunked: SpaceSavingContribution[][]; 15 | i: number; 16 | }> = ({ chunked, i }) => { 17 | const { fps } = useVideoConfig(); 18 | const frame = useCurrentFrame(); 19 | const pop = spring({ 20 | fps, 21 | frame, 22 | config: { 23 | mass: 0.4, 24 | damping: 200, 25 | }, 26 | }); 27 | const scale = interpolate(pop, [0, 1], [0.85, 1]); 28 | return ( 29 |
34 |

35 | { 36 | [ 37 | "January", 38 | "February", 39 | "March", 40 | "April", 41 | "May", 42 | "June", 43 | "July", 44 | "August", 45 | "September", 46 | "October", 47 | "November", 48 | "December", 49 | ][i] 50 | } 51 |

52 | {chunked.map((c, j) => { 53 | return ( 54 |
55 | {c.map((chunk, k) => { 56 | const [weekday, contributions, date, color] = chunk; 57 | const prog = 58 | i === 0 59 | ? spring({ 60 | fps, 61 | frame: frame - (j * 7 + k) * 0.35, 62 | config: { 63 | damping: 200, 64 | }, 65 | }) 66 | : 1; 67 | return ( 68 |
83 | ); 84 | })} 85 |
86 | ); 87 | })} 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /remotion/IDidALot.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { AbsoluteFill } from "remotion"; 3 | import { BASE_COLOR } from "../src/palette"; 4 | 5 | const title: React.CSSProperties = { 6 | color: BASE_COLOR, 7 | fontWeight: "bold", 8 | fontSize: 80, 9 | fontFamily: "Jelle", 10 | paddingLeft: 50, 11 | paddingRight: 50, 12 | textAlign: "center", 13 | }; 14 | 15 | export const IDidALot: React.FC<{ 16 | commitCount: number; 17 | }> = ({ commitCount }) => { 18 | const text = useMemo(() => { 19 | if (commitCount < 10) { 20 | return "2021 was chill! Just look at my commits:"; 21 | } 22 | if (commitCount < 100) { 23 | return "I made a few contributions..."; 24 | } 25 | if (commitCount < 1000) { 26 | return "I made lots of contributions!"; 27 | } 28 | return "I made tons of contributions!"; 29 | }, [commitCount]); 30 | return ( 31 | 37 |

{text}

38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /remotion/Issues.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AbsoluteFill, 4 | interpolate, 5 | spring, 6 | useCurrentFrame, 7 | useVideoConfig, 8 | } from "remotion"; 9 | import { BACKGROUND_COLOR } from "../src/palette"; 10 | import { CompactStats } from "./map-response-to-stats"; 11 | 12 | export const Issues: React.FC<{ 13 | stats: CompactStats; 14 | }> = ({ stats }) => { 15 | const { 16 | issues: { closed, open }, 17 | } = stats; 18 | const frame = useCurrentFrame(); 19 | const { fps } = useVideoConfig(); 20 | 21 | const opening = spring({ 22 | fps, 23 | frame: frame - 30, 24 | config: { 25 | damping: 200, 26 | }, 27 | }); 28 | 29 | const spr = spring({ 30 | fps, 31 | frame: frame - 110, 32 | config: { 33 | mass: 3, 34 | damping: 200, 35 | }, 36 | }); 37 | 38 | const total = closed + open; 39 | 40 | const numInterpolate = interpolate( 41 | opening, 42 | [0, 1], 43 | [ 44 | 0, 45 | interpolate(spr, [0, 1], [total, open], { 46 | extrapolateRight: "clamp", 47 | }), 48 | ] 49 | ); 50 | 51 | const openRatio = interpolate(spr, [0, 1], [1, open / total]); 52 | const closeRatio = interpolate(spr, [0, 1], [0, closed / total]); 53 | 54 | return ( 55 | 62 |
69 | 77 | 90 | 91 | 99 | 113 | 114 |
115 | 121 |
128 | {Math.round(numInterpolate)} 129 |
130 |
131 |
140 | {open + closed === 1 ? "issue" : "issues"} opened 141 |
142 |
150 | {open === 1 ? "is" : "are"} still open 151 |
152 |
153 |
154 |
155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /remotion/Lang.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React, { useMemo } from "react"; 3 | import { 4 | AbsoluteFill, 5 | interpolate, 6 | spring, 7 | useCurrentFrame, 8 | useVideoConfig, 9 | } from "remotion"; 10 | import { CompactStats, TopLanguage } from "./map-response-to-stats"; 11 | 12 | const title: React.CSSProperties = { 13 | color: "#111", 14 | fontWeight: "bold", 15 | fontSize: 80, 16 | fontFamily: "Jelle", 17 | paddingLeft: 20, 18 | paddingRight: 20, 19 | textAlign: "center", 20 | }; 21 | 22 | export const Lang: React.FC<{ 23 | stats: CompactStats; 24 | }> = ({ stats }) => { 25 | const frame = useCurrentFrame(); 26 | const { fps } = useVideoConfig(); 27 | 28 | const scale = spring({ 29 | fps, 30 | frame, 31 | config: { 32 | damping: 200, 33 | }, 34 | }); 35 | 36 | const rotateProgress = spring({ 37 | fps, 38 | frame: frame - 60, 39 | config: { 40 | damping: 200, 41 | }, 42 | }); 43 | 44 | const topLanguage: TopLanguage = useMemo(() => { 45 | if (!stats.topLanguage) { 46 | return { 47 | color: null, 48 | name: "None", 49 | }; 50 | } 51 | return stats.topLanguage; 52 | }, [stats.topLanguage]); 53 | 54 | const rotate = interpolate( 55 | rotateProgress, 56 | [0, 0.5, 0.500001, 1], 57 | [1, 0, 0, 1] 58 | ); 59 | 60 | const text = 61 | rotateProgress < 0.5 62 | ? topLanguage.name === "None" 63 | ? "I really couldn't care about any of them" 64 | : "there's one that I like the most!" 65 | : topLanguage.name === "None" 66 | ? "🤷‍♀️" 67 | : topLanguage.name; 68 | 69 | return ( 70 | 82 |
90 | {text} 91 |
92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /remotion/Main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AbsoluteFill, Audio, Series } from "remotion"; 3 | import { Contributions } from "./Contrib"; 4 | import { EndCard } from "./EndCard"; 5 | import { EndCard2 } from "./EndCard2"; 6 | import { Issues } from "./Issues"; 7 | import { Lang } from "./Lang"; 8 | import { ManyLanguages } from "./ManyLanguages"; 9 | import { CompactStats } from "./map-response-to-stats"; 10 | import { TitleCard } from "./TitleCard"; 11 | import { TopWeekDays } from "./TopWeekday"; 12 | import { Transition } from "./Transition"; 13 | 14 | export const Main: React.FC<{ 15 | stats: CompactStats; 16 | enableDecoration: boolean; 17 | }> = ({ stats, enableDecoration }) => { 18 | if (!stats) { 19 | return null; 20 | } 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /remotion/ManyLanguages.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AbsoluteFill, 4 | interpolate, 5 | spring, 6 | useCurrentFrame, 7 | useVideoConfig, 8 | } from "remotion"; 9 | import { ORANGE, ORANGE_BACKGROUND } from "../src/palette"; 10 | import { C } from "./icons/C"; 11 | import { Dart } from "./icons/Dart"; 12 | import { Elixir } from "./icons/Elixir"; 13 | import { Erlang } from "./icons/Erlang"; 14 | import { Flutter } from "./icons/Flutter"; 15 | import { Go } from "./icons/Go"; 16 | import { Haskell } from "./icons/Haskell"; 17 | import { Javascript } from "./icons/Javascript"; 18 | import { Php } from "./icons/Php"; 19 | import { Python } from "./icons/Python"; 20 | import { Ruby } from "./icons/Ruby"; 21 | import { Rust } from "./icons/Rust"; 22 | import { Scala } from "./icons/Scala"; 23 | import { Swift } from "./icons/Swift"; 24 | import { Typescript } from "./icons/Typescript"; 25 | import { Zig } from "./icons/Zig"; 26 | 27 | const title: React.CSSProperties = { 28 | fontWeight: 700, 29 | fontSize: 90, 30 | fontFamily: "Jelle", 31 | paddingLeft: 70, 32 | paddingRight: 70, 33 | textAlign: "center", 34 | color: ORANGE, 35 | }; 36 | 37 | const row: React.CSSProperties = { 38 | flexDirection: "row", 39 | display: "flex", 40 | flex: 1, 41 | perspective: 900, 42 | }; 43 | 44 | const item = ( 45 | frame: number, 46 | fps: number, 47 | index: number 48 | ): React.CSSProperties => { 49 | const progress = spring({ 50 | frame: frame - 40 - index * 1, 51 | fps, 52 | config: { 53 | damping: 200, 54 | }, 55 | }); 56 | 57 | const rad = interpolate(progress, [0, 1], [-Math.PI, 0]); 58 | 59 | return { 60 | flex: 1, 61 | justifyContent: "center", 62 | alignItems: "center", 63 | display: "flex", 64 | opacity: rad < -Math.PI / 2 ? 0 : 1, 65 | position: "relative", 66 | transform: `rotateX(${rad}rad)`, 67 | }; 68 | }; 69 | 70 | export const ManyLanguages: React.FC = () => { 71 | const frame = useCurrentFrame(); 72 | const { fps } = useVideoConfig(); 73 | return ( 74 | 79 | 85 |
Out of all the languages out there...
86 |
87 |
88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 | 96 |
97 |
98 | 99 |
100 |
101 |
102 |
103 | 104 |
105 |
106 | 107 |
108 |
109 | 110 |
111 |
112 | 113 |
114 |
115 |
116 |
117 | 118 |
119 |
120 | 121 |
122 |
123 | 124 |
125 |
126 | 127 |
128 |
129 |
130 |
131 | 132 |
133 |
134 | 135 |
136 |
137 | 138 |
139 |
140 | 141 |
142 |
143 |
144 | ); 145 | }; 146 | -------------------------------------------------------------------------------- /remotion/TitleCard.tsx: -------------------------------------------------------------------------------- 1 | import { transparentize } from "polished"; 2 | import React from "react"; 3 | import { 4 | AbsoluteFill, 5 | Img, 6 | interpolate, 7 | spring, 8 | useCurrentFrame, 9 | useVideoConfig, 10 | } from "remotion"; 11 | import { BACKGROUND_COLOR, BASE_COLOR } from "../src/palette"; 12 | import { Decoration } from "./Decoration"; 13 | import { CompactStats } from "./map-response-to-stats"; 14 | 15 | const outerImage: React.CSSProperties = { 16 | padding: 24, 17 | backgroundColor: "white", 18 | borderRadius: "50%", 19 | display: "flex", 20 | justifyContent: "center", 21 | alignItems: "center", 22 | boxShadow: "0 0 40px " + transparentize(0.9, BASE_COLOR), 23 | position: "relative", 24 | }; 25 | 26 | const imageStyle: React.CSSProperties = { 27 | borderRadius: "50%", 28 | width: 450, 29 | height: 450, 30 | border: "10px solid " + BACKGROUND_COLOR, 31 | overflow: "hidden", 32 | position: "relative", 33 | }; 34 | 35 | const image: React.CSSProperties = { 36 | borderRadius: "50%", 37 | width: "100%", 38 | height: "100%", 39 | }; 40 | 41 | const titleStyle: React.CSSProperties = { 42 | color: BASE_COLOR, 43 | fontFamily: "Jelle", 44 | fontSize: 80, 45 | textAlign: "center", 46 | fontWeight: "bold", 47 | marginTop: 20, 48 | }; 49 | 50 | export const TitleCard: React.FC<{ 51 | stats: CompactStats; 52 | enableDecoration: boolean; 53 | }> = ({ stats, enableDecoration }) => { 54 | const { fps } = useVideoConfig(); 55 | const frame = useCurrentFrame(); 56 | const appear = spring({ 57 | fps, 58 | frame, 59 | config: { 60 | mass: 2, 61 | damping: 200, 62 | }, 63 | }); 64 | 65 | const scale = interpolate(appear, [0, 1], [0.8, 1]); 66 | 67 | const avatarScale = interpolate(appear, [0, 1], [0, 1]); 68 | 69 | const rotateProg = spring({ 70 | fps, 71 | frame: frame - 60, 72 | config: { 73 | damping: 200, 74 | }, 75 | }); 76 | 77 | const scaleY = interpolate(rotateProg, [0, 1], [1, -1]); 78 | const scaleY2 = interpolate(rotateProg, [0, 1], [-1, 1]); 79 | 80 | const { width, height } = useVideoConfig(); 81 | const line = spring({ 82 | fps, 83 | frame: frame - 10, 84 | config: { 85 | mass: 4, 86 | damping: 200, 87 | }, 88 | }); 89 | 90 | return ( 91 | 96 | 101 | {enableDecoration ? ( 102 | <> 103 | 111 | 119 | 120 | ) : null} 121 | 122 | {scaleY > 0 ? ( 123 | 130 |
135 |
142 |
143 | Your avatar 144 |
145 |
163 | 2021 164 |
165 |
166 |
167 |
168 | ) : null} 169 | {scaleY2 > 0 ? ( 170 | 177 |
178 | This is my 179 |
180 | #GitHubUnwrapped 181 |
182 |
183 | ) : null} 184 |
185 | ); 186 | }; 187 | -------------------------------------------------------------------------------- /remotion/TopWeekday.tsx: -------------------------------------------------------------------------------- 1 | import { lighten } from "polished"; 2 | import React from "react"; 3 | import { 4 | AbsoluteFill, 5 | interpolate, 6 | interpolateColors, 7 | spring, 8 | useCurrentFrame, 9 | useVideoConfig, 10 | } from "remotion"; 11 | import { PINK, PINK_BACKGROUND } from "../src/palette"; 12 | import { CompactStats, Weekday } from "./map-response-to-stats"; 13 | 14 | const weekdayToName = (weekday: Weekday) => { 15 | return [ 16 | "Monday", 17 | "Tuesday", 18 | "Wednesday", 19 | "Thursday", 20 | "Friday", 21 | "Saturday", 22 | "Sunday", 23 | ][weekday]; 24 | }; 25 | 26 | const label: React.CSSProperties = { 27 | textAlign: "center", 28 | color: PINK, 29 | fontFamily: "Jelle", 30 | marginTop: 20, 31 | fontWeight: "bold", 32 | fontSize: 36, 33 | }; 34 | 35 | const title: React.CSSProperties = { 36 | color: PINK, 37 | fontWeight: "bold", 38 | fontSize: 80, 39 | fontFamily: "Jelle", 40 | paddingLeft: 50, 41 | paddingRight: 50, 42 | textAlign: "center", 43 | marginBottom: 100, 44 | }; 45 | 46 | const higher = 400; 47 | 48 | export const TopWeekDays: React.FC<{ 49 | stats: CompactStats; 50 | }> = ({ stats }) => { 51 | const { fps } = useVideoConfig(); 52 | const frame = useCurrentFrame(); 53 | 54 | return ( 55 | 63 |
64 | {stats.weekdays.mostCount === 0 65 | ? "I'm rather outside than in front of the screen." 66 | : `${weekdayToName(stats.weekdays.most)} was my most productive day.`} 67 |
68 |
74 | {stats.weekdays.days.map((d, i) => { 75 | const lower = Math.max(150, (d / stats.weekdays.mostCount) * higher); 76 | const isMostProductive = 77 | stats.weekdays.most === String(i) && stats.weekdays.mostCount > 0; 78 | 79 | const progress = spring({ 80 | fps, 81 | frame: frame - i * 3 - 20, 82 | config: { 83 | damping: 200, 84 | }, 85 | }); 86 | 87 | return ( 88 |
89 |
97 |
98 |
120 |
126 | {["M", "T", "W", "T", "F", "S", "S"][i]} 127 |
128 |
129 |
134 |
135 |
136 | ); 137 | })} 138 |
139 | 140 | ); 141 | }; 142 | -------------------------------------------------------------------------------- /remotion/TotalContributions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AbsoluteFill, 4 | interpolate, 5 | spring, 6 | useCurrentFrame, 7 | useVideoConfig, 8 | } from "remotion"; 9 | import { BASE_COLOR } from "../src/palette"; 10 | 11 | const title: React.CSSProperties = { 12 | textAlign: "center", 13 | fontSize: 200, 14 | fontFamily: "Jelle", 15 | color: BASE_COLOR, 16 | fontWeight: "bold", 17 | }; 18 | 19 | const subtitle: React.CSSProperties = { 20 | textAlign: "center", 21 | fontSize: 36, 22 | fontFamily: "Jelle", 23 | color: BASE_COLOR, 24 | fontWeight: "bold", 25 | }; 26 | 27 | export const TotalContributions: React.FC<{ 28 | totalContributions: number; 29 | }> = ({ totalContributions }) => { 30 | const { fps } = useVideoConfig(); 31 | const frame = useCurrentFrame(); 32 | 33 | const prog = spring({ 34 | fps, 35 | frame, 36 | config: { 37 | damping: 200, 38 | }, 39 | }); 40 | 41 | const num = interpolate(prog, [0, 0.9], [0, totalContributions], { 42 | extrapolateRight: "clamp", 43 | }); 44 | const scale = interpolate(prog, [0, 1], [0.6, 1.2]); 45 | 46 | const op = interpolate(prog, [0.9, 1], [0, 1]); 47 | 48 | return ( 49 | 56 |
{Math.round(num)}
57 |
to be exact!
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /remotion/Transition.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AbsoluteFill, 4 | interpolate, 5 | spring, 6 | useCurrentFrame, 7 | useVideoConfig, 8 | } from "remotion"; 9 | 10 | export const Transition: React.FC = ({ children }) => { 11 | const { fps, width } = useVideoConfig(); 12 | const frame = useCurrentFrame(); 13 | const spr = spring({ 14 | fps, 15 | frame, 16 | config: { 17 | mass: 0.5, 18 | damping: 200, 19 | }, 20 | }); 21 | const translation = interpolate(spr, [0, 1], [width, 0]); 22 | const perc = interpolate(spr, [0, 1], [50, 0]); 23 | return ( 24 | 32 | {children} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /remotion/TransitionDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AbsoluteFill } from "remotion"; 3 | import { Transition } from "./Transition"; 4 | 5 | export const TransitionDemo: React.FC = () => { 6 | return ( 7 | 8 | 13 |

A

14 |
15 | 16 |

B

17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /remotion/Video.tsx: -------------------------------------------------------------------------------- 1 | import { Composition, Still } from "remotion"; 2 | import { COMP_NAME } from "../src/config"; 3 | import { all } from "./all"; 4 | import { Contributions } from "./Contrib"; 5 | import { DecorativeLines } from "./DecorativeLines"; 6 | import { Flashcard } from "./Flashcard"; 7 | import { Issues } from "./Issues"; 8 | import { Main } from "./Main"; 9 | import { ManyLanguages } from "./ManyLanguages"; 10 | import { mapResponseToStats } from "./map-response-to-stats"; 11 | import { TitleCard } from "./TitleCard"; 12 | import { TopWeekDays } from "./TopWeekday"; 13 | import { TransitionDemo } from "./TransitionDemo"; 14 | 15 | export const Root: React.FC = () => { 16 | return ( 17 | <> 18 | 30 | 41 | 49 | 61 | 69 | 80 | 91 | 102 | 108 | 109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /remotion/font.ts: -------------------------------------------------------------------------------- 1 | import { continueRender, delayRender } from "remotion"; 2 | 3 | if (typeof window !== "undefined" && "FontFace" in window) { 4 | const font = new FontFace( 5 | "Jelle", 6 | "url(https://jonnyburger.s3.eu-central-1.amazonaws.com/Jellee-Bold.woff2) format('woff2')" 7 | ); 8 | const handle = delayRender(); 9 | font.load().then(() => { 10 | document.fonts.add(font); 11 | continueRender(handle); 12 | }); 13 | } 14 | 15 | export const getFont = () => null; 16 | -------------------------------------------------------------------------------- /remotion/icons/C.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const C: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Dart.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Dart: React.FC = () => { 6 | return ( 7 | 14 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Elixir.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Elixir: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 29 | 33 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /remotion/icons/Erlang.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Erlang: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Flutter.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Flutter: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Go.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Go: React.FC = () => { 6 | return ( 7 | 14 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /remotion/icons/Haskell.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AbsoluteFill } from "remotion"; 3 | import makeColorMoreChill from "make-color-more-chill"; 4 | 5 | export const Haskell: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /remotion/icons/Javascript.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Javascript: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Php.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Php: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Python.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Python: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/icons/Ruby.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Ruby: React.FC = () => { 6 | return ( 7 | 14 | 21 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /remotion/icons/Rust.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Rust: React.FC = () => { 6 | return ( 7 | 14 | 21 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /remotion/icons/Scala.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Scala: React.FC = () => { 6 | return ( 7 | 14 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /remotion/icons/Swift.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Swift: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /remotion/icons/Typescript.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Typescript: React.FC = () => { 6 | return ( 7 | 14 | 21 | 25 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /remotion/icons/Zig.tsx: -------------------------------------------------------------------------------- 1 | import makeColorMoreChill from "make-color-more-chill"; 2 | import React from "react"; 3 | import { AbsoluteFill } from "remotion"; 4 | 5 | export const Zig: React.FC = () => { 6 | return ( 7 | 14 | 15 | 16 | 17 | 21 | 22 | 26 | 27 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /remotion/index.tsx: -------------------------------------------------------------------------------- 1 | import { registerRoot } from "remotion"; 2 | import { getFont } from "./font"; 3 | import { Root } from "./Video"; 4 | 5 | registerRoot(Root); 6 | 7 | getFont(); 8 | -------------------------------------------------------------------------------- /remotion/letters/github.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from "react"; 2 | 3 | export const GithubSize = [384, 512] as const; 4 | 5 | export const Github: React.FC< 6 | SVGProps & { 7 | fill: string; 8 | } 9 | > = ({ fill, ...props }) => { 10 | return ( 11 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /remotion/letters/one.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from "react"; 2 | 3 | export const OneSize = [253, 185] as const; 4 | 5 | export const One: React.FC< 6 | SVGProps & { 7 | fill: string; 8 | } 9 | > = ({ fill, ...props }) => { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /remotion/letters/two.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from "react"; 2 | 3 | export const TwoSize = [257, 264] as const; 4 | 5 | export const Two: React.FC< 6 | SVGProps & { 7 | fill: string; 8 | } 9 | > = ({ fill, ...props }) => { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /remotion/letters/zero.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from "react"; 2 | 3 | export const ZeroSize = [184, 448] as const; 4 | 5 | export const Zero: React.FC< 6 | SVGProps & { 7 | fill: string; 8 | } 9 | > = ({ fill, ...props }) => { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /remotion/map-response-to-stats.ts: -------------------------------------------------------------------------------- 1 | import groupBy from "lodash.groupby"; 2 | import { All } from "../src/get-all"; 3 | 4 | // Space saving format for contributions to not run into 256KB payload format 5 | // weekday, contributions, date, color 6 | export type SpaceSavingContribution = [number, number, string, string]; 7 | 8 | export type TopLanguage = { 9 | color: string | null; 10 | name: string; 11 | }; 12 | 13 | type Weekdays = { 14 | least: Weekday; 15 | leastCount: number; 16 | mostCount: number; 17 | most: Weekday; 18 | ratio: number; 19 | days: number[]; 20 | }; 21 | 22 | type Issues = { 23 | closed: number; 24 | open: number; 25 | }; 26 | 27 | export type CompactStats = { 28 | contributionCount: number; 29 | contributions: { [key: string]: SpaceSavingContribution[] }; 30 | avatar: string; 31 | topLanguage: TopLanguage | null; 32 | weekdays: Weekdays; 33 | issues: Issues; 34 | fixedDec18Issues: boolean | undefined; 35 | }; 36 | 37 | export const getIssues = (response: All): Issues => { 38 | return { 39 | closed: response.data.user.closedIssues.totalCount, 40 | open: response.data.user.openIssues.totalCount, 41 | }; 42 | }; 43 | 44 | export type Weekday = "0" | "1" | "2" | "3" | "4" | "5" | "6"; 45 | 46 | export const getMostProductive = (response: All): Weekdays => { 47 | const weekdays: { [key in Weekday]: number } = { 48 | 0: 0, 49 | 1: 0, 50 | 2: 0, 51 | 3: 0, 52 | 4: 0, 53 | 5: 0, 54 | 6: 0, 55 | }; 56 | for (const r of response.data.user.contributionsCollection.contributionCalendar.weeks 57 | .map((w) => w.contributionDays) 58 | .flat(1)) { 59 | weekdays[remapWeekdays(r.weekday)] += r.contributionCount; 60 | } 61 | 62 | const entries = Object.entries(weekdays) as [Weekday, number][]; 63 | 64 | const sortedDays = entries.slice().sort((a, b) => a[1] - b[1]); 65 | 66 | const [leastDay, leastAmount] = sortedDays[0]; 67 | const [mostDay, mostAmount] = sortedDays[sortedDays.length - 1]; 68 | 69 | const ratio = Math.max(mostAmount, 1) / Math.max(leastAmount, 1); 70 | 71 | return { 72 | least: leastDay, 73 | most: mostDay, 74 | ratio: ratio, 75 | leastCount: leastAmount, 76 | mostCount: mostAmount, 77 | days: Object.values(weekdays), 78 | }; 79 | }; 80 | 81 | export const remapWeekdays = (weekday: number): Weekday => { 82 | if (weekday === 0) { 83 | return "6"; 84 | } 85 | if (weekday === 1) { 86 | return "0"; 87 | } 88 | if (weekday === 2) { 89 | return "1"; 90 | } 91 | if (weekday === 3) { 92 | return "2"; 93 | } 94 | if (weekday === 4) { 95 | return "3"; 96 | } 97 | if (weekday === 5) { 98 | return "4"; 99 | } 100 | if (weekday === 6) { 101 | return "5"; 102 | } 103 | throw new Error("unknown weekday" + weekday); 104 | }; 105 | 106 | export const getTopLanguage = (response: All): TopLanguage | null => { 107 | const langs: { [key: string]: number } = {}; 108 | const languages = response.data.user.repositories.nodes 109 | .filter((n) => n.languages.edges?.[0]) 110 | .map((n) => n.languages.edges[0].node); 111 | 112 | for (const lang of languages) { 113 | if (!langs[lang.id]) { 114 | langs[lang.id] = 0; 115 | } 116 | langs[lang.id]++; 117 | } 118 | 119 | const topEntries = Object.entries(langs) 120 | .sort((a, b) => a[1] - b[1]) 121 | .reverse(); 122 | 123 | const lang = languages.find((l) => l.id === topEntries[0][0]); 124 | 125 | if (!lang) { 126 | return null; 127 | } 128 | return { 129 | color: lang.color, 130 | name: lang.name, 131 | }; 132 | }; 133 | 134 | export const mapResponseToStats = (response: All): CompactStats => { 135 | const allDays = 136 | response.data.user.contributionsCollection.contributionCalendar.weeks 137 | .map((w) => w.contributionDays) 138 | .flat(1) 139 | .filter((d) => d.date.startsWith("2021")); 140 | 141 | const groupedByMonth = groupBy( 142 | allDays.map( 143 | (d) => 144 | [ 145 | d.weekday, 146 | d.contributionCount, 147 | d.date, 148 | d.color, 149 | ] as SpaceSavingContribution 150 | ), 151 | ([_, __, date]) => date.split("-")[1] 152 | ); 153 | 154 | return { 155 | contributionCount: allDays.reduce((a, b) => a + b.contributionCount, 0), 156 | contributions: groupedByMonth, 157 | avatar: response.data.user.avatarUrl, 158 | topLanguage: getTopLanguage(response), 159 | weekdays: getMostProductive(response), 160 | issues: getIssues(response), 161 | fixedDec18Issues: true, 162 | }; 163 | }; 164 | -------------------------------------------------------------------------------- /src/components/Download.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import { RenderProgressOrFinality } from "../../pages/api/progress"; 3 | import { button } from "./button"; 4 | 5 | const downloadButton: React.CSSProperties = { 6 | ...button, 7 | width: "100%", 8 | paddingTop: 28, 9 | paddingBottom: 28, 10 | textAlign: "center", 11 | }; 12 | 13 | const Download: React.FC<{ 14 | username: string; 15 | downloadProgress: RenderProgressOrFinality | null; 16 | retrying: boolean; 17 | retry: () => Promise; 18 | }> = ({ username, downloadProgress, retrying, retry }, ref) => { 19 | return ( 20 |
21 | {downloadProgress === null ? ( 22 |
23 | 26 |
27 | ) : downloadProgress.type == "finality" && 28 | downloadProgress.finality && 29 | downloadProgress.finality.type === "success" ? ( 30 | 31 |
Download video
32 |
33 | ) : downloadProgress.type === "finality" && 34 | downloadProgress.finality && 35 | downloadProgress.finality.type === "error" ? ( 36 | <> 37 |
43 | Oops, sorry the render failed! We will fix all render bugs, so come 44 | back tomorrow and it should be fixed! Or just press the retry button 45 | which will work most of the time. 46 |
47 |
52 | 55 | 56 | ) : downloadProgress.type === "progress" ? ( 57 | 62 | ) : null} 63 |
64 | ); 65 | }; 66 | 67 | export default forwardRef(Download); 68 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { lighten } from "polished"; 3 | import React from "react"; 4 | import { BASE_COLOR } from "../palette"; 5 | 6 | export const FOOTER_HEIGHT = 50; 7 | 8 | const container: React.CSSProperties = { 9 | minHeight: FOOTER_HEIGHT, 10 | fontFamily: "Jelle", 11 | alignItems: "center", 12 | paddingLeft: 20, 13 | paddingRight: 20, 14 | paddingTop: 20, 15 | paddingBottom: 20, 16 | backgroundColor: lighten(0.8, BASE_COLOR), 17 | color: BASE_COLOR, 18 | justifyContent: "column", 19 | fontSize: 14, 20 | display: "flex", 21 | }; 22 | 23 | export const Footer: React.FC = () => { 24 | return ( 25 |
26 |
27 | Built with{" "} 28 | 36 | Remotion 37 | {" "} 38 | by{" "} 39 | 47 | Jonny Burger 48 | 49 | . Not affiliated with GitHub. 50 | 51 | 57 | {" "} 58 | About this site 59 | 60 | 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/Rerender.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useState } from "react"; 2 | import { RenderProgressOrFinality } from "../../pages/api/progress"; 3 | import { CompactStats } from "../../remotion/map-response-to-stats"; 4 | import { BASE_COLOR } from "../palette"; 5 | import { backButton } from "./button"; 6 | 7 | const downloadButton: React.CSSProperties = { 8 | ...backButton, 9 | width: "100%", 10 | textAlign: "center", 11 | }; 12 | 13 | const Rerender: React.FC<{ 14 | username: string; 15 | downloadProgress: RenderProgressOrFinality | null; 16 | stats: CompactStats; 17 | }> = ({ downloadProgress, username, stats }, ref) => { 18 | const [retrying, setRetrying] = useState(false); 19 | 20 | const retry = useCallback(async () => { 21 | setRetrying(true); 22 | const res = await fetch("/api/clear", { 23 | method: "POST", 24 | body: JSON.stringify({ 25 | username, 26 | }), 27 | }); 28 | (await res.json()) as RenderProgressOrFinality; 29 | setRetrying(false); 30 | window.location.reload(); 31 | }, [username]); 32 | 33 | if (stats.fixedDec18Issues) { 34 | return null; 35 | } 36 | 37 | return ( 38 |
39 |

47 | We have improved your statistics since the video was last rendered. You 48 | can re-render it! 49 |

50 | {downloadProgress !== null && 51 | downloadProgress.type == "finality" && 52 | downloadProgress.finality && 53 | downloadProgress.finality.type === "success" ? ( 54 | 57 | ) : null} 58 |
59 | ); 60 | }; 61 | 62 | export default forwardRef(Rerender); 63 | -------------------------------------------------------------------------------- /src/components/button.ts: -------------------------------------------------------------------------------- 1 | import lighten from "polished/lib/color/lighten"; 2 | import React from "react"; 3 | import { BASE_COLOR } from "../palette"; 4 | 5 | export const button: React.CSSProperties = { 6 | appearance: "none", 7 | WebkitAppearance: "none", 8 | padding: "14px 28px", 9 | border: 0, 10 | color: "white", 11 | backgroundColor: lighten(0.1, BASE_COLOR), 12 | borderRadius: 10, 13 | fontSize: 20, 14 | fontWeight: "bold", 15 | fontFamily: "Jelle", 16 | borderBottom: "3px solid " + BASE_COLOR, 17 | cursor: "pointer", 18 | }; 19 | 20 | export const backButton: React.CSSProperties = { 21 | ...button, 22 | backgroundColor: lighten(0.6, BASE_COLOR), 23 | borderBottomColor: lighten(0.4, BASE_COLOR), 24 | color: BASE_COLOR, 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { AbsoluteFill } from "remotion"; 3 | import { getFont } from "../../remotion/font"; 4 | import { BACKGROUND_COLOR, BASE_COLOR } from "../palette"; 5 | 6 | getFont(); 7 | 8 | const Spinner: React.FC = () => { 9 | const [frame, setFrame] = useState(0); 10 | useEffect(() => { 11 | let handle = 0; 12 | const loop = () => { 13 | handle = requestAnimationFrame(() => { 14 | setFrame((f) => f + 1); 15 | loop(); 16 | }); 17 | }; 18 | 19 | loop(); 20 | 21 | return () => { 22 | cancelAnimationFrame(handle); 23 | }; 24 | }, []); 25 | 26 | return ( 27 | 36 | 58 | 81 |

Wrapping...

82 |
83 | ); 84 | }; 85 | 86 | export default Spinner; 87 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { VERSION } from "remotion"; 2 | 3 | export const COMP_NAME = "main"; 4 | export const SITE_ID = `unwrapped-${VERSION}`; 5 | -------------------------------------------------------------------------------- /src/db/cache.ts: -------------------------------------------------------------------------------- 1 | import { CompactStats } from "../../remotion/map-response-to-stats"; 2 | import { mongoClient } from "./mongo"; 3 | 4 | type CacheCollection = { 5 | username: string; 6 | stats: CompactStats; 7 | }; 8 | 9 | export const collection = async () => { 10 | const client = await mongoClient; 11 | return client.db("wrapped").collection("wrapped"); 12 | }; 13 | 14 | export const saveCache = async (username: string, stats: CompactStats) => { 15 | const coll = await collection(); 16 | return coll.insertOne({ 17 | stats, 18 | username: username.toLowerCase(), 19 | }); 20 | }; 21 | 22 | export const getFromCache = async ( 23 | username: string 24 | ): Promise => { 25 | const coll = await collection(); 26 | const f = await coll.findOne({ 27 | username: username.toLowerCase(), 28 | }); 29 | if (f) { 30 | return f.stats; 31 | } 32 | return null; 33 | }; 34 | 35 | export const deleteCache = async (username: string) => { 36 | const coll = await collection(); 37 | await coll.deleteMany({ 38 | username: username.toLowerCase(), 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/db/mongo.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from "mongodb"; 2 | const uri = process.env.MONGO_URL as string; 3 | 4 | declare global { 5 | var _mongoClientPromise: Promise; 6 | } 7 | 8 | let client: MongoClient; 9 | let clientPromise: Promise; 10 | if (process.env.NODE_ENV === "development") { 11 | if (!global._mongoClientPromise) { 12 | client = new MongoClient(uri); 13 | global._mongoClientPromise = client.connect(); 14 | } 15 | clientPromise = global._mongoClientPromise; 16 | } else { 17 | client = new MongoClient(uri); 18 | clientPromise = client.connect(); 19 | } 20 | 21 | export const mongoClient = clientPromise; 22 | -------------------------------------------------------------------------------- /src/db/renders.ts: -------------------------------------------------------------------------------- 1 | import { AwsRegion } from "@remotion/lambda"; 2 | import { WithId } from "mongodb"; 3 | import { mongoClient } from "./mongo"; 4 | 5 | export type Finality = 6 | | { 7 | type: "success"; 8 | url: string; 9 | } 10 | | { 11 | type: "error"; 12 | errors: string; 13 | }; 14 | 15 | export type Render = { 16 | renderId: string | null; 17 | region: AwsRegion; 18 | username: string; 19 | bucketName: string | null; 20 | finality: Finality | null; 21 | functionName: string; 22 | account: number | undefined; 23 | }; 24 | 25 | export const rendersCollection = async () => { 26 | const client = await mongoClient; 27 | return client.db("wrapped").collection("renders"); 28 | }; 29 | 30 | export const lockRender = async ( 31 | region: AwsRegion, 32 | username: string, 33 | account: number, 34 | functionName: string 35 | ) => { 36 | const coll = await rendersCollection(); 37 | await coll.insertOne({ 38 | region, 39 | username: username.toLowerCase(), 40 | bucketName: null, 41 | finality: null, 42 | renderId: null, 43 | account: account, 44 | functionName, 45 | }); 46 | }; 47 | 48 | export const saveRender = async ({ 49 | region, 50 | username, 51 | renderId, 52 | bucketName, 53 | }: { 54 | region: AwsRegion; 55 | username: string; 56 | renderId: string; 57 | bucketName: string; 58 | }) => { 59 | const coll = await rendersCollection(); 60 | await coll.updateOne( 61 | { 62 | region, 63 | username: username.toLowerCase(), 64 | }, 65 | { 66 | $set: { 67 | renderId, 68 | bucketName, 69 | finality: null, 70 | }, 71 | } 72 | ); 73 | }; 74 | 75 | export const updateRenderWithFinality = async ( 76 | renderId: string, 77 | username: string, 78 | region: AwsRegion, 79 | finality: Finality 80 | ) => { 81 | if (finality && finality.type === "success") { 82 | console.log(`Successfully rendered video for ${username}.`); 83 | } else { 84 | console.log(`Failed to render video for ${username}!`); 85 | } 86 | const coll = await rendersCollection(); 87 | return coll.updateOne( 88 | { 89 | renderId, 90 | region, 91 | }, 92 | { 93 | $set: { 94 | finality: finality, 95 | }, 96 | } 97 | ); 98 | }; 99 | 100 | export const getRender = async ( 101 | username: string 102 | ): Promise | null> => { 103 | const coll = await rendersCollection(); 104 | const render = await coll.findOne({ 105 | username: username.toLowerCase(), 106 | }); 107 | 108 | return render ?? null; 109 | }; 110 | 111 | export const deleteRender = async (render: WithId) => { 112 | const coll = await rendersCollection(); 113 | await coll.deleteOne({ 114 | _id: render._id, 115 | }); 116 | }; 117 | -------------------------------------------------------------------------------- /src/get-account-count.ts: -------------------------------------------------------------------------------- 1 | // Define in the .env file as many AWS account credentials as possible: 2 | // | AWS_KEY_1= 3 | // | AWS_SECRET_1= 4 | // | AWS_KEY_2= 5 | // | AWS_SECRET_2= 6 | // This method will count how many there are so they can be rotated between each other. 7 | 8 | export const getAccountCount = () => { 9 | let count = 0; 10 | while ( 11 | process.env["AWS_KEY_" + (count + 1)] && 12 | process.env["AWS_SECRET_" + (count + 1)] 13 | ) { 14 | count++; 15 | } 16 | 17 | return count; 18 | }; 19 | -------------------------------------------------------------------------------- /src/get-all.ts: -------------------------------------------------------------------------------- 1 | import { all } from "../remotion/all"; 2 | 3 | export type All = typeof all; 4 | 5 | const query = (username: string) => 6 | `{ 7 | user(login: "${username}") { 8 | openIssues: issues (first: 100, orderBy: {field:CREATED_AT, direction: ASC} filterBy: {since: "2021-01-01T00:00:00.000Z"}, states: OPEN) { 9 | totalCount 10 | } 11 | closedIssues: issues (first: 100, orderBy: {field:CREATED_AT, direction: ASC} filterBy: {since: "2021-01-01T00:00:00.000Z"}, states: CLOSED) { 12 | totalCount 13 | } 14 | repositories(last: 50, isFork: false) { 15 | nodes { 16 | name 17 | url 18 | languages(first: 1, orderBy: {field: SIZE, direction: DESC}) { 19 | edges { 20 | size 21 | node { 22 | id 23 | color 24 | name 25 | } 26 | } 27 | } 28 | } 29 | } 30 | avatarUrl 31 | contributionsCollection { 32 | contributionCalendar { 33 | weeks { 34 | contributionDays { 35 | contributionCount 36 | weekday 37 | date 38 | color 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | `.trim(); 46 | 47 | export const getAll = async (username: string, token: string): Promise => { 48 | const res = await fetch(`https://api.github.com/graphql`, { 49 | method: "post", 50 | body: JSON.stringify({ query: query(username) }), 51 | headers: { 52 | Authorization: `Bearer ${token}`, 53 | "content-type": "application/json", 54 | }, 55 | }); 56 | const rateLimit = res.headers.get("x-ratelimit-remaining"); 57 | if (Math.random() < 0.1) { 58 | console.log("Rate limit remaining: ", rateLimit); 59 | } 60 | return res.json(); 61 | }; 62 | -------------------------------------------------------------------------------- /src/get-random-aws-account.ts: -------------------------------------------------------------------------------- 1 | import { getAccountCount } from "./get-account-count"; 2 | 3 | export const getRandomAwsAccount = () => { 4 | return Math.ceil(Math.random() * getAccountCount()); 5 | }; 6 | -------------------------------------------------------------------------------- /src/get-render-or-make.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AwsRegion, 3 | getFunctions, 4 | renderMediaOnLambda, 5 | RenderProgress, 6 | } from "@remotion/lambda"; 7 | import { RenderProgressOrFinality } from "../pages/api/progress"; 8 | import { CompactStats } from "../remotion/map-response-to-stats"; 9 | import { COMP_NAME, SITE_ID } from "./config"; 10 | import { 11 | Finality, 12 | getRender, 13 | lockRender, 14 | saveRender, 15 | updateRenderWithFinality, 16 | } from "./db/renders"; 17 | import { getRandomAwsAccount } from "./get-random-aws-account"; 18 | import { getRenderProgressWithFinality } from "./get-render-progress-with-finality"; 19 | import { getRandomRegion } from "./regions"; 20 | import { setEnvForKey } from "./set-env-for-key"; 21 | 22 | export const getRenderOrMake = async ( 23 | username: string, 24 | stats: CompactStats 25 | ): Promise => { 26 | const cache = await getRender(username); 27 | let _renderId: string | null = cache?.renderId ?? null; 28 | let _region: AwsRegion | null = cache?.region ?? null; 29 | try { 30 | if (cache) { 31 | const progress = await getRenderProgressWithFinality( 32 | cache, 33 | cache.account ?? 1 34 | ); 35 | return progress; 36 | } 37 | const region = getRandomRegion(); 38 | const account = getRandomAwsAccount(); 39 | setEnvForKey(account); 40 | const [first] = await getFunctions({ 41 | compatibleOnly: true, 42 | region, 43 | }); 44 | console.log(`Username=${username} Account=${account} Region=${region}`); 45 | await lockRender(region, username, account, first.functionName); 46 | 47 | const { renderId, bucketName } = await renderMediaOnLambda({ 48 | region: region, 49 | functionName: first.functionName, 50 | serveUrl: SITE_ID, 51 | composition: COMP_NAME, 52 | inputProps: { stats: stats }, 53 | codec: "h264", 54 | imageFormat: "jpeg", 55 | maxRetries: 1, 56 | privacy: "public", 57 | downloadBehavior: { 58 | type: "download", 59 | fileName: `${username}.mp4`, 60 | }, 61 | }); 62 | _renderId = renderId; 63 | _region = region; 64 | await saveRender({ 65 | region: region, 66 | bucketName, 67 | renderId, 68 | username, 69 | }); 70 | const render = await getRender(username); 71 | if (!render) { 72 | throw new Error(`Didn't have error for ${username}`); 73 | } 74 | const progress = await getRenderProgressWithFinality(render, account); 75 | return progress; 76 | } catch (err) { 77 | console.log(`Failed to render video for ${username}`, (err as Error).stack); 78 | if (_renderId && _region) { 79 | await updateRenderWithFinality(_renderId, username, _region, { 80 | type: "error", 81 | errors: (err as Error).stack as string, 82 | }); 83 | } 84 | return { 85 | finality: { 86 | type: "error", 87 | errors: (err as Error).stack as string, 88 | }, 89 | type: "finality", 90 | }; 91 | } 92 | }; 93 | 94 | export const getFinality = ( 95 | renderProgress: RenderProgress 96 | ): Finality | null => { 97 | if (renderProgress.outputFile) { 98 | return { 99 | type: "success", 100 | url: renderProgress.outputFile, 101 | }; 102 | } 103 | if (renderProgress.fatalErrorEncountered) { 104 | return { 105 | type: "error", 106 | errors: renderProgress.errors[0].stack, 107 | }; 108 | } 109 | return null; 110 | }; 111 | -------------------------------------------------------------------------------- /src/get-render-progress-with-finality.ts: -------------------------------------------------------------------------------- 1 | import { getRenderProgress } from "@remotion/lambda"; 2 | import { WithId } from "mongodb"; 3 | import { RenderProgressOrFinality } from "../pages/api/progress"; 4 | import { Render, updateRenderWithFinality } from "./db/renders"; 5 | import { getFinality } from "./get-render-or-make"; 6 | import { setEnvForKey } from "./set-env-for-key"; 7 | 8 | export const getRenderProgressWithFinality = async ( 9 | render: WithId, 10 | accountNumber: number 11 | ): Promise => { 12 | setEnvForKey(accountNumber); 13 | 14 | if (render.finality) { 15 | return { 16 | type: "finality", 17 | finality: render.finality, 18 | }; 19 | } 20 | 21 | if (!render.renderId || !render.bucketName) { 22 | return { 23 | progress: { 24 | percent: 0, 25 | }, 26 | type: "progress", 27 | }; 28 | } 29 | 30 | const progress = await getRenderProgress({ 31 | renderId: render.renderId, 32 | bucketName: render.bucketName, 33 | functionName: render.functionName, 34 | region: render.region, 35 | }); 36 | 37 | const finality = getFinality(progress); 38 | 39 | if (finality) { 40 | await updateRenderWithFinality( 41 | render.renderId, 42 | render.username, 43 | render.region, 44 | finality 45 | ); 46 | console.log(`Updated ${render.renderId} with finality`, finality); 47 | return { 48 | type: "finality", 49 | finality, 50 | }; 51 | } 52 | 53 | return { 54 | type: "progress", 55 | progress: { 56 | percent: progress.overallProgress, 57 | }, 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/get-stats-or-fetch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompactStats, 3 | mapResponseToStats, 4 | } from "../remotion/map-response-to-stats"; 5 | import { getFromCache, saveCache } from "./db/cache"; 6 | import { getAll } from "./get-all"; 7 | 8 | const githubToken = process.env.GITHUB_TOKEN; 9 | 10 | if (!githubToken) { 11 | throw new TypeError(`Expected GITHUB_TOKEN env variable`); 12 | } 13 | 14 | export const getStatsOrFetch = async ( 15 | user: string 16 | ): Promise => { 17 | const cache = await getFromCache(user); 18 | if (cache) { 19 | return cache; 20 | } 21 | const ast = await getAll(user, githubToken); 22 | if (!ast.data.user) { 23 | return null; 24 | } 25 | const compact = mapResponseToStats(ast); 26 | saveCache(user, compact); 27 | 28 | return compact; 29 | }; 30 | -------------------------------------------------------------------------------- /src/palette.ts: -------------------------------------------------------------------------------- 1 | import { lighten } from "polished"; 2 | 3 | export const BASE_COLOR = "#124f01"; 4 | export const BACKGROUND_COLOR = lighten(0.75, BASE_COLOR); 5 | export const LINE_COLOR = lighten(0.82, BASE_COLOR); 6 | 7 | export const ORANGE = "#EA2027"; 8 | export const ORANGE_BACKGROUND = lighten(0.4, ORANGE); 9 | 10 | export const BLUE = "#1B1464"; 11 | export const BLUE_BACKGROUND = lighten(0.72, BLUE); 12 | 13 | export const PINK = "#D980FA"; 14 | export const PINK_BACKGROUND = lighten(0.21, PINK); 15 | -------------------------------------------------------------------------------- /src/regions.ts: -------------------------------------------------------------------------------- 1 | import { AwsRegion, getRegions } from "@remotion/lambda"; 2 | 3 | // Two regions were reserved to save the concurrency for other projects. 4 | // Adjust to you own use. 5 | export const usedRegions: AwsRegion[] = getRegions().filter( 6 | (r) => r !== "eu-central-1" && r !== "us-east-1" 7 | ); 8 | 9 | export const getRandomRegion = (): AwsRegion => { 10 | return usedRegions[Math.floor(Math.random() * usedRegions.length)]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/set-env-for-key.ts: -------------------------------------------------------------------------------- 1 | export const setEnvForKey = (key: number) => { 2 | process.env.REMOTION_AWS_ACCESS_KEY_ID = process.env[`AWS_KEY_${key}`]; 3 | process.env.REMOTION_AWS_SECRET_ACCESS_KEY = process.env[`AWS_SECRET_${key}`]; 4 | }; 5 | -------------------------------------------------------------------------------- /src/use-window-size.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | // Define general type for useWindowSize hook, which includes width and height 3 | interface Size { 4 | width: number | undefined; 5 | height: number | undefined; 6 | } 7 | 8 | // Hook 9 | export function useWindowSize(): Size { 10 | // Initialize state with undefined width/height so server and client renders match 11 | // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ 12 | const [windowSize, setWindowSize] = useState({ 13 | width: undefined, 14 | height: undefined, 15 | }); 16 | useEffect(() => { 17 | // Handler to call on window resize 18 | function handleResize() { 19 | // Set window width/height to state 20 | setWindowSize({ 21 | width: window.innerWidth, 22 | height: window.innerHeight, 23 | }); 24 | } 25 | // Add event listener 26 | document.addEventListener("resize", handleResize); 27 | // Call handler right away so state gets updated with initial window size 28 | handleResize(); 29 | // Remove event listener on cleanup 30 | return () => document.removeEventListener("resize", handleResize); 31 | }, []); // Empty array ensures that effect is only run on mount 32 | return windowSize; 33 | } 34 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | .github-username { 19 | background-color: #f0ffec; 20 | border: 3px solid #c8faba; 21 | } 22 | 23 | .github-username:focus { 24 | outline: none; 25 | border: 3px solid red; 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"], 20 | "ts-node": { 21 | "compilerOptions": { 22 | "module": "commonjs" 23 | } 24 | } 25 | } 26 | --------------------------------------------------------------------------------