├── 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�� 0PLTE Z? 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��0 VFbP��!
6 | Io40 ��[?p #�|�@ !.E� 3��4p Bq �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 | ���� JFIF H H �� �Exif MM * J R( �i Z H H � � � �� 8Photoshop 3.0 8BIM 8BIM% ��ُ �� ���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 |
38 |
41 |
44 |
47 |
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