├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── architecture.excalidraw ├── drizzle.config.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── seed.ts ├── src ├── app │ ├── api │ │ └── webhooks │ │ │ └── clerk │ │ │ └── route.ts │ ├── components │ │ └── element.tsx │ ├── config.ts │ ├── db │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── 0000_red_proemial_gods.sql │ │ │ ├── 0001_abandoned_rachel_grey.sql │ │ │ ├── 0002_nappy_obadiah_stane.sql │ │ │ ├── 0003_panoramic_marten_broadcloak.sql │ │ │ ├── 0004_square_falcon.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ ├── 0001_snapshot.json │ │ │ │ ├── 0002_snapshot.json │ │ │ │ ├── 0003_snapshot.json │ │ │ │ ├── 0004_snapshot.json │ │ │ │ └── _journal.json │ │ └── schema.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── log.ts │ ├── page.tsx │ └── voted │ │ └── page.tsx └── middleware.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # This can be found on dashboard.clerk.com 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replaceThisWithRealValues 3 | CLERK_SECRET_KEY=sk_test_replaceThisWithRealValues 4 | 5 | # Get your connection string from console.neon.tech 6 | DATABASE_URL='postgresql://neondb_owner:replacethis@ep-adj-noun-123245.us-east-1.aws.neon.tech/neondb?sslmode=require' 7 | 8 | # Can be found in the webhook endpoints section for 9 | # your application dashboard.clerk.com 10 | CLERK_WEBHOOK_SECRET='whsec_foobar' 11 | 12 | # Set log level to trace. Defaults to "info" if not explicitly defined 13 | LOG_LEVEL=trace 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neon, Clerk, and Vercel Example 2 | 3 | Companion repository for [this article on the Neon blog](https://neon.tech/blog/nextjs-authentication-using-clerk-drizzle-orm-and-neon). 4 | 5 | A live preview of this application with Discord login is available at https://neon-clerk-drizzle-nextjs.vercel.app/. 6 | 7 | A sample application that demonstrates how to use [Clerk](https://clerk.com/) authentication with [Next.js](https://nextjs.org/), and store user identifiers in [Neon's Serverless Postgres](https://neon.tech/github/) using [Drizzle ORM](https://orm.drizzle.team/). 8 | 9 | ## Local Development 10 | 11 | Requires Node.js 18.x. 12 | 13 | 1. Sign up to [Neon](https://neon.tech/github/) to access serverless Postgres by creating a project. 14 | 1. Sign up to [Clerk](https://clerk.com/) for user management and authentication. Create an application that supports sign in using a providers such as Discord and Google; 15 | 1. Clone this repository, install dependencies, and prepare a _.env.local_ file: 16 | ```bash 17 | git clone $REPO_URL neon-clerk-vercel 18 | 19 | cd neon-clerk-vercel 20 | 21 | npm install 22 | 23 | cp .env.example .env.local 24 | ``` 25 | 1. Replace the values in _.env.local_ as follows: 26 | * `DATABASE_URL` - With your Neon [connection string](https://neon.tech/docs/connect/connect-from-any-app)` 27 | * `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` - With the value from the **API Keys** section in the Clerk dashboard. 28 | * `CLERK_SECRET_KEY` - With the value from the **API Keys** section in the Clerk dashboard. 29 | * `CLERK_WEBHOOK_SECRET` - This will be obtained later. 30 | 1. Generate and push the database schemas, and insert seed data: 31 | ```bash 32 | npm run drizzle:generate -- dotenv_config_path=.env.local 33 | npm run drizzle:push -- dotenv_config_path=.env.local 34 | npm run seed -- dotenv_config_path=.env.local 35 | ``` 36 | 37 | > [!TIP] 38 | > Consider creating a separate [Neon database branch(es)](https://neon.tech/docs/manage/branches#create-a-branch) for your development environment(s). 39 | 40 | Since this application uses Clerk webhooks to create user references in the 41 | Neon Postgres database, you need a way to expose the application from your 42 | local network as a public HTTPS endpoint during local development. You can use 43 | [localtunnel](https://www.npmjs.com/package/localtunnel) to do this. 44 | 45 | 1. Open a terminal and start the Next.js application in development mode: 46 | ```bash 47 | npm run dev 48 | ``` 49 | 1. Open another terminal and run the following command to obtain a public HTTPS URL to access your Next.js application: 50 | ```bash 51 | npx localtunnel@2.0 –port 3000 -s $USER 52 | ``` 53 | 1. Go to [dashboard.clerk.com](https://dashboard.clerk.com) and select your application. 54 | 1. Navigate to the **Webhooks** screen. 55 | 1. Click the **Add Endpoint** button. 56 | 1. Enter the public HTTPS URL provided by localtunnel followed by _/api/webhooks/clerk_ in the **Endpoint URL** field. 57 | 1. Under the **Message Filtering** section select the user events. 58 | 1. Click the **Create** button. 59 | 60 | You can now visit http://localhost:3000/ to verify the application is working 61 | end-to-end. The application should display a login page, and once you've logged 62 | in your user's ID should be inserted into your Postgres users table a few 63 | seconds later. 64 | 65 | ## Vercel Deployment 66 | 67 | TODO: Vercel Deploy. 68 | -------------------------------------------------------------------------------- /architecture.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "rectangle", 8 | "version": 654, 9 | "versionNonce": 142368455, 10 | "isDeleted": false, 11 | "id": "nZ9Dc0kjn1tiYxJuMsm93", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 2, 14 | "strokeStyle": "solid", 15 | "roughness": 0, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 2417.6148986435396, 19 | "y": 3174.080093534389, 20 | "strokeColor": "#1e1e1e", 21 | "backgroundColor": "#b2f2bb", 22 | "width": 258.01072980041806, 23 | "height": 237.5663362060164, 24 | "seed": 174392934, 25 | "groupIds": [], 26 | "frameId": null, 27 | "roundness": { 28 | "type": 3 29 | }, 30 | "boundElements": [ 31 | { 32 | "type": "text", 33 | "id": "7a2t7KDEbZs-iBNfgdn3g" 34 | }, 35 | { 36 | "id": "0EMbyffG1-zhFoAMzUYps", 37 | "type": "arrow" 38 | }, 39 | { 40 | "id": "jUkTh90tCmkw8ctSVMWRX", 41 | "type": "arrow" 42 | }, 43 | { 44 | "id": "St3Es2_iIEC9dXxUJWcXM", 45 | "type": "arrow" 46 | } 47 | ], 48 | "updated": 1711494704492, 49 | "link": null, 50 | "locked": false 51 | }, 52 | { 53 | "type": "text", 54 | "version": 332, 55 | "versionNonce": 724757991, 56 | "isDeleted": false, 57 | "id": "7a2t7KDEbZs-iBNfgdn3g", 58 | "fillStyle": "hachure", 59 | "strokeWidth": 2, 60 | "strokeStyle": "dashed", 61 | "roughness": 1, 62 | "opacity": 100, 63 | "angle": 0, 64 | "x": 2468.4282927185527, 65 | "y": 3247.8632616373975, 66 | "strokeColor": "#1e1e1e", 67 | "backgroundColor": "#d0bfff", 68 | "width": 156.38394165039062, 69 | "height": 90, 70 | "seed": 1447983590, 71 | "groupIds": [], 72 | "frameId": null, 73 | "roundness": null, 74 | "boundElements": [], 75 | "updated": 1711494704492, 76 | "link": null, 77 | "locked": false, 78 | "fontSize": 36, 79 | "fontFamily": 1, 80 | "text": "Neon\nPostgres", 81 | "textAlign": "center", 82 | "verticalAlign": "middle", 83 | "containerId": "nZ9Dc0kjn1tiYxJuMsm93", 84 | "originalText": "Neon\nPostgres", 85 | "lineHeight": 1.25 86 | }, 87 | { 88 | "type": "rectangle", 89 | "version": 190, 90 | "versionNonce": 1635730247, 91 | "isDeleted": false, 92 | "id": "h8sLOWMaJE3LSdZzqvVNN", 93 | "fillStyle": "hachure", 94 | "strokeWidth": 1, 95 | "strokeStyle": "solid", 96 | "roughness": 1, 97 | "opacity": 100, 98 | "angle": 0, 99 | "x": 1851.1232253954709, 100 | "y": 3174.6818894380926, 101 | "strokeColor": "#1e1e1e", 102 | "backgroundColor": "#e9ecef", 103 | "width": 251.80565106752763, 104 | "height": 248.2749152703749, 105 | "seed": 2125596518, 106 | "groupIds": [], 107 | "frameId": null, 108 | "roundness": { 109 | "type": 3 110 | }, 111 | "boundElements": [ 112 | { 113 | "type": "text", 114 | "id": "gk_nw5VdK_G5eRb3dcprN" 115 | }, 116 | { 117 | "id": "SJpr2doNnhQlrw6ElMFGB", 118 | "type": "arrow" 119 | }, 120 | { 121 | "id": "3cdIc50uNGGvAbQUg08rE", 122 | "type": "arrow" 123 | }, 124 | { 125 | "id": "jUkTh90tCmkw8ctSVMWRX", 126 | "type": "arrow" 127 | }, 128 | { 129 | "id": "YYHlAuNxgcntiSweB4Arb", 130 | "type": "arrow" 131 | }, 132 | { 133 | "id": "St3Es2_iIEC9dXxUJWcXM", 134 | "type": "arrow" 135 | } 136 | ], 137 | "updated": 1711494704492, 138 | "link": null, 139 | "locked": false 140 | }, 141 | { 142 | "type": "text", 143 | "version": 122, 144 | "versionNonce": 459620967, 145 | "isDeleted": false, 146 | "id": "gk_nw5VdK_G5eRb3dcprN", 147 | "fillStyle": "hachure", 148 | "strokeWidth": 2, 149 | "strokeStyle": "dashed", 150 | "roughness": 1, 151 | "opacity": 100, 152 | "angle": 0, 153 | "x": 1878.3140758315785, 154 | "y": 3253.81934707328, 155 | "strokeColor": "#1e1e1e", 156 | "backgroundColor": "#d0bfff", 157 | "width": 197.4239501953125, 158 | "height": 90, 159 | "seed": 619452774, 160 | "groupIds": [], 161 | "frameId": null, 162 | "roundness": null, 163 | "boundElements": [], 164 | "updated": 1711494704492, 165 | "link": null, 166 | "locked": false, 167 | "fontSize": 36, 168 | "fontFamily": 1, 169 | "text": "Next.js on \nVercel", 170 | "textAlign": "center", 171 | "verticalAlign": "middle", 172 | "containerId": "h8sLOWMaJE3LSdZzqvVNN", 173 | "originalText": "Next.js on Vercel", 174 | "lineHeight": 1.25 175 | }, 176 | { 177 | "type": "rectangle", 178 | "version": 420, 179 | "versionNonce": 1922042151, 180 | "isDeleted": false, 181 | "id": "Np0ajYvtlgJWKQ_mrYrNL", 182 | "fillStyle": "hachure", 183 | "strokeWidth": 1, 184 | "strokeStyle": "solid", 185 | "roughness": 1, 186 | "opacity": 100, 187 | "angle": 0, 188 | "x": 1849.3993493742587, 189 | "y": 3597.370607100792, 190 | "strokeColor": "#1e1e1e", 191 | "backgroundColor": "#d0bfff", 192 | "width": 259.57274582100064, 193 | "height": 233.04274778979288, 194 | "seed": 1117400250, 195 | "groupIds": [], 196 | "frameId": null, 197 | "roundness": { 198 | "type": 3 199 | }, 200 | "boundElements": [ 201 | { 202 | "type": "text", 203 | "id": "ZFhMNueSCNj1EJQK1xF5W" 204 | }, 205 | { 206 | "id": "3cdIc50uNGGvAbQUg08rE", 207 | "type": "arrow" 208 | }, 209 | { 210 | "id": "7CDx8KJpN8Ou9RxUHj9eT", 211 | "type": "arrow" 212 | } 213 | ], 214 | "updated": 1711494704492, 215 | "link": null, 216 | "locked": false 217 | }, 218 | { 219 | "type": "text", 220 | "version": 280, 221 | "versionNonce": 1042435143, 222 | "isDeleted": false, 223 | "id": "ZFhMNueSCNj1EJQK1xF5W", 224 | "fillStyle": "hachure", 225 | "strokeWidth": 2, 226 | "strokeStyle": "dashed", 227 | "roughness": 1, 228 | "opacity": 100, 229 | "angle": 0, 230 | "x": 1936.5077437691339, 231 | "y": 3668.8919809956883, 232 | "strokeColor": "#1e1e1e", 233 | "backgroundColor": "#d0bfff", 234 | "width": 85.35595703125, 235 | "height": 90, 236 | "seed": 282478458, 237 | "groupIds": [], 238 | "frameId": null, 239 | "roundness": null, 240 | "boundElements": [], 241 | "updated": 1711494704492, 242 | "link": null, 243 | "locked": false, 244 | "fontSize": 36, 245 | "fontFamily": 1, 246 | "text": "Clerk\nAuth", 247 | "textAlign": "center", 248 | "verticalAlign": "middle", 249 | "containerId": "Np0ajYvtlgJWKQ_mrYrNL", 250 | "originalText": "Clerk\nAuth", 251 | "lineHeight": 1.25 252 | }, 253 | { 254 | "type": "ellipse", 255 | "version": 241, 256 | "versionNonce": 1589296551, 257 | "isDeleted": false, 258 | "id": "tP2NM8fHquJpLRWGNpeMS", 259 | "fillStyle": "hachure", 260 | "strokeWidth": 1, 261 | "strokeStyle": "solid", 262 | "roughness": 1, 263 | "opacity": 100, 264 | "angle": 0, 265 | "x": 1330.1102812947918, 266 | "y": 3214.5986394783204, 267 | "strokeColor": "#1e1e1e", 268 | "backgroundColor": "#a5d8ff", 269 | "width": 150.05935887249228, 270 | "height": 143.96763158601922, 271 | "seed": 1965001914, 272 | "groupIds": [], 273 | "frameId": null, 274 | "roundness": { 275 | "type": 2 276 | }, 277 | "boundElements": [ 278 | { 279 | "type": "text", 280 | "id": "uoNQSQUrr4aasR7MTUUn1" 281 | }, 282 | { 283 | "id": "SJpr2doNnhQlrw6ElMFGB", 284 | "type": "arrow" 285 | }, 286 | { 287 | "id": "YYHlAuNxgcntiSweB4Arb", 288 | "type": "arrow" 289 | }, 290 | { 291 | "id": "7CDx8KJpN8Ou9RxUHj9eT", 292 | "type": "arrow" 293 | } 294 | ], 295 | "updated": 1711494704492, 296 | "link": null, 297 | "locked": false 298 | }, 299 | { 300 | "type": "text", 301 | "version": 182, 302 | "versionNonce": 738357447, 303 | "isDeleted": false, 304 | "id": "uoNQSQUrr4aasR7MTUUn1", 305 | "fillStyle": "hachure", 306 | "strokeWidth": 2, 307 | "strokeStyle": "dashed", 308 | "roughness": 1, 309 | "opacity": 100, 310 | "angle": 0, 311 | "x": 1364.8739828843645, 312 | "y": 3264.1822109884097, 313 | "strokeColor": "#1e1e1e", 314 | "backgroundColor": "#a5d8ff", 315 | "width": 80.42396545410156, 316 | "height": 45, 317 | "seed": 1446013370, 318 | "groupIds": [], 319 | "frameId": null, 320 | "roundness": null, 321 | "boundElements": [], 322 | "updated": 1711494704492, 323 | "link": null, 324 | "locked": false, 325 | "fontSize": 36, 326 | "fontFamily": 1, 327 | "text": "User", 328 | "textAlign": "center", 329 | "verticalAlign": "middle", 330 | "containerId": "tP2NM8fHquJpLRWGNpeMS", 331 | "originalText": "User", 332 | "lineHeight": 1.25 333 | }, 334 | { 335 | "type": "arrow", 336 | "version": 382, 337 | "versionNonce": 396436489, 338 | "isDeleted": false, 339 | "id": "SJpr2doNnhQlrw6ElMFGB", 340 | "fillStyle": "hachure", 341 | "strokeWidth": 2, 342 | "strokeStyle": "dashed", 343 | "roughness": 1, 344 | "opacity": 100, 345 | "angle": 0, 346 | "x": 1490.7083358573677, 347 | "y": 3250.899189145555, 348 | "strokeColor": "#1e1e1e", 349 | "backgroundColor": "#a5d8ff", 350 | "width": 340.56128709269865, 351 | "height": 0.08626370090769342, 352 | "seed": 1520036262, 353 | "groupIds": [], 354 | "frameId": null, 355 | "roundness": { 356 | "type": 2 357 | }, 358 | "boundElements": [], 359 | "updated": 1711494704504, 360 | "link": null, 361 | "locked": false, 362 | "startBinding": { 363 | "elementId": "tP2NM8fHquJpLRWGNpeMS", 364 | "focus": -0.49541122736371607, 365 | "gap": 18.149915410486457 366 | }, 367 | "endBinding": { 368 | "elementId": "h8sLOWMaJE3LSdZzqvVNN", 369 | "focus": 0.386917883143017, 370 | "gap": 19.853602445404476 371 | }, 372 | "lastCommittedPoint": null, 373 | "startArrowhead": null, 374 | "endArrowhead": "arrow", 375 | "points": [ 376 | [ 377 | 0, 378 | 0 379 | ], 380 | [ 381 | 340.56128709269865, 382 | -0.08626370090769342 383 | ] 384 | ] 385 | }, 386 | { 387 | "type": "arrow", 388 | "version": 638, 389 | "versionNonce": 730625481, 390 | "isDeleted": false, 391 | "id": "YYHlAuNxgcntiSweB4Arb", 392 | "fillStyle": "hachure", 393 | "strokeWidth": 2, 394 | "strokeStyle": "dashed", 395 | "roughness": 1, 396 | "opacity": 100, 397 | "angle": 0, 398 | "x": 1487.0835414163787, 399 | "y": 3343.7016770617097, 400 | "strokeColor": "#1e1e1e", 401 | "backgroundColor": "#a5d8ff", 402 | "width": 339.3101798930629, 403 | "height": 0.32080820194778426, 404 | "seed": 1602743738, 405 | "groupIds": [], 406 | "frameId": null, 407 | "roundness": { 408 | "type": 2 409 | }, 410 | "boundElements": [], 411 | "updated": 1711494704504, 412 | "link": null, 413 | "locked": false, 414 | "startBinding": { 415 | "elementId": "tP2NM8fHquJpLRWGNpeMS", 416 | "focus": 0.792424215358263, 417 | "gap": 25.880398905556888 418 | }, 419 | "endBinding": { 420 | "elementId": "h8sLOWMaJE3LSdZzqvVNN", 421 | "focus": -0.36493510245487293, 422 | "gap": 24.729504086029237 423 | }, 424 | "lastCommittedPoint": null, 425 | "startArrowhead": null, 426 | "endArrowhead": "arrow", 427 | "points": [ 428 | [ 429 | 0, 430 | 0 431 | ], 432 | [ 433 | 339.3101798930629, 434 | 0.32080820194778426 435 | ] 436 | ] 437 | }, 438 | { 439 | "type": "text", 440 | "version": 183, 441 | "versionNonce": 1657728903, 442 | "isDeleted": false, 443 | "id": "uDeF8jPXtfFMd4JCIzotR", 444 | "fillStyle": "hachure", 445 | "strokeWidth": 2, 446 | "strokeStyle": "solid", 447 | "roughness": 1, 448 | "opacity": 100, 449 | "angle": 0, 450 | "x": 1572.225599257121, 451 | "y": 3210.7138953142353, 452 | "strokeColor": "#1e1e1e", 453 | "backgroundColor": "#ffffff", 454 | "width": 156.73988342285156, 455 | "height": 25, 456 | "seed": 926156090, 457 | "groupIds": [], 458 | "frameId": null, 459 | "roundness": null, 460 | "boundElements": [], 461 | "updated": 1711494704492, 462 | "link": null, 463 | "locked": false, 464 | "fontSize": 20, 465 | "fontFamily": 1, 466 | "text": "Sign up / Sign in", 467 | "textAlign": "center", 468 | "verticalAlign": "top", 469 | "containerId": null, 470 | "originalText": "Sign up / Sign in", 471 | "lineHeight": 1.25 472 | }, 473 | { 474 | "type": "text", 475 | "version": 446, 476 | "versionNonce": 1718970023, 477 | "isDeleted": false, 478 | "id": "T8smpxHWB6lNzxWh42-FB", 479 | "fillStyle": "hachure", 480 | "strokeWidth": 2, 481 | "strokeStyle": "solid", 482 | "roughness": 1, 483 | "opacity": 100, 484 | "angle": 0, 485 | "x": 1526.39221258768, 486 | "y": 3302.236339593994, 487 | "strokeColor": "#1e1e1e", 488 | "backgroundColor": "#ffffff", 489 | "width": 239.3597869873047, 490 | "height": 25, 491 | "seed": 1873248998, 492 | "groupIds": [], 493 | "frameId": null, 494 | "roundness": null, 495 | "boundElements": [], 496 | "updated": 1711494704492, 497 | "link": null, 498 | "locked": false, 499 | "fontSize": 20, 500 | "fontFamily": 1, 501 | "text": "Authenticated Requests", 502 | "textAlign": "center", 503 | "verticalAlign": "top", 504 | "containerId": null, 505 | "originalText": "Authenticated Requests", 506 | "lineHeight": 1.25 507 | }, 508 | { 509 | "type": "arrow", 510 | "version": 259, 511 | "versionNonce": 446584713, 512 | "isDeleted": false, 513 | "id": "3cdIc50uNGGvAbQUg08rE", 514 | "fillStyle": "hachure", 515 | "strokeWidth": 2, 516 | "strokeStyle": "dashed", 517 | "roughness": 1, 518 | "opacity": 100, 519 | "angle": 0, 520 | "x": 1976.937417171494, 521 | "y": 3435.1943878287966, 522 | "strokeColor": "#1e1e1e", 523 | "backgroundColor": "#a5d8ff", 524 | "width": 2.75487179263655, 525 | "height": 143.70549926124932, 526 | "seed": 1791264934, 527 | "groupIds": [], 528 | "frameId": null, 529 | "roundness": { 530 | "type": 2 531 | }, 532 | "boundElements": [], 533 | "updated": 1711494704504, 534 | "link": null, 535 | "locked": false, 536 | "startBinding": { 537 | "elementId": "h8sLOWMaJE3LSdZzqvVNN", 538 | "focus": 0.02107050625663146, 539 | "gap": 12.237583120328736 540 | }, 541 | "endBinding": { 542 | "elementId": "Np0ajYvtlgJWKQ_mrYrNL", 543 | "focus": 0.02343885366403433, 544 | "gap": 18.470720010745936 545 | }, 546 | "lastCommittedPoint": null, 547 | "startArrowhead": null, 548 | "endArrowhead": "arrow", 549 | "points": [ 550 | [ 551 | 0, 552 | 0 553 | ], 554 | [ 555 | 2.75487179263655, 556 | 143.70549926124932 557 | ] 558 | ] 559 | }, 560 | { 561 | "type": "text", 562 | "version": 292, 563 | "versionNonce": 1309354215, 564 | "isDeleted": false, 565 | "id": "ud7QNKJknjMYaVOyF3BWJ", 566 | "fillStyle": "hachure", 567 | "strokeWidth": 2, 568 | "strokeStyle": "solid", 569 | "roughness": 1, 570 | "opacity": 100, 571 | "angle": 0, 572 | "x": 1818.222264317722, 573 | "y": 3481.721039389629, 574 | "strokeColor": "#1e1e1e", 575 | "backgroundColor": "#a5d8ff", 576 | "width": 146.73988342285156, 577 | "height": 50, 578 | "seed": 1985492602, 579 | "groupIds": [], 580 | "frameId": null, 581 | "roundness": null, 582 | "boundElements": [], 583 | "updated": 1711494704492, 584 | "link": null, 585 | "locked": false, 586 | "fontSize": 20, 587 | "fontFamily": 1, 588 | "text": "Sign up/ Sign in\nRedirect", 589 | "textAlign": "center", 590 | "verticalAlign": "top", 591 | "containerId": null, 592 | "originalText": "Sign up/ Sign in\nRedirect", 593 | "lineHeight": 1.25 594 | }, 595 | { 596 | "type": "arrow", 597 | "version": 755, 598 | "versionNonce": 410175817, 599 | "isDeleted": false, 600 | "id": "jUkTh90tCmkw8ctSVMWRX", 601 | "fillStyle": "hachure", 602 | "strokeWidth": 2, 603 | "strokeStyle": "dashed", 604 | "roughness": 1, 605 | "opacity": 100, 606 | "angle": 0, 607 | "x": 2119.9597408889745, 608 | "y": 3265.462383867956, 609 | "strokeColor": "#1e1e1e", 610 | "backgroundColor": "#a5d8ff", 611 | "width": 280.68673453930455, 612 | "height": 1.8206189199577238, 613 | "seed": 511234746, 614 | "groupIds": [], 615 | "frameId": null, 616 | "roundness": { 617 | "type": 2 618 | }, 619 | "boundElements": [], 620 | "updated": 1711494704504, 621 | "link": null, 622 | "locked": false, 623 | "startBinding": { 624 | "elementId": "h8sLOWMaJE3LSdZzqvVNN", 625 | "focus": -0.27437334110380135, 626 | "gap": 17.03086442597578 627 | }, 628 | "endBinding": { 629 | "elementId": "nZ9Dc0kjn1tiYxJuMsm93", 630 | "focus": 0.2059307974328775, 631 | "gap": 16.9684232152606 632 | }, 633 | "lastCommittedPoint": null, 634 | "startArrowhead": null, 635 | "endArrowhead": "arrow", 636 | "points": [ 637 | [ 638 | 0, 639 | 0 640 | ], 641 | [ 642 | 280.68673453930455, 643 | 1.8206189199577238 644 | ] 645 | ] 646 | }, 647 | { 648 | "type": "arrow", 649 | "version": 806, 650 | "versionNonce": 564513545, 651 | "isDeleted": false, 652 | "id": "St3Es2_iIEC9dXxUJWcXM", 653 | "fillStyle": "hachure", 654 | "strokeWidth": 2, 655 | "strokeStyle": "dashed", 656 | "roughness": 1, 657 | "opacity": 100, 658 | "angle": 6.272666212319525, 659 | "x": 2121.689771848503, 660 | "y": 3346.996491096031, 661 | "strokeColor": "#1e1e1e", 662 | "backgroundColor": "#a5d8ff", 663 | "width": 280.67057918006617, 664 | "height": 5.394493314251122, 665 | "seed": 1679256634, 666 | "groupIds": [], 667 | "frameId": null, 668 | "roundness": { 669 | "type": 2 670 | }, 671 | "boundElements": [], 672 | "updated": 1711494704504, 673 | "link": null, 674 | "locked": false, 675 | "startBinding": { 676 | "elementId": "h8sLOWMaJE3LSdZzqvVNN", 677 | "focus": 0.3865094596104668, 678 | "gap": 18.734656926715616 679 | }, 680 | "endBinding": { 681 | "elementId": "nZ9Dc0kjn1tiYxJuMsm93", 682 | "focus": -0.4946773533616025, 683 | "gap": 15.239570080344265 684 | }, 685 | "lastCommittedPoint": null, 686 | "startArrowhead": null, 687 | "endArrowhead": "arrow", 688 | "points": [ 689 | [ 690 | 0, 691 | 0 692 | ], 693 | [ 694 | 280.67057918006617, 695 | 5.394493314251122 696 | ] 697 | ] 698 | }, 699 | { 700 | "type": "text", 701 | "version": 454, 702 | "versionNonce": 559767111, 703 | "isDeleted": false, 704 | "id": "gDoLHxn2KiXzeIWta779Z", 705 | "fillStyle": "hachure", 706 | "strokeWidth": 2, 707 | "strokeStyle": "solid", 708 | "roughness": 1, 709 | "opacity": 100, 710 | "angle": 0, 711 | "x": 2169.1697068795775, 712 | "y": 3231.5305543286167, 713 | "strokeColor": "#1e1e1e", 714 | "backgroundColor": "#a5d8ff", 715 | "width": 136.77987670898438, 716 | "height": 25, 717 | "seed": 245374438, 718 | "groupIds": [], 719 | "frameId": null, 720 | "roundness": null, 721 | "boundElements": [], 722 | "updated": 1711494704492, 723 | "link": null, 724 | "locked": false, 725 | "fontSize": 20, 726 | "fontFamily": 1, 727 | "text": "Create Quote", 728 | "textAlign": "center", 729 | "verticalAlign": "top", 730 | "containerId": null, 731 | "originalText": "Create Quote", 732 | "lineHeight": 1.25 733 | }, 734 | { 735 | "type": "text", 736 | "version": 501, 737 | "versionNonce": 247987559, 738 | "isDeleted": false, 739 | "id": "2dEBJEHwoMAh4UoKjGnTj", 740 | "fillStyle": "hachure", 741 | "strokeWidth": 2, 742 | "strokeStyle": "solid", 743 | "roughness": 1, 744 | "opacity": 100, 745 | "angle": 6.272666212319525, 746 | "x": 2171.0969056322097, 747 | "y": 3313.0982258109248, 748 | "strokeColor": "#1e1e1e", 749 | "backgroundColor": "#a5d8ff", 750 | "width": 133.75987243652344, 751 | "height": 25, 752 | "seed": 479202406, 753 | "groupIds": [], 754 | "frameId": null, 755 | "roundness": null, 756 | "boundElements": [], 757 | "updated": 1711494704492, 758 | "link": null, 759 | "locked": false, 760 | "fontSize": 20, 761 | "fontFamily": 1, 762 | "text": "Delete Quote", 763 | "textAlign": "center", 764 | "verticalAlign": "top", 765 | "containerId": null, 766 | "originalText": "Delete Quote", 767 | "lineHeight": 1.25 768 | }, 769 | { 770 | "type": "arrow", 771 | "version": 267, 772 | "versionNonce": 1003707593, 773 | "isDeleted": false, 774 | "id": "7CDx8KJpN8Ou9RxUHj9eT", 775 | "fillStyle": "hachure", 776 | "strokeWidth": 2, 777 | "strokeStyle": "dashed", 778 | "roughness": 1, 779 | "opacity": 100, 780 | "angle": 0, 781 | "x": 1837.8267704161258, 782 | "y": 3719.382993470732, 783 | "strokeColor": "#1e1e1e", 784 | "backgroundColor": "#a5d8ff", 785 | "width": 434.55307786686444, 786 | "height": 355.41154539207855, 787 | "seed": 628524646, 788 | "groupIds": [], 789 | "frameId": null, 790 | "roundness": { 791 | "type": 2 792 | }, 793 | "boundElements": [], 794 | "updated": 1711494704504, 795 | "link": null, 796 | "locked": false, 797 | "startBinding": { 798 | "elementId": "Np0ajYvtlgJWKQ_mrYrNL", 799 | "focus": -0.013284723008534132, 800 | "gap": 11.572578958132908 801 | }, 802 | "endBinding": { 803 | "elementId": "tP2NM8fHquJpLRWGNpeMS", 804 | "focus": -0.030252263779424575, 805 | "gap": 17.39133940928083 806 | }, 807 | "lastCommittedPoint": null, 808 | "startArrowhead": null, 809 | "endArrowhead": "arrow", 810 | "points": [ 811 | [ 812 | 0, 813 | 0 814 | ], 815 | [ 816 | -434.55307786686444, 817 | 11.975333116320144 818 | ], 819 | [ 820 | -431.2479418181897, 821 | -343.4362122757584 822 | ] 823 | ] 824 | }, 825 | { 826 | "type": "text", 827 | "version": 535, 828 | "versionNonce": 2050428839, 829 | "isDeleted": false, 830 | "id": "xwIWM3L3vwWcYoskQzDre", 831 | "fillStyle": "hachure", 832 | "strokeWidth": 2, 833 | "strokeStyle": "solid", 834 | "roughness": 1, 835 | "opacity": 100, 836 | "angle": 0, 837 | "x": 1422.2823151606128, 838 | "y": 3666.478254105988, 839 | "strokeColor": "#1e1e1e", 840 | "backgroundColor": "#a5d8ff", 841 | "width": 268.27978515625, 842 | "height": 50, 843 | "seed": 2018426874, 844 | "groupIds": [], 845 | "frameId": null, 846 | "roundness": null, 847 | "boundElements": [], 848 | "updated": 1711494704492, 849 | "link": null, 850 | "locked": false, 851 | "fontSize": 20, 852 | "fontFamily": 1, 853 | "text": "Redirect Authenticated\nUser to Next.js Appliaction", 854 | "textAlign": "center", 855 | "verticalAlign": "top", 856 | "containerId": null, 857 | "originalText": "Redirect Authenticated\nUser to Next.js Appliaction", 858 | "lineHeight": 1.25 859 | } 860 | ], 861 | "appState": { 862 | "gridSize": null, 863 | "viewBackgroundColor": "#ffffff" 864 | }, 865 | "files": {} 866 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit'; 2 | import * as dotenv from "dotenv"; 3 | 4 | dotenv.config(); 5 | 6 | if (!('DATABASE_URL' in process.env)) throw new Error('DATABASE_URL not found in environment'); 7 | 8 | export default { 9 | schema: './src/app/db/schema.ts', 10 | out: './src/app/db/migrations', 11 | driver: 'pg', 12 | dbCredentials: { 13 | connectionString: process.env.DATABASE_URL!, 14 | } 15 | } satisfies Config; 16 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neon-clerk-discord", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "drizzle:generate": "drizzle-kit generate:pg", 8 | "drizzle:push": "drizzle-kit push:pg", 9 | "build": "next build", 10 | "start": "next start", 11 | "lint": "next lint", 12 | "seed": "tsx --require dotenv/config seed.ts" 13 | }, 14 | "dependencies": { 15 | "@clerk/nextjs": "^4.29.9", 16 | "@clerk/types": "^3.62.1", 17 | "@neondatabase/serverless": "^0.9.0", 18 | "drizzle-orm": "^0.30.2", 19 | "env-var": "^7.4.1", 20 | "next": "14.1.3", 21 | "pino": "^8.19.0", 22 | "react": "^18", 23 | "react-dom": "^18", 24 | "svix": "^1.21.0" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^20", 28 | "@types/react": "^18", 29 | "@types/react-dom": "^18", 30 | "autoprefixer": "^10.0.1", 31 | "dotenv": "^16.4.5", 32 | "drizzle-kit": "^0.20.14", 33 | "eslint": "^8", 34 | "eslint-config-next": "14.1.3", 35 | "postcss": "^8", 36 | "tailwindcss": "^3.3.0", 37 | "tsx": "^4.7.1", 38 | "typescript": "^5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /seed.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./src/app/db" 2 | import { Elements } from "./src/app/db/schema" 3 | 4 | type ElementData = { 5 | elements: { 6 | name: string 7 | number: number 8 | symbol: string 9 | }[] 10 | } 11 | 12 | async function main () { 13 | console.log('Downloading seed data...') 14 | const res = await fetch('https://raw.githubusercontent.com/Bowserinator/Periodic-Table-JSON/master/PeriodicTableJSON.json') 15 | 16 | if (res.status !== 200) { 17 | throw new Error(`received status code ${res.status} when fetching elements JSON`) 18 | } 19 | console.log('Parsing seed data...') 20 | const elementJson = await res.json() as ElementData 21 | 22 | const values = elementJson.elements.map(el => { 23 | const { symbol, name, number } = el 24 | 25 | return { 26 | symbol, name, atomicNumber: number 27 | } 28 | }) 29 | 30 | console.log('Delete existing element data...') 31 | await db.delete(Elements) 32 | console.log('Insert new element data...') 33 | await db.insert(Elements).values(values) 34 | } 35 | 36 | main() 37 | -------------------------------------------------------------------------------- /src/app/api/webhooks/clerk/route.ts: -------------------------------------------------------------------------------- 1 | import { WebhookEvent } from '@clerk/nextjs/server' 2 | import { NextResponse } from 'next/server' 3 | import { db } from '@/app/db' 4 | import { headers } from 'next/headers' 5 | import { Webhook } from 'svix' 6 | import { Users } from '@/app/db/schema' 7 | import { log } from '@/app/log' 8 | import { eq } from 'drizzle-orm' 9 | 10 | async function validateRequest(request: Request, secret: string) { 11 | const payloadString = await request.text() 12 | const headerPayload = headers() 13 | 14 | const svixHeaders = { 15 | 'svix-id': headerPayload.get('svix-id')!, 16 | 'svix-timestamp': headerPayload.get('svix-timestamp')!, 17 | 'svix-signature': headerPayload.get('svix-signature')!, 18 | } 19 | const wh = new Webhook(secret) 20 | 21 | try { 22 | return wh.verify(payloadString, svixHeaders) as WebhookEvent 23 | } catch (e) { 24 | console.error('incoming webhook failed verification') 25 | return 26 | } 27 | } 28 | 29 | export async function POST(req: Request): Promise { 30 | if (!process.env.CLERK_WEBHOOK_SECRET) { 31 | throw new Error('CLERK_WEBHOOK_SECRET environment variable is missing') 32 | } 33 | 34 | const payload = await validateRequest(req, process.env.CLERK_WEBHOOK_SECRET) 35 | 36 | if (!payload) { 37 | return NextResponse.json( 38 | { error: 'webhook verification failed or payload was malformed' }, 39 | { status: 400 } 40 | ) 41 | } 42 | 43 | const { type, data } = payload 44 | 45 | log.trace(`clerk webhook payload: ${{ data, type }}`) 46 | 47 | if (type === 'user.created') { 48 | return createUser(data.id, data.created_at) 49 | } else if (type === 'user.deleted') { 50 | return deleteUser(data.id) 51 | } else { 52 | log.warn(`${req.url} received event type "${type}", but no handler is defined for this type`) 53 | return NextResponse.json({ 54 | error: `uncreognised payload type: ${type}` 55 | }, { 56 | status: 400 57 | }) 58 | } 59 | } 60 | 61 | async function createUser(id: string, createdAt: number) { 62 | log.info('creating user due to clerk webhook') 63 | await db.insert(Users).values({ 64 | id, 65 | clerkCreateTs: new Date(createdAt) 66 | }) 67 | 68 | return NextResponse.json({ 69 | message: 'user created' 70 | }, { status: 200 }) 71 | } 72 | 73 | async function deleteUser(id?: string) { 74 | if (id) { 75 | log.info('delete user due to clerk webhook') 76 | await db.delete(Users).where( 77 | eq(Users.id, id) 78 | ) 79 | 80 | return NextResponse.json({ 81 | message: 'user deleted' 82 | }, { status: 200 }) 83 | } else { 84 | log.warn('clerk sent a delete user request, but no user ID was included in the payload') 85 | return NextResponse.json({ 86 | message: 'ok' 87 | }, { status: 200 }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/components/element.tsx: -------------------------------------------------------------------------------- 1 | import { Element } from "../db/schema" 2 | 3 | export function ElementComponent (props: { name: string, symbol: string, atomicNumber: number, children: any }) { 4 | const { atomicNumber, name, symbol, children } = props 5 | 6 | return ( 7 |
  • 8 |

    9 | {atomicNumber} 10 |

    11 |

    {symbol}

    12 |

    {name}

    13 | {children} 14 |
  • 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/config.ts: -------------------------------------------------------------------------------- 1 | 2 | import { from } from 'env-var' 3 | 4 | export default function getEnv (env: NodeJS.ProcessEnv) { 5 | const { get } = from(env) 6 | 7 | return { 8 | WEBHOOK_SECRET: get('WEBHOOK_SECRET').required().asString() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/db/index.ts: -------------------------------------------------------------------------------- 1 | import { neon } from '@neondatabase/serverless'; 2 | import { drizzle } from 'drizzle-orm/neon-http'; 3 | 4 | // Redefining generic fixes a type error. Fix coming soon: 5 | // https://github.com/drizzle-team/drizzle-orm/issues/1945#event-12152755813 6 | const sql = neon(process.env.DATABASE_URL!); 7 | export const db = drizzle(sql); 8 | -------------------------------------------------------------------------------- /src/app/db/migrations/0000_red_proemial_gods.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "element_votes" ( 2 | "element_id" integer, 3 | "user_id" text NOT NULL, 4 | "create_ts" timestamp DEFAULT now(), 5 | CONSTRAINT "element_votes_user_id_unique" UNIQUE("user_id") 6 | ); 7 | --> statement-breakpoint 8 | CREATE TABLE IF NOT EXISTS "element" ( 9 | "name" text NOT NULL, 10 | "symbol" varchar(3) NOT NULL, 11 | "atomicNumber" integer PRIMARY KEY NOT NULL 12 | ); 13 | --> statement-breakpoint 14 | CREATE TABLE IF NOT EXISTS "users" ( 15 | "id" text PRIMARY KEY NOT NULL 16 | ); 17 | --> statement-breakpoint 18 | DO $$ BEGIN 19 | ALTER TABLE "element_votes" ADD CONSTRAINT "element_votes_element_id_element_atomicNumber_fk" FOREIGN KEY ("element_id") REFERENCES "element"("atomicNumber") ON DELETE no action ON UPDATE no action; 20 | EXCEPTION 21 | WHEN duplicate_object THEN null; 22 | END $$; 23 | --> statement-breakpoint 24 | DO $$ BEGIN 25 | ALTER TABLE "element_votes" ADD CONSTRAINT "element_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; 26 | EXCEPTION 27 | WHEN duplicate_object THEN null; 28 | END $$; 29 | -------------------------------------------------------------------------------- /src/app/db/migrations/0001_abandoned_rachel_grey.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "element_votes" ALTER COLUMN "element_id" SET NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "element_votes" ALTER COLUMN "create_ts" SET NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "users" ADD COLUMN "create_ts" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint 4 | ALTER TABLE "users" ADD COLUMN "clerk_create_ts" timestamp NOT NULL; -------------------------------------------------------------------------------- /src/app/db/migrations/0002_nappy_obadiah_stane.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "element_votes" DROP CONSTRAINT "element_votes_element_id_element_atomicNumber_fk"; 2 | --> statement-breakpoint 3 | DO $$ BEGIN 4 | ALTER TABLE "element_votes" ADD CONSTRAINT "element_votes_element_id_element_atomicNumber_fk" FOREIGN KEY ("element_id") REFERENCES "element"("atomicNumber") ON DELETE cascade ON UPDATE no action; 5 | EXCEPTION 6 | WHEN duplicate_object THEN null; 7 | END $$; 8 | -------------------------------------------------------------------------------- /src/app/db/migrations/0003_panoramic_marten_broadcloak.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "element_votes" DROP CONSTRAINT "element_votes_element_id_element_atomicNumber_fk"; 2 | --> statement-breakpoint 3 | DO $$ BEGIN 4 | ALTER TABLE "element_votes" ADD CONSTRAINT "element_votes_element_id_element_atomicNumber_fk" FOREIGN KEY ("element_id") REFERENCES "element"("atomicNumber") ON DELETE no action ON UPDATE no action; 5 | EXCEPTION 6 | WHEN duplicate_object THEN null; 7 | END $$; 8 | -------------------------------------------------------------------------------- /src/app/db/migrations/0004_square_falcon.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "element" RENAME COLUMN "atomicNumber" TO "atomic_number";--> statement-breakpoint 2 | ALTER TABLE "element_votes" DROP CONSTRAINT "element_votes_element_id_element_atomicNumber_fk"; 3 | --> statement-breakpoint 4 | DO $$ BEGIN 5 | ALTER TABLE "element_votes" ADD CONSTRAINT "element_votes_element_id_element_atomic_number_fk" FOREIGN KEY ("element_id") REFERENCES "element"("atomic_number") ON DELETE no action ON UPDATE no action; 6 | EXCEPTION 7 | WHEN duplicate_object THEN null; 8 | END $$; 9 | -------------------------------------------------------------------------------- /src/app/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "fc858a9a-ca71-4f1c-9956-806ad458fb6f", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "element_votes": { 8 | "name": "element_votes", 9 | "schema": "", 10 | "columns": { 11 | "element_id": { 12 | "name": "element_id", 13 | "type": "integer", 14 | "primaryKey": false, 15 | "notNull": false 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "create_ts": { 24 | "name": "create_ts", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": false, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": { 33 | "element_votes_element_id_element_atomicNumber_fk": { 34 | "name": "element_votes_element_id_element_atomicNumber_fk", 35 | "tableFrom": "element_votes", 36 | "tableTo": "element", 37 | "columnsFrom": [ 38 | "element_id" 39 | ], 40 | "columnsTo": [ 41 | "atomicNumber" 42 | ], 43 | "onDelete": "no action", 44 | "onUpdate": "no action" 45 | }, 46 | "element_votes_user_id_users_id_fk": { 47 | "name": "element_votes_user_id_users_id_fk", 48 | "tableFrom": "element_votes", 49 | "tableTo": "users", 50 | "columnsFrom": [ 51 | "user_id" 52 | ], 53 | "columnsTo": [ 54 | "id" 55 | ], 56 | "onDelete": "no action", 57 | "onUpdate": "no action" 58 | } 59 | }, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": { 62 | "element_votes_user_id_unique": { 63 | "name": "element_votes_user_id_unique", 64 | "nullsNotDistinct": false, 65 | "columns": [ 66 | "user_id" 67 | ] 68 | } 69 | } 70 | }, 71 | "element": { 72 | "name": "element", 73 | "schema": "", 74 | "columns": { 75 | "name": { 76 | "name": "name", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true 80 | }, 81 | "symbol": { 82 | "name": "symbol", 83 | "type": "varchar(3)", 84 | "primaryKey": false, 85 | "notNull": true 86 | }, 87 | "atomicNumber": { 88 | "name": "atomicNumber", 89 | "type": "integer", 90 | "primaryKey": true, 91 | "notNull": true 92 | } 93 | }, 94 | "indexes": {}, 95 | "foreignKeys": {}, 96 | "compositePrimaryKeys": {}, 97 | "uniqueConstraints": {} 98 | }, 99 | "users": { 100 | "name": "users", 101 | "schema": "", 102 | "columns": { 103 | "id": { 104 | "name": "id", 105 | "type": "text", 106 | "primaryKey": true, 107 | "notNull": true 108 | } 109 | }, 110 | "indexes": {}, 111 | "foreignKeys": {}, 112 | "compositePrimaryKeys": {}, 113 | "uniqueConstraints": {} 114 | } 115 | }, 116 | "enums": {}, 117 | "schemas": {}, 118 | "_meta": { 119 | "columns": {}, 120 | "schemas": {}, 121 | "tables": {} 122 | } 123 | } -------------------------------------------------------------------------------- /src/app/db/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "18094a25-10f8-4a72-817f-c9c2c6a2e51c", 3 | "prevId": "fc858a9a-ca71-4f1c-9956-806ad458fb6f", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "element_votes": { 8 | "name": "element_votes", 9 | "schema": "", 10 | "columns": { 11 | "element_id": { 12 | "name": "element_id", 13 | "type": "integer", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "create_ts": { 24 | "name": "create_ts", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": true, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": { 33 | "element_votes_element_id_element_atomicNumber_fk": { 34 | "name": "element_votes_element_id_element_atomicNumber_fk", 35 | "tableFrom": "element_votes", 36 | "tableTo": "element", 37 | "columnsFrom": [ 38 | "element_id" 39 | ], 40 | "columnsTo": [ 41 | "atomicNumber" 42 | ], 43 | "onDelete": "no action", 44 | "onUpdate": "no action" 45 | }, 46 | "element_votes_user_id_users_id_fk": { 47 | "name": "element_votes_user_id_users_id_fk", 48 | "tableFrom": "element_votes", 49 | "tableTo": "users", 50 | "columnsFrom": [ 51 | "user_id" 52 | ], 53 | "columnsTo": [ 54 | "id" 55 | ], 56 | "onDelete": "no action", 57 | "onUpdate": "no action" 58 | } 59 | }, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": { 62 | "element_votes_user_id_unique": { 63 | "name": "element_votes_user_id_unique", 64 | "nullsNotDistinct": false, 65 | "columns": [ 66 | "user_id" 67 | ] 68 | } 69 | } 70 | }, 71 | "element": { 72 | "name": "element", 73 | "schema": "", 74 | "columns": { 75 | "name": { 76 | "name": "name", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true 80 | }, 81 | "symbol": { 82 | "name": "symbol", 83 | "type": "varchar(3)", 84 | "primaryKey": false, 85 | "notNull": true 86 | }, 87 | "atomicNumber": { 88 | "name": "atomicNumber", 89 | "type": "integer", 90 | "primaryKey": true, 91 | "notNull": true 92 | } 93 | }, 94 | "indexes": {}, 95 | "foreignKeys": {}, 96 | "compositePrimaryKeys": {}, 97 | "uniqueConstraints": {} 98 | }, 99 | "users": { 100 | "name": "users", 101 | "schema": "", 102 | "columns": { 103 | "id": { 104 | "name": "id", 105 | "type": "text", 106 | "primaryKey": true, 107 | "notNull": true 108 | }, 109 | "create_ts": { 110 | "name": "create_ts", 111 | "type": "timestamp", 112 | "primaryKey": false, 113 | "notNull": true, 114 | "default": "now()" 115 | }, 116 | "clerk_create_ts": { 117 | "name": "clerk_create_ts", 118 | "type": "timestamp", 119 | "primaryKey": false, 120 | "notNull": true 121 | } 122 | }, 123 | "indexes": {}, 124 | "foreignKeys": {}, 125 | "compositePrimaryKeys": {}, 126 | "uniqueConstraints": {} 127 | } 128 | }, 129 | "enums": {}, 130 | "schemas": {}, 131 | "_meta": { 132 | "columns": {}, 133 | "schemas": {}, 134 | "tables": {} 135 | } 136 | } -------------------------------------------------------------------------------- /src/app/db/migrations/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "5171fdef-27d3-46e9-9c40-9fe774c9e21f", 3 | "prevId": "18094a25-10f8-4a72-817f-c9c2c6a2e51c", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "element_votes": { 8 | "name": "element_votes", 9 | "schema": "", 10 | "columns": { 11 | "element_id": { 12 | "name": "element_id", 13 | "type": "integer", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "create_ts": { 24 | "name": "create_ts", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": true, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": { 33 | "element_votes_element_id_element_atomicNumber_fk": { 34 | "name": "element_votes_element_id_element_atomicNumber_fk", 35 | "tableFrom": "element_votes", 36 | "tableTo": "element", 37 | "columnsFrom": [ 38 | "element_id" 39 | ], 40 | "columnsTo": [ 41 | "atomicNumber" 42 | ], 43 | "onDelete": "cascade", 44 | "onUpdate": "no action" 45 | }, 46 | "element_votes_user_id_users_id_fk": { 47 | "name": "element_votes_user_id_users_id_fk", 48 | "tableFrom": "element_votes", 49 | "tableTo": "users", 50 | "columnsFrom": [ 51 | "user_id" 52 | ], 53 | "columnsTo": [ 54 | "id" 55 | ], 56 | "onDelete": "no action", 57 | "onUpdate": "no action" 58 | } 59 | }, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": { 62 | "element_votes_user_id_unique": { 63 | "name": "element_votes_user_id_unique", 64 | "nullsNotDistinct": false, 65 | "columns": [ 66 | "user_id" 67 | ] 68 | } 69 | } 70 | }, 71 | "element": { 72 | "name": "element", 73 | "schema": "", 74 | "columns": { 75 | "name": { 76 | "name": "name", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true 80 | }, 81 | "symbol": { 82 | "name": "symbol", 83 | "type": "varchar(3)", 84 | "primaryKey": false, 85 | "notNull": true 86 | }, 87 | "atomicNumber": { 88 | "name": "atomicNumber", 89 | "type": "integer", 90 | "primaryKey": true, 91 | "notNull": true 92 | } 93 | }, 94 | "indexes": {}, 95 | "foreignKeys": {}, 96 | "compositePrimaryKeys": {}, 97 | "uniqueConstraints": {} 98 | }, 99 | "users": { 100 | "name": "users", 101 | "schema": "", 102 | "columns": { 103 | "id": { 104 | "name": "id", 105 | "type": "text", 106 | "primaryKey": true, 107 | "notNull": true 108 | }, 109 | "clerk_create_ts": { 110 | "name": "clerk_create_ts", 111 | "type": "timestamp", 112 | "primaryKey": false, 113 | "notNull": true 114 | }, 115 | "create_ts": { 116 | "name": "create_ts", 117 | "type": "timestamp", 118 | "primaryKey": false, 119 | "notNull": true, 120 | "default": "now()" 121 | } 122 | }, 123 | "indexes": {}, 124 | "foreignKeys": {}, 125 | "compositePrimaryKeys": {}, 126 | "uniqueConstraints": {} 127 | } 128 | }, 129 | "enums": {}, 130 | "schemas": {}, 131 | "_meta": { 132 | "columns": {}, 133 | "schemas": {}, 134 | "tables": {} 135 | } 136 | } -------------------------------------------------------------------------------- /src/app/db/migrations/meta/0003_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "b7ba8ee3-6a0b-438d-a08e-525ba6c2d770", 3 | "prevId": "5171fdef-27d3-46e9-9c40-9fe774c9e21f", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "element_votes": { 8 | "name": "element_votes", 9 | "schema": "", 10 | "columns": { 11 | "element_id": { 12 | "name": "element_id", 13 | "type": "integer", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "create_ts": { 24 | "name": "create_ts", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": true, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": { 33 | "element_votes_element_id_element_atomicNumber_fk": { 34 | "name": "element_votes_element_id_element_atomicNumber_fk", 35 | "tableFrom": "element_votes", 36 | "tableTo": "element", 37 | "columnsFrom": [ 38 | "element_id" 39 | ], 40 | "columnsTo": [ 41 | "atomicNumber" 42 | ], 43 | "onDelete": "no action", 44 | "onUpdate": "no action" 45 | }, 46 | "element_votes_user_id_users_id_fk": { 47 | "name": "element_votes_user_id_users_id_fk", 48 | "tableFrom": "element_votes", 49 | "tableTo": "users", 50 | "columnsFrom": [ 51 | "user_id" 52 | ], 53 | "columnsTo": [ 54 | "id" 55 | ], 56 | "onDelete": "cascade", 57 | "onUpdate": "no action" 58 | } 59 | }, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": { 62 | "element_votes_user_id_unique": { 63 | "name": "element_votes_user_id_unique", 64 | "nullsNotDistinct": false, 65 | "columns": [ 66 | "user_id" 67 | ] 68 | } 69 | } 70 | }, 71 | "element": { 72 | "name": "element", 73 | "schema": "", 74 | "columns": { 75 | "name": { 76 | "name": "name", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true 80 | }, 81 | "symbol": { 82 | "name": "symbol", 83 | "type": "varchar(3)", 84 | "primaryKey": false, 85 | "notNull": true 86 | }, 87 | "atomicNumber": { 88 | "name": "atomicNumber", 89 | "type": "integer", 90 | "primaryKey": true, 91 | "notNull": true 92 | } 93 | }, 94 | "indexes": {}, 95 | "foreignKeys": {}, 96 | "compositePrimaryKeys": {}, 97 | "uniqueConstraints": {} 98 | }, 99 | "users": { 100 | "name": "users", 101 | "schema": "", 102 | "columns": { 103 | "id": { 104 | "name": "id", 105 | "type": "text", 106 | "primaryKey": true, 107 | "notNull": true 108 | }, 109 | "clerk_create_ts": { 110 | "name": "clerk_create_ts", 111 | "type": "timestamp", 112 | "primaryKey": false, 113 | "notNull": true 114 | }, 115 | "create_ts": { 116 | "name": "create_ts", 117 | "type": "timestamp", 118 | "primaryKey": false, 119 | "notNull": true, 120 | "default": "now()" 121 | } 122 | }, 123 | "indexes": {}, 124 | "foreignKeys": {}, 125 | "compositePrimaryKeys": {}, 126 | "uniqueConstraints": {} 127 | } 128 | }, 129 | "enums": {}, 130 | "schemas": {}, 131 | "_meta": { 132 | "columns": {}, 133 | "schemas": {}, 134 | "tables": {} 135 | } 136 | } -------------------------------------------------------------------------------- /src/app/db/migrations/meta/0004_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "fe42788a-7b2f-4a29-bc70-7935a76a920c", 3 | "prevId": "b7ba8ee3-6a0b-438d-a08e-525ba6c2d770", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "element_votes": { 8 | "name": "element_votes", 9 | "schema": "", 10 | "columns": { 11 | "element_id": { 12 | "name": "element_id", 13 | "type": "integer", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "create_ts": { 24 | "name": "create_ts", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": true, 28 | "default": "now()" 29 | } 30 | }, 31 | "indexes": {}, 32 | "foreignKeys": { 33 | "element_votes_element_id_element_atomic_number_fk": { 34 | "name": "element_votes_element_id_element_atomic_number_fk", 35 | "tableFrom": "element_votes", 36 | "tableTo": "element", 37 | "columnsFrom": [ 38 | "element_id" 39 | ], 40 | "columnsTo": [ 41 | "atomic_number" 42 | ], 43 | "onDelete": "no action", 44 | "onUpdate": "no action" 45 | }, 46 | "element_votes_user_id_users_id_fk": { 47 | "name": "element_votes_user_id_users_id_fk", 48 | "tableFrom": "element_votes", 49 | "tableTo": "users", 50 | "columnsFrom": [ 51 | "user_id" 52 | ], 53 | "columnsTo": [ 54 | "id" 55 | ], 56 | "onDelete": "cascade", 57 | "onUpdate": "no action" 58 | } 59 | }, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": { 62 | "element_votes_user_id_unique": { 63 | "name": "element_votes_user_id_unique", 64 | "nullsNotDistinct": false, 65 | "columns": [ 66 | "user_id" 67 | ] 68 | } 69 | } 70 | }, 71 | "element": { 72 | "name": "element", 73 | "schema": "", 74 | "columns": { 75 | "name": { 76 | "name": "name", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true 80 | }, 81 | "symbol": { 82 | "name": "symbol", 83 | "type": "varchar(3)", 84 | "primaryKey": false, 85 | "notNull": true 86 | }, 87 | "atomic_number": { 88 | "name": "atomic_number", 89 | "type": "integer", 90 | "primaryKey": true, 91 | "notNull": true 92 | } 93 | }, 94 | "indexes": {}, 95 | "foreignKeys": {}, 96 | "compositePrimaryKeys": {}, 97 | "uniqueConstraints": {} 98 | }, 99 | "users": { 100 | "name": "users", 101 | "schema": "", 102 | "columns": { 103 | "id": { 104 | "name": "id", 105 | "type": "text", 106 | "primaryKey": true, 107 | "notNull": true 108 | }, 109 | "clerk_create_ts": { 110 | "name": "clerk_create_ts", 111 | "type": "timestamp", 112 | "primaryKey": false, 113 | "notNull": true 114 | }, 115 | "create_ts": { 116 | "name": "create_ts", 117 | "type": "timestamp", 118 | "primaryKey": false, 119 | "notNull": true, 120 | "default": "now()" 121 | } 122 | }, 123 | "indexes": {}, 124 | "foreignKeys": {}, 125 | "compositePrimaryKeys": {}, 126 | "uniqueConstraints": {} 127 | } 128 | }, 129 | "enums": {}, 130 | "schemas": {}, 131 | "_meta": { 132 | "columns": {}, 133 | "schemas": {}, 134 | "tables": {} 135 | } 136 | } -------------------------------------------------------------------------------- /src/app/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1710799135647, 9 | "tag": "0000_red_proemial_gods", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1710871241685, 16 | "tag": "0001_abandoned_rachel_grey", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1710874857877, 23 | "tag": "0002_nappy_obadiah_stane", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "5", 29 | "when": 1710874997922, 30 | "tag": "0003_panoramic_marten_broadcloak", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "5", 36 | "when": 1710883427375, 37 | "tag": "0004_square_falcon", 38 | "breakpoints": true 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/app/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core'; 2 | 3 | /** 4 | * This table stores users. Users are created in Clerk, then Clerk calls a 5 | * webhook at /api/webhook/clerk to inform this application a user was created. 6 | */ 7 | export const Users = pgTable('users', { 8 | // The ID will be the user's ID from Clerk 9 | id: text('id').primaryKey().notNull(), 10 | 11 | // Time user was created in Clerk 12 | clerkCreateTs: timestamp('clerk_create_ts').notNull(), 13 | 14 | // Time when user was created in our database. 15 | createTs: timestamp('create_ts').defaultNow().notNull() 16 | }); 17 | 18 | export const Elements = pgTable('element', { 19 | name: text('name').notNull(), 20 | symbol: varchar('symbol', { length: 3 }).notNull(), 21 | atomicNumber: integer('atomic_number').notNull().primaryKey(), 22 | }); 23 | 24 | export const ElementVotes = pgTable('element_votes', { 25 | elementId: integer('element_id').references(() => Elements.atomicNumber).notNull(), 26 | userId: text('user_id').references(() => Users.id, { onDelete: 'cascade' }).unique().notNull(), 27 | createTs: timestamp('create_ts').defaultNow().notNull() 28 | }); 29 | 30 | export type User = typeof Users.$inferSelect; 31 | export type Element = typeof Elements.$inferSelect; 32 | export type ElementVote = typeof ElementVotes.$inferSelect; 33 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raoufchebri/neon-clerk-drizzle-nextjs/f3929bbe4197e32dbff9ff89ec9c520c198d4ac3/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | 29 | @layer utilities { 30 | .text-balance { 31 | text-wrap: balance; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ClerkProvider, UserButton } from "@clerk/nextjs"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Create Next App", 10 | description: "Generated by create next app", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | return ( 19 | 20 | 21 | 22 |
    23 | 24 |
    25 | {children} 26 | 27 | 28 |
    29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/log.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | 3 | export const log = pino({ 4 | level: process.env.LOG_LEVEL ? process.env.LOG_LEVEL : 'info' 5 | }) 6 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { ElementVotes, Elements } from "./db/schema"; 3 | import { eq, sql } from 'drizzle-orm' 4 | import { currentUser } from '@clerk/nextjs'; 5 | import { ElementComponent } from "./components/element"; 6 | import Link from 'next/link' 7 | 8 | export const dynamic = 'force-dynamic'; 9 | 10 | const getData = async () => { 11 | const user = await currentUser() 12 | 13 | if (!user) { 14 | throw new Error('user must be defined') 15 | } 16 | 17 | const vote = await db.select().from(ElementVotes).where( 18 | eq(ElementVotes.userId, user.id) 19 | ) 20 | 21 | const elements = await db. 22 | select({ 23 | votes: sql`COALESCE(COUNT(DISTINCT ${ElementVotes.userId}), 0)`, 24 | name: Elements.name, 25 | atomicNumber: Elements.atomicNumber, 26 | symbol: Elements.symbol 27 | }) 28 | .from(Elements) 29 | .leftJoin(ElementVotes, eq(Elements.atomicNumber, ElementVotes.elementId)) 30 | .groupBy(sql`${Elements.name},${Elements.atomicNumber}`) 31 | 32 | 33 | return { elements, vote }; 34 | }; 35 | 36 | export default async function Home() { 37 | const { elements, vote } = await getData(); 38 | let message = 'Vote for your favourite element by clicking on it.' 39 | 40 | if (vote[0]) { 41 | message = `You voted for ${elements.find(el => el.atomicNumber === vote[0].elementId)?.name}! Change your vote by clicking an element.` 42 | elements.sort((e1, e2) => { 43 | return e1.votes > e2.votes ? -1 : 1 44 | }) 45 | } 46 | 47 | return ( 48 |
    49 |
    50 |

    {message}

    51 |
    52 |
    53 |
      54 | { 55 | elements.map(el => { 56 | return ( 57 | 58 | 59 | Votes: {el.votes} 60 | 61 | 62 | ) 63 | }) 64 | } 65 |
    66 |
    67 |
    68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/app/voted/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/app/db"; 2 | import { ElementVotes } from "@/app/db/schema"; 3 | import { eq } from 'drizzle-orm' 4 | import { currentUser } from '@clerk/nextjs'; 5 | import { redirect } from 'next/navigation' 6 | import { log } from "../log"; 7 | 8 | export const dynamic = 'force-dynamic'; 9 | 10 | const voteAndGetData = async (elementId?: string) => { 11 | if (!elementId) { 12 | log.warn('a user visited /voted without specifying an elementId') 13 | return redirect('/') 14 | } 15 | 16 | const user = await currentUser() 17 | 18 | if (!user) { 19 | throw new Error('user must be defined') 20 | } 21 | 22 | const existingVote = await db 23 | .select() 24 | .from(ElementVotes) 25 | .where( 26 | eq(ElementVotes.userId, user.id) 27 | ) 28 | 29 | if (existingVote[0]) { 30 | // User has already voted, update it 31 | log.info(`updating user ${user.id} vote`) 32 | return db 33 | .update(ElementVotes) 34 | .set({ 35 | elementId: parseInt(elementId) 36 | }) 37 | .where(eq(ElementVotes.userId, user.id)) 38 | .returning() 39 | } else { 40 | log.info(`insert vote for user ${user.id}`) 41 | return db 42 | .insert(ElementVotes) 43 | .values({ 44 | userId: user.id, 45 | elementId: parseInt(elementId) 46 | }).returning() 47 | } 48 | }; 49 | 50 | export default async function Voted({ 51 | searchParams, 52 | }: { 53 | searchParams: { [key: string]: string | string[] | undefined }; 54 | }) { 55 | const { elementId } = searchParams 56 | 57 | await voteAndGetData(elementId as string) 58 | 59 | return redirect(`/?element=${elementId}`) 60 | } 61 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs"; 2 | 3 | export default authMiddleware({ 4 | // Routes that can be accessed while signed out 5 | publicRoutes: ['/api/webhooks/clerk'] 6 | }); 7 | 8 | export const config = { 9 | // Protects all routes, including api/trpc. 10 | // See https://clerk.com/docs/references/nextjs/auth-middleware 11 | // for more information about configuring your Middleware 12 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 13 | }; 14 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------