├── LICENSE ├── README.md ├── slides.pdf └── typeboard ├── .gitignore ├── README.md ├── components.json ├── go.mod ├── go.sum ├── index.html ├── package.json ├── postcss.config.js ├── public ├── placeholder-logo.png ├── placeholder-logo.svg ├── placeholder-user.jpg ├── placeholder.jpg ├── placeholder.svg └── vite.svg ├── server.go ├── src ├── App.tsx ├── components │ ├── AuthCallback.tsx │ ├── Layout.tsx │ ├── Leaderboard.tsx │ ├── LeaderboardEntry.tsx │ ├── TypingTest.tsx │ └── ui │ │ ├── avatar.tsx │ │ └── button.tsx ├── context │ └── AuthContext.tsx ├── hooks │ └── useLeaderboard.ts ├── index.css ├── lib │ ├── text-generator.ts │ └── utils.ts └── main.tsx ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 lmnzx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rescon25 2 | 3 | ### references 4 | - [arc browser exploit](https://kibty.town/blog/arc/) 5 | - [cursor exploit](https://kibty.town/blog/todesktop/) 6 | - [Hussein Nasser YouTube](https://www.youtube.com/@hnasr) 7 | 8 | ### alternative frameworks 9 | - [remix](https://remix.run/) 10 | - [soild](https://www.solidjs.com/) 11 | - [qwik](https://qwik.dev/) 12 | - [fresh](https://fresh.deno.dev/) 13 | - [vue](https://vuejs.org/) 14 | - [svelte](https://svelte.dev/) 15 | 16 | ### auth providers/libs 17 | - [openAUTH](https://openauth.js.org/) 18 | - [nextauth](https://next-auth.js.org/) 19 | - [clerk](https://clerk.com/) 20 | - [workos](https://workos.com/) 21 | - [better-auth](https://www.better-auth.com/) 22 | 23 | ### database 24 | - [database-ranking](https://db-engines.com/en/ranking) 25 | - [how to pick a db](https://docs.aws.amazon.com/decision-guides/latest/databases-on-aws-how-to-choose/databases-on-aws-how-to-choose.html) 26 | -------------------------------------------------------------------------------- /slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmnzx/rescon25/b6c29a281161ff3fcb4ee8344b598481fb5a1016/slides.pdf -------------------------------------------------------------------------------- /typeboard/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # If you prefer the allow list template instead of the deny list, see community template: 139 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 140 | # 141 | # Binaries for programs and plugins 142 | *.exe 143 | *.exe~ 144 | *.dll 145 | *.so 146 | *.dylib 147 | 148 | # Test binary, built with `go test -c` 149 | *.test 150 | 151 | # Output of the go coverage tool, specifically when used with LiteIDE 152 | *.out 153 | 154 | # Dependency directories (remove the comment below to include it) 155 | # vendor/ 156 | 157 | # Go workspace file 158 | go.work 159 | go.work.sum 160 | 161 | # env file 162 | .envrc 163 | -------------------------------------------------------------------------------- /typeboard/README.md: -------------------------------------------------------------------------------- 1 | # typeboard 2 | 3 | a simple app to see how fast you can type!!! 4 | 5 | config GitHub OAuth, have these env 6 | ` 7 | GITHUB_CLIENT_ID 8 | GITHUB_CLIENT_SECRET 9 | JWT_SECRET 10 | OAUTH_REDIRECT_URL 11 | ` 12 | 13 | ```bash 14 | npm i && npm run build 15 | go run server.go 16 | ``` 17 | -------------------------------------------------------------------------------- /typeboard/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /typeboard/go.mod: -------------------------------------------------------------------------------- 1 | module typeboardserver 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/andybalholm/brotli v1.1.1 // indirect 7 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 8 | github.com/ebitengine/purego v0.8.2 // indirect 9 | github.com/fasthttp/websocket v1.5.12 // indirect 10 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 11 | github.com/go-ole/go-ole v1.3.0 // indirect 12 | github.com/goccy/go-json v0.10.5 // indirect 13 | github.com/gofiber/contrib/monitor v0.1.0 // indirect 14 | github.com/gofiber/contrib/websocket v1.3.3 // indirect 15 | github.com/gofiber/fiber/v2 v2.52.6 // indirect 16 | github.com/gofiber/fiber/v3 v3.0.0-beta.4 // indirect 17 | github.com/gofiber/schema v1.3.0 // indirect 18 | github.com/gofiber/storage/sqlite3/v2 v2.1.1 // indirect 19 | github.com/gofiber/utils/v2 v2.0.0-beta.8 // indirect 20 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 21 | github.com/google/uuid v1.6.0 // indirect 22 | github.com/klauspost/compress v1.18.0 // indirect 23 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect 24 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect 25 | github.com/mattn/go-colorable v0.1.14 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/mattn/go-runewidth v0.0.16 // indirect 28 | github.com/mattn/go-sqlite3 v1.14.26 // indirect 29 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect 30 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect 33 | github.com/shirou/gopsutil/v4 v4.25.3 // indirect 34 | github.com/tinylib/msgp v1.2.5 // indirect 35 | github.com/tklauser/go-sysconf v0.3.15 // indirect 36 | github.com/tklauser/numcpus v0.10.0 // indirect 37 | github.com/tursodatabase/go-libsql v0.0.0-20250401144753-0be9a6ec7849 // indirect 38 | github.com/valyala/bytebufferpool v1.0.0 // indirect 39 | github.com/valyala/fasthttp v1.60.0 // indirect 40 | github.com/valyala/tcplisten v1.0.0 // indirect 41 | github.com/x448/float16 v0.8.4 // indirect 42 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 43 | golang.org/x/crypto v0.36.0 // indirect 44 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect 45 | golang.org/x/net v0.38.0 // indirect 46 | golang.org/x/oauth2 v0.28.0 // indirect 47 | golang.org/x/sys v0.31.0 // indirect 48 | golang.org/x/text v0.23.0 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /typeboard/go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 2 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 3 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 4 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 5 | github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= 6 | github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 7 | github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= 8 | github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 9 | github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= 10 | github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= 11 | github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= 12 | github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 13 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 14 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 15 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 16 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 17 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 18 | github.com/gofiber/contrib/monitor v0.1.0 h1:H1z4M6JDVfaJoDPr2APegkJicU9MOfGX1zlfQ/+Ide8= 19 | github.com/gofiber/contrib/monitor v0.1.0/go.mod h1:MPfxBjHqo4MwuA93y/+xhtb0AwEg4v1Mj31HXxS0PmI= 20 | github.com/gofiber/contrib/websocket v1.3.3 h1:R6DlDKieGPMiDrqYNyobsHbvjqvxMHeCj/lLaca4jg8= 21 | github.com/gofiber/contrib/websocket v1.3.3/go.mod h1:07u6QGMsvX+sx7iGNCl5xhzuUVArWwLQ3tBIH24i+S8= 22 | github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= 23 | github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 24 | github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= 25 | github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk= 26 | github.com/gofiber/schema v1.3.0 h1:K3F3wYzAY+aivfCCEHPufCthu5/13r/lzp1nuk6mr3Q= 27 | github.com/gofiber/schema v1.3.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c= 28 | github.com/gofiber/storage/sqlite3/v2 v2.1.1 h1:drmm7ghsnZINzmdpN12l3IUHd3xRu964hu1+47uekbw= 29 | github.com/gofiber/storage/sqlite3/v2 v2.1.1/go.mod h1:1Rx3S+pGR6NUDz6TLn1hrtTEUllD9AcZNrU3rxm+pkc= 30 | github.com/gofiber/utils/v2 v2.0.0-beta.8 h1:ZifwbHZqZO3YJsx1ZhDsWnPjaQ7C0YD20LHt+DQeXOU= 31 | github.com/gofiber/utils/v2 v2.0.0-beta.8/go.mod h1:1lCBo9vEF4RFEtTgWntipnaScJZQiM8rrsYycLZ4n9c= 32 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 33 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 34 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 35 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 36 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 37 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 38 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 39 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 40 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= 41 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= 42 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= 43 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 44 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 45 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 46 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 47 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 48 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 49 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 50 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 51 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 52 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 53 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 54 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 55 | github.com/mattn/go-sqlite3 v1.14.26 h1:h72fc7d3zXGhHpwjWw+fPOBxYUupuKlbhUAQi5n6t58= 56 | github.com/mattn/go-sqlite3 v1.14.26/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 57 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= 58 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 59 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= 60 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 61 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 62 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 63 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 64 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 65 | github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= 66 | github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= 67 | github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= 68 | github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= 69 | github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= 70 | github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= 71 | github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= 72 | github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= 73 | github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= 74 | github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= 75 | github.com/tursodatabase/go-libsql v0.0.0-20250401144753-0be9a6ec7849 h1:unrMd0PSX4/JY7gbdQ8qlc/FVJRbi6fjW+spSHJgRoI= 76 | github.com/tursodatabase/go-libsql v0.0.0-20250401144753-0be9a6ec7849/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= 77 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 78 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 79 | github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= 80 | github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= 81 | github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw= 82 | github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc= 83 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 84 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 85 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 86 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 87 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 88 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 89 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 90 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 91 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 92 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= 93 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 94 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 95 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 96 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= 97 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 98 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 99 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 100 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 104 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 105 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 106 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 107 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 108 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 109 | -------------------------------------------------------------------------------- /typeboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TypeBoard 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /typeboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeboard", 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "preview": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@radix-ui/react-avatar": "^1.1.3", 11 | "@radix-ui/react-slot": "^1.1.2", 12 | "class-variance-authority": "^0.7.1", 13 | "clsx": "^2.1.1", 14 | "lucide-react": "^0.363.0", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-router-dom": "^7.4.1", 18 | "tailwind-merge": "^2.6.0", 19 | "tailwindcss-animate": "^1.0.7" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.11.30", 23 | "@types/react": "^18.2.66", 24 | "@types/react-dom": "^18.2.22", 25 | "@vitejs/plugin-react": "^4.2.1", 26 | "autoprefixer": "^10.4.18", 27 | "postcss": "^8.4.35", 28 | "tailwindcss": "^3.4.1", 29 | "typescript": "^5.2.2", 30 | "vite": "^5.1.6" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /typeboard/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /typeboard/public/placeholder-logo.png: -------------------------------------------------------------------------------- 1 | �PNG 2 |  3 | IHDR�M��0PLTEZ? tRNS� �@��`P0p���w �IDATx��ؽJ3Q�7'��%�|?� ���E�l�7���(X�D������w`����[�*t����D���mD�}��4; ;�DDDDDDDDDDDD_�_İ��!�y�`�_�:�� ;Ļ�'|� ��;.I"����3*5����J�1�� �T��FI�� ��=��3܃�2~�b���0��U9\��]�4�#w0��Gt\&1 �?21,���o!e�m��ĻR�����5�� ؽAJ�9��R)�5�0.FFASaǃ�T�#|�K���I�������1� 4 | M������N"��$����G�V�T� ��T^^��A�$S��h(�������G]co"J׸^^�'�=���%� �W�6Ы�W��w�a�߇*�^^�YG�c���`'F����������������^5_�,�S�%IEND�B`� -------------------------------------------------------------------------------- /typeboard/public/placeholder-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typeboard/public/placeholder-user.jpg: -------------------------------------------------------------------------------- 1 | ����JFIF��C 2 |   3 |  4 | $ &%# #"(-90(*6+"#2D26;=@@@&0FKE>J9?@=��C  =)#)==================================================���������� ـ|�r4�-�̈"x�'�0�Í��8�H�N�q�����Q�������V�`=�($q"_�� 5 | �S8�P��0VFbP��! 6 | Io40��[?p#�|� @!.E�3��4pBq �Z s���C  AQR�!1@Ua��02Tq���56cps�� "#$PS�����?��R���,�� ��� 7 | �n�k��n8rZ�����9Vv��V��ms$9zWʏh�-+@Z�2�PGE������EY9��i�Ͻ�S ��O��Ȕ��_I��W髵�}�����B�ՎT��>%r �[e/,W�D}��D�>b�e>�v�Z�p&�*VS��V�sV�c�:��~K�������C��:��k�'An|ʶ�}\��C� �f����a�;�h��J����q!i=���"�q�NF�IZ�`�wĝ5hAj� 8 | �RXl䎉�lk���@I�%l��Ն���FDY-����Eq�i����O�I�_�2b�lNj�Yu��k���AO����٣��ܭ�n��cam�jN�j���VL�}� ;��oކ6��շs��,���ք���l�i����l{I�O��(!%J $ ���n�-@G����n��ܮi!�괁G�:�^��n�g3l�F%���9]�Pq��)�:��� @�*ɍmׅ�VLY'�s+�z ���V�m�J9��S�_���#��;�����NJ!5�#�q\�M@��@�]yz�����A;e�k��@�s�^���G����\�5F��(��S��Ly���c�i8�����o�8T��i�N��7D����t-�p�3`r�q r�;|�.��bTG��[i H��͚-� 9 | ��Oj�H����M�ؒFE�{�3X�n���e� �R3/�~����� 10 | ��a����!�j&@^r�����Y�������l�Z? �7땵��)ki�w��\.�u�����X��\.�u�����X��\.�u�����X��\.�u��p�M����o(N��3�Vg�����Z�s��%�\�]q}d�k\_Y5���MG�����Q��q}d�k\_Y5���MG�����Q��q}d�kV|5���8���//�������?�����?�� -------------------------------------------------------------------------------- /typeboard/public/placeholder.jpg: -------------------------------------------------------------------------------- 1 | ����JFIFHH���ExifMM*JR(�iZHH�����8Photoshop 3.08BIM8BIM%��ُ�� ���B~���� 2 | ���s!1"AQ2aq#� �B�R3�$b0�r�C�4��S@%c5�s�PD���&T6d�t�`҄�p�'E7e�Uu��Å��Fv��GVf� 3 | ()*89:HIJWXYZghijwxyz����������������������������������������������������������� 4 | ����! 1A0"2Q@3#aBqR4�P$��C�b5S��%`�D�r��c6p&ET�'�� 5 | ()*789:FGHIJUVWXYZdefghijstuvwxyz�����������������������������������������������������������������������������C  6 |  7 | 8 | ")$+*($''-2@7-0=0''8L9=CEHIH+6OUNFT@GHE��C !!E.'.EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE�� �k����?��?��?��3 !1AQaq��������� 0@P`p���������?!��� ��3 !1AQa q𑁡�����0@P`p���������?���?���?��� -------------------------------------------------------------------------------- /typeboard/public/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typeboard/public/vite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /typeboard/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "log/slog" 8 | "os" 9 | "time" 10 | 11 | "github.com/goccy/go-json" 12 | "github.com/gofiber/fiber/v2" 13 | "github.com/gofiber/fiber/v2/middleware/cache" 14 | "github.com/gofiber/fiber/v2/middleware/session" 15 | "github.com/gofiber/storage/sqlite3/v2" 16 | "github.com/golang-jwt/jwt/v5" 17 | "golang.org/x/oauth2" 18 | "golang.org/x/oauth2/github" 19 | 20 | _ "github.com/mattn/go-sqlite3" 21 | ) 22 | 23 | type TypeTestRequest struct { 24 | Wpm int `json:"wpm"` 25 | Accuracy int `json:"accuracy"` 26 | } 27 | 28 | type Claims struct { 29 | Username string `json:"username"` 30 | jwt.RegisteredClaims 31 | } 32 | 33 | type User struct { 34 | ID int `json:"id"` 35 | Login string `json:"login"` 36 | Name string `json:"name"` 37 | AvatarURL string `json:"avatar_url"` 38 | } 39 | 40 | type LeaderboardEntry struct { 41 | Login string `json:"login"` 42 | WPM int `json:"max_wpm"` 43 | Accuracy int `json:"accuracy"` 44 | } 45 | 46 | var ( 47 | githubOAuthConfig *oauth2.Config 48 | jwtSecret []byte 49 | store *session.Store 50 | typeboardDb *sql.DB 51 | ) 52 | 53 | func init() { 54 | githubOAuthConfig = &oauth2.Config{ 55 | ClientID: os.Getenv("GITHUB_CLIENT_ID"), 56 | ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), 57 | RedirectURL: os.Getenv("OAUTH_REDIRECT_URL"), 58 | Scopes: []string{"read:user"}, 59 | Endpoint: github.Endpoint, 60 | } 61 | jwtSecret = []byte(os.Getenv("JWT_SECRET")) 62 | sessionStore := sqlite3.New() 63 | store = session.New( 64 | session.Config{ 65 | Storage: sessionStore, 66 | }) 67 | 68 | db, err := sql.Open("sqlite3", "./typeboard.sqlite3?_busy_timeout=5000&_journal_mode=WAL") 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | db.SetMaxOpenConns(25) 73 | db.SetMaxIdleConns(10) 74 | typeboardDb = db 75 | 76 | _, err = typeboardDb.Exec(` 77 | CREATE TABLE IF NOT EXISTS users ( 78 | id INTEGER PRIMARY KEY, 79 | login TEXT NOT NULL UNIQUE, 80 | name TEXT, 81 | avatar_url TEXT 82 | ); 83 | 84 | CREATE TABLE IF NOT EXISTS typetests ( 85 | id INTEGER PRIMARY KEY AUTOINCREMENT, 86 | wpm INTEGER NOT NULL, 87 | accuracy INTEGER NOT NULL, 88 | userid INTEGER NOT NULL, 89 | FOREIGN KEY (userid) REFERENCES users(id) 90 | ); 91 | 92 | CREATE INDEX IF NOT EXISTS idx_typetests_wpm ON typetests(wpm); 93 | CREATE INDEX IF NOT EXISTS idx_typetests_userid ON typetests(userid); 94 | `) 95 | 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | } 100 | 101 | func (u *User) InsertNewUser() { 102 | _, err := typeboardDb.Exec(` 103 | INSERT INTO users (id, login, name, avatar_url) 104 | VALUES (?, ?, ?, ?) 105 | `, u.ID, u.Login, u.Name, u.AvatarURL) 106 | if err != nil { 107 | fmt.Println(err) 108 | } 109 | } 110 | 111 | func (u *User) InsertNewRecord(t *TypeTestRequest) { 112 | _, err := typeboardDb.Exec(` 113 | INSERT INTO typetests (wpm, accuracy, userid) 114 | VALUES (?, ?, ?) 115 | `, t.Wpm, t.Accuracy, u.ID) 116 | if err != nil { 117 | fmt.Println(err) 118 | } 119 | } 120 | 121 | func (u *User) GetUserByID() *User { 122 | row := typeboardDb.QueryRow(` 123 | SELECT id, login, name, avatar_url 124 | FROM users 125 | WHERE id = ? 126 | `, u.ID) 127 | 128 | var user User 129 | var nullableName sql.NullString 130 | 131 | err := row.Scan(&user.ID, &user.Login, &nullableName, &user.AvatarURL) 132 | if err == sql.ErrNoRows { 133 | return nil 134 | } 135 | 136 | if err != nil { 137 | fmt.Println(err) 138 | return nil 139 | } 140 | if nullableName.Valid { 141 | user.Name = nullableName.String 142 | } else { 143 | user.Name = "" 144 | } 145 | 146 | return &user 147 | } 148 | 149 | func GetUserByLogin(login string) *User { 150 | row := typeboardDb.QueryRow(` 151 | SELECT id, login, name, avatar_url 152 | FROM users 153 | WHERE login = ? 154 | `, login) 155 | 156 | var user User 157 | var nullableName sql.NullString 158 | 159 | err := row.Scan(&user.ID, &user.Login, &nullableName, &user.AvatarURL) 160 | if err == sql.ErrNoRows { 161 | return nil 162 | } 163 | 164 | if err != nil { 165 | fmt.Println(err) 166 | return nil 167 | } 168 | if nullableName.Valid { 169 | user.Name = nullableName.String 170 | } else { 171 | user.Name = "" 172 | } 173 | 174 | return &user 175 | } 176 | 177 | func GetLeaderBoardData() []LeaderboardEntry { 178 | rows, err := typeboardDb.Query(` 179 | SELECT 180 | users.login, 181 | MAX(typetests.wpm) as max_wpm, 182 | (SELECT accuracy FROM typetests t 183 | WHERE t.userid = users.id 184 | ORDER BY wpm DESC LIMIT 1) as best_accuracy 185 | FROM typetests 186 | JOIN users ON typetests.userid = users.id 187 | GROUP BY users.id 188 | ORDER BY max_wpm DESC 189 | LIMIT 10; 190 | `) 191 | if err != nil { 192 | return nil 193 | } 194 | defer rows.Close() 195 | 196 | var leaderboard []LeaderboardEntry 197 | for rows.Next() { 198 | var entry LeaderboardEntry 199 | if err := rows.Scan(&entry.Login, &entry.WPM, &entry.Accuracy); err != nil { 200 | return nil 201 | } 202 | leaderboard = append(leaderboard, entry) 203 | } 204 | return leaderboard 205 | } 206 | 207 | func HealthCheck(c *fiber.Ctx) error { 208 | return c.SendString("all ok 👍🏻") 209 | } 210 | 211 | func InitiateGitHubLogin(c *fiber.Ctx) error { 212 | state := fmt.Sprintf("%d", time.Now().UnixNano()) 213 | 214 | sess, err := store.Get(c) 215 | if err != nil { 216 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 217 | "error": "Failed to create session", 218 | }) 219 | } 220 | sess.Set("oauth_state", state) 221 | if err := sess.Save(); err != nil { 222 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 223 | "error": "Failed to save session", 224 | }) 225 | } 226 | 227 | url := githubOAuthConfig.AuthCodeURL(state) 228 | return c.Redirect(url) 229 | } 230 | 231 | func HandleGitHubCallback(c *fiber.Ctx) error { 232 | code := c.Query("code") 233 | returnedState := c.Query("state") 234 | 235 | sess, err := store.Get(c) 236 | if err != nil { 237 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 238 | "error": "Failed to get session", 239 | }) 240 | } 241 | 242 | originalState := sess.Get("oauth_state") 243 | if originalState == nil || originalState.(string) != returnedState { 244 | return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ 245 | "error": "Invalid OAuth state", 246 | }) 247 | } 248 | 249 | token, err := githubOAuthConfig.Exchange(c.Context(), code) 250 | if err != nil { 251 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 252 | "error": "Failed to exchange code for token", 253 | }) 254 | } 255 | client := githubOAuthConfig.Client(c.Context(), token) 256 | resp, err := client.Get("https://api.github.com/user") 257 | if err != nil { 258 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 259 | "error": "Failed to get user info from GitHub", 260 | }) 261 | } 262 | defer resp.Body.Close() 263 | 264 | var githubUser User 265 | if err := json.NewDecoder(resp.Body).Decode(&githubUser); err != nil { 266 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 267 | "error": "Failed to parse user info", 268 | }) 269 | } 270 | 271 | if githubUser.GetUserByID() == nil { 272 | githubUser.InsertNewUser() 273 | slog.Info("new user added", githubUser.Login, githubUser.ID) 274 | } else { 275 | slog.Info("old user login", githubUser.Login, githubUser.ID) 276 | } 277 | 278 | expirationTime := time.Now().Add(24 * time.Hour) 279 | claims := &Claims{ 280 | Username: githubUser.Login, 281 | RegisteredClaims: jwt.RegisteredClaims{ 282 | ExpiresAt: jwt.NewNumericDate(expirationTime), 283 | }, 284 | } 285 | 286 | token2 := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 287 | tokenString, err := token2.SignedString(jwtSecret) 288 | if err != nil { 289 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ 290 | "error": "Failed to generate token", 291 | }) 292 | } 293 | 294 | cookie := fiber.Cookie{ 295 | Name: "token", 296 | Value: tokenString, 297 | Expires: expirationTime, 298 | HTTPOnly: true, 299 | Path: "/", 300 | } 301 | c.Cookie(&cookie) 302 | 303 | return c.Status(fiber.StatusOK).Redirect("/") 304 | } 305 | 306 | func GetAuthStatus(c *fiber.Ctx) error { 307 | username := c.Locals("username") 308 | 309 | if username == nil { 310 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ 311 | "authenticated": false, 312 | }) 313 | } 314 | 315 | row := typeboardDb.QueryRow(` 316 | SELECT id, login, name, avatar_url 317 | FROM users 318 | WHERE login = ? 319 | `, username.(string)) 320 | 321 | var user User 322 | var nullableName sql.NullString 323 | 324 | err := row.Scan(&user.ID, &user.Login, &nullableName, &user.AvatarURL) 325 | if err == sql.ErrNoRows { 326 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ 327 | "authenticated": false, 328 | }) 329 | } 330 | if err != nil { 331 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ 332 | "authenticated": false, 333 | }) 334 | } 335 | if nullableName.Valid { 336 | user.Name = nullableName.String 337 | } else { 338 | user.Name = "" 339 | } 340 | 341 | return c.Status(fiber.StatusOK).JSON(fiber.Map{ 342 | "username": user.Login, 343 | "avatar_url": user.AvatarURL, 344 | }) 345 | } 346 | func AuthMiddleware() fiber.Handler { 347 | return func(c *fiber.Ctx) error { 348 | tokenString := "" 349 | cookie := c.Cookies("token") 350 | if cookie != "" { 351 | tokenString = cookie 352 | } else if len(tokenString) > 7 && tokenString[:7] == "Bearer " { 353 | tokenString = tokenString[7:] 354 | } 355 | 356 | if tokenString == "" { 357 | return c.Next() 358 | } 359 | 360 | claims := &Claims{} 361 | token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { 362 | return jwtSecret, nil 363 | }) 364 | 365 | if err != nil || !token.Valid { 366 | return c.Next() 367 | } 368 | 369 | c.Locals("username", claims.Username) 370 | return c.Next() 371 | } 372 | } 373 | 374 | func SubmitTypeTest(c *fiber.Ctx) error { 375 | username := c.Locals("username") 376 | 377 | if username == nil { 378 | return c.SendStatus(200) 379 | } 380 | 381 | u := GetUserByLogin(username.(string)) 382 | 383 | t := new(TypeTestRequest) 384 | if err := c.BodyParser(&t); err != nil { 385 | return err 386 | } 387 | 388 | u.InsertNewRecord(t) 389 | 390 | return c.SendStatus(200) 391 | } 392 | 393 | func GetLeaderBoard(c *fiber.Ctx) error { 394 | return c.JSON(GetLeaderBoardData()) 395 | } 396 | 397 | func main() { 398 | logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) 399 | slog.SetDefault(logger) 400 | 401 | app := fiber.New(fiber.Config{ 402 | JSONEncoder: json.Marshal, 403 | JSONDecoder: json.Unmarshal, 404 | Prefork: true, 405 | }) 406 | 407 | app.Use(AuthMiddleware()) 408 | 409 | app.Get("/api/login", InitiateGitHubLogin) 410 | app.Get("/api/login/callback", HandleGitHubCallback) 411 | app.Get("/api/auth/status", GetAuthStatus) 412 | 413 | app.Get("/api/health_check", HealthCheck) 414 | app.Post("/api/submit", SubmitTypeTest) 415 | app.Get("/api/leaderboard", cache.New(cache.Config{ 416 | Expiration: 30 * time.Second, 417 | CacheControl: true, 418 | }), GetLeaderBoard) 419 | 420 | app.Static("/", "/root/typeboard/dist/") 421 | app.Get("/*", func(ctx *fiber.Ctx) error { 422 | return ctx.SendFile("/root/typeboard/dist/index.html") 423 | }) 424 | 425 | app.Listen(":3000") 426 | } 427 | -------------------------------------------------------------------------------- /typeboard/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from "react-router-dom"; 2 | import Layout from "@/components/Layout"; 3 | import TypingTest from "@/components/TypingTest"; 4 | import Leaderboard from "@/components/Leaderboard"; 5 | import AuthCallback from "./components/AuthCallback"; 6 | 7 | export default function App() { 8 | return ( 9 | 10 | } /> 11 | }> 12 | } /> 13 | } /> 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /typeboard/src/components/AuthCallback.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | export default function AuthCallback() { 5 | const navigate = useNavigate(); 6 | 7 | useEffect(() => { 8 | navigate("/"); 9 | }, [navigate]); 10 | 11 | return ( 12 |
13 |
14 |
15 |

