Deploy Next.js signup Space
Browse files- .dockerignore +8 -0
- .gitignore +23 -5
- AGENTS.md +5 -0
- CLAUDE.md +1 -0
- Dockerfile +33 -0
- README.md +30 -68
- eslint.config.mjs +18 -0
- next.config.ts +23 -0
- package-lock.json +0 -0
- package.json +18 -31
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/api/auth/huggingface/callback/route.ts +96 -0
- src/app/api/auth/huggingface/start/route.ts +23 -0
- src/app/api/auth/logout/route.ts +13 -0
- src/app/api/signup/route.ts +46 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +57 -0
- src/app/layout.tsx +28 -0
- src/app/page.module.css +308 -0
- src/app/page.tsx +9 -0
- src/app/signup-panel.tsx +244 -0
- src/lib/huggingface.ts +255 -0
- src/lib/session.ts +135 -0
- tsconfig.json +34 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.next
|
| 4 |
+
.env
|
| 5 |
+
.env.*
|
| 6 |
+
node_modules
|
| 7 |
+
npm-debug.log*
|
| 8 |
+
.DS_Store
|
.gitignore
CHANGED
|
@@ -3,21 +3,39 @@
|
|
| 3 |
# dependencies
|
| 4 |
/node_modules
|
| 5 |
/.pnp
|
| 6 |
-
.pnp.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# testing
|
| 9 |
/coverage
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# production
|
| 12 |
/build
|
| 13 |
|
| 14 |
# misc
|
| 15 |
.DS_Store
|
| 16 |
-
.
|
| 17 |
-
.env.development.local
|
| 18 |
-
.env.test.local
|
| 19 |
-
.env.production.local
|
| 20 |
|
|
|
|
| 21 |
npm-debug.log*
|
| 22 |
yarn-debug.log*
|
| 23 |
yarn-error.log*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
# dependencies
|
| 4 |
/node_modules
|
| 5 |
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
|
| 13 |
# testing
|
| 14 |
/coverage
|
| 15 |
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
# production
|
| 21 |
/build
|
| 22 |
|
| 23 |
# misc
|
| 24 |
.DS_Store
|
| 25 |
+
*.pem
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
# debug
|
| 28 |
npm-debug.log*
|
| 29 |
yarn-debug.log*
|
| 30 |
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-bookworm-slim AS deps
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY package.json package-lock.json ./
|
| 6 |
+
RUN npm ci
|
| 7 |
+
|
| 8 |
+
FROM node:20-bookworm-slim AS builder
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 13 |
+
|
| 14 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
RUN npm run build
|
| 18 |
+
|
| 19 |
+
FROM node:20-bookworm-slim AS runner
|
| 20 |
+
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
|
| 23 |
+
ENV NODE_ENV=production
|
| 24 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 25 |
+
ENV PORT=7860
|
| 26 |
+
ENV HOSTNAME=0.0.0.0
|
| 27 |
+
|
| 28 |
+
COPY --from=builder /app/.next/standalone ./
|
| 29 |
+
COPY --from=builder /app/.next/static ./.next/static
|
| 30 |
+
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -1,83 +1,45 @@
|
|
| 1 |
---
|
| 2 |
-
title: Signup
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
app_build_command: npm run build
|
| 9 |
-
app_file: build/index.html
|
| 10 |
-
license: apache-2.0
|
| 11 |
-
short_description: Sign up to Humanity's Last Hackathon
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
##
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
You may also see any lint errors in the console.
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
| 34 |
|
| 35 |
-
##
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
Your app is ready to be deployed!
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
### `npm run eject`
|
| 46 |
-
|
| 47 |
-
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
| 48 |
-
|
| 49 |
-
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
| 50 |
-
|
| 51 |
-
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
| 52 |
-
|
| 53 |
-
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
| 54 |
-
|
| 55 |
-
## Learn More
|
| 56 |
-
|
| 57 |
-
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
| 58 |
-
|
| 59 |
-
To learn React, check out the [React documentation](https://reactjs.org/).
|
| 60 |
-
|
| 61 |
-
### Code Splitting
|
| 62 |
-
|
| 63 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
| 64 |
-
|
| 65 |
-
### Analyzing the Bundle Size
|
| 66 |
-
|
| 67 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
| 68 |
-
|
| 69 |
-
### Making a Progressive Web App
|
| 70 |
-
|
| 71 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
| 72 |
-
|
| 73 |
-
### Advanced Configuration
|
| 74 |
-
|
| 75 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
| 76 |
-
|
| 77 |
-
### Deployment
|
| 78 |
-
|
| 79 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
| 80 |
-
|
| 81 |
-
### `npm run build` fails to minify
|
| 82 |
-
|
| 83 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Humanity's Last Hackathon Signup
|
| 3 |
+
emoji: "🖥️"
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Humanity's Last Hackathon Signup
|
| 11 |
|
| 12 |
+
Black-and-white Next.js landing page for **Humanity's Last Hackathon**.
|
| 13 |
|
| 14 |
+
The current production flow is intentionally minimal:
|
| 15 |
|
| 16 |
+
1. Open the signup page.
|
| 17 |
+
2. Click **Sign up with Hugging Face**.
|
| 18 |
+
3. Continue on the official Hugging Face registration link.
|
| 19 |
|
| 20 |
+
## Stack
|
| 21 |
|
| 22 |
+
- Next.js 16 App Router
|
| 23 |
+
- TypeScript
|
| 24 |
+
- CSS modules
|
| 25 |
+
- Docker-based Hugging Face Space deployment
|
| 26 |
|
| 27 |
+
## Local Development
|
|
|
|
| 28 |
|
| 29 |
+
```bash
|
| 30 |
+
npm install
|
| 31 |
+
npm run dev
|
| 32 |
+
```
|
| 33 |
|
| 34 |
+
Open `http://localhost:3000`.
|
|
|
|
| 35 |
|
| 36 |
+
## Verification
|
| 37 |
|
| 38 |
+
```bash
|
| 39 |
+
npm run lint
|
| 40 |
+
npm run build
|
| 41 |
+
```
|
| 42 |
|
| 43 |
+
## Deployment
|
|
|
|
| 44 |
|
| 45 |
+
This repository is configured as a **Docker Space** and serves the Next.js app on port `7860`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
images: {
|
| 6 |
+
remotePatterns: [
|
| 7 |
+
{
|
| 8 |
+
protocol: "https",
|
| 9 |
+
hostname: "cdn-avatars.huggingface.co",
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
protocol: "https",
|
| 13 |
+
hostname: "www.gravatar.com",
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
protocol: "https",
|
| 17 |
+
hostname: "avatars.githubusercontent.com",
|
| 18 |
+
},
|
| 19 |
+
],
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -1,39 +1,26 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
-
"dependencies": {
|
| 6 |
-
"@testing-library/dom": "^10.4.0",
|
| 7 |
-
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
-
"@testing-library/react": "^16.3.0",
|
| 9 |
-
"@testing-library/user-event": "^13.5.0",
|
| 10 |
-
"react": "^19.1.0",
|
| 11 |
-
"react-dom": "^19.1.0",
|
| 12 |
-
"react-scripts": "5.0.1",
|
| 13 |
-
"web-vitals": "^2.1.4"
|
| 14 |
-
},
|
| 15 |
"scripts": {
|
| 16 |
-
"
|
| 17 |
-
"build": "
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
},
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 26 |
},
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"
|
| 34 |
-
"last 1 chrome version",
|
| 35 |
-
"last 1 firefox version",
|
| 36 |
-
"last 1 safari version"
|
| 37 |
-
]
|
| 38 |
}
|
| 39 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "hlh-signup-app",
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@huggingface/hub": "^2.11.0",
|
| 13 |
+
"next": "16.2.4",
|
| 14 |
+
"react": "19.2.4",
|
| 15 |
+
"react-dom": "19.2.4",
|
| 16 |
+
"zod": "^4.3.6"
|
| 17 |
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@types/node": "^20",
|
| 20 |
+
"@types/react": "^19",
|
| 21 |
+
"@types/react-dom": "^19",
|
| 22 |
+
"eslint": "^9",
|
| 23 |
+
"eslint-config-next": "16.2.4",
|
| 24 |
+
"typescript": "^5"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
}
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/api/auth/huggingface/callback/route.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
createSignupRecord,
|
| 5 |
+
exchangeCodeForToken,
|
| 6 |
+
fetchUserInfo,
|
| 7 |
+
fetchViewer,
|
| 8 |
+
getAppUrl,
|
| 9 |
+
getOAuthRedirectUri,
|
| 10 |
+
storeSignupRecord,
|
| 11 |
+
} from "@/lib/huggingface";
|
| 12 |
+
import {
|
| 13 |
+
clearOAuthCookies,
|
| 14 |
+
clearSessionCookie,
|
| 15 |
+
readOAuthCookies,
|
| 16 |
+
setSessionCookie,
|
| 17 |
+
} from "@/lib/session";
|
| 18 |
+
|
| 19 |
+
export const runtime = "nodejs";
|
| 20 |
+
|
| 21 |
+
function redirectWithAuthError(request: NextRequest, code: string) {
|
| 22 |
+
const response = NextResponse.redirect(new URL(`/?error=${code}`, getAppUrl(request)));
|
| 23 |
+
clearOAuthCookies(response);
|
| 24 |
+
clearSessionCookie(response);
|
| 25 |
+
return response;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export async function GET(request: NextRequest) {
|
| 29 |
+
const url = new URL(request.url);
|
| 30 |
+
const providerError = url.searchParams.get("error");
|
| 31 |
+
|
| 32 |
+
if (providerError) {
|
| 33 |
+
return redirectWithAuthError(
|
| 34 |
+
request,
|
| 35 |
+
providerError === "access_denied" ? "oauth_denied" : "oauth_failed",
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const code = url.searchParams.get("code");
|
| 40 |
+
const returnedState = url.searchParams.get("state");
|
| 41 |
+
const { state, verifier } = readOAuthCookies(request);
|
| 42 |
+
|
| 43 |
+
if (!code || !returnedState || !state || returnedState !== state || !verifier) {
|
| 44 |
+
return redirectWithAuthError(request, "oauth_state");
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
try {
|
| 48 |
+
const token = await exchangeCodeForToken({
|
| 49 |
+
code,
|
| 50 |
+
codeVerifier: verifier,
|
| 51 |
+
redirectUri: getOAuthRedirectUri(request),
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
const [userInfo, viewer] = await Promise.all([
|
| 55 |
+
fetchUserInfo(token.access_token),
|
| 56 |
+
fetchViewer(token.access_token),
|
| 57 |
+
]);
|
| 58 |
+
|
| 59 |
+
const session = {
|
| 60 |
+
id: userInfo.sub,
|
| 61 |
+
username: userInfo.preferred_username || viewer.name,
|
| 62 |
+
name: userInfo.name.trim() || viewer.fullname || viewer.name,
|
| 63 |
+
email: userInfo.email ?? viewer.email,
|
| 64 |
+
emailVerified: userInfo.email_verified ?? viewer.emailVerified,
|
| 65 |
+
avatarUrl: userInfo.picture || viewer.avatarUrl,
|
| 66 |
+
profileUrl: userInfo.profile || `${process.env.HF_HUB_URL ?? "https://huggingface.co"}/${viewer.name}`,
|
| 67 |
+
website: userInfo.website ?? null,
|
| 68 |
+
isPro: userInfo.isPro ?? viewer.isPro,
|
| 69 |
+
registrationComplete: false,
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
await storeSignupRecord(createSignupRecord({ session }));
|
| 74 |
+
|
| 75 |
+
const response = NextResponse.redirect(new URL("/?signed_up=1", getAppUrl(request)));
|
| 76 |
+
clearOAuthCookies(response);
|
| 77 |
+
setSessionCookie(response, {
|
| 78 |
+
...session,
|
| 79 |
+
registrationComplete: true,
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
return response;
|
| 83 |
+
} catch (error) {
|
| 84 |
+
console.error(error);
|
| 85 |
+
|
| 86 |
+
const response = NextResponse.redirect(new URL("/?error=signup_store", getAppUrl(request)));
|
| 87 |
+
clearOAuthCookies(response);
|
| 88 |
+
setSessionCookie(response, session);
|
| 89 |
+
|
| 90 |
+
return response;
|
| 91 |
+
}
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error(error);
|
| 94 |
+
return redirectWithAuthError(request, "oauth_exchange");
|
| 95 |
+
}
|
| 96 |
+
}
|
src/app/api/auth/huggingface/start/route.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
import { createOAuthAuthorizationUrl } from "@/lib/huggingface";
|
| 4 |
+
import { createOpaqueToken, createPkcePair, setOAuthCookies } from "@/lib/session";
|
| 5 |
+
|
| 6 |
+
export const runtime = "nodejs";
|
| 7 |
+
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
const state = createOpaqueToken(24);
|
| 10 |
+
const { verifier, challenge } = createPkcePair();
|
| 11 |
+
|
| 12 |
+
const response = NextResponse.redirect(
|
| 13 |
+
createOAuthAuthorizationUrl({
|
| 14 |
+
state,
|
| 15 |
+
codeChallenge: challenge,
|
| 16 |
+
request,
|
| 17 |
+
}),
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
setOAuthCookies(response, state, verifier);
|
| 21 |
+
|
| 22 |
+
return response;
|
| 23 |
+
}
|
src/app/api/auth/logout/route.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
import { getAppUrl } from "@/lib/huggingface";
|
| 4 |
+
import { clearOAuthCookies, clearSessionCookie } from "@/lib/session";
|
| 5 |
+
|
| 6 |
+
export const runtime = "nodejs";
|
| 7 |
+
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
const response = NextResponse.redirect(new URL("/", getAppUrl(request)));
|
| 10 |
+
clearOAuthCookies(response);
|
| 11 |
+
clearSessionCookie(response);
|
| 12 |
+
return response;
|
| 13 |
+
}
|
src/app/api/signup/route.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
import { createSignupRecord, storeSignupRecord } from "@/lib/huggingface";
|
| 4 |
+
import { parseSessionCookie, SESSION_COOKIE_NAME, setSessionCookie } from "@/lib/session";
|
| 5 |
+
|
| 6 |
+
export const runtime = "nodejs";
|
| 7 |
+
|
| 8 |
+
export async function POST(request: NextRequest) {
|
| 9 |
+
const session = parseSessionCookie(request.cookies.get(SESSION_COOKIE_NAME)?.value);
|
| 10 |
+
|
| 11 |
+
if (!session) {
|
| 12 |
+
return NextResponse.json(
|
| 13 |
+
{
|
| 14 |
+
error: "Authenticate with Hugging Face before completing signup.",
|
| 15 |
+
},
|
| 16 |
+
{ status: 401 },
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const record = createSignupRecord({ session });
|
| 22 |
+
|
| 23 |
+
const saved = await storeSignupRecord(record);
|
| 24 |
+
|
| 25 |
+
const response = NextResponse.json({
|
| 26 |
+
ok: true,
|
| 27 |
+
repo: saved.repoName,
|
| 28 |
+
path: saved.path,
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
setSessionCookie(response, {
|
| 32 |
+
...session,
|
| 33 |
+
registrationComplete: true,
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
return response;
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.error(error);
|
| 39 |
+
return NextResponse.json(
|
| 40 |
+
{
|
| 41 |
+
error: "Failed to write the signup record to Hugging Face.",
|
| 42 |
+
},
|
| 43 |
+
{ status: 500 },
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
}
|
src/app/favicon.ico
ADDED
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--background: #020202;
|
| 3 |
+
--foreground: #f4f4f4;
|
| 4 |
+
--muted: #9b9b9b;
|
| 5 |
+
--border: rgba(255, 255, 255, 0.18);
|
| 6 |
+
--panel: rgba(7, 7, 7, 0.92);
|
| 7 |
+
--panel-strong: rgba(12, 12, 12, 0.98);
|
| 8 |
+
--shadow: rgba(0, 0, 0, 0.48);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
html {
|
| 12 |
+
height: 100%;
|
| 13 |
+
color-scheme: dark;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
html,
|
| 17 |
+
body {
|
| 18 |
+
max-width: 100vw;
|
| 19 |
+
overflow-x: clip;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body {
|
| 23 |
+
min-height: 100vh;
|
| 24 |
+
color: var(--foreground);
|
| 25 |
+
background: var(--background);
|
| 26 |
+
font-family: var(--font-ibm-plex-mono), monospace;
|
| 27 |
+
background-image:
|
| 28 |
+
radial-gradient(circle at top left, rgba(255, 255, 255, 0.06), transparent 28%),
|
| 29 |
+
radial-gradient(circle at top right, rgba(255, 255, 255, 0.04), transparent 24%),
|
| 30 |
+
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
| 31 |
+
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
| 32 |
+
background-size: auto, auto, 38px 38px, 38px 38px;
|
| 33 |
+
-webkit-font-smoothing: antialiased;
|
| 34 |
+
-moz-osx-font-smoothing: grayscale;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
* {
|
| 38 |
+
box-sizing: border-box;
|
| 39 |
+
padding: 0;
|
| 40 |
+
margin: 0;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
a {
|
| 44 |
+
color: inherit;
|
| 45 |
+
text-decoration: none;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
button,
|
| 49 |
+
input,
|
| 50 |
+
textarea {
|
| 51 |
+
font: inherit;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
::selection {
|
| 55 |
+
background: #ffffff;
|
| 56 |
+
color: #000000;
|
| 57 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { IBM_Plex_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const ibmPlexMono = IBM_Plex_Mono({
|
| 6 |
+
display: "swap",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
variable: "--font-ibm-plex-mono",
|
| 9 |
+
weight: ["400", "500", "600", "700"],
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
export const metadata: Metadata = {
|
| 13 |
+
title: "Humanity's Last Hackathon | Signup",
|
| 14 |
+
description:
|
| 15 |
+
"Simple Hugging Face OAuth signup app for Humanity's Last Hackathon, with private dataset registration storage.",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export default function RootLayout({
|
| 19 |
+
children,
|
| 20 |
+
}: Readonly<{
|
| 21 |
+
children: React.ReactNode;
|
| 22 |
+
}>) {
|
| 23 |
+
return (
|
| 24 |
+
<html lang="en" className={ibmPlexMono.variable}>
|
| 25 |
+
<body>{children}</body>
|
| 26 |
+
</html>
|
| 27 |
+
);
|
| 28 |
+
}
|
src/app/page.module.css
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.page {
|
| 2 |
+
min-height: 100vh;
|
| 3 |
+
padding: 40px 20px 72px;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.shell {
|
| 7 |
+
width: min(1120px, 100%);
|
| 8 |
+
margin: 0 auto;
|
| 9 |
+
display: grid;
|
| 10 |
+
gap: 28px;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.hero {
|
| 14 |
+
display: grid;
|
| 15 |
+
gap: 14px;
|
| 16 |
+
max-width: 860px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.eyebrow {
|
| 20 |
+
font-size: 0.78rem;
|
| 21 |
+
letter-spacing: 0.28em;
|
| 22 |
+
text-transform: uppercase;
|
| 23 |
+
color: var(--muted);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.title {
|
| 27 |
+
font-size: clamp(2.3rem, 7vw, 4.6rem);
|
| 28 |
+
line-height: 0.95;
|
| 29 |
+
letter-spacing: -0.05em;
|
| 30 |
+
text-transform: uppercase;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.subtitle {
|
| 34 |
+
max-width: 760px;
|
| 35 |
+
font-size: 1rem;
|
| 36 |
+
line-height: 1.7;
|
| 37 |
+
color: #c6c6c6;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.heroMeta {
|
| 41 |
+
display: flex;
|
| 42 |
+
flex-wrap: wrap;
|
| 43 |
+
gap: 10px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.heroMeta span {
|
| 47 |
+
border: 1px solid var(--border);
|
| 48 |
+
background: rgba(255, 255, 255, 0.03);
|
| 49 |
+
padding: 10px 12px;
|
| 50 |
+
font-size: 0.76rem;
|
| 51 |
+
letter-spacing: 0.08em;
|
| 52 |
+
text-transform: uppercase;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.grid {
|
| 56 |
+
display: grid;
|
| 57 |
+
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1.05fr);
|
| 58 |
+
gap: 20px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.panel {
|
| 62 |
+
border: 1px solid var(--border);
|
| 63 |
+
background: linear-gradient(180deg, rgba(20, 20, 20, 0.96), rgba(8, 8, 8, 0.96));
|
| 64 |
+
box-shadow: 0 28px 70px var(--shadow);
|
| 65 |
+
padding: 24px;
|
| 66 |
+
display: grid;
|
| 67 |
+
align-content: start;
|
| 68 |
+
gap: 18px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.panelLabel {
|
| 72 |
+
font-size: 0.74rem;
|
| 73 |
+
letter-spacing: 0.18em;
|
| 74 |
+
text-transform: uppercase;
|
| 75 |
+
color: var(--muted);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.panelTitle {
|
| 79 |
+
font-size: clamp(1.35rem, 3vw, 2rem);
|
| 80 |
+
line-height: 1.05;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.panelCopy {
|
| 84 |
+
color: #d0d0d0;
|
| 85 |
+
line-height: 1.7;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.list {
|
| 89 |
+
display: grid;
|
| 90 |
+
gap: 12px;
|
| 91 |
+
padding-left: 18px;
|
| 92 |
+
color: #d0d0d0;
|
| 93 |
+
line-height: 1.7;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.noteBlock {
|
| 97 |
+
border: 1px solid var(--border);
|
| 98 |
+
background: rgba(255, 255, 255, 0.03);
|
| 99 |
+
padding: 16px;
|
| 100 |
+
display: grid;
|
| 101 |
+
gap: 8px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.noteTitle {
|
| 105 |
+
font-size: 0.76rem;
|
| 106 |
+
letter-spacing: 0.14em;
|
| 107 |
+
text-transform: uppercase;
|
| 108 |
+
color: var(--muted);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.noteBody {
|
| 112 |
+
font-size: 1rem;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.noteFoot {
|
| 116 |
+
color: var(--muted);
|
| 117 |
+
font-size: 0.86rem;
|
| 118 |
+
line-height: 1.6;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.profileCard {
|
| 122 |
+
display: flex;
|
| 123 |
+
gap: 16px;
|
| 124 |
+
align-items: center;
|
| 125 |
+
border: 1px solid var(--border);
|
| 126 |
+
background: rgba(255, 255, 255, 0.03);
|
| 127 |
+
padding: 18px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.avatar {
|
| 131 |
+
width: 72px;
|
| 132 |
+
height: 72px;
|
| 133 |
+
object-fit: cover;
|
| 134 |
+
border: 1px solid var(--border);
|
| 135 |
+
background: #111111;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.profileCopy {
|
| 139 |
+
display: grid;
|
| 140 |
+
gap: 6px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.profileName {
|
| 144 |
+
font-size: 1.1rem;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.profileMeta {
|
| 148 |
+
color: var(--muted);
|
| 149 |
+
font-size: 0.9rem;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.inlineLink {
|
| 153 |
+
width: fit-content;
|
| 154 |
+
color: #ffffff;
|
| 155 |
+
text-decoration: underline;
|
| 156 |
+
text-underline-offset: 3px;
|
| 157 |
+
font-size: 0.86rem;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.detailGrid {
|
| 161 |
+
display: grid;
|
| 162 |
+
gap: 12px;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.detailRow {
|
| 166 |
+
display: grid;
|
| 167 |
+
gap: 6px;
|
| 168 |
+
border: 1px solid var(--border);
|
| 169 |
+
background: rgba(255, 255, 255, 0.02);
|
| 170 |
+
padding: 14px 16px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.detailLabel {
|
| 174 |
+
font-size: 0.74rem;
|
| 175 |
+
letter-spacing: 0.14em;
|
| 176 |
+
text-transform: uppercase;
|
| 177 |
+
color: var(--muted);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.detailValue {
|
| 181 |
+
line-height: 1.55;
|
| 182 |
+
word-break: break-word;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.actions {
|
| 186 |
+
display: flex;
|
| 187 |
+
flex-wrap: wrap;
|
| 188 |
+
gap: 12px;
|
| 189 |
+
align-items: center;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.primaryButton,
|
| 193 |
+
.secondaryButton {
|
| 194 |
+
display: inline-flex;
|
| 195 |
+
align-items: center;
|
| 196 |
+
justify-content: center;
|
| 197 |
+
min-height: 50px;
|
| 198 |
+
padding: 0 18px;
|
| 199 |
+
border: 1px solid var(--border);
|
| 200 |
+
text-transform: uppercase;
|
| 201 |
+
letter-spacing: 0.12em;
|
| 202 |
+
font-size: 0.76rem;
|
| 203 |
+
transition:
|
| 204 |
+
background 0.18s ease,
|
| 205 |
+
color 0.18s ease,
|
| 206 |
+
border-color 0.18s ease,
|
| 207 |
+
transform 0.18s ease;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.primaryButton {
|
| 211 |
+
background: #ffffff;
|
| 212 |
+
color: #000000;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.primaryButton:disabled {
|
| 216 |
+
opacity: 0.6;
|
| 217 |
+
cursor: wait;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.secondaryButton {
|
| 221 |
+
background: transparent;
|
| 222 |
+
color: #ffffff;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.successBadge {
|
| 226 |
+
display: inline-flex;
|
| 227 |
+
align-items: center;
|
| 228 |
+
min-height: 44px;
|
| 229 |
+
padding: 0 14px;
|
| 230 |
+
border: 1px solid rgba(255, 255, 255, 0.35);
|
| 231 |
+
background: rgba(255, 255, 255, 0.08);
|
| 232 |
+
text-transform: uppercase;
|
| 233 |
+
letter-spacing: 0.12em;
|
| 234 |
+
font-size: 0.72rem;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.status {
|
| 238 |
+
border: 1px solid var(--border);
|
| 239 |
+
background: rgba(255, 255, 255, 0.03);
|
| 240 |
+
padding: 14px 16px;
|
| 241 |
+
line-height: 1.6;
|
| 242 |
+
color: #d8d8d8;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.statusInfo {
|
| 246 |
+
border-color: rgba(255, 255, 255, 0.22);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.statusSuccess {
|
| 250 |
+
border-color: rgba(255, 255, 255, 0.42);
|
| 251 |
+
color: #ffffff;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.statusError {
|
| 255 |
+
border-color: rgba(255, 255, 255, 0.42);
|
| 256 |
+
background: rgba(255, 255, 255, 0.08);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
@media (hover: hover) and (pointer: fine) {
|
| 260 |
+
.primaryButton:hover,
|
| 261 |
+
.secondaryButton:hover {
|
| 262 |
+
transform: translateY(-1px);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.primaryButton:hover {
|
| 266 |
+
background: transparent;
|
| 267 |
+
color: #ffffff;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.secondaryButton:hover {
|
| 271 |
+
background: rgba(255, 255, 255, 0.08);
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
@media (max-width: 900px) {
|
| 276 |
+
.grid {
|
| 277 |
+
grid-template-columns: 1fr;
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
@media (max-width: 720px) {
|
| 282 |
+
.page {
|
| 283 |
+
padding: 24px 14px 40px;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.heroMeta span {
|
| 287 |
+
width: 100%;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.panel {
|
| 291 |
+
padding: 18px;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.profileCard {
|
| 295 |
+
align-items: flex-start;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.actions {
|
| 299 |
+
flex-direction: column;
|
| 300 |
+
align-items: stretch;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.primaryButton,
|
| 304 |
+
.secondaryButton,
|
| 305 |
+
.successBadge {
|
| 306 |
+
width: 100%;
|
| 307 |
+
}
|
| 308 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getSessionFromCookies } from "@/lib/session";
|
| 2 |
+
|
| 3 |
+
import SignupPanel from "./signup-panel";
|
| 4 |
+
|
| 5 |
+
export default async function Home() {
|
| 6 |
+
const session = await getSessionFromCookies();
|
| 7 |
+
|
| 8 |
+
return <SignupPanel session={session} />;
|
| 9 |
+
}
|
src/app/signup-panel.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Image from "next/image";
|
| 4 |
+
import { useState } from "react";
|
| 5 |
+
import { useSearchParams } from "next/navigation";
|
| 6 |
+
|
| 7 |
+
import type { SignupSession } from "@/lib/session";
|
| 8 |
+
|
| 9 |
+
import styles from "./page.module.css";
|
| 10 |
+
|
| 11 |
+
interface SignupPanelProps {
|
| 12 |
+
session: SignupSession | null;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface StatusState {
|
| 16 |
+
kind: "idle" | "info" | "success" | "error";
|
| 17 |
+
message: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const HACKATHON_SIGNUP_URL =
|
| 21 |
+
"https://huggingface.co/organizations/humanitys-last-hackathon/share/ItCCmUCGldPpHioCeqQcymUPTDXuwEepwV";
|
| 22 |
+
|
| 23 |
+
function getQueryStatus(params: URLSearchParams, session: SignupSession | null): StatusState | null {
|
| 24 |
+
const error = params.get("error");
|
| 25 |
+
const signedUp = params.get("signed_up") === "1";
|
| 26 |
+
|
| 27 |
+
if (error === "oauth_denied") {
|
| 28 |
+
return {
|
| 29 |
+
kind: "error",
|
| 30 |
+
message: "Hugging Face authorization was denied before signup completed.",
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (error === "oauth_state") {
|
| 35 |
+
return {
|
| 36 |
+
kind: "error",
|
| 37 |
+
message: "The Hugging Face login session expired. Start the signup flow again.",
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (error === "oauth_exchange") {
|
| 42 |
+
return {
|
| 43 |
+
kind: "error",
|
| 44 |
+
message: "The server could not complete the Hugging Face OAuth handshake.",
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (error === "signup_store") {
|
| 49 |
+
return {
|
| 50 |
+
kind: "error",
|
| 51 |
+
message: "Your Hugging Face account was read successfully, but the signup record could not be written.",
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (signedUp || session?.registrationComplete) {
|
| 56 |
+
return {
|
| 57 |
+
kind: "success",
|
| 58 |
+
message: "You are signed up. Your Hugging Face profile details were added to the private roster.",
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if (session) {
|
| 63 |
+
return {
|
| 64 |
+
kind: "info",
|
| 65 |
+
message: "Your Hugging Face account is connected. Finish signup to write your profile details to the roster.",
|
| 66 |
+
};
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return null;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export default function SignupPanel({ session }: SignupPanelProps) {
|
| 73 |
+
const searchParams = useSearchParams();
|
| 74 |
+
const [submissionStatus, setSubmissionStatus] = useState<StatusState>({
|
| 75 |
+
kind: "idle",
|
| 76 |
+
message: "",
|
| 77 |
+
});
|
| 78 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 79 |
+
|
| 80 |
+
const status = submissionStatus.kind !== "idle" ? submissionStatus : getQueryStatus(searchParams, session);
|
| 81 |
+
const isRegistered =
|
| 82 |
+
submissionStatus.kind === "success" ||
|
| 83 |
+
searchParams.get("signed_up") === "1" ||
|
| 84 |
+
session?.registrationComplete === true;
|
| 85 |
+
|
| 86 |
+
async function handleSignup() {
|
| 87 |
+
setIsSubmitting(true);
|
| 88 |
+
setSubmissionStatus({
|
| 89 |
+
kind: "info",
|
| 90 |
+
message: "Writing your Hugging Face profile details into the private signup dataset...",
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
try {
|
| 94 |
+
const response = await fetch("/api/signup", {
|
| 95 |
+
method: "POST",
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
| 99 |
+
|
| 100 |
+
if (!response.ok) {
|
| 101 |
+
setSubmissionStatus({
|
| 102 |
+
kind: "error",
|
| 103 |
+
message: payload?.error ?? "Signup failed before the roster write completed.",
|
| 104 |
+
});
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
setSubmissionStatus({
|
| 109 |
+
kind: "success",
|
| 110 |
+
message: "You are signed up. Your Hugging Face profile details were added to the private roster.",
|
| 111 |
+
});
|
| 112 |
+
} catch {
|
| 113 |
+
setSubmissionStatus({
|
| 114 |
+
kind: "error",
|
| 115 |
+
message: "Network failure while trying to write the signup record.",
|
| 116 |
+
});
|
| 117 |
+
} finally {
|
| 118 |
+
setIsSubmitting(false);
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return (
|
| 123 |
+
<div className={styles.page}>
|
| 124 |
+
<main className={styles.shell}>
|
| 125 |
+
<section className={styles.hero}>
|
| 126 |
+
<p className={styles.eyebrow}>OpenAi + Hugging Face + GPU Mode</p>
|
| 127 |
+
<h1 className={styles.title}>Humanity's Last Hackathon</h1>
|
| 128 |
+
<p className={styles.subtitle}>
|
| 129 |
+
Sign up to the hardest most AGI pilled hackathon of 2026. Your task is to liberate AI models from the cloud, by optimizing kernels. <b>Your tools are Codex by OpenAI, Hugging Face, and pure mac metal silicon.</b>
|
| 130 |
+
</p>
|
| 131 |
+
<div className={styles.heroMeta}>
|
| 132 |
+
<span>Launch: May 4th, 2026</span>
|
| 133 |
+
<span>Format: Qualify online for the final battle</span>
|
| 134 |
+
</div>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<section className={styles.grid}>
|
| 138 |
+
<article className={styles.panel}>
|
| 139 |
+
<p className={styles.panelLabel}>What You're Signing Up For</p>
|
| 140 |
+
<h2 className={styles.panelTitle}>Three weeks of ML systems work aimed at the hardest kernel problems.</h2>
|
| 141 |
+
<ul className={styles.list}>
|
| 142 |
+
<li>Week 1: fundamental inference and onboarding through a fun optimization task.</li>
|
| 143 |
+
<li>Week 2: scaling production inference on the latest hardware.</li>
|
| 144 |
+
<li>Week 3: a frontier systems engineering challenge around state-of-the-art kernels.</li>
|
| 145 |
+
</ul>
|
| 146 |
+
|
| 147 |
+
{/* <div className={styles.noteBlock}>
|
| 148 |
+
<p className={styles.noteTitle}>OAuth scope</p>
|
| 149 |
+
<p className={styles.noteBody}>openid profile email</p>
|
| 150 |
+
<p className={styles.noteFoot}>
|
| 151 |
+
GitHub and X are not requested. The signup uses only the details Hugging Face exposes in the basic
|
| 152 |
+
profile flow.
|
| 153 |
+
</p>
|
| 154 |
+
</div> */}
|
| 155 |
+
</article>
|
| 156 |
+
|
| 157 |
+
<section className={styles.panel}>
|
| 158 |
+
<p className={styles.panelLabel}>Signup</p>
|
| 159 |
+
<h2 className={styles.panelTitle}>Register with your Hugging Face account.</h2>
|
| 160 |
+
|
| 161 |
+
{!session ? (
|
| 162 |
+
<>
|
| 163 |
+
<p className={styles.panelCopy}>
|
| 164 |
+
Continue on Hugging Face to open the official Humanity's Last Hackathon signup page.
|
| 165 |
+
</p>
|
| 166 |
+
<div className={styles.actions}>
|
| 167 |
+
<a className={styles.primaryButton} href={HACKATHON_SIGNUP_URL}>
|
| 168 |
+
Sign up with Hugging Face
|
| 169 |
+
</a>
|
| 170 |
+
</div>
|
| 171 |
+
</>
|
| 172 |
+
) : (
|
| 173 |
+
<>
|
| 174 |
+
<div className={styles.profileCard}>
|
| 175 |
+
<Image
|
| 176 |
+
alt={`${session.username} avatar`}
|
| 177 |
+
className={styles.avatar}
|
| 178 |
+
height={72}
|
| 179 |
+
src={session.avatarUrl}
|
| 180 |
+
width={72}
|
| 181 |
+
/>
|
| 182 |
+
<div className={styles.profileCopy}>
|
| 183 |
+
<p className={styles.profileName}>{session.name}</p>
|
| 184 |
+
<p className={styles.profileMeta}>@{session.username}</p>
|
| 185 |
+
<a className={styles.inlineLink} href={session.profileUrl} rel="noreferrer" target="_blank">
|
| 186 |
+
View Hugging Face profile
|
| 187 |
+
</a>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div className={styles.detailGrid}>
|
| 192 |
+
<div className={styles.detailRow}>
|
| 193 |
+
<span className={styles.detailLabel}>Email</span>
|
| 194 |
+
<span className={styles.detailValue}>{session.email}</span>
|
| 195 |
+
</div>
|
| 196 |
+
<div className={styles.detailRow}>
|
| 197 |
+
<span className={styles.detailLabel}>Email verified</span>
|
| 198 |
+
<span className={styles.detailValue}>{session.emailVerified ? "Yes" : "No"}</span>
|
| 199 |
+
</div>
|
| 200 |
+
<div className={styles.detailRow}>
|
| 201 |
+
<span className={styles.detailLabel}>Website</span>
|
| 202 |
+
<span className={styles.detailValue}>{session.website ?? "Not set on profile"}</span>
|
| 203 |
+
</div>
|
| 204 |
+
<div className={styles.detailRow}>
|
| 205 |
+
<span className={styles.detailLabel}>Plan</span>
|
| 206 |
+
<span className={styles.detailValue}>{session.isPro ? "Pro" : "Free"}</span>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div className={styles.actions}>
|
| 211 |
+
{!isRegistered ? (
|
| 212 |
+
<button className={styles.primaryButton} disabled={isSubmitting} onClick={handleSignup} type="button">
|
| 213 |
+
{isSubmitting ? "Signing up..." : "Complete signup"}
|
| 214 |
+
</button>
|
| 215 |
+
) : (
|
| 216 |
+
<span className={styles.successBadge}>Signed up</span>
|
| 217 |
+
)}
|
| 218 |
+
<a className={styles.secondaryButton} href="/api/auth/logout">
|
| 219 |
+
{isRegistered ? "Use another account" : "Disconnect"}
|
| 220 |
+
</a>
|
| 221 |
+
</div>
|
| 222 |
+
</>
|
| 223 |
+
)}
|
| 224 |
+
|
| 225 |
+
{status ? (
|
| 226 |
+
<div
|
| 227 |
+
className={[
|
| 228 |
+
styles.status,
|
| 229 |
+
status.kind === "error" ? styles.statusError : "",
|
| 230 |
+
status.kind === "success" ? styles.statusSuccess : "",
|
| 231 |
+
status.kind === "info" ? styles.statusInfo : "",
|
| 232 |
+
]
|
| 233 |
+
.filter(Boolean)
|
| 234 |
+
.join(" ")}
|
| 235 |
+
>
|
| 236 |
+
{status.message}
|
| 237 |
+
</div>
|
| 238 |
+
) : null}
|
| 239 |
+
</section>
|
| 240 |
+
</section>
|
| 241 |
+
</main>
|
| 242 |
+
</div>
|
| 243 |
+
);
|
| 244 |
+
}
|
src/lib/huggingface.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createRepo, repoExists, uploadFile, whoAmI } from "@huggingface/hub";
|
| 2 |
+
import type { NextRequest } from "next/server";
|
| 3 |
+
import { z } from "zod";
|
| 4 |
+
|
| 5 |
+
import type { SignupSession } from "./session";
|
| 6 |
+
|
| 7 |
+
const DEFAULT_HUB_URL = "https://huggingface.co";
|
| 8 |
+
const OAUTH_SCOPE = "openid profile email";
|
| 9 |
+
const EVENT_NAME = "Humanity's Last Hackathon";
|
| 10 |
+
const EVENT_OBJECTIVE = "Write Mac Metal kernels with Codex.";
|
| 11 |
+
|
| 12 |
+
const tokenResponseSchema = z.object({
|
| 13 |
+
access_token: z.string().min(1),
|
| 14 |
+
expires_in: z.number().int().positive(),
|
| 15 |
+
scope: z.string().min(1),
|
| 16 |
+
token_type: z.string().min(1),
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
const userInfoSchema = z.object({
|
| 20 |
+
sub: z.string().min(1),
|
| 21 |
+
name: z.string(),
|
| 22 |
+
preferred_username: z.string().min(1),
|
| 23 |
+
email: z.string().email().optional(),
|
| 24 |
+
email_verified: z.boolean().optional(),
|
| 25 |
+
picture: z.string().url(),
|
| 26 |
+
profile: z.string().url(),
|
| 27 |
+
website: z.string().url().optional(),
|
| 28 |
+
isPro: z.boolean(),
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
export interface SignupRecord {
|
| 32 |
+
schemaVersion: 2;
|
| 33 |
+
event: {
|
| 34 |
+
name: string;
|
| 35 |
+
objective: string;
|
| 36 |
+
};
|
| 37 |
+
signedUpAt: string;
|
| 38 |
+
source: {
|
| 39 |
+
type: "huggingface-oauth";
|
| 40 |
+
scope: string;
|
| 41 |
+
};
|
| 42 |
+
participant: {
|
| 43 |
+
hf: {
|
| 44 |
+
id: string;
|
| 45 |
+
username: string;
|
| 46 |
+
fullName: string;
|
| 47 |
+
email: string;
|
| 48 |
+
emailVerified: boolean;
|
| 49 |
+
avatarUrl: string;
|
| 50 |
+
profileUrl: string;
|
| 51 |
+
website: string | null;
|
| 52 |
+
isPro: boolean;
|
| 53 |
+
};
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function getHubUrl(): string {
|
| 58 |
+
return (process.env.HF_HUB_URL?.trim() || DEFAULT_HUB_URL).replace(/\/+$/, "");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function requireEnv(name: string): string {
|
| 62 |
+
const value = process.env[name]?.trim();
|
| 63 |
+
|
| 64 |
+
if (!value) {
|
| 65 |
+
throw new Error(`Missing ${name}.`);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return value;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function getAppUrl(request?: NextRequest): string {
|
| 72 |
+
const configured = process.env.APP_URL?.trim();
|
| 73 |
+
|
| 74 |
+
if (configured) {
|
| 75 |
+
return configured.replace(/\/+$/, "");
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (!request) {
|
| 79 |
+
return "http://localhost:3000";
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
| 83 |
+
const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
| 84 |
+
|
| 85 |
+
if (forwardedProto && forwardedHost) {
|
| 86 |
+
return `${forwardedProto}://${forwardedHost}`;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return new URL(request.url).origin;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export function getOAuthRedirectUri(request?: NextRequest): string {
|
| 93 |
+
return `${getAppUrl(request)}/api/auth/huggingface/callback`;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export function createOAuthAuthorizationUrl(params: {
|
| 97 |
+
state: string;
|
| 98 |
+
codeChallenge: string;
|
| 99 |
+
request?: NextRequest;
|
| 100 |
+
}): string {
|
| 101 |
+
const search = new URLSearchParams({
|
| 102 |
+
client_id: requireEnv("HF_OAUTH_CLIENT_ID"),
|
| 103 |
+
redirect_uri: getOAuthRedirectUri(params.request),
|
| 104 |
+
response_type: "code",
|
| 105 |
+
scope: OAUTH_SCOPE,
|
| 106 |
+
state: params.state,
|
| 107 |
+
code_challenge: params.codeChallenge,
|
| 108 |
+
code_challenge_method: "S256",
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
return `${getHubUrl()}/oauth/authorize?${search.toString()}`;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
export async function exchangeCodeForToken(params: {
|
| 115 |
+
code: string;
|
| 116 |
+
codeVerifier: string;
|
| 117 |
+
redirectUri: string;
|
| 118 |
+
}) {
|
| 119 |
+
const response = await fetch(`${getHubUrl()}/oauth/token`, {
|
| 120 |
+
method: "POST",
|
| 121 |
+
headers: {
|
| 122 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 123 |
+
},
|
| 124 |
+
body: new URLSearchParams({
|
| 125 |
+
grant_type: "authorization_code",
|
| 126 |
+
code: params.code,
|
| 127 |
+
redirect_uri: params.redirectUri,
|
| 128 |
+
client_id: requireEnv("HF_OAUTH_CLIENT_ID"),
|
| 129 |
+
client_secret: requireEnv("HF_OAUTH_CLIENT_SECRET"),
|
| 130 |
+
code_verifier: params.codeVerifier,
|
| 131 |
+
}),
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
if (!response.ok) {
|
| 135 |
+
throw new Error(`Hugging Face token exchange failed with status ${response.status}.`);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
return tokenResponseSchema.parse(await response.json());
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export async function fetchUserInfo(accessToken: string) {
|
| 142 |
+
const response = await fetch(`${getHubUrl()}/oauth/userinfo`, {
|
| 143 |
+
headers: {
|
| 144 |
+
Authorization: `Bearer ${accessToken}`,
|
| 145 |
+
},
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
if (!response.ok) {
|
| 149 |
+
throw new Error(`Hugging Face userinfo failed with status ${response.status}.`);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return userInfoSchema.parse(await response.json());
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export async function fetchViewer(accessToken: string) {
|
| 156 |
+
const viewer = await whoAmI({ accessToken });
|
| 157 |
+
|
| 158 |
+
if (viewer.type !== "user") {
|
| 159 |
+
throw new Error("Expected a user account from Hugging Face OAuth.");
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
return viewer;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
export function createSignupRecord(params: { session: SignupSession }): SignupRecord {
|
| 166 |
+
const signedUpAt = new Date().toISOString();
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
schemaVersion: 2,
|
| 170 |
+
event: {
|
| 171 |
+
name: EVENT_NAME,
|
| 172 |
+
objective: EVENT_OBJECTIVE,
|
| 173 |
+
},
|
| 174 |
+
signedUpAt,
|
| 175 |
+
source: {
|
| 176 |
+
type: "huggingface-oauth",
|
| 177 |
+
scope: OAUTH_SCOPE,
|
| 178 |
+
},
|
| 179 |
+
participant: {
|
| 180 |
+
hf: {
|
| 181 |
+
id: params.session.id,
|
| 182 |
+
username: params.session.username,
|
| 183 |
+
fullName: params.session.name,
|
| 184 |
+
email: params.session.email,
|
| 185 |
+
emailVerified: params.session.emailVerified,
|
| 186 |
+
avatarUrl: params.session.avatarUrl,
|
| 187 |
+
profileUrl: params.session.profileUrl,
|
| 188 |
+
website: params.session.website,
|
| 189 |
+
isPro: params.session.isPro,
|
| 190 |
+
},
|
| 191 |
+
},
|
| 192 |
+
};
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function createDatasetReadme(): string {
|
| 196 |
+
return `---
|
| 197 |
+
tags:
|
| 198 |
+
- private
|
| 199 |
+
- registrations
|
| 200 |
+
- hackathon
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
# Humanity's Last Hackathon Signups
|
| 204 |
+
|
| 205 |
+
Private registration dataset for the Humanity's Last Hackathon landing page.
|
| 206 |
+
|
| 207 |
+
- Event: ${EVENT_NAME}
|
| 208 |
+
- Objective: ${EVENT_OBJECTIVE}
|
| 209 |
+
- Source: simple Hugging Face OAuth signup app
|
| 210 |
+
|
| 211 |
+
Each participant record is stored at \`signups/<hf-user-id>.json\`.
|
| 212 |
+
`;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
export async function storeSignupRecord(record: SignupRecord) {
|
| 216 |
+
const repo = {
|
| 217 |
+
type: "dataset" as const,
|
| 218 |
+
name: requireEnv("HF_DATASET_REPO"),
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
const accessToken = requireEnv("HF_DATASET_WRITE_TOKEN");
|
| 222 |
+
const filePath = `signups/${record.participant.hf.id}.json`;
|
| 223 |
+
|
| 224 |
+
if (!(await repoExists({ repo, accessToken }))) {
|
| 225 |
+
await createRepo({
|
| 226 |
+
repo,
|
| 227 |
+
accessToken,
|
| 228 |
+
private: true,
|
| 229 |
+
files: [
|
| 230 |
+
{
|
| 231 |
+
path: "README.md",
|
| 232 |
+
content: new Blob([createDatasetReadme()], { type: "text/markdown" }),
|
| 233 |
+
},
|
| 234 |
+
],
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
await uploadFile({
|
| 239 |
+
repo,
|
| 240 |
+
accessToken,
|
| 241 |
+
file: {
|
| 242 |
+
path: filePath,
|
| 243 |
+
content: new Blob([`${JSON.stringify(record, null, 2)}\n`], {
|
| 244 |
+
type: "application/json",
|
| 245 |
+
}),
|
| 246 |
+
},
|
| 247 |
+
commitTitle: `Register ${record.participant.hf.username} for Humanity's Last Hackathon`,
|
| 248 |
+
commitDescription: "Write or update a signup record from the Hugging Face OAuth signup app.",
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
repoName: repo.name,
|
| 253 |
+
path: filePath,
|
| 254 |
+
};
|
| 255 |
+
}
|
src/lib/session.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
| 2 |
+
|
| 3 |
+
import { cookies } from "next/headers";
|
| 4 |
+
import type { NextRequest, NextResponse } from "next/server";
|
| 5 |
+
import { z } from "zod";
|
| 6 |
+
|
| 7 |
+
export const SESSION_COOKIE_NAME = "hlh_signup_session";
|
| 8 |
+
export const OAUTH_STATE_COOKIE_NAME = "hlh_signup_oauth_state";
|
| 9 |
+
export const OAUTH_VERIFIER_COOKIE_NAME = "hlh_signup_oauth_verifier";
|
| 10 |
+
|
| 11 |
+
const OAUTH_COOKIE_MAX_AGE_SECONDS = 60 * 10;
|
| 12 |
+
const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
| 13 |
+
|
| 14 |
+
const signupSessionSchema = z.object({
|
| 15 |
+
id: z.string().min(1),
|
| 16 |
+
username: z.string().min(1),
|
| 17 |
+
name: z.string().min(1),
|
| 18 |
+
email: z.string().email(),
|
| 19 |
+
emailVerified: z.boolean(),
|
| 20 |
+
avatarUrl: z.string().url(),
|
| 21 |
+
profileUrl: z.string().url(),
|
| 22 |
+
website: z.string().url().nullable(),
|
| 23 |
+
isPro: z.boolean(),
|
| 24 |
+
registrationComplete: z.boolean().default(false),
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
export type SignupSession = z.infer<typeof signupSessionSchema>;
|
| 28 |
+
|
| 29 |
+
function getSessionSecret(): string {
|
| 30 |
+
const secret = process.env.SESSION_SECRET?.trim();
|
| 31 |
+
|
| 32 |
+
if (!secret) {
|
| 33 |
+
throw new Error("Missing SESSION_SECRET.");
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return secret;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function getCookieOptions(maxAge?: number) {
|
| 40 |
+
return {
|
| 41 |
+
httpOnly: true,
|
| 42 |
+
sameSite: "lax" as const,
|
| 43 |
+
secure: process.env.NODE_ENV === "production",
|
| 44 |
+
path: "/",
|
| 45 |
+
...(typeof maxAge === "number" ? { maxAge } : {}),
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function encodeJsonPayload(value: unknown): string {
|
| 50 |
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function decodeJsonPayload(value: string): unknown {
|
| 54 |
+
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function signPayload(payload: string): string {
|
| 58 |
+
return createHmac("sha256", getSessionSecret()).update(payload).digest("base64url");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function verifyPayload(payload: string, signature: string): boolean {
|
| 62 |
+
const expected = Buffer.from(signPayload(payload), "utf8");
|
| 63 |
+
const actual = Buffer.from(signature, "utf8");
|
| 64 |
+
|
| 65 |
+
if (expected.length !== actual.length) {
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return timingSafeEqual(expected, actual);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export function createOpaqueToken(bytes = 32): string {
|
| 73 |
+
return randomBytes(bytes).toString("base64url");
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export function createPkcePair(): { verifier: string; challenge: string } {
|
| 77 |
+
const verifier = createOpaqueToken(48);
|
| 78 |
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
| 79 |
+
|
| 80 |
+
return { verifier, challenge };
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export function parseSessionCookie(value?: string | null): SignupSession | null {
|
| 84 |
+
if (!value) {
|
| 85 |
+
return null;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const [payload, signature] = value.split(".");
|
| 89 |
+
|
| 90 |
+
if (!payload || !signature || !verifyPayload(payload, signature)) {
|
| 91 |
+
return null;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
try {
|
| 95 |
+
return signupSessionSchema.parse(decodeJsonPayload(payload));
|
| 96 |
+
} catch {
|
| 97 |
+
return null;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export async function getSessionFromCookies(): Promise<SignupSession | null> {
|
| 102 |
+
const cookieStore = await cookies();
|
| 103 |
+
return parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export function setSessionCookie(response: NextResponse, session: SignupSession) {
|
| 107 |
+
const payload = encodeJsonPayload(session);
|
| 108 |
+
const signedValue = `${payload}.${signPayload(payload)}`;
|
| 109 |
+
|
| 110 |
+
response.cookies.set(SESSION_COOKIE_NAME, signedValue, getCookieOptions(SESSION_COOKIE_MAX_AGE_SECONDS));
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export function clearSessionCookie(response: NextResponse) {
|
| 114 |
+
response.cookies.set(SESSION_COOKIE_NAME, "", getCookieOptions(0));
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export function setOAuthCookies(response: NextResponse, state: string, verifier: string) {
|
| 118 |
+
response.cookies.set(OAUTH_STATE_COOKIE_NAME, state, getCookieOptions(OAUTH_COOKIE_MAX_AGE_SECONDS));
|
| 119 |
+
response.cookies.set(OAUTH_VERIFIER_COOKIE_NAME, verifier, getCookieOptions(OAUTH_COOKIE_MAX_AGE_SECONDS));
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export function readOAuthCookies(request: NextRequest): {
|
| 123 |
+
state: string | null;
|
| 124 |
+
verifier: string | null;
|
| 125 |
+
} {
|
| 126 |
+
return {
|
| 127 |
+
state: request.cookies.get(OAUTH_STATE_COOKIE_NAME)?.value ?? null,
|
| 128 |
+
verifier: request.cookies.get(OAUTH_VERIFIER_COOKIE_NAME)?.value ?? null,
|
| 129 |
+
};
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
export function clearOAuthCookies(response: NextResponse) {
|
| 133 |
+
response.cookies.set(OAUTH_STATE_COOKIE_NAME, "", getCookieOptions(0));
|
| 134 |
+
response.cookies.set(OAUTH_VERIFIER_COOKIE_NAME, "", getCookieOptions(0));
|
| 135 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|