Completing login...

16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /typeboard/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, NavLink } from "react-router-dom"; 2 | import { Github } from "lucide-react"; 3 | import { Button } from "@/components/ui/button"; 4 | import { useAuth } from "@/context/AuthContext"; 5 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 6 | 7 | export default function Layout() { 8 | const { user, isLoading, login } = useAuth(); 9 | 10 | return ( 11 |
12 |
13 |
14 |
15 | {isLoading ? ( 16 |
17 | ) : user ? ( 18 |
19 | 20 | 21 | 22 | {user.username.substring(0, 2).toUpperCase()} 23 | 24 | 25 | {user.username} 26 |
27 | ) : ( 28 | 36 | )} 37 |
38 | 39 | 58 | 59 |
60 |
61 |
62 | 63 |
64 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /typeboard/src/components/Leaderboard.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Trophy } from "lucide-react"; 3 | import { useLeaderboard } from "@/hooks/useLeaderboard"; 4 | import LeaderboardEntry from "@/components/LeaderboardEntry"; 5 | 6 | export default function Leaderboard() { 7 | const { leaderboard, isLoading, error, refetch } = useLeaderboard(); 8 | 9 | return ( 10 |
11 |
12 |

13 | 14 | Global Leaderboard 15 | 16 | Auto-refreshes every 30s 17 | 18 |

19 |
20 | 21 |
22 |
23 |
#
24 |
User
25 |
WPM
26 |
Accuracy
27 |
28 | 29 | {isLoading ? ( 30 | Array.from({ length: 10 }).map((_, index) => ( 31 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | )) 52 | ) : error ? ( 53 |
54 |

Failed to load leaderboard

55 | 63 |
64 | ) : leaderboard.length === 0 ? ( 65 |
66 |

No leaderboard data available

67 |
68 | ) : ( 69 |
70 | {leaderboard.map((entry, index) => ( 71 | 72 | ))} 73 |
74 | )} 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /typeboard/src/components/LeaderboardEntry.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Trophy, Medal, Award } from "lucide-react"; 3 | import type { LeaderboardEntryWithId } from "@/hooks/useLeaderboard"; 4 | import { cn } from "@/lib/utils"; 5 | import { useAuth } from "@/context/AuthContext"; 6 | 7 | interface LeaderboardEntryProps { 8 | entry: LeaderboardEntryWithId; 9 | index: number; 10 | } 11 | 12 | export default function LeaderboardEntry({ 13 | entry, 14 | index, 15 | }: LeaderboardEntryProps) { 16 | const { user } = useAuth(); 17 | const [highlight, setHighlight] = useState(false); 18 | const [animatePosition, setAnimatePosition] = useState(false); 19 | const isCurrentUser = user?.username === entry.login; 20 | 21 | useEffect(() => { 22 | if (entry.isNew || entry.hasChanged) { 23 | setHighlight(true); 24 | const timer = setTimeout(() => { 25 | setHighlight(false); 26 | }, 2000); 27 | return () => clearTimeout(timer); 28 | } 29 | }, [entry.isNew, entry.hasChanged, entry.max_wpm, entry.accuracy]); 30 | 31 | useEffect(() => { 32 | if (entry.previousPosition && entry.previousPosition !== entry.position) { 33 | setAnimatePosition(true); 34 | const timer = setTimeout(() => { 35 | setAnimatePosition(false); 36 | }, 2000); 37 | return () => clearTimeout(timer); 38 | } 39 | }, [entry.position, entry.previousPosition]); 40 | 41 | const positionChange = 42 | entry.previousPosition && entry.position < entry.previousPosition 43 | ? "up" 44 | : entry.previousPosition && entry.position > entry.previousPosition 45 | ? "down" 46 | : null; 47 | 48 | return ( 49 |
60 |
61 | {index === 0 ? ( 62 | 63 | ) : index === 1 ? ( 64 | 65 | ) : index === 2 ? ( 66 | 67 | ) : ( 68 |
69 | {index + 1} 70 | {positionChange && ( 71 | 82 | {positionChange === "up" ? "↑" : "↓"} 83 | 84 | )} 85 |
86 | )} 87 |
88 | 89 |
90 | {entry.login} 91 | {isCurrentUser && ( 92 | 93 | You 94 | 95 | )} 96 |
97 | 98 |
107 | {entry.max_wpm} 108 |
109 | 110 |
116 | {entry.accuracy}% 117 |
118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /typeboard/src/components/TypingTest.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | import { useCallback, useEffect, useRef, useState } from "react"; 4 | import { Button } from "./ui/button"; 5 | import { generateText } from "../lib/text-generator"; 6 | import { RefreshCw } from "lucide-react"; 7 | 8 | export default function TypingTest() { 9 | const [_text, setText] = useState(""); 10 | const [words, setWords] = useState([]); 11 | const [typedText, setTypedText] = useState(""); 12 | const [currentWordIndex, setCurrentWordIndex] = useState(0); 13 | const [currentCharIndex, setCurrentCharIndex] = useState(0); 14 | const [timer, setTimer] = useState(30); 15 | const [isActive, setIsActive] = useState(false); 16 | const [isFinished, setIsFinished] = useState(false); 17 | const [startTime, setStartTime] = useState(0); 18 | const [correctChars, setCorrectChars] = useState(0); 19 | const [totalChars, setTotalChars] = useState(0); 20 | const [wpm, setWpm] = useState(0); 21 | const [accuracy, setAccuracy] = useState(100); 22 | 23 | const hiddenInputRef = useRef(null); 24 | const containerRef = useRef(null); 25 | 26 | useEffect(() => { 27 | resetTest(); 28 | }, []); 29 | 30 | useEffect(() => { 31 | let interval: ReturnType | null = null; 32 | 33 | if (isActive && timer > 0) { 34 | interval = setInterval(() => { 35 | setTimer((prevTimer) => prevTimer - 1); 36 | }, 1000); 37 | } else if (timer === 0 && isActive) { 38 | if (interval) clearInterval(interval); 39 | finishTest(); 40 | } 41 | 42 | return () => { 43 | if (interval) clearInterval(interval); 44 | }; 45 | }, [isActive, timer]); 46 | 47 | useEffect(() => { 48 | if (isActive && !isFinished) { 49 | const timeElapsed = (Date.now() - startTime) / 1000 / 60; // in minutes 50 | if (timeElapsed > 0) { 51 | const currentWpm = Math.round(correctChars / 5 / timeElapsed); 52 | setWpm(currentWpm); 53 | } 54 | } 55 | }, [isActive, correctChars, startTime, isFinished]); 56 | 57 | useEffect(() => { 58 | const focusInput = () => { 59 | if (hiddenInputRef.current && !isFinished) { 60 | hiddenInputRef.current.focus(); 61 | } 62 | }; 63 | 64 | if (containerRef.current) { 65 | containerRef.current.addEventListener("click", focusInput); 66 | } 67 | 68 | return () => { 69 | if (containerRef.current) { 70 | containerRef.current.removeEventListener("click", focusInput); 71 | } 72 | }; 73 | }, [isFinished]); 74 | 75 | const startTest = useCallback(() => { 76 | if (!isActive && !isFinished) { 77 | setIsActive(true); 78 | setStartTime(Date.now()); 79 | } 80 | }, [isActive, isFinished]); 81 | 82 | const finishTest = useCallback(() => { 83 | setIsActive(false); 84 | setIsFinished(true); 85 | 86 | const timeElapsed = (Date.now() - startTime) / 1000 / 60; 87 | const finalWpm = Math.round( 88 | correctChars / 5 / (timeElapsed > 0 ? timeElapsed : 0.01), 89 | ); 90 | const finalAccuracy = 91 | totalChars > 0 ? Math.round((correctChars / totalChars) * 100) : 0; 92 | 93 | const sendData = async (wpm: number, accuracy: number) => { 94 | try { 95 | await fetch("/api/submit", { 96 | credentials: "include", 97 | method: "POST", 98 | headers: { 99 | "Content-Type": "application/json", 100 | }, 101 | body: JSON.stringify({ wpm, accuracy }), 102 | }); 103 | } catch (error) { 104 | console.error(error); 105 | } 106 | }; 107 | 108 | sendData(finalWpm, finalAccuracy); 109 | setWpm(finalWpm); 110 | setAccuracy(finalAccuracy); 111 | }, [startTime, correctChars, totalChars]); 112 | 113 | const resetTest = useCallback(() => { 114 | const newText = generateText(); 115 | const newWords = newText.split(" "); 116 | 117 | setText(newText); 118 | setWords(newWords); 119 | setTypedText(""); 120 | setCurrentWordIndex(0); 121 | setCurrentCharIndex(0); 122 | setTimer(30); 123 | setIsActive(false); 124 | setIsFinished(false); 125 | setStartTime(0); 126 | setCorrectChars(0); 127 | setTotalChars(0); 128 | setWpm(0); 129 | setAccuracy(100); 130 | 131 | if (hiddenInputRef.current) { 132 | hiddenInputRef.current.focus(); 133 | } 134 | }, []); 135 | 136 | const handleKeyDown = (e: React.KeyboardEvent) => { 137 | if (isFinished) return; 138 | 139 | if (!isActive) { 140 | startTest(); 141 | } 142 | 143 | if (e.key === " ") { 144 | e.preventDefault(); 145 | 146 | if (currentWordIndex < words.length - 1) { 147 | const currentWord = words[currentWordIndex]; 148 | const typedWord = typedText.split(" ")[currentWordIndex] || ""; 149 | 150 | let correctInWord = 0; 151 | for ( 152 | let i = 0; 153 | i < Math.min(typedWord.length, currentWord.length); 154 | i++ 155 | ) { 156 | if (typedWord[i] === currentWord[i]) { 157 | correctInWord++; 158 | } 159 | } 160 | 161 | setCorrectChars((prev) => prev + correctInWord); 162 | setTotalChars((prev) => prev + currentWord.length); 163 | 164 | setTypedText((prev) => prev + " "); 165 | setCurrentWordIndex((prev) => prev + 1); 166 | setCurrentCharIndex(0); 167 | } 168 | return; 169 | } 170 | 171 | if (e.key === "Backspace") { 172 | if (typedText.length > 0) { 173 | if (currentCharIndex === 0 && currentWordIndex > 0) { 174 | const newTypedText = typedText.slice(0, -1); // Remove the space 175 | setTypedText(newTypedText); 176 | setCurrentWordIndex((prev) => prev - 1); 177 | const prevWord = words[currentWordIndex - 1]; 178 | setCurrentCharIndex(prevWord.length); 179 | } else if (currentCharIndex > 0) { 180 | const newTypedText = typedText.slice(0, -1); 181 | setTypedText(newTypedText); 182 | setCurrentCharIndex((prev) => prev - 1); 183 | } 184 | } 185 | return; 186 | } 187 | 188 | if (e.key.length > 1) return; 189 | 190 | const currentWord = words[currentWordIndex]; 191 | 192 | if (currentCharIndex < currentWord.length) { 193 | const newTypedText = typedText + e.key; 194 | setTypedText(newTypedText); 195 | setCurrentCharIndex((prev) => prev + 1); 196 | 197 | if (e.key === currentWord[currentCharIndex]) { 198 | } 199 | 200 | if ( 201 | currentWordIndex === words.length - 1 && 202 | currentCharIndex === currentWord.length - 1 203 | ) { 204 | finishTest(); 205 | } 206 | } 207 | }; 208 | 209 | const renderWords = () => { 210 | return words.map((word, wordIndex) => { 211 | const isCurrentWord = wordIndex === currentWordIndex; 212 | 213 | const typedWords = typedText.split(" "); 214 | const typedWord = typedWords[wordIndex] || ""; 215 | 216 | return ( 217 |
221 | {word.split("").map((char, charIndex) => { 222 | let className = "text-zinc-500"; 223 | 224 | if (wordIndex < typedWords.length) { 225 | if (wordIndex < currentWordIndex) { 226 | className = 227 | typedWord[charIndex] === char 228 | ? "text-zinc-300" 229 | : "text-red-400"; 230 | } else if (wordIndex === currentWordIndex) { 231 | if (charIndex < typedWord.length) { 232 | className = 233 | typedWord[charIndex] === char 234 | ? "text-zinc-300" 235 | : "text-red-400"; 236 | } else if (charIndex === typedWord.length && isCurrentWord) { 237 | className = 238 | "text-zinc-300 border-b-2 border-emerald-500 animate-pulse"; 239 | } else { 240 | className = "text-zinc-500"; 241 | } 242 | } 243 | } 244 | 245 | return ( 246 | 247 | {char} 248 | 249 | ); 250 | })} 251 | {isCurrentWord && typedWord.length > word.length && ( 252 | 253 | {typedWord 254 | .slice(word.length) 255 | .split("") 256 | .map((_, i) => ( 257 | 258 | ))} 259 | 260 | )} 261 |
262 | ); 263 | }); 264 | }; 265 | 266 | return ( 267 |
268 |
269 |
270 |
271 |
272 | wpm 273 |
274 |
275 | {wpm} 276 |
277 |
278 |
279 |
280 | time 281 |
282 |
285 | {timer}s 286 |
287 |
288 |
289 | 290 | 299 |
300 | 301 |
306 |
307 | {renderWords()} 308 |
309 | 310 | 318 | 319 | {isFinished && ( 320 |
321 |
322 |

Test Complete!

323 |
324 |
325 |
326 | WPM 327 |
328 |
{wpm}
329 |
330 |
331 |
332 | Accuracy 333 |
334 |
{accuracy}%
335 |
336 |
337 | 343 |
344 |
345 | )} 346 |
347 | 348 |
349 | press{" "} 350 | space{" "} 351 | to skip to next word{" "} 352 |
353 |
354 | ); 355 | } 356 | -------------------------------------------------------------------------------- /typeboard/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )); 19 | Avatar.displayName = AvatarPrimitive.Root.displayName; 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )); 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )); 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 47 | 48 | export { Avatar, AvatarImage, AvatarFallback }; 49 | -------------------------------------------------------------------------------- /typeboard/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | }, 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /typeboard/src/context/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useContext, 4 | useState, 5 | useEffect, 6 | type ReactNode, 7 | } from "react"; 8 | 9 | type User = { 10 | username: string; 11 | avatar_url: string; 12 | } | null; 13 | 14 | type AuthContextType = { 15 | user: User; 16 | isLoading: boolean; 17 | login: () => void; 18 | }; 19 | 20 | const AuthContext = createContext(undefined); 21 | 22 | export function AuthProvider({ children }: { children: ReactNode }) { 23 | const [user, setUser] = useState(null); 24 | const [isLoading, setIsLoading] = useState(true); 25 | 26 | useEffect(() => { 27 | const checkLoginStatus = async () => { 28 | try { 29 | const response = await fetch("/api/auth/status", { 30 | credentials: "include", 31 | }); 32 | 33 | if (response.ok) { 34 | const userData = await response.json(); 35 | setUser(userData); 36 | } 37 | } catch (error) { 38 | console.error(error); 39 | } finally { 40 | setIsLoading(false); 41 | } 42 | }; 43 | 44 | checkLoginStatus(); 45 | }, []); 46 | 47 | const login = () => { 48 | window.location.href = "/api/login"; 49 | }; 50 | 51 | return ( 52 | 53 | {children} 54 | 55 | ); 56 | } 57 | 58 | export function useAuth() { 59 | const context = useContext(AuthContext); 60 | if (context === undefined) { 61 | throw new Error("useAuth must be used within an AuthProvider"); 62 | } 63 | return context; 64 | } 65 | -------------------------------------------------------------------------------- /typeboard/src/hooks/useLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | 3 | export type LeaderboardEntry = { 4 | login: string; 5 | max_wpm: number; 6 | accuracy: number; 7 | }; 8 | 9 | export type LeaderboardEntryWithId = LeaderboardEntry & { 10 | id: string; 11 | position: number; 12 | previousPosition?: number; 13 | isNew?: boolean; 14 | hasChanged?: boolean; 15 | }; 16 | 17 | export function useLeaderboard() { 18 | const [leaderboard, setLeaderboard] = useState([]); 19 | const [isLoading, setIsLoading] = useState(true); 20 | const [error, setError] = useState(null); 21 | const previousDataRef = useRef>( 22 | new Map(), 23 | ); 24 | 25 | const fetchLeaderboard = async () => { 26 | try { 27 | const response = await fetch("/api/leaderboard"); 28 | 29 | if (!response.ok) { 30 | throw new Error(`Failed to fetch leaderboard: ${response.status}`); 31 | } 32 | 33 | const data: LeaderboardEntry[] = await response.json(); 34 | 35 | const prevEntries = new Map(); 36 | leaderboard.forEach((entry) => { 37 | prevEntries.set(entry.login, { ...entry }); 38 | }); 39 | 40 | const processedData = data 41 | .map((entry, index) => { 42 | const id = entry.login; 43 | const prevEntry = prevEntries.get(id); 44 | const isNew = !prevEntry; 45 | 46 | const hasChanged = 47 | prevEntry && 48 | (prevEntry.max_wpm !== entry.max_wpm || 49 | prevEntry.accuracy !== entry.accuracy); 50 | 51 | return { 52 | ...entry, 53 | id, 54 | position: index + 1, 55 | previousPosition: prevEntry?.position, 56 | isNew, 57 | hasChanged, 58 | }; 59 | }) 60 | .sort((a, b) => b.max_wpm - a.max_wpm); 61 | 62 | const newEntriesMap = new Map(); 63 | processedData.forEach((entry) => { 64 | newEntriesMap.set(entry.login, entry); 65 | }); 66 | previousDataRef.current = newEntriesMap; 67 | 68 | setLeaderboard(processedData); 69 | setIsLoading(false); 70 | setError(null); 71 | } catch (err) { 72 | console.error("Error fetching leaderboard:", err); 73 | setError( 74 | err instanceof Error ? err.message : "Failed to fetch leaderboard", 75 | ); 76 | setIsLoading(false); 77 | } 78 | }; 79 | 80 | useEffect(() => { 81 | fetchLeaderboard(); 82 | 83 | const intervalId = setInterval(() => { 84 | fetchLeaderboard(); 85 | }, 30000); // 30 seconds 86 | 87 | return () => clearInterval(intervalId); 88 | }, []); 89 | 90 | return { leaderboard, isLoading, error, refetch: fetchLeaderboard }; 91 | } 92 | -------------------------------------------------------------------------------- /typeboard/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 160 84% 39%; 14 | --primary-foreground: 355.7 100% 97.3%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 160 84% 39%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 20 14.3% 4.1%; 31 | --foreground: 0 0% 95%; 32 | --card: 24 9.8% 10%; 33 | --card-foreground: 0 0% 95%; 34 | --popover: 0 0% 9%; 35 | --popover-foreground: 0 0% 95%; 36 | --primary: 160 84% 39%; 37 | --primary-foreground: 144.9 80.4% 10%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 0 0% 15%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 12 6.5% 15.1%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 85.7% 97.3%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 160 84% 39%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | 61 | html, 62 | body { 63 | height: 100%; 64 | overflow-x: hidden; 65 | } 66 | 67 | ::selection { 68 | @apply bg-primary/30 text-white; 69 | } 70 | -------------------------------------------------------------------------------- /typeboard/src/lib/text-generator.ts: -------------------------------------------------------------------------------- 1 | const paragraphs = [ 2 | "the quick brown fox jumps over the lazy dog this pangram contains every letter of the english alphabet at least once pangrams are often used to test fonts keyboards and other text related tools they are also useful for typing practice and speed tests", 3 | "programming is the process of creating a set of instructions that tell a computer how to perform a task programming can be done using a variety of computer programming languages such as javascript python and c", 4 | "the internet is a global network of billions of computers and other electronic devices with the internet it is possible to access almost any information communicate with anyone else in the world and do much more you can do all this by connecting a computer to the internet which is also called going online", 5 | "artificial intelligence is intelligence demonstrated by machines as opposed to natural intelligence displayed by animals including humans leading ai textbooks define the field as the study of intelligent agents any system that perceives its environment and takes actions that maximize its chance of achieving its goals", 6 | "typing speed is typically measured in words per minute the average typing speed is around 40 wpm with professional typists reaching speeds of 65 to 75 wpm the world record for typing speed is over 200 wpm achieved on a standard qwerty keyboard", 7 | "cloud computing is the on demand availability of computer system resources especially data storage and computing power without direct active management by the user the term is generally used to describe data centers available to many users over the internet", 8 | "cybersecurity is the practice of protecting systems networks and programs from digital attacks these cyberattacks are usually aimed at accessing changing or destroying sensitive information extorting money from users or interrupting normal business processes", 9 | "machine learning is a method of data analysis that automates analytical model building it is a branch of artificial intelligence based on the idea that systems can learn from data identify patterns and make decisions with minimal human intervention", 10 | "the world wide web commonly known as the web is an information system where documents and other web resources are identified by uniform resource locators which may be interlinked by hypertext and are accessible over the internet", 11 | "software development is the process of conceiving specifying designing programming documenting testing and bug fixing involved in creating and maintaining applications frameworks or other software components", 12 | ]; 13 | 14 | export function generateText(): string { 15 | const randomIndex = Math.floor(Math.random() * paragraphs.length); 16 | return paragraphs[randomIndex]; 17 | } 18 | -------------------------------------------------------------------------------- /typeboard/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /typeboard/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter } from "react-router-dom"; 3 | import ReactDOM from "react-dom/client"; 4 | import { AuthProvider } from "./context/AuthContext.tsx"; 5 | import App from "./App.tsx"; 6 | import "./index.css"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root")!).render( 9 | 10 | 11 | 12 | 13 | 14 | 15 | , 16 | ); 17 | -------------------------------------------------------------------------------- /typeboard/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 142.1 76.2% 36.3%; 14 | --primary-foreground: 355.7 100% 97.3%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 142.1 76.2% 36.3%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 20 14.3% 4.1%; 31 | --foreground: 0 0% 95%; 32 | --card: 24 9.8% 10%; 33 | --card-foreground: 0 0% 95%; 34 | --popover: 0 0% 9%; 35 | --popover-foreground: 0 0% 95%; 36 | --primary: 142.1 70.6% 45.3%; 37 | --primary-foreground: 144.9 80.4% 10%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 0 0% 15%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 12 6.5% 15.1%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 85.7% 97.3%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 142.4 71.8% 29.2%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | 61 | html, 62 | body { 63 | height: 100%; 64 | overflow-x: hidden; 65 | } 66 | 67 | ::selection { 68 | @apply bg-primary/30 text-white; 69 | } 70 | 71 | @keyframes slideIn { 72 | 0% { 73 | opacity: 0; 74 | transform: translateY(10px); 75 | } 76 | 100% { 77 | opacity: 1; 78 | transform: translateY(0); 79 | } 80 | } 81 | 82 | .animate-slide-in { 83 | animation: slideIn 0.3s ease-out forwards; 84 | } 85 | 86 | @keyframes pulse-scale { 87 | 0%, 88 | 100% { 89 | transform: scale(1); 90 | } 91 | 50% { 92 | transform: scale(1.05); 93 | } 94 | } 95 | 96 | .animate-pulse-scale { 97 | animation: pulse-scale 1s ease-in-out infinite; 98 | } 99 | -------------------------------------------------------------------------------- /typeboard/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | "*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | container: { 11 | center: true, 12 | padding: "2rem", 13 | screens: { 14 | "2xl": "1400px", 15 | }, 16 | }, 17 | extend: { 18 | colors: { 19 | border: "hsl(var(--border))", 20 | input: "hsl(var(--input))", 21 | ring: "hsl(var(--ring))", 22 | background: "hsl(var(--background))", 23 | foreground: "hsl(var(--foreground))", 24 | primary: { 25 | DEFAULT: "hsl(var(--primary))", 26 | foreground: "hsl(var(--primary-foreground))", 27 | }, 28 | secondary: { 29 | DEFAULT: "hsl(var(--secondary))", 30 | foreground: "hsl(var(--secondary-foreground))", 31 | }, 32 | destructive: { 33 | DEFAULT: "hsl(var(--destructive))", 34 | foreground: "hsl(var(--destructive-foreground))", 35 | }, 36 | muted: { 37 | DEFAULT: "hsl(var(--muted))", 38 | foreground: "hsl(var(--muted-foreground))", 39 | }, 40 | accent: { 41 | DEFAULT: "hsl(var(--accent))", 42 | foreground: "hsl(var(--accent-foreground))", 43 | }, 44 | popover: { 45 | DEFAULT: "hsl(var(--popover))", 46 | foreground: "hsl(var(--popover-foreground))", 47 | }, 48 | card: { 49 | DEFAULT: "hsl(var(--card))", 50 | foreground: "hsl(var(--card-foreground))", 51 | }, 52 | chart: { 53 | 1: "hsl(var(--chart-1))", 54 | 2: "hsl(var(--chart-2))", 55 | 3: "hsl(var(--chart-3))", 56 | 4: "hsl(var(--chart-4))", 57 | 5: "hsl(var(--chart-5))", 58 | }, 59 | }, 60 | borderRadius: { 61 | lg: "var(--radius)", 62 | md: "calc(var(--radius) - 2px)", 63 | sm: "calc(var(--radius) - 4px)", 64 | }, 65 | keyframes: { 66 | "accordion-down": { 67 | from: { 68 | height: "0", 69 | }, 70 | to: { 71 | height: "var(--radix-accordion-content-height)", 72 | }, 73 | }, 74 | "accordion-up": { 75 | from: { 76 | height: "var(--radix-accordion-content-height)", 77 | }, 78 | to: { 79 | height: "0", 80 | }, 81 | }, 82 | }, 83 | animation: { 84 | "accordion-down": "accordion-down 0.2s ease-out", 85 | "accordion-up": "accordion-up 0.2s ease-out", 86 | }, 87 | }, 88 | }, 89 | plugins: [require("tailwindcss-animate")], 90 | }; 91 | -------------------------------------------------------------------------------- /typeboard/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | /* Paths */ 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["./src/*"] 27 | } 28 | }, 29 | "include": ["src"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /typeboard/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /typeboard/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------