Commit
·
101ebaa
0
Parent(s):
init
Browse files- .dockerignore +8 -0
- .gitattributes +35 -0
- .gitignore +35 -0
- Dockerfile +55 -0
- README copy.md +48 -0
- README.md +11 -0
- next.config.ts +7 -0
- package.json +42 -0
- postcss.config.mjs +7 -0
- public/.gitkeep +0 -0
- public/teich.svg +11 -0
- src/app/admin/page.tsx +391 -0
- src/app/api/admin/login/route.ts +28 -0
- src/app/api/admin/logout/route.ts +13 -0
- src/app/api/admin/me/route.ts +6 -0
- src/app/api/admin/requests/[type]/[id]/comments/route.ts +43 -0
- src/app/api/admin/requests/[type]/[id]/route.ts +63 -0
- src/app/api/dataset/route.ts +83 -0
- src/app/api/distillation/route.ts +81 -0
- src/app/api/openrouter-models/route.ts +52 -0
- src/app/api/requests/[type]/[id]/comments/[commentId]/route.ts +106 -0
- src/app/api/requests/[type]/[id]/comments/route.ts +46 -0
- src/app/api/requests/[type]/[id]/route.ts +156 -0
- src/app/api/teichai-datasets/route.ts +52 -0
- src/app/globals.css +73 -0
- src/app/layout.tsx +26 -0
- src/app/page.tsx +741 -0
- src/app/requests/[type]/[id]/page.tsx +650 -0
- src/components/Footer.tsx +45 -0
- src/components/Navbar.tsx +129 -0
- src/components/Providers.tsx +13 -0
- src/components/ThemeProvider.tsx +45 -0
- src/components/ui/badge.tsx +32 -0
- src/components/ui/button.tsx +48 -0
- src/components/ui/card.tsx +55 -0
- src/components/ui/combobox.tsx +108 -0
- src/components/ui/input.tsx +23 -0
- src/components/ui/label.tsx +21 -0
- src/components/ui/popover.tsx +29 -0
- src/components/ui/select.tsx +149 -0
- src/components/ui/tabs.tsx +54 -0
- src/components/ui/textarea.tsx +22 -0
- src/components/ui/toaster.tsx +185 -0
- src/lib/adminAuth.ts +67 -0
- src/lib/store.ts +409 -0
- src/lib/userIdentity.ts +30 -0
- src/lib/utils.ts +6 -0
- tailwind.config.ts +41 -0
- tsconfig.json +41 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Dockerfile
|
| 2 |
+
.dockerignore
|
| 3 |
+
node_modules
|
| 4 |
+
npm-debug.log
|
| 5 |
+
README.md
|
| 6 |
+
.next
|
| 7 |
+
.git
|
| 8 |
+
.gitignore
|
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules
|
| 3 |
+
.pnp
|
| 4 |
+
.pnp.js
|
| 5 |
+
|
| 6 |
+
# Testing
|
| 7 |
+
coverage
|
| 8 |
+
|
| 9 |
+
# Next.js
|
| 10 |
+
.next/
|
| 11 |
+
out/
|
| 12 |
+
build/
|
| 13 |
+
|
| 14 |
+
# Misc
|
| 15 |
+
.DS_Store
|
| 16 |
+
*.pem
|
| 17 |
+
|
| 18 |
+
# Debug
|
| 19 |
+
npm-debug.log*
|
| 20 |
+
yarn-debug.log*
|
| 21 |
+
yarn-error.log*
|
| 22 |
+
|
| 23 |
+
# Local env files
|
| 24 |
+
.env*.local
|
| 25 |
+
.env
|
| 26 |
+
|
| 27 |
+
# Vercel
|
| 28 |
+
.vercel
|
| 29 |
+
|
| 30 |
+
# TypeScript
|
| 31 |
+
*.tsbuildinfo
|
| 32 |
+
next-env.d.ts
|
| 33 |
+
|
| 34 |
+
# Data
|
| 35 |
+
data/
|
Dockerfile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS base
|
| 2 |
+
|
| 3 |
+
# Install dependencies only when needed
|
| 4 |
+
FROM base AS deps
|
| 5 |
+
RUN apk add --no-cache libc6-compat
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Copy package files
|
| 9 |
+
COPY package.json package-lock.json* ./
|
| 10 |
+
RUN npm ci
|
| 11 |
+
|
| 12 |
+
# Rebuild the source code only when needed
|
| 13 |
+
FROM base AS builder
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
# Create data directory
|
| 19 |
+
RUN mkdir -p /app/data
|
| 20 |
+
|
| 21 |
+
# Build the application
|
| 22 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 23 |
+
RUN npm run build
|
| 24 |
+
|
| 25 |
+
# Production image, copy all the files and run next
|
| 26 |
+
FROM base AS runner
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
|
| 29 |
+
ENV NODE_ENV=production
|
| 30 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 31 |
+
|
| 32 |
+
RUN addgroup --system --gid 1001 nodejs
|
| 33 |
+
RUN adduser --system --uid 1001 nextjs
|
| 34 |
+
|
| 35 |
+
# Create data directory with proper permissions
|
| 36 |
+
RUN mkdir -p /data && chown -R nextjs:nodejs /data
|
| 37 |
+
|
| 38 |
+
COPY --from=builder /app/public ./public
|
| 39 |
+
|
| 40 |
+
# Set the correct permission for prerender cache
|
| 41 |
+
RUN mkdir .next
|
| 42 |
+
RUN chown nextjs:nodejs .next
|
| 43 |
+
|
| 44 |
+
# Automatically leverage output traces to reduce image size
|
| 45 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 46 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 47 |
+
|
| 48 |
+
USER nextjs
|
| 49 |
+
|
| 50 |
+
EXPOSE 7860
|
| 51 |
+
|
| 52 |
+
ENV PORT=7860
|
| 53 |
+
ENV HOSTNAME="0.0.0.0"
|
| 54 |
+
|
| 55 |
+
CMD ["node", "server.js"]
|
README copy.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: TeichAI Community Requests
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# TeichAI Community Requests
|
| 12 |
+
|
| 13 |
+
A community platform for submitting and voting on model distillation and dataset requests for [TeichAI](https://teichai.com).
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- **Model Distillation Requests**: Request distilled versions of frontier models (Claude, GPT, Gemini) into open-source student models (Qwen3, Llama, etc.)
|
| 18 |
+
- **Dataset Requests**: Request reasoning datasets generated from various AI models
|
| 19 |
+
- **Upvoting System**: Vote on requests to help prioritize what gets built next
|
| 20 |
+
- **Persistent Storage**: All requests are saved and persisted
|
| 21 |
+
|
| 22 |
+
## How It Works
|
| 23 |
+
|
| 24 |
+
1. Submit a request for a model distillation or reasoning dataset
|
| 25 |
+
2. Upvote requests from other community members
|
| 26 |
+
3. We prioritize requests based on community interest
|
| 27 |
+
4. Models and datasets are published on our [Hugging Face page](https://huggingface.co/TeichAI)
|
| 28 |
+
|
| 29 |
+
## Tech Stack
|
| 30 |
+
|
| 31 |
+
- Next.js 15
|
| 32 |
+
- React 19
|
| 33 |
+
- Tailwind CSS
|
| 34 |
+
- Radix UI
|
| 35 |
+
- Docker
|
| 36 |
+
|
| 37 |
+
## Development
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
npm install
|
| 41 |
+
npm run dev
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Links
|
| 45 |
+
|
| 46 |
+
- [TeichAI Website](https://teichai.com)
|
| 47 |
+
- [TeichAI on Hugging Face](https://huggingface.co/TeichAI)
|
| 48 |
+
- [Support Us](https://paypal.me/TeichAI)
|
README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Requests
|
| 3 |
+
emoji: 🔥
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: A space to request datasets and model distillations
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "teichai-requests",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start -p 7860",
|
| 9 |
+
"lint": "eslint . --ext .ts,.tsx"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-dialog": "^1.1.15",
|
| 13 |
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 14 |
+
"@radix-ui/react-label": "^2.1.2",
|
| 15 |
+
"@radix-ui/react-popover": "^1.1.15",
|
| 16 |
+
"@radix-ui/react-select": "^2.1.6",
|
| 17 |
+
"@radix-ui/react-separator": "^1.1.8",
|
| 18 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 19 |
+
"@radix-ui/react-tabs": "^1.1.3",
|
| 20 |
+
"@radix-ui/react-toast": "^1.2.6",
|
| 21 |
+
"class-variance-authority": "^0.7.1",
|
| 22 |
+
"clsx": "^2.1.1",
|
| 23 |
+
"geist": "^1.5.1",
|
| 24 |
+
"lucide-react": "^0.468.0",
|
| 25 |
+
"next": "^16.0.10",
|
| 26 |
+
"react": "^19.0.0",
|
| 27 |
+
"react-dom": "^19.0.0",
|
| 28 |
+
"tailwind-merge": "^2.6.0",
|
| 29 |
+
"uuid": "^11.0.3"
|
| 30 |
+
},
|
| 31 |
+
"devDependencies": {
|
| 32 |
+
"@types/node": "^22.10.2",
|
| 33 |
+
"@types/react": "^19.0.1",
|
| 34 |
+
"@types/react-dom": "^19.0.1",
|
| 35 |
+
"@types/uuid": "^10.0.0",
|
| 36 |
+
"eslint": "^9.17.0",
|
| 37 |
+
"eslint-config-next": "15.1.0",
|
| 38 |
+
"postcss": "^8.4.49",
|
| 39 |
+
"tailwindcss": "^3.4.17",
|
| 40 |
+
"typescript": "^5.7.2"
|
| 41 |
+
}
|
| 42 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/.gitkeep
ADDED
|
File without changes
|
public/teich.svg
ADDED
|
|
src/app/admin/page.tsx
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import Navbar from "@/components/Navbar";
|
| 6 |
+
import Footer from "@/components/Footer";
|
| 7 |
+
import { Button } from "@/components/ui/button";
|
| 8 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
import { Badge } from "@/components/ui/badge";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 12 |
+
import { toast } from "@/components/ui/toaster";
|
| 13 |
+
import {
|
| 14 |
+
Select,
|
| 15 |
+
SelectContent,
|
| 16 |
+
SelectItem,
|
| 17 |
+
SelectTrigger,
|
| 18 |
+
SelectValue,
|
| 19 |
+
} from "@/components/ui/select";
|
| 20 |
+
|
| 21 |
+
type RequestStatus = "pending" | "in_progress" | "completed";
|
| 22 |
+
|
| 23 |
+
type DistillationRequest = {
|
| 24 |
+
id: string;
|
| 25 |
+
sourceDataset: string;
|
| 26 |
+
studentModel: string;
|
| 27 |
+
additionalNotes: string;
|
| 28 |
+
upvotes: number;
|
| 29 |
+
createdAt: string;
|
| 30 |
+
status: RequestStatus;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
type DatasetRequest = {
|
| 34 |
+
id: string;
|
| 35 |
+
sourceModel: string;
|
| 36 |
+
datasetSize: string;
|
| 37 |
+
reasoningDepth: string;
|
| 38 |
+
topics: string[];
|
| 39 |
+
additionalNotes: string;
|
| 40 |
+
upvotes: number;
|
| 41 |
+
createdAt: string;
|
| 42 |
+
status: RequestStatus;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const STATUS_OPTIONS: RequestStatus[] = ["pending", "in_progress", "completed"];
|
| 46 |
+
|
| 47 |
+
function StatusBadge({ status }: { status: RequestStatus }) {
|
| 48 |
+
if (status === "completed") return <Badge variant="success">Completed</Badge>;
|
| 49 |
+
if (status === "in_progress") return <Badge variant="warning">In Progress</Badge>;
|
| 50 |
+
return <Badge variant="secondary">Pending</Badge>;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export default function AdminPage() {
|
| 54 |
+
const [checking, setChecking] = useState(true);
|
| 55 |
+
const [admin, setAdmin] = useState(false);
|
| 56 |
+
|
| 57 |
+
const [password, setPassword] = useState("");
|
| 58 |
+
const [loginLoading, setLoginLoading] = useState(false);
|
| 59 |
+
|
| 60 |
+
const [distillationRequests, setDistillationRequests] = useState<DistillationRequest[]>([]);
|
| 61 |
+
const [datasetRequests, setDatasetRequests] = useState<DatasetRequest[]>([]);
|
| 62 |
+
const [loading, setLoading] = useState(false);
|
| 63 |
+
|
| 64 |
+
const [replyOpen, setReplyOpen] = useState<Record<string, boolean>>({});
|
| 65 |
+
const [replyBody, setReplyBody] = useState<Record<string, string>>({});
|
| 66 |
+
const [replySubmitting, setReplySubmitting] = useState<Record<string, boolean>>({});
|
| 67 |
+
|
| 68 |
+
const allRequests = useMemo(() => {
|
| 69 |
+
const dist = distillationRequests.map((r) => ({ type: "distillation" as const, request: r }));
|
| 70 |
+
const data = datasetRequests.map((r) => ({ type: "dataset" as const, request: r }));
|
| 71 |
+
return [...dist, ...data].sort((a, b) => b.request.upvotes - a.request.upvotes);
|
| 72 |
+
}, [distillationRequests, datasetRequests]);
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
checkAdmin();
|
| 76 |
+
}, []);
|
| 77 |
+
|
| 78 |
+
useEffect(() => {
|
| 79 |
+
if (admin) {
|
| 80 |
+
fetchAll();
|
| 81 |
+
}
|
| 82 |
+
}, [admin]);
|
| 83 |
+
|
| 84 |
+
async function checkAdmin() {
|
| 85 |
+
setChecking(true);
|
| 86 |
+
try {
|
| 87 |
+
const res = await fetch("/api/admin/me", { cache: "no-store" });
|
| 88 |
+
const data = await res.json();
|
| 89 |
+
setAdmin(Boolean(data?.admin));
|
| 90 |
+
} catch {
|
| 91 |
+
setAdmin(false);
|
| 92 |
+
} finally {
|
| 93 |
+
setChecking(false);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function fetchAll() {
|
| 98 |
+
setLoading(true);
|
| 99 |
+
try {
|
| 100 |
+
const [distillRes, datasetRes] = await Promise.all([
|
| 101 |
+
fetch("/api/distillation", { cache: "no-store" }),
|
| 102 |
+
fetch("/api/dataset", { cache: "no-store" }),
|
| 103 |
+
]);
|
| 104 |
+
const [distillData, datasetData] = await Promise.all([distillRes.json(), datasetRes.json()]);
|
| 105 |
+
setDistillationRequests(Array.isArray(distillData) ? distillData : []);
|
| 106 |
+
setDatasetRequests(Array.isArray(datasetData) ? datasetData : []);
|
| 107 |
+
} catch (error) {
|
| 108 |
+
console.error(error);
|
| 109 |
+
toast({ title: "Error", description: "Failed to fetch requests", variant: "destructive" });
|
| 110 |
+
} finally {
|
| 111 |
+
setLoading(false);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
async function login() {
|
| 116 |
+
setLoginLoading(true);
|
| 117 |
+
try {
|
| 118 |
+
const res = await fetch("/api/admin/login", {
|
| 119 |
+
method: "POST",
|
| 120 |
+
headers: { "Content-Type": "application/json" },
|
| 121 |
+
body: JSON.stringify({ password }),
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
const data = await res.json();
|
| 125 |
+
if (!res.ok) {
|
| 126 |
+
toast({ title: "Login failed", description: data?.error || "Invalid password", variant: "destructive" });
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
toast({ title: "Logged in", description: "Admin session started" });
|
| 131 |
+
setPassword("");
|
| 132 |
+
await checkAdmin();
|
| 133 |
+
} catch (error) {
|
| 134 |
+
console.error(error);
|
| 135 |
+
toast({ title: "Login failed", description: "Unexpected error", variant: "destructive" });
|
| 136 |
+
} finally {
|
| 137 |
+
setLoginLoading(false);
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
async function logout() {
|
| 142 |
+
try {
|
| 143 |
+
await fetch("/api/admin/logout", { method: "POST" });
|
| 144 |
+
setAdmin(false);
|
| 145 |
+
toast({ title: "Logged out" });
|
| 146 |
+
} catch {
|
| 147 |
+
toast({ title: "Error", description: "Failed to logout", variant: "destructive" });
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async function updateStatus(type: "distillation" | "dataset", id: string, status: RequestStatus) {
|
| 152 |
+
try {
|
| 153 |
+
const res = await fetch(`/api/admin/requests/${type}/${id}`, {
|
| 154 |
+
method: "PATCH",
|
| 155 |
+
headers: { "Content-Type": "application/json" },
|
| 156 |
+
body: JSON.stringify({ status }),
|
| 157 |
+
});
|
| 158 |
+
const data = await res.json();
|
| 159 |
+
if (!res.ok) {
|
| 160 |
+
toast({ title: "Error", description: data?.error || "Failed to update status", variant: "destructive" });
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
toast({ title: "Updated", description: "Status updated" });
|
| 164 |
+
await fetchAll();
|
| 165 |
+
} catch {
|
| 166 |
+
toast({ title: "Error", description: "Failed to update status", variant: "destructive" });
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
async function removeRequest(type: "distillation" | "dataset", id: string) {
|
| 171 |
+
try {
|
| 172 |
+
const res = await fetch(`/api/admin/requests/${type}/${id}`, { method: "DELETE" });
|
| 173 |
+
const data = await res.json();
|
| 174 |
+
if (!res.ok) {
|
| 175 |
+
toast({ title: "Error", description: data?.error || "Failed to delete", variant: "destructive" });
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
toast({ title: "Deleted", description: "Request removed" });
|
| 179 |
+
await fetchAll();
|
| 180 |
+
} catch {
|
| 181 |
+
toast({ title: "Error", description: "Failed to delete", variant: "destructive" });
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async function submitReply(type: "distillation" | "dataset", id: string) {
|
| 186 |
+
const key = `${type}:${id}`;
|
| 187 |
+
const body = (replyBody[key] || "").trim();
|
| 188 |
+
if (!body) {
|
| 189 |
+
toast({ title: "Error", description: "Reply cannot be empty", variant: "destructive" });
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
setReplySubmitting((prev) => ({ ...prev, [key]: true }));
|
| 194 |
+
try {
|
| 195 |
+
const res = await fetch(`/api/admin/requests/${type}/${id}/comments`, {
|
| 196 |
+
method: "POST",
|
| 197 |
+
headers: { "Content-Type": "application/json" },
|
| 198 |
+
body: JSON.stringify({ body }),
|
| 199 |
+
});
|
| 200 |
+
const data = await res.json();
|
| 201 |
+
if (!res.ok) {
|
| 202 |
+
toast({ title: "Error", description: data?.error || "Failed to reply", variant: "destructive" });
|
| 203 |
+
return;
|
| 204 |
+
}
|
| 205 |
+
toast({ title: "Replied", description: "Comment posted" });
|
| 206 |
+
setReplyBody((prev) => ({ ...prev, [key]: "" }));
|
| 207 |
+
setReplyOpen((prev) => ({ ...prev, [key]: false }));
|
| 208 |
+
} catch {
|
| 209 |
+
toast({ title: "Error", description: "Failed to reply", variant: "destructive" });
|
| 210 |
+
} finally {
|
| 211 |
+
setReplySubmitting((prev) => ({ ...prev, [key]: false }));
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
<main className="min-h-screen bg-background">
|
| 217 |
+
<Navbar />
|
| 218 |
+
|
| 219 |
+
<section className="pt-24 pb-10">
|
| 220 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 221 |
+
<div className="flex items-start justify-between gap-4">
|
| 222 |
+
<div>
|
| 223 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground">Admin</h1>
|
| 224 |
+
<p className="mt-1 text-muted-foreground">Manage requests, update status, and reply.</p>
|
| 225 |
+
</div>
|
| 226 |
+
{admin && (
|
| 227 |
+
<Button variant="outline" onClick={logout}>
|
| 228 |
+
Logout
|
| 229 |
+
</Button>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<div className="mt-6">
|
| 234 |
+
{checking ? (
|
| 235 |
+
<Card>
|
| 236 |
+
<CardContent className="p-6 text-muted-foreground">Checking session…</CardContent>
|
| 237 |
+
</Card>
|
| 238 |
+
) : !admin ? (
|
| 239 |
+
<Card>
|
| 240 |
+
<CardHeader>
|
| 241 |
+
<CardTitle>Admin Login</CardTitle>
|
| 242 |
+
<CardDescription>Password is set via ADMIN_PASSWORD</CardDescription>
|
| 243 |
+
</CardHeader>
|
| 244 |
+
<CardContent className="space-y-4">
|
| 245 |
+
<div className="space-y-2">
|
| 246 |
+
<Label htmlFor="password">Password</Label>
|
| 247 |
+
<input
|
| 248 |
+
id="password"
|
| 249 |
+
type="password"
|
| 250 |
+
placeholder="Enter admin password"
|
| 251 |
+
title="Admin password"
|
| 252 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 253 |
+
value={password}
|
| 254 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 255 |
+
/>
|
| 256 |
+
</div>
|
| 257 |
+
<Button onClick={login} disabled={loginLoading}>
|
| 258 |
+
{loginLoading ? "Logging in…" : "Login"}
|
| 259 |
+
</Button>
|
| 260 |
+
</CardContent>
|
| 261 |
+
</Card>
|
| 262 |
+
) : (
|
| 263 |
+
<div className="space-y-4">
|
| 264 |
+
<div className="flex items-center justify-between">
|
| 265 |
+
<div className="text-sm text-muted-foreground">
|
| 266 |
+
Total: {allRequests.length}
|
| 267 |
+
</div>
|
| 268 |
+
<Button variant="outline" onClick={fetchAll} disabled={loading}>
|
| 269 |
+
{loading ? "Refreshing…" : "Refresh"}
|
| 270 |
+
</Button>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{allRequests.length === 0 ? (
|
| 274 |
+
<Card>
|
| 275 |
+
<CardContent className="p-6 text-muted-foreground">No requests yet.</CardContent>
|
| 276 |
+
</Card>
|
| 277 |
+
) : (
|
| 278 |
+
<div className="grid gap-4">
|
| 279 |
+
{allRequests.map(({ type, request }) => {
|
| 280 |
+
const key = `${type}:${request.id}`;
|
| 281 |
+
const isReplyOpen = Boolean(replyOpen[key]);
|
| 282 |
+
const title =
|
| 283 |
+
type === "distillation"
|
| 284 |
+
? `${(request as DistillationRequest).sourceDataset} → ${(request as DistillationRequest).studentModel}`
|
| 285 |
+
: `${(request as DatasetRequest).sourceModel} Dataset (${(request as DatasetRequest).datasetSize})`;
|
| 286 |
+
|
| 287 |
+
return (
|
| 288 |
+
<Card key={key} className="overflow-hidden">
|
| 289 |
+
<CardContent className="p-5">
|
| 290 |
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
| 291 |
+
<div className="min-w-[280px] flex-1">
|
| 292 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 293 |
+
<h3 className="font-medium text-foreground">{title}</h3>
|
| 294 |
+
<StatusBadge status={request.status} />
|
| 295 |
+
<Badge variant="outline">{type}</Badge>
|
| 296 |
+
<Badge variant="secondary">{request.upvotes} upvotes</Badge>
|
| 297 |
+
</div>
|
| 298 |
+
{request.additionalNotes ? (
|
| 299 |
+
<p className="mt-2 text-sm text-muted-foreground">{request.additionalNotes}</p>
|
| 300 |
+
) : null}
|
| 301 |
+
{type === "dataset" ? (
|
| 302 |
+
<div className="mt-2 flex flex-wrap gap-1">
|
| 303 |
+
<Badge variant="outline">{(request as DatasetRequest).reasoningDepth} reasoning</Badge>
|
| 304 |
+
{(request as DatasetRequest).topics?.slice(0, 6)?.map((t) => (
|
| 305 |
+
<Badge key={t} variant="secondary" className="text-xs">
|
| 306 |
+
{t}
|
| 307 |
+
</Badge>
|
| 308 |
+
))}
|
| 309 |
+
</div>
|
| 310 |
+
) : null}
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 314 |
+
<div className="w-[180px]">
|
| 315 |
+
<Select
|
| 316 |
+
value={request.status}
|
| 317 |
+
onValueChange={(v) => updateStatus(type, request.id, v as RequestStatus)}
|
| 318 |
+
>
|
| 319 |
+
<SelectTrigger>
|
| 320 |
+
<SelectValue />
|
| 321 |
+
</SelectTrigger>
|
| 322 |
+
<SelectContent>
|
| 323 |
+
{STATUS_OPTIONS.map((s) => (
|
| 324 |
+
<SelectItem key={s} value={s}>
|
| 325 |
+
{s}
|
| 326 |
+
</SelectItem>
|
| 327 |
+
))}
|
| 328 |
+
</SelectContent>
|
| 329 |
+
</Select>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<Button variant="outline" asChild>
|
| 333 |
+
<Link href={`/requests/${type}/${request.id}`}>Discussion</Link>
|
| 334 |
+
</Button>
|
| 335 |
+
|
| 336 |
+
<Button
|
| 337 |
+
variant="outline"
|
| 338 |
+
onClick={() => setReplyOpen((prev) => ({ ...prev, [key]: !prev[key] }))}
|
| 339 |
+
>
|
| 340 |
+
{isReplyOpen ? "Close Reply" : "Reply"}
|
| 341 |
+
</Button>
|
| 342 |
+
|
| 343 |
+
<Button
|
| 344 |
+
variant="destructive"
|
| 345 |
+
onClick={() => removeRequest(type, request.id)}
|
| 346 |
+
>
|
| 347 |
+
Delete
|
| 348 |
+
</Button>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
{isReplyOpen && (
|
| 353 |
+
<div className="mt-4 space-y-2">
|
| 354 |
+
<Label>Admin Reply</Label>
|
| 355 |
+
<Textarea
|
| 356 |
+
value={replyBody[key] || ""}
|
| 357 |
+
onChange={(e) => setReplyBody((prev) => ({ ...prev, [key]: e.target.value }))}
|
| 358 |
+
placeholder="Write a reply as TeichAI…"
|
| 359 |
+
/>
|
| 360 |
+
<div className="flex gap-2">
|
| 361 |
+
<Button
|
| 362 |
+
onClick={() => submitReply(type, request.id)}
|
| 363 |
+
disabled={Boolean(replySubmitting[key])}
|
| 364 |
+
>
|
| 365 |
+
{replySubmitting[key] ? "Posting…" : "Post Reply"}
|
| 366 |
+
</Button>
|
| 367 |
+
<Button
|
| 368 |
+
variant="outline"
|
| 369 |
+
onClick={() => setReplyOpen((prev) => ({ ...prev, [key]: false }))}
|
| 370 |
+
>
|
| 371 |
+
Cancel
|
| 372 |
+
</Button>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
)}
|
| 376 |
+
</CardContent>
|
| 377 |
+
</Card>
|
| 378 |
+
);
|
| 379 |
+
})}
|
| 380 |
+
</div>
|
| 381 |
+
)}
|
| 382 |
+
</div>
|
| 383 |
+
)}
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
</section>
|
| 387 |
+
|
| 388 |
+
<Footer />
|
| 389 |
+
</main>
|
| 390 |
+
);
|
| 391 |
+
}
|
src/app/api/admin/login/route.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { adminCookieOptions, createAdminSessionValue } from "@/lib/adminAuth";
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const adminPassword = process.env.ADMIN_PASSWORD || "";
|
| 7 |
+
if (!adminPassword) {
|
| 8 |
+
return NextResponse.json({ error: "ADMIN_PASSWORD is not set" }, { status: 500 });
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const body = await request.json();
|
| 12 |
+
const password = typeof body?.password === "string" ? body.password : "";
|
| 13 |
+
|
| 14 |
+
if (password !== adminPassword) {
|
| 15 |
+
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const response = NextResponse.json({ ok: true });
|
| 19 |
+
response.cookies.set({
|
| 20 |
+
...adminCookieOptions(),
|
| 21 |
+
value: createAdminSessionValue(),
|
| 22 |
+
});
|
| 23 |
+
return response;
|
| 24 |
+
} catch (error) {
|
| 25 |
+
console.error("Admin login error:", error);
|
| 26 |
+
return NextResponse.json({ error: "Failed to login" }, { status: 500 });
|
| 27 |
+
}
|
| 28 |
+
}
|
src/app/api/admin/logout/route.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { adminCookieOptions, ADMIN_COOKIE_NAME } from "@/lib/adminAuth";
|
| 3 |
+
|
| 4 |
+
export async function POST() {
|
| 5 |
+
const response = NextResponse.json({ ok: true });
|
| 6 |
+
response.cookies.set({
|
| 7 |
+
...adminCookieOptions(),
|
| 8 |
+
name: ADMIN_COOKIE_NAME,
|
| 9 |
+
value: "",
|
| 10 |
+
maxAge: 0,
|
| 11 |
+
});
|
| 12 |
+
return response;
|
| 13 |
+
}
|
src/app/api/admin/me/route.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { isAdminRequest } from "@/lib/adminAuth";
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
return NextResponse.json({ admin: isAdminRequest(request) });
|
| 6 |
+
}
|
src/app/api/admin/requests/[type]/[id]/comments/route.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { isAdminRequest } from "@/lib/adminAuth";
|
| 3 |
+
import { addAdminComment, getRequest } from "@/lib/store";
|
| 4 |
+
import { getOrCreateUserId, userCookieOptions } from "@/lib/userIdentity";
|
| 5 |
+
|
| 6 |
+
export async function POST(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 9 |
+
) {
|
| 10 |
+
try {
|
| 11 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 12 |
+
const params = await context.params;
|
| 13 |
+
if (!isAdminRequest(request)) {
|
| 14 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const type = params.type === "dataset" ? "dataset" : params.type === "distillation" ? "distillation" : null;
|
| 18 |
+
if (!type) {
|
| 19 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const existing = getRequest(type, params.id);
|
| 23 |
+
if (!existing) {
|
| 24 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const body = await request.json();
|
| 28 |
+
const text = typeof body?.body === "string" ? body.body.trim() : "";
|
| 29 |
+
if (!text) {
|
| 30 |
+
return NextResponse.json({ error: "Comment body is required" }, { status: 400 });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const comment = addAdminComment(type, params.id, text, userId);
|
| 34 |
+
const response = NextResponse.json({ ok: true, comment });
|
| 35 |
+
if (shouldSetCookie) {
|
| 36 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 37 |
+
}
|
| 38 |
+
return response;
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error("Admin add comment error:", error);
|
| 41 |
+
return NextResponse.json({ error: "Failed to add comment" }, { status: 500 });
|
| 42 |
+
}
|
| 43 |
+
}
|
src/app/api/admin/requests/[type]/[id]/route.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { isAdminRequest } from "@/lib/adminAuth";
|
| 3 |
+
import { deleteRequest, updateRequestStatus } from "@/lib/store";
|
| 4 |
+
|
| 5 |
+
export async function PATCH(
|
| 6 |
+
request: NextRequest,
|
| 7 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
try {
|
| 10 |
+
const params = await context.params;
|
| 11 |
+
if (!isAdminRequest(request)) {
|
| 12 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const type = params.type === "dataset" ? "dataset" : params.type === "distillation" ? "distillation" : null;
|
| 16 |
+
if (!type) {
|
| 17 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const body = await request.json();
|
| 21 |
+
const status = body?.status;
|
| 22 |
+
if (status !== "pending" && status !== "in_progress" && status !== "completed") {
|
| 23 |
+
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const ok = updateRequestStatus(type, params.id, status);
|
| 27 |
+
if (!ok) {
|
| 28 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return NextResponse.json({ ok: true });
|
| 32 |
+
} catch (error) {
|
| 33 |
+
console.error("Admin update status error:", error);
|
| 34 |
+
return NextResponse.json({ error: "Failed to update status" }, { status: 500 });
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export async function DELETE(
|
| 39 |
+
request: NextRequest,
|
| 40 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 41 |
+
) {
|
| 42 |
+
try {
|
| 43 |
+
const params = await context.params;
|
| 44 |
+
if (!isAdminRequest(request)) {
|
| 45 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const type = params.type === "dataset" ? "dataset" : params.type === "distillation" ? "distillation" : null;
|
| 49 |
+
if (!type) {
|
| 50 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const ok = deleteRequest(type, params.id);
|
| 54 |
+
if (!ok) {
|
| 55 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return NextResponse.json({ ok: true });
|
| 59 |
+
} catch (error) {
|
| 60 |
+
console.error("Admin delete request error:", error);
|
| 61 |
+
return NextResponse.json({ error: "Failed to delete request" }, { status: 500 });
|
| 62 |
+
}
|
| 63 |
+
}
|
src/app/api/dataset/route.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import {
|
| 3 |
+
getDatasetRequests,
|
| 4 |
+
addDatasetRequest,
|
| 5 |
+
upvoteDataset,
|
| 6 |
+
} from "@/lib/store";
|
| 7 |
+
import { getOrCreateUserId, userCookieOptions } from "@/lib/userIdentity";
|
| 8 |
+
|
| 9 |
+
export async function GET() {
|
| 10 |
+
try {
|
| 11 |
+
const requests = getDatasetRequests();
|
| 12 |
+
return NextResponse.json(requests);
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error("Error fetching dataset requests:", error);
|
| 15 |
+
return NextResponse.json({ error: "Failed to fetch requests" }, { status: 500 });
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function POST(request: NextRequest) {
|
| 20 |
+
try {
|
| 21 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 22 |
+
const body = await request.json();
|
| 23 |
+
const { sourceModel, datasetSize, reasoningDepth, topics, additionalNotes } = body;
|
| 24 |
+
|
| 25 |
+
if (!sourceModel) {
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: "Source model is required" },
|
| 28 |
+
{ status: 400 }
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const newRequest = addDatasetRequest({
|
| 33 |
+
sourceModel,
|
| 34 |
+
datasetSize: datasetSize || "250x",
|
| 35 |
+
reasoningDepth: reasoningDepth || "high",
|
| 36 |
+
topics: topics || [],
|
| 37 |
+
additionalNotes: additionalNotes || "",
|
| 38 |
+
ownerId: userId,
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
const response = NextResponse.json(newRequest, { status: 201 });
|
| 42 |
+
if (shouldSetCookie) {
|
| 43 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 44 |
+
}
|
| 45 |
+
return response;
|
| 46 |
+
} catch (error) {
|
| 47 |
+
console.error("Error creating dataset request:", error);
|
| 48 |
+
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export async function PATCH(request: NextRequest) {
|
| 53 |
+
try {
|
| 54 |
+
const body = await request.json();
|
| 55 |
+
const { id } = body;
|
| 56 |
+
|
| 57 |
+
if (!id) {
|
| 58 |
+
return NextResponse.json({ error: "Request ID is required" }, { status: 400 });
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const ip = request.headers.get("x-forwarded-for") ||
|
| 62 |
+
request.headers.get("x-real-ip") ||
|
| 63 |
+
"anonymous";
|
| 64 |
+
|
| 65 |
+
const result = upvoteDataset(id, ip);
|
| 66 |
+
|
| 67 |
+
if (!result.success) {
|
| 68 |
+
return NextResponse.json(
|
| 69 |
+
{ error: "Request not found", upvotes: result.upvotes },
|
| 70 |
+
{ status: 404 }
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return NextResponse.json({
|
| 75 |
+
success: true,
|
| 76 |
+
upvotes: result.upvotes,
|
| 77 |
+
action: result.action,
|
| 78 |
+
});
|
| 79 |
+
} catch (error) {
|
| 80 |
+
console.error("Error upvoting dataset request:", error);
|
| 81 |
+
return NextResponse.json({ error: "Failed to upvote" }, { status: 500 });
|
| 82 |
+
}
|
| 83 |
+
}
|
src/app/api/distillation/route.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import {
|
| 3 |
+
getDistillationRequests,
|
| 4 |
+
addDistillationRequest,
|
| 5 |
+
upvoteDistillation,
|
| 6 |
+
} from "@/lib/store";
|
| 7 |
+
import { getOrCreateUserId, userCookieOptions } from "@/lib/userIdentity";
|
| 8 |
+
|
| 9 |
+
export async function GET() {
|
| 10 |
+
try {
|
| 11 |
+
const requests = getDistillationRequests();
|
| 12 |
+
return NextResponse.json(requests);
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error("Error fetching distillation requests:", error);
|
| 15 |
+
return NextResponse.json({ error: "Failed to fetch requests" }, { status: 500 });
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function POST(request: NextRequest) {
|
| 20 |
+
try {
|
| 21 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 22 |
+
const body = await request.json();
|
| 23 |
+
const { sourceDataset, studentModel, additionalNotes } = body;
|
| 24 |
+
|
| 25 |
+
if (!sourceDataset || !studentModel) {
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: "Source dataset and student model are required" },
|
| 28 |
+
{ status: 400 }
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const newRequest = addDistillationRequest({
|
| 33 |
+
sourceDataset,
|
| 34 |
+
studentModel,
|
| 35 |
+
additionalNotes: additionalNotes || "",
|
| 36 |
+
ownerId: userId,
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
const response = NextResponse.json(newRequest, { status: 201 });
|
| 40 |
+
if (shouldSetCookie) {
|
| 41 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 42 |
+
}
|
| 43 |
+
return response;
|
| 44 |
+
} catch (error) {
|
| 45 |
+
console.error("Error creating distillation request:", error);
|
| 46 |
+
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export async function PATCH(request: NextRequest) {
|
| 51 |
+
try {
|
| 52 |
+
const body = await request.json();
|
| 53 |
+
const { id } = body;
|
| 54 |
+
|
| 55 |
+
if (!id) {
|
| 56 |
+
return NextResponse.json({ error: "Request ID is required" }, { status: 400 });
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const ip = request.headers.get("x-forwarded-for") ||
|
| 60 |
+
request.headers.get("x-real-ip") ||
|
| 61 |
+
"anonymous";
|
| 62 |
+
|
| 63 |
+
const result = upvoteDistillation(id, ip);
|
| 64 |
+
|
| 65 |
+
if (!result.success) {
|
| 66 |
+
return NextResponse.json(
|
| 67 |
+
{ error: "Request not found", upvotes: result.upvotes },
|
| 68 |
+
{ status: 404 }
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return NextResponse.json({
|
| 73 |
+
success: true,
|
| 74 |
+
upvotes: result.upvotes,
|
| 75 |
+
action: result.action,
|
| 76 |
+
});
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error("Error upvoting distillation request:", error);
|
| 79 |
+
return NextResponse.json({ error: "Failed to upvote" }, { status: 500 });
|
| 80 |
+
}
|
| 81 |
+
}
|
src/app/api/openrouter-models/route.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
interface OpenRouterModel {
|
| 4 |
+
id: string;
|
| 5 |
+
name: string;
|
| 6 |
+
pricing: {
|
| 7 |
+
prompt: string;
|
| 8 |
+
completion: string;
|
| 9 |
+
};
|
| 10 |
+
context_length: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
let cachedModels: { id: string; name: string }[] | null = null;
|
| 14 |
+
let cacheTimestamp: number = 0;
|
| 15 |
+
const CACHE_DURATION = 1000 * 60 * 60; // 1 hour
|
| 16 |
+
|
| 17 |
+
export async function GET() {
|
| 18 |
+
try {
|
| 19 |
+
const now = Date.now();
|
| 20 |
+
if (cachedModels && now - cacheTimestamp < CACHE_DURATION) {
|
| 21 |
+
return NextResponse.json(cachedModels);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
| 25 |
+
headers: {
|
| 26 |
+
Accept: "application/json",
|
| 27 |
+
},
|
| 28 |
+
next: { revalidate: 3600 },
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
if (!response.ok) {
|
| 32 |
+
throw new Error(`OpenRouter API error: ${response.status}`);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const data = await response.json();
|
| 36 |
+
|
| 37 |
+
cachedModels = data.data.map((model: OpenRouterModel) => ({
|
| 38 |
+
id: model.id,
|
| 39 |
+
name: model.name || model.id,
|
| 40 |
+
})).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
|
| 41 |
+
|
| 42 |
+
cacheTimestamp = now;
|
| 43 |
+
|
| 44 |
+
return NextResponse.json(cachedModels);
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Error fetching OpenRouter models:", error);
|
| 47 |
+
if (cachedModels) {
|
| 48 |
+
return NextResponse.json(cachedModels);
|
| 49 |
+
}
|
| 50 |
+
return NextResponse.json([], { status: 500 });
|
| 51 |
+
}
|
| 52 |
+
}
|
src/app/api/requests/[type]/[id]/comments/[commentId]/route.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { isAdminRequest } from "@/lib/adminAuth";
|
| 3 |
+
import { deleteComment, getThread, updateComment } from "@/lib/store";
|
| 4 |
+
import { getOrCreateUserId, userCookieOptions } from "@/lib/userIdentity";
|
| 5 |
+
|
| 6 |
+
export async function PATCH(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
context: { params: Promise<{ type: string; id: string; commentId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
try {
|
| 11 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 12 |
+
const params = await context.params;
|
| 13 |
+
|
| 14 |
+
const type =
|
| 15 |
+
params.type === "dataset"
|
| 16 |
+
? "dataset"
|
| 17 |
+
: params.type === "distillation"
|
| 18 |
+
? "distillation"
|
| 19 |
+
: null;
|
| 20 |
+
|
| 21 |
+
if (!type) {
|
| 22 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const thread = getThread(type, params.id);
|
| 26 |
+
const comment = thread.comments.find((c) => c.id === params.commentId);
|
| 27 |
+
if (!comment) {
|
| 28 |
+
return NextResponse.json({ error: "Comment not found" }, { status: 404 });
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const admin = isAdminRequest(request);
|
| 32 |
+
const canEdit = admin || (comment.role === "user" && comment.ownerId === userId);
|
| 33 |
+
if (!canEdit) {
|
| 34 |
+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const body = await request.json();
|
| 38 |
+
const text = typeof body?.body === "string" ? body.body.trim() : "";
|
| 39 |
+
if (!text) {
|
| 40 |
+
return NextResponse.json({ error: "Comment body is required" }, { status: 400 });
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const updated = updateComment(type, params.id, params.commentId, text);
|
| 44 |
+
if (!updated) {
|
| 45 |
+
return NextResponse.json({ error: "Comment not found" }, { status: 404 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const response = NextResponse.json({ ok: true, comment: updated });
|
| 49 |
+
if (shouldSetCookie) {
|
| 50 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 51 |
+
}
|
| 52 |
+
return response;
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error("Update comment error:", error);
|
| 55 |
+
return NextResponse.json({ error: "Failed to update comment" }, { status: 500 });
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function DELETE(
|
| 60 |
+
request: NextRequest,
|
| 61 |
+
context: { params: Promise<{ type: string; id: string; commentId: string }> }
|
| 62 |
+
) {
|
| 63 |
+
try {
|
| 64 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 65 |
+
const params = await context.params;
|
| 66 |
+
|
| 67 |
+
const type =
|
| 68 |
+
params.type === "dataset"
|
| 69 |
+
? "dataset"
|
| 70 |
+
: params.type === "distillation"
|
| 71 |
+
? "distillation"
|
| 72 |
+
: null;
|
| 73 |
+
|
| 74 |
+
if (!type) {
|
| 75 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const thread = getThread(type, params.id);
|
| 79 |
+
const comment = thread.comments.find((c) => c.id === params.commentId);
|
| 80 |
+
if (!comment) {
|
| 81 |
+
return NextResponse.json({ error: "Comment not found" }, { status: 404 });
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const admin = isAdminRequest(request);
|
| 85 |
+
const canDelete =
|
| 86 |
+
admin ||
|
| 87 |
+
(comment.role === "user" && Boolean(comment.ownerId) && comment.ownerId === userId);
|
| 88 |
+
if (!canDelete) {
|
| 89 |
+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const ok = deleteComment(type, params.id, params.commentId);
|
| 93 |
+
if (!ok) {
|
| 94 |
+
return NextResponse.json({ error: "Comment not found" }, { status: 404 });
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const response = NextResponse.json({ ok: true });
|
| 98 |
+
if (shouldSetCookie) {
|
| 99 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 100 |
+
}
|
| 101 |
+
return response;
|
| 102 |
+
} catch (error) {
|
| 103 |
+
console.error("Delete comment error:", error);
|
| 104 |
+
return NextResponse.json({ error: "Failed to delete comment" }, { status: 500 });
|
| 105 |
+
}
|
| 106 |
+
}
|
src/app/api/requests/[type]/[id]/comments/route.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { addUserComment, getRequest } from "@/lib/store";
|
| 3 |
+
import { getOrCreateUserId, userCookieOptions } from "@/lib/userIdentity";
|
| 4 |
+
|
| 5 |
+
export async function POST(
|
| 6 |
+
request: NextRequest,
|
| 7 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
try {
|
| 10 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 11 |
+
const params = await context.params;
|
| 12 |
+
const type =
|
| 13 |
+
params.type === "dataset"
|
| 14 |
+
? "dataset"
|
| 15 |
+
: params.type === "distillation"
|
| 16 |
+
? "distillation"
|
| 17 |
+
: null;
|
| 18 |
+
|
| 19 |
+
if (!type) {
|
| 20 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const existing = getRequest(type, params.id);
|
| 24 |
+
if (!existing) {
|
| 25 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const body = await request.json();
|
| 29 |
+
const text = typeof body?.body === "string" ? body.body.trim() : "";
|
| 30 |
+
const author = typeof body?.author === "string" ? body.author.trim() : "";
|
| 31 |
+
|
| 32 |
+
if (!text) {
|
| 33 |
+
return NextResponse.json({ error: "Comment body is required" }, { status: 400 });
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const comment = addUserComment(type, params.id, text, author, userId);
|
| 37 |
+
const response = NextResponse.json({ ok: true, comment });
|
| 38 |
+
if (shouldSetCookie) {
|
| 39 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 40 |
+
}
|
| 41 |
+
return response;
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error("Add comment error:", error);
|
| 44 |
+
return NextResponse.json({ error: "Failed to add comment" }, { status: 500 });
|
| 45 |
+
}
|
| 46 |
+
}
|
src/app/api/requests/[type]/[id]/route.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { isAdminRequest } from "@/lib/adminAuth";
|
| 3 |
+
import {
|
| 4 |
+
deleteRequest,
|
| 5 |
+
getRequest,
|
| 6 |
+
getThread,
|
| 7 |
+
updateDatasetRequest,
|
| 8 |
+
updateDistillationRequest,
|
| 9 |
+
} from "@/lib/store";
|
| 10 |
+
import { getOrCreateUserId, userCookieOptions } from "@/lib/userIdentity";
|
| 11 |
+
|
| 12 |
+
export async function GET(
|
| 13 |
+
request: NextRequest,
|
| 14 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 15 |
+
) {
|
| 16 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 17 |
+
const params = await context.params;
|
| 18 |
+
const type = params.type === "dataset" ? "dataset" : params.type === "distillation" ? "distillation" : null;
|
| 19 |
+
if (!type) {
|
| 20 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const requestItem = getRequest(type, params.id);
|
| 24 |
+
if (!requestItem) {
|
| 25 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const admin = isAdminRequest(request);
|
| 29 |
+
const ownerId = (requestItem as any).ownerId as string | undefined;
|
| 30 |
+
const canEditRequest = admin || (Boolean(ownerId) && ownerId === userId);
|
| 31 |
+
const canDeleteRequest = canEditRequest;
|
| 32 |
+
|
| 33 |
+
const thread = getThread(type, params.id);
|
| 34 |
+
const threadWithPerms = {
|
| 35 |
+
...thread,
|
| 36 |
+
comments: thread.comments.map((c) => {
|
| 37 |
+
const cOwnerId = (c as any).ownerId as string | undefined;
|
| 38 |
+
const canEdit = admin || (c.role === "user" && Boolean(cOwnerId) && cOwnerId === userId);
|
| 39 |
+
const canDelete = admin || (c.role === "user" && Boolean(cOwnerId) && cOwnerId === userId);
|
| 40 |
+
return {
|
| 41 |
+
...c,
|
| 42 |
+
canEdit,
|
| 43 |
+
canDelete,
|
| 44 |
+
};
|
| 45 |
+
}),
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const response = NextResponse.json({
|
| 49 |
+
request: {
|
| 50 |
+
...requestItem,
|
| 51 |
+
canEdit: canEditRequest,
|
| 52 |
+
canDelete: canDeleteRequest,
|
| 53 |
+
},
|
| 54 |
+
thread: threadWithPerms,
|
| 55 |
+
admin,
|
| 56 |
+
});
|
| 57 |
+
if (shouldSetCookie) {
|
| 58 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 59 |
+
}
|
| 60 |
+
return response;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export async function PATCH(
|
| 64 |
+
request: NextRequest,
|
| 65 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 66 |
+
) {
|
| 67 |
+
try {
|
| 68 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 69 |
+
const params = await context.params;
|
| 70 |
+
const type = params.type === "dataset" ? "dataset" : params.type === "distillation" ? "distillation" : null;
|
| 71 |
+
if (!type) {
|
| 72 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const existing = getRequest(type, params.id);
|
| 76 |
+
if (!existing) {
|
| 77 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const admin = isAdminRequest(request);
|
| 81 |
+
const ownerId = (existing as any).ownerId as string | undefined;
|
| 82 |
+
const canEdit = admin || (Boolean(ownerId) && ownerId === userId);
|
| 83 |
+
if (!canEdit) {
|
| 84 |
+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const body = await request.json();
|
| 88 |
+
const updated =
|
| 89 |
+
type === "distillation"
|
| 90 |
+
? updateDistillationRequest(params.id, {
|
| 91 |
+
sourceDataset: typeof body?.sourceDataset === "string" ? body.sourceDataset : undefined,
|
| 92 |
+
studentModel: typeof body?.studentModel === "string" ? body.studentModel : undefined,
|
| 93 |
+
additionalNotes: typeof body?.additionalNotes === "string" ? body.additionalNotes : undefined,
|
| 94 |
+
})
|
| 95 |
+
: updateDatasetRequest(params.id, {
|
| 96 |
+
sourceModel: typeof body?.sourceModel === "string" ? body.sourceModel : undefined,
|
| 97 |
+
datasetSize: typeof body?.datasetSize === "string" ? body.datasetSize : undefined,
|
| 98 |
+
reasoningDepth: typeof body?.reasoningDepth === "string" ? body.reasoningDepth : undefined,
|
| 99 |
+
topics: Array.isArray(body?.topics) ? body.topics : undefined,
|
| 100 |
+
additionalNotes: typeof body?.additionalNotes === "string" ? body.additionalNotes : undefined,
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
if (!updated) {
|
| 104 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const response = NextResponse.json({ ok: true, request: updated });
|
| 108 |
+
if (shouldSetCookie) {
|
| 109 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 110 |
+
}
|
| 111 |
+
return response;
|
| 112 |
+
} catch (error) {
|
| 113 |
+
console.error("Update request error:", error);
|
| 114 |
+
return NextResponse.json({ error: "Failed to update request" }, { status: 500 });
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export async function DELETE(
|
| 119 |
+
request: NextRequest,
|
| 120 |
+
context: { params: Promise<{ type: string; id: string }> }
|
| 121 |
+
) {
|
| 122 |
+
try {
|
| 123 |
+
const { userId, shouldSetCookie } = getOrCreateUserId(request);
|
| 124 |
+
const params = await context.params;
|
| 125 |
+
const type = params.type === "dataset" ? "dataset" : params.type === "distillation" ? "distillation" : null;
|
| 126 |
+
if (!type) {
|
| 127 |
+
return NextResponse.json({ error: "Invalid request type" }, { status: 400 });
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const existing = getRequest(type, params.id);
|
| 131 |
+
if (!existing) {
|
| 132 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const admin = isAdminRequest(request);
|
| 136 |
+
const ownerId = (existing as any).ownerId as string | undefined;
|
| 137 |
+
const canDelete = admin || (Boolean(ownerId) && ownerId === userId);
|
| 138 |
+
if (!canDelete) {
|
| 139 |
+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const ok = deleteRequest(type, params.id);
|
| 143 |
+
if (!ok) {
|
| 144 |
+
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const response = NextResponse.json({ ok: true });
|
| 148 |
+
if (shouldSetCookie) {
|
| 149 |
+
response.cookies.set({ ...userCookieOptions(), value: userId });
|
| 150 |
+
}
|
| 151 |
+
return response;
|
| 152 |
+
} catch (error) {
|
| 153 |
+
console.error("Delete request error:", error);
|
| 154 |
+
return NextResponse.json({ error: "Failed to delete request" }, { status: 500 });
|
| 155 |
+
}
|
| 156 |
+
}
|
src/app/api/teichai-datasets/route.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
interface HFDataset {
|
| 4 |
+
id: string;
|
| 5 |
+
author: string;
|
| 6 |
+
downloads: number;
|
| 7 |
+
likes: number;
|
| 8 |
+
tags: string[];
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
let cachedDatasets: { id: string; name: string }[] | null = null;
|
| 12 |
+
let cacheTimestamp: number = 0;
|
| 13 |
+
const CACHE_DURATION = 1000 * 60 * 60; // 1 hour
|
| 14 |
+
|
| 15 |
+
export async function GET() {
|
| 16 |
+
try {
|
| 17 |
+
const now = Date.now();
|
| 18 |
+
if (cachedDatasets && now - cacheTimestamp < CACHE_DURATION) {
|
| 19 |
+
return NextResponse.json(cachedDatasets);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const response = await fetch(
|
| 23 |
+
"https://huggingface.co/api/datasets?author=TeichAI&limit=100",
|
| 24 |
+
{
|
| 25 |
+
headers: {
|
| 26 |
+
Accept: "application/json",
|
| 27 |
+
},
|
| 28 |
+
next: { revalidate: 3600 },
|
| 29 |
+
}
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
if (!response.ok) {
|
| 33 |
+
throw new Error(`HF API error: ${response.status}`);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const data: HFDataset[] = await response.json();
|
| 37 |
+
|
| 38 |
+
cachedDatasets = data.map((dataset) => ({
|
| 39 |
+
id: dataset.id,
|
| 40 |
+
name: dataset.id.replace("TeichAI/", ""),
|
| 41 |
+
}));
|
| 42 |
+
cacheTimestamp = now;
|
| 43 |
+
|
| 44 |
+
return NextResponse.json(cachedDatasets);
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Error fetching TeichAI datasets:", error);
|
| 47 |
+
if (cachedDatasets) {
|
| 48 |
+
return NextResponse.json(cachedDatasets);
|
| 49 |
+
}
|
| 50 |
+
return NextResponse.json([], { status: 500 });
|
| 51 |
+
}
|
| 52 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* Light theme */
|
| 6 |
+
:root {
|
| 7 |
+
--background: #ffffff;
|
| 8 |
+
--foreground: #09090b;
|
| 9 |
+
--muted: #f4f4f5;
|
| 10 |
+
--muted-foreground: #71717a;
|
| 11 |
+
--border: #e4e4e7;
|
| 12 |
+
--card: #ffffff;
|
| 13 |
+
--card-foreground: #09090b;
|
| 14 |
+
--accent: #ff4c00;
|
| 15 |
+
--accent-hover: #e64500;
|
| 16 |
+
--accent-light: rgba(255, 76, 0, 0.1);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Dark theme */
|
| 20 |
+
.dark {
|
| 21 |
+
--background: #09090b;
|
| 22 |
+
--foreground: #fafafa;
|
| 23 |
+
--muted: #18181b;
|
| 24 |
+
--muted-foreground: #a1a1aa;
|
| 25 |
+
--border: #27272a;
|
| 26 |
+
--card: #18181b;
|
| 27 |
+
--card-foreground: #fafafa;
|
| 28 |
+
--accent: #ff4c00;
|
| 29 |
+
--accent-hover: #ff6a2a;
|
| 30 |
+
--accent-light: rgba(255, 76, 0, 0.15);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
body {
|
| 34 |
+
background: var(--background);
|
| 35 |
+
color: var(--foreground);
|
| 36 |
+
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
| 37 |
+
transition: background-color 0.2s, color 0.2s;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
html {
|
| 41 |
+
scroll-behavior: smooth;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Scrollbar */
|
| 45 |
+
::-webkit-scrollbar {
|
| 46 |
+
width: 6px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
::-webkit-scrollbar-track {
|
| 50 |
+
background: transparent;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.dark ::-webkit-scrollbar-thumb {
|
| 54 |
+
background: #3f3f46;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
:root:not(.dark) ::-webkit-scrollbar-thumb {
|
| 58 |
+
background: #d4d4d8;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
::-webkit-scrollbar-thumb {
|
| 62 |
+
border-radius: 3px;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
::-webkit-scrollbar-thumb:hover {
|
| 66 |
+
background: #52525b;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@layer base {
|
| 70 |
+
* {
|
| 71 |
+
@apply border-border;
|
| 72 |
+
}
|
| 73 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { GeistMono } from "geist/font/mono";
|
| 3 |
+
import { GeistSans } from "geist/font/sans";
|
| 4 |
+
import { Providers } from "@/components/Providers";
|
| 5 |
+
import "./globals.css";
|
| 6 |
+
|
| 7 |
+
export const metadata: Metadata = {
|
| 8 |
+
title: "TeichAI - Community Requests",
|
| 9 |
+
description: "Submit and vote on model distillation and dataset requests for TeichAI",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export default function RootLayout({
|
| 13 |
+
children,
|
| 14 |
+
}: Readonly<{
|
| 15 |
+
children: React.ReactNode;
|
| 16 |
+
}>) {
|
| 17 |
+
return (
|
| 18 |
+
<html lang="en" className="dark" suppressHydrationWarning>
|
| 19 |
+
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}>
|
| 20 |
+
<Providers>
|
| 21 |
+
{children}
|
| 22 |
+
</Providers>
|
| 23 |
+
</body>
|
| 24 |
+
</html>
|
| 25 |
+
);
|
| 26 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { ArrowUp, Cpu, Database, Plus, Loader2, ChevronRight } from "lucide-react";
|
| 5 |
+
import Navbar from "@/components/Navbar";
|
| 6 |
+
import Footer from "@/components/Footer";
|
| 7 |
+
import { useRouter } from "next/navigation";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 12 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 13 |
+
import { Badge } from "@/components/ui/badge";
|
| 14 |
+
import {
|
| 15 |
+
Select,
|
| 16 |
+
SelectContent,
|
| 17 |
+
SelectItem,
|
| 18 |
+
SelectTrigger,
|
| 19 |
+
SelectValue,
|
| 20 |
+
} from "@/components/ui/select";
|
| 21 |
+
import { Combobox } from "@/components/ui/combobox";
|
| 22 |
+
import { toast } from "@/components/ui/toaster";
|
| 23 |
+
|
| 24 |
+
interface DistillationRequest {
|
| 25 |
+
id: string;
|
| 26 |
+
sourceDataset: string;
|
| 27 |
+
studentModel: string;
|
| 28 |
+
additionalNotes: string;
|
| 29 |
+
upvotes: number;
|
| 30 |
+
createdAt: string;
|
| 31 |
+
status: "pending" | "in_progress" | "completed";
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface DatasetRequest {
|
| 35 |
+
id: string;
|
| 36 |
+
sourceModel: string;
|
| 37 |
+
datasetSize: string;
|
| 38 |
+
reasoningDepth: string;
|
| 39 |
+
topics: string[];
|
| 40 |
+
additionalNotes: string;
|
| 41 |
+
upvotes: number;
|
| 42 |
+
createdAt: string;
|
| 43 |
+
status: "pending" | "in_progress" | "completed";
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
interface TeichAIDataset {
|
| 47 |
+
id: string;
|
| 48 |
+
name: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface OpenRouterModel {
|
| 52 |
+
id: string;
|
| 53 |
+
name: string;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const STUDENT_MODELS = [
|
| 57 |
+
"Qwen3-4B",
|
| 58 |
+
"Qwen3-8B",
|
| 59 |
+
"Qwen3-14B",
|
| 60 |
+
"Qwen3-30B-A3B",
|
| 61 |
+
"Qwen3-32B",
|
| 62 |
+
"Nemotron-Cascade-14B",
|
| 63 |
+
"Nemotron-Cascade-8B",
|
| 64 |
+
"Other",
|
| 65 |
+
];
|
| 66 |
+
|
| 67 |
+
const REASONING_DEPTHS = ["low", "medium", "high"];
|
| 68 |
+
const DATASET_SIZES = ["100x", "250x", "500x", "1000x", "3000x", "11000x"];
|
| 69 |
+
const TOPICS = [
|
| 70 |
+
"Coding",
|
| 71 |
+
"Math",
|
| 72 |
+
"Science",
|
| 73 |
+
"Web Development",
|
| 74 |
+
"Data Science",
|
| 75 |
+
"Machine Learning",
|
| 76 |
+
"Creative Writing",
|
| 77 |
+
"Reasoning",
|
| 78 |
+
"Logic",
|
| 79 |
+
"General Knowledge",
|
| 80 |
+
];
|
| 81 |
+
|
| 82 |
+
export default function Home() {
|
| 83 |
+
const router = useRouter();
|
| 84 |
+
const [distillationRequests, setDistillationRequests] = useState<DistillationRequest[]>([]);
|
| 85 |
+
const [datasetRequests, setDatasetRequests] = useState<DatasetRequest[]>([]);
|
| 86 |
+
const [loading, setLoading] = useState(true);
|
| 87 |
+
const [submitting, setSubmitting] = useState(false);
|
| 88 |
+
const [showDistillForm, setShowDistillForm] = useState(false);
|
| 89 |
+
const [showDatasetForm, setShowDatasetForm] = useState(false);
|
| 90 |
+
|
| 91 |
+
// External data
|
| 92 |
+
const [teichaiDatasets, setTeichaiDatasets] = useState<TeichAIDataset[]>([]);
|
| 93 |
+
const [openrouterModels, setOpenrouterModels] = useState<OpenRouterModel[]>([]);
|
| 94 |
+
const [loadingDatasets, setLoadingDatasets] = useState(false);
|
| 95 |
+
const [loadingModels, setLoadingModels] = useState(false);
|
| 96 |
+
|
| 97 |
+
// Distillation form state
|
| 98 |
+
const [sourceDataset, setSourceDataset] = useState("");
|
| 99 |
+
const [sourceDatasetOther, setSourceDatasetOther] = useState("");
|
| 100 |
+
const [studentModel, setStudentModel] = useState("");
|
| 101 |
+
const [studentModelOther, setStudentModelOther] = useState("");
|
| 102 |
+
const [distillNotes, setDistillNotes] = useState("");
|
| 103 |
+
|
| 104 |
+
// Dataset form state
|
| 105 |
+
const [sourceModel, setSourceModel] = useState("");
|
| 106 |
+
const [sourceModelOther, setSourceModelOther] = useState("");
|
| 107 |
+
const [datasetSize, setDatasetSize] = useState("250x");
|
| 108 |
+
const [reasoningDepth, setReasoningDepth] = useState("high");
|
| 109 |
+
const [selectedTopics, setSelectedTopics] = useState<string[]>([]);
|
| 110 |
+
const [datasetNotes, setDatasetNotes] = useState("");
|
| 111 |
+
|
| 112 |
+
useEffect(() => {
|
| 113 |
+
fetchRequests();
|
| 114 |
+
fetchTeichaiDatasets();
|
| 115 |
+
fetchOpenrouterModels();
|
| 116 |
+
}, []);
|
| 117 |
+
|
| 118 |
+
async function fetchRequests() {
|
| 119 |
+
try {
|
| 120 |
+
const [distillRes, datasetRes] = await Promise.all([
|
| 121 |
+
fetch("/api/distillation"),
|
| 122 |
+
fetch("/api/dataset"),
|
| 123 |
+
]);
|
| 124 |
+
const distillData = await distillRes.json();
|
| 125 |
+
const datasetData = await datasetRes.json();
|
| 126 |
+
setDistillationRequests(Array.isArray(distillData) ? distillData : []);
|
| 127 |
+
setDatasetRequests(Array.isArray(datasetData) ? datasetData : []);
|
| 128 |
+
} catch (error) {
|
| 129 |
+
console.error("Error fetching requests:", error);
|
| 130 |
+
} finally {
|
| 131 |
+
setLoading(false);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async function fetchTeichaiDatasets() {
|
| 136 |
+
setLoadingDatasets(true);
|
| 137 |
+
try {
|
| 138 |
+
const res = await fetch("/api/teichai-datasets");
|
| 139 |
+
const data = await res.json();
|
| 140 |
+
setTeichaiDatasets(Array.isArray(data) ? data : []);
|
| 141 |
+
} catch (error) {
|
| 142 |
+
console.error("Error fetching TeichAI datasets:", error);
|
| 143 |
+
} finally {
|
| 144 |
+
setLoadingDatasets(false);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
async function fetchOpenrouterModels() {
|
| 149 |
+
setLoadingModels(true);
|
| 150 |
+
try {
|
| 151 |
+
const res = await fetch("/api/openrouter-models");
|
| 152 |
+
const data = await res.json();
|
| 153 |
+
setOpenrouterModels(Array.isArray(data) ? data : []);
|
| 154 |
+
} catch (error) {
|
| 155 |
+
console.error("Error fetching OpenRouter models:", error);
|
| 156 |
+
} finally {
|
| 157 |
+
setLoadingModels(false);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
async function handleDistillSubmit(e: React.FormEvent) {
|
| 162 |
+
e.preventDefault();
|
| 163 |
+
const resolvedSourceDataset = sourceDataset === "Other" ? sourceDatasetOther.trim() : sourceDataset;
|
| 164 |
+
const resolvedStudentModel = studentModel === "Other" ? studentModelOther.trim() : studentModel;
|
| 165 |
+
if (!resolvedSourceDataset || !resolvedStudentModel) {
|
| 166 |
+
toast({ title: "Error", description: "Please fill in required fields", variant: "destructive" });
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
setSubmitting(true);
|
| 170 |
+
try {
|
| 171 |
+
const res = await fetch("/api/distillation", {
|
| 172 |
+
method: "POST",
|
| 173 |
+
headers: { "Content-Type": "application/json" },
|
| 174 |
+
body: JSON.stringify({
|
| 175 |
+
sourceDataset: resolvedSourceDataset,
|
| 176 |
+
studentModel: resolvedStudentModel,
|
| 177 |
+
additionalNotes: distillNotes,
|
| 178 |
+
}),
|
| 179 |
+
});
|
| 180 |
+
if (res.ok) {
|
| 181 |
+
toast({ title: "Success", description: "Distillation request submitted!" });
|
| 182 |
+
setSourceDataset("");
|
| 183 |
+
setSourceDatasetOther("");
|
| 184 |
+
setStudentModel("");
|
| 185 |
+
setStudentModelOther("");
|
| 186 |
+
setDistillNotes("");
|
| 187 |
+
setShowDistillForm(false);
|
| 188 |
+
fetchRequests();
|
| 189 |
+
} else {
|
| 190 |
+
throw new Error("Failed to submit");
|
| 191 |
+
}
|
| 192 |
+
} catch (error) {
|
| 193 |
+
toast({ title: "Error", description: "Failed to submit request", variant: "destructive" });
|
| 194 |
+
} finally {
|
| 195 |
+
setSubmitting(false);
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async function handleDatasetSubmit(e: React.FormEvent) {
|
| 200 |
+
e.preventDefault();
|
| 201 |
+
const resolvedSourceModel = sourceModel === "Other" ? sourceModelOther.trim() : sourceModel;
|
| 202 |
+
if (!resolvedSourceModel) {
|
| 203 |
+
toast({ title: "Error", description: "Please select a source model", variant: "destructive" });
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
setSubmitting(true);
|
| 207 |
+
try {
|
| 208 |
+
const res = await fetch("/api/dataset", {
|
| 209 |
+
method: "POST",
|
| 210 |
+
headers: { "Content-Type": "application/json" },
|
| 211 |
+
body: JSON.stringify({
|
| 212 |
+
sourceModel: resolvedSourceModel,
|
| 213 |
+
datasetSize,
|
| 214 |
+
reasoningDepth,
|
| 215 |
+
topics: selectedTopics,
|
| 216 |
+
additionalNotes: datasetNotes,
|
| 217 |
+
}),
|
| 218 |
+
});
|
| 219 |
+
if (res.ok) {
|
| 220 |
+
toast({ title: "Success", description: "Dataset request submitted!" });
|
| 221 |
+
setSourceModel("google/gemini-3-flash-preview");
|
| 222 |
+
setSourceModelOther("");
|
| 223 |
+
setDatasetSize("250x");
|
| 224 |
+
setReasoningDepth("high");
|
| 225 |
+
setSelectedTopics([]);
|
| 226 |
+
setDatasetNotes("");
|
| 227 |
+
setShowDatasetForm(false);
|
| 228 |
+
fetchRequests();
|
| 229 |
+
} else {
|
| 230 |
+
throw new Error("Failed to submit");
|
| 231 |
+
}
|
| 232 |
+
} catch (error) {
|
| 233 |
+
toast({ title: "Error", description: "Failed to submit request", variant: "destructive" });
|
| 234 |
+
} finally {
|
| 235 |
+
setSubmitting(false);
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
async function handleUpvote(type: "distillation" | "dataset", id: string) {
|
| 240 |
+
try {
|
| 241 |
+
const endpoint = type === "distillation" ? "/api/distillation" : "/api/dataset";
|
| 242 |
+
const res = await fetch(endpoint, {
|
| 243 |
+
method: "PATCH",
|
| 244 |
+
headers: { "Content-Type": "application/json" },
|
| 245 |
+
body: JSON.stringify({ id }),
|
| 246 |
+
});
|
| 247 |
+
const data = await res.json();
|
| 248 |
+
|
| 249 |
+
if (!res.ok) {
|
| 250 |
+
if (res.status === 404) {
|
| 251 |
+
toast({ title: "Not found", description: "This request no longer exists", variant: "destructive" });
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
toast({ title: "Error", description: data?.error || "Failed to vote", variant: "destructive" });
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
if (type === "distillation") {
|
| 259 |
+
setDistillationRequests((prev) =>
|
| 260 |
+
prev.map((r) => (r.id === id ? { ...r, upvotes: data.upvotes } : r))
|
| 261 |
+
);
|
| 262 |
+
} else {
|
| 263 |
+
setDatasetRequests((prev) =>
|
| 264 |
+
prev.map((r) => (r.id === id ? { ...r, upvotes: data.upvotes } : r))
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
if (data.action === "unvoted") {
|
| 269 |
+
toast({ title: "Vote removed", description: "Your vote has been removed" });
|
| 270 |
+
} else {
|
| 271 |
+
toast({ title: "Voted!", description: "Your vote has been recorded" });
|
| 272 |
+
}
|
| 273 |
+
} catch (error) {
|
| 274 |
+
toast({ title: "Error", description: "Failed to vote", variant: "destructive" });
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function toggleTopic(topic: string) {
|
| 279 |
+
setSelectedTopics((prev) =>
|
| 280 |
+
prev.includes(topic) ? prev.filter((t) => t !== topic) : [...prev, topic]
|
| 281 |
+
);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function getStatusBadge(status: string) {
|
| 285 |
+
switch (status) {
|
| 286 |
+
case "completed":
|
| 287 |
+
return <Badge variant="success">Completed</Badge>;
|
| 288 |
+
case "in_progress":
|
| 289 |
+
return <Badge variant="warning">In Progress</Badge>;
|
| 290 |
+
default:
|
| 291 |
+
return <Badge variant="secondary">Pending</Badge>;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function formatDate(dateStr: string) {
|
| 296 |
+
return new Date(dateStr).toLocaleDateString("en-US", {
|
| 297 |
+
month: "short",
|
| 298 |
+
day: "numeric",
|
| 299 |
+
year: "numeric",
|
| 300 |
+
});
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function goToDiscussion(type: "distillation" | "dataset", id: string) {
|
| 304 |
+
router.push(`/requests/${type}/${id}`);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
return (
|
| 308 |
+
<main className="min-h-screen bg-background">
|
| 309 |
+
<Navbar />
|
| 310 |
+
|
| 311 |
+
{/* Hero */}
|
| 312 |
+
<section className="relative overflow-hidden pt-24 pb-16">
|
| 313 |
+
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top,rgba(255,76,0,0.10),transparent_55%)] dark:bg-[radial-gradient(circle_at_top,rgba(255,76,0,0.16),transparent_55%)]" />
|
| 314 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 315 |
+
<div className="max-w-3xl">
|
| 316 |
+
<p className="mb-3 text-sm font-medium text-primary">Community Requests</p>
|
| 317 |
+
<h1 className="mb-6 text-4xl font-bold leading-tight tracking-tight text-foreground md:text-5xl">
|
| 318 |
+
Request Model Distillations & Datasets
|
| 319 |
+
</h1>
|
| 320 |
+
<p className="mb-8 text-lg leading-relaxed text-muted-foreground">
|
| 321 |
+
Submit your requests for new distilled models or reasoning datasets. Vote on requests
|
| 322 |
+
from other community members to help us prioritize what to build next.
|
| 323 |
+
</p>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</section>
|
| 327 |
+
|
| 328 |
+
{/* Main Content */}
|
| 329 |
+
<section className="py-8">
|
| 330 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 331 |
+
<Tabs defaultValue="distillation" className="w-full">
|
| 332 |
+
<TabsList className="mb-6 grid w-full grid-cols-2">
|
| 333 |
+
<TabsTrigger value="distillation" className="flex items-center gap-2">
|
| 334 |
+
<Cpu className="h-4 w-4" />
|
| 335 |
+
Model Distillation
|
| 336 |
+
</TabsTrigger>
|
| 337 |
+
<TabsTrigger value="dataset" className="flex items-center gap-2">
|
| 338 |
+
<Database className="h-4 w-4" />
|
| 339 |
+
Dataset
|
| 340 |
+
</TabsTrigger>
|
| 341 |
+
</TabsList>
|
| 342 |
+
|
| 343 |
+
{/* Distillation Tab */}
|
| 344 |
+
<TabsContent value="distillation">
|
| 345 |
+
<div className="mb-6 flex items-center justify-between">
|
| 346 |
+
<h2 className="text-xl font-semibold text-foreground">Distillation Requests</h2>
|
| 347 |
+
<Button onClick={() => setShowDistillForm(!showDistillForm)}>
|
| 348 |
+
<Plus className="h-4 w-4" />
|
| 349 |
+
New Request
|
| 350 |
+
</Button>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
{showDistillForm && (
|
| 354 |
+
<Card className="mb-6">
|
| 355 |
+
<CardHeader>
|
| 356 |
+
<CardTitle>Request a Distilled Model</CardTitle>
|
| 357 |
+
<CardDescription>
|
| 358 |
+
Select one of our existing datasets to distill into a student model
|
| 359 |
+
</CardDescription>
|
| 360 |
+
</CardHeader>
|
| 361 |
+
<CardContent>
|
| 362 |
+
<form onSubmit={handleDistillSubmit} className="space-y-4">
|
| 363 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 364 |
+
<div className="space-y-2">
|
| 365 |
+
<Label htmlFor="dataset">Source Dataset *</Label>
|
| 366 |
+
<Combobox
|
| 367 |
+
options={[...teichaiDatasets, { id: "Other", name: "Other" }]}
|
| 368 |
+
value={sourceDataset}
|
| 369 |
+
onValueChange={(v) => {
|
| 370 |
+
setSourceDataset(v);
|
| 371 |
+
if (v !== "Other") setSourceDatasetOther("");
|
| 372 |
+
}}
|
| 373 |
+
placeholder="Select a TeichAI dataset"
|
| 374 |
+
searchPlaceholder="Search datasets..."
|
| 375 |
+
emptyMessage="No datasets found"
|
| 376 |
+
loading={loadingDatasets}
|
| 377 |
+
/>
|
| 378 |
+
</div>
|
| 379 |
+
<div className="space-y-2">
|
| 380 |
+
<Label htmlFor="student">Student Model *</Label>
|
| 381 |
+
<Select
|
| 382 |
+
value={studentModel}
|
| 383 |
+
onValueChange={(v) => {
|
| 384 |
+
setStudentModel(v);
|
| 385 |
+
if (v !== "Other") setStudentModelOther("");
|
| 386 |
+
}}
|
| 387 |
+
>
|
| 388 |
+
<SelectTrigger>
|
| 389 |
+
<SelectValue placeholder="Select student model" />
|
| 390 |
+
</SelectTrigger>
|
| 391 |
+
<SelectContent>
|
| 392 |
+
{STUDENT_MODELS.map((model) => (
|
| 393 |
+
<SelectItem key={model} value={model}>
|
| 394 |
+
{model}
|
| 395 |
+
</SelectItem>
|
| 396 |
+
))}
|
| 397 |
+
</SelectContent>
|
| 398 |
+
</Select>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
{sourceDataset === "Other" && (
|
| 402 |
+
<div className="space-y-2">
|
| 403 |
+
<Label htmlFor="datasetOther">Source Dataset (Other) *</Label>
|
| 404 |
+
<input
|
| 405 |
+
id="datasetOther"
|
| 406 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 407 |
+
value={sourceDatasetOther}
|
| 408 |
+
onChange={(e) => setSourceDatasetOther(e.target.value)}
|
| 409 |
+
placeholder="Type the dataset name"
|
| 410 |
+
/>
|
| 411 |
+
</div>
|
| 412 |
+
)}
|
| 413 |
+
{studentModel === "Other" && (
|
| 414 |
+
<div className="space-y-2">
|
| 415 |
+
<Label htmlFor="studentOther">Student Model (Other) *</Label>
|
| 416 |
+
<input
|
| 417 |
+
id="studentOther"
|
| 418 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 419 |
+
value={studentModelOther}
|
| 420 |
+
onChange={(e) => setStudentModelOther(e.target.value)}
|
| 421 |
+
placeholder="Type the student model name"
|
| 422 |
+
/>
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
<div className="space-y-2">
|
| 426 |
+
<Label htmlFor="notes">Additional Notes</Label>
|
| 427 |
+
<Textarea
|
| 428 |
+
id="notes"
|
| 429 |
+
placeholder="Any specific requirements or context..."
|
| 430 |
+
value={distillNotes}
|
| 431 |
+
onChange={(e) => setDistillNotes(e.target.value)}
|
| 432 |
+
/>
|
| 433 |
+
</div>
|
| 434 |
+
<div className="flex gap-2">
|
| 435 |
+
<Button type="submit" disabled={submitting}>
|
| 436 |
+
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
| 437 |
+
Submit Request
|
| 438 |
+
</Button>
|
| 439 |
+
<Button type="button" variant="outline" onClick={() => setShowDistillForm(false)}>
|
| 440 |
+
Cancel
|
| 441 |
+
</Button>
|
| 442 |
+
</div>
|
| 443 |
+
</form>
|
| 444 |
+
</CardContent>
|
| 445 |
+
</Card>
|
| 446 |
+
)}
|
| 447 |
+
|
| 448 |
+
{loading ? (
|
| 449 |
+
<div className="flex justify-center py-12">
|
| 450 |
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
| 451 |
+
</div>
|
| 452 |
+
) : distillationRequests.length === 0 ? (
|
| 453 |
+
<Card className="p-12 text-center">
|
| 454 |
+
<p className="text-muted-foreground">No distillation requests yet. Be the first!</p>
|
| 455 |
+
</Card>
|
| 456 |
+
) : (
|
| 457 |
+
<div className="space-y-4">
|
| 458 |
+
{distillationRequests.map((request) => (
|
| 459 |
+
<Card
|
| 460 |
+
key={request.id}
|
| 461 |
+
className="group cursor-pointer transition-all hover:shadow-md"
|
| 462 |
+
onClick={() => goToDiscussion("distillation", request.id)}
|
| 463 |
+
role="link"
|
| 464 |
+
tabIndex={0}
|
| 465 |
+
onKeyDown={(e) => {
|
| 466 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 467 |
+
e.preventDefault();
|
| 468 |
+
goToDiscussion("distillation", request.id);
|
| 469 |
+
}
|
| 470 |
+
}}
|
| 471 |
+
>
|
| 472 |
+
<CardContent className="p-5">
|
| 473 |
+
<div className="flex items-start gap-4">
|
| 474 |
+
<button
|
| 475 |
+
onClick={(e) => {
|
| 476 |
+
e.stopPropagation();
|
| 477 |
+
handleUpvote("distillation", request.id);
|
| 478 |
+
}}
|
| 479 |
+
className="flex flex-col items-center gap-1 rounded-lg border border-border bg-muted px-3 py-2 transition-colors hover:border-primary hover:bg-accent"
|
| 480 |
+
>
|
| 481 |
+
<ArrowUp className="h-4 w-4 text-primary" />
|
| 482 |
+
<span className="text-sm font-semibold">{request.upvotes}</span>
|
| 483 |
+
</button>
|
| 484 |
+
<div className="flex-1">
|
| 485 |
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
| 486 |
+
<h3 className="font-medium text-foreground">
|
| 487 |
+
{request.sourceDataset} → {request.studentModel}
|
| 488 |
+
</h3>
|
| 489 |
+
{getStatusBadge(request.status)}
|
| 490 |
+
</div>
|
| 491 |
+
{request.additionalNotes && (
|
| 492 |
+
<p className="mb-2 text-sm text-muted-foreground">{request.additionalNotes}</p>
|
| 493 |
+
)}
|
| 494 |
+
<p className="text-xs text-muted-foreground">
|
| 495 |
+
Requested on {formatDate(request.createdAt)}
|
| 496 |
+
</p>
|
| 497 |
+
</div>
|
| 498 |
+
<div className="flex items-center text-muted-foreground">
|
| 499 |
+
<ChevronRight className="h-5 w-5 opacity-50 transition-opacity group-hover:opacity-100" />
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
</CardContent>
|
| 503 |
+
</Card>
|
| 504 |
+
))}
|
| 505 |
+
</div>
|
| 506 |
+
)}
|
| 507 |
+
</TabsContent>
|
| 508 |
+
|
| 509 |
+
{/* Dataset Tab */}
|
| 510 |
+
<TabsContent value="dataset">
|
| 511 |
+
<div className="mb-6 flex items-center justify-between">
|
| 512 |
+
<h2 className="text-xl font-semibold text-foreground">Dataset</h2>
|
| 513 |
+
<Button onClick={() => setShowDatasetForm(!showDatasetForm)}>
|
| 514 |
+
<Plus className="h-4 w-4" />
|
| 515 |
+
New Request
|
| 516 |
+
</Button>
|
| 517 |
+
</div>
|
| 518 |
+
|
| 519 |
+
{showDatasetForm && (
|
| 520 |
+
<Card className="mb-6">
|
| 521 |
+
<CardHeader>
|
| 522 |
+
<CardTitle>Request a Dataset</CardTitle>
|
| 523 |
+
<CardDescription>
|
| 524 |
+
Specify which model to generate reasoning data from
|
| 525 |
+
</CardDescription>
|
| 526 |
+
</CardHeader>
|
| 527 |
+
<CardContent>
|
| 528 |
+
<form onSubmit={handleDatasetSubmit} className="space-y-4">
|
| 529 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 530 |
+
<div className="space-y-2">
|
| 531 |
+
<Label htmlFor="sourceModel">Source Model *</Label>
|
| 532 |
+
<Combobox
|
| 533 |
+
options={[...openrouterModels, { id: "Other", name: "Other" }]}
|
| 534 |
+
value={sourceModel}
|
| 535 |
+
onValueChange={(v) => {
|
| 536 |
+
setSourceModel(v);
|
| 537 |
+
if (v !== "Other") setSourceModelOther("");
|
| 538 |
+
}}
|
| 539 |
+
placeholder="Select an OpenRouter model"
|
| 540 |
+
searchPlaceholder="Search models..."
|
| 541 |
+
emptyMessage="No models found"
|
| 542 |
+
loading={loadingModels}
|
| 543 |
+
/>
|
| 544 |
+
</div>
|
| 545 |
+
<div className="space-y-2">
|
| 546 |
+
<Label htmlFor="size">Dataset Size</Label>
|
| 547 |
+
<Select value={datasetSize} onValueChange={setDatasetSize}>
|
| 548 |
+
<SelectTrigger>
|
| 549 |
+
<SelectValue />
|
| 550 |
+
</SelectTrigger>
|
| 551 |
+
<SelectContent>
|
| 552 |
+
{DATASET_SIZES.map((size) => (
|
| 553 |
+
<SelectItem key={size} value={size}>
|
| 554 |
+
{size} samples
|
| 555 |
+
</SelectItem>
|
| 556 |
+
))}
|
| 557 |
+
</SelectContent>
|
| 558 |
+
</Select>
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
{sourceModel === "Other" && (
|
| 562 |
+
<div className="space-y-2">
|
| 563 |
+
<Label htmlFor="sourceModelOther">Source Model (Other) *</Label>
|
| 564 |
+
<input
|
| 565 |
+
id="sourceModelOther"
|
| 566 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 567 |
+
value={sourceModelOther}
|
| 568 |
+
onChange={(e) => setSourceModelOther(e.target.value)}
|
| 569 |
+
placeholder="Type the model name"
|
| 570 |
+
/>
|
| 571 |
+
</div>
|
| 572 |
+
)}
|
| 573 |
+
<div className="space-y-2">
|
| 574 |
+
<Label htmlFor="depth">Reasoning Depth</Label>
|
| 575 |
+
<Select value={reasoningDepth} onValueChange={setReasoningDepth}>
|
| 576 |
+
<SelectTrigger className="w-[200px]">
|
| 577 |
+
<SelectValue />
|
| 578 |
+
</SelectTrigger>
|
| 579 |
+
<SelectContent>
|
| 580 |
+
{REASONING_DEPTHS.map((depth) => (
|
| 581 |
+
<SelectItem key={depth} value={depth}>
|
| 582 |
+
{depth.charAt(0).toUpperCase() + depth.slice(1)}
|
| 583 |
+
</SelectItem>
|
| 584 |
+
))}
|
| 585 |
+
</SelectContent>
|
| 586 |
+
</Select>
|
| 587 |
+
</div>
|
| 588 |
+
<div className="space-y-2">
|
| 589 |
+
<Label>Topics (select multiple)</Label>
|
| 590 |
+
<div className="flex flex-wrap gap-2">
|
| 591 |
+
{TOPICS.map((topic) => (
|
| 592 |
+
<button
|
| 593 |
+
key={topic}
|
| 594 |
+
type="button"
|
| 595 |
+
onClick={() => toggleTopic(topic)}
|
| 596 |
+
className={`rounded-full px-3 py-1 text-sm transition-colors ${selectedTopics.includes(topic)
|
| 597 |
+
? "bg-primary text-primary-foreground"
|
| 598 |
+
: "bg-muted text-muted-foreground hover:bg-accent"
|
| 599 |
+
}`}
|
| 600 |
+
>
|
| 601 |
+
{topic}
|
| 602 |
+
</button>
|
| 603 |
+
))}
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
<div className="space-y-2">
|
| 607 |
+
<Label htmlFor="datasetNotes">Additional Notes</Label>
|
| 608 |
+
<Textarea
|
| 609 |
+
id="datasetNotes"
|
| 610 |
+
placeholder="Any specific requirements or context..."
|
| 611 |
+
value={datasetNotes}
|
| 612 |
+
onChange={(e) => setDatasetNotes(e.target.value)}
|
| 613 |
+
/>
|
| 614 |
+
</div>
|
| 615 |
+
<div className="flex gap-2">
|
| 616 |
+
<Button type="submit" disabled={submitting}>
|
| 617 |
+
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
| 618 |
+
Submit Request
|
| 619 |
+
</Button>
|
| 620 |
+
<Button type="button" variant="outline" onClick={() => setShowDatasetForm(false)}>
|
| 621 |
+
Cancel
|
| 622 |
+
</Button>
|
| 623 |
+
</div>
|
| 624 |
+
</form>
|
| 625 |
+
</CardContent>
|
| 626 |
+
</Card>
|
| 627 |
+
)}
|
| 628 |
+
|
| 629 |
+
{loading ? (
|
| 630 |
+
<div className="flex justify-center py-12">
|
| 631 |
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
| 632 |
+
</div>
|
| 633 |
+
) : datasetRequests.length === 0 ? (
|
| 634 |
+
<Card className="p-12 text-center">
|
| 635 |
+
<p className="text-muted-foreground">No dataset requests yet. Be the first!</p>
|
| 636 |
+
</Card>
|
| 637 |
+
) : (
|
| 638 |
+
<div className="space-y-4">
|
| 639 |
+
{datasetRequests.map((request) => (
|
| 640 |
+
<Card
|
| 641 |
+
key={request.id}
|
| 642 |
+
className="group cursor-pointer transition-all hover:shadow-md"
|
| 643 |
+
onClick={() => goToDiscussion("dataset", request.id)}
|
| 644 |
+
role="link"
|
| 645 |
+
tabIndex={0}
|
| 646 |
+
onKeyDown={(e) => {
|
| 647 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 648 |
+
e.preventDefault();
|
| 649 |
+
goToDiscussion("dataset", request.id);
|
| 650 |
+
}
|
| 651 |
+
}}
|
| 652 |
+
>
|
| 653 |
+
<CardContent className="p-5">
|
| 654 |
+
<div className="flex items-start gap-4">
|
| 655 |
+
<button
|
| 656 |
+
onClick={(e) => {
|
| 657 |
+
e.stopPropagation();
|
| 658 |
+
handleUpvote("dataset", request.id);
|
| 659 |
+
}}
|
| 660 |
+
className="flex flex-col items-center gap-1 rounded-lg border border-border bg-muted px-3 py-2 transition-colors hover:border-primary hover:bg-accent"
|
| 661 |
+
>
|
| 662 |
+
<ArrowUp className="h-4 w-4 text-primary" />
|
| 663 |
+
<span className="text-sm font-semibold">{request.upvotes}</span>
|
| 664 |
+
</button>
|
| 665 |
+
<div className="flex-1">
|
| 666 |
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
| 667 |
+
<h3 className="font-medium text-foreground">
|
| 668 |
+
{request.sourceModel} Dataset ({request.datasetSize})
|
| 669 |
+
</h3>
|
| 670 |
+
{getStatusBadge(request.status)}
|
| 671 |
+
<Badge variant="outline">{request.reasoningDepth} reasoning</Badge>
|
| 672 |
+
</div>
|
| 673 |
+
{request.topics.length > 0 && (
|
| 674 |
+
<div className="mb-2 flex flex-wrap gap-1">
|
| 675 |
+
{request.topics.map((topic) => (
|
| 676 |
+
<Badge key={topic} variant="secondary" className="text-xs">
|
| 677 |
+
{topic}
|
| 678 |
+
</Badge>
|
| 679 |
+
))}
|
| 680 |
+
</div>
|
| 681 |
+
)}
|
| 682 |
+
{request.additionalNotes && (
|
| 683 |
+
<p className="mb-2 text-sm text-muted-foreground">{request.additionalNotes}</p>
|
| 684 |
+
)}
|
| 685 |
+
<p className="text-xs text-muted-foreground">
|
| 686 |
+
Requested on {formatDate(request.createdAt)}
|
| 687 |
+
</p>
|
| 688 |
+
</div>
|
| 689 |
+
<div className="flex items-center text-muted-foreground">
|
| 690 |
+
<ChevronRight className="h-5 w-5 opacity-50 transition-opacity group-hover:opacity-100" />
|
| 691 |
+
</div>
|
| 692 |
+
</div>
|
| 693 |
+
</CardContent>
|
| 694 |
+
</Card>
|
| 695 |
+
))}
|
| 696 |
+
</div>
|
| 697 |
+
)}
|
| 698 |
+
</TabsContent>
|
| 699 |
+
</Tabs>
|
| 700 |
+
</div>
|
| 701 |
+
</section>
|
| 702 |
+
|
| 703 |
+
{/* Info Section */}
|
| 704 |
+
<section className="py-16">
|
| 705 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 706 |
+
<Card className="overflow-hidden">
|
| 707 |
+
<CardContent className="p-8 md:p-12">
|
| 708 |
+
<div className="max-w-2xl">
|
| 709 |
+
<h2 className="mb-4 text-2xl font-bold text-foreground">How It Works</h2>
|
| 710 |
+
<ul className="space-y-3 text-muted-foreground">
|
| 711 |
+
<li className="flex items-start gap-2">
|
| 712 |
+
<span className="font-bold text-primary">1.</span>
|
| 713 |
+
Submit a request for a model distillation or reasoning dataset
|
| 714 |
+
</li>
|
| 715 |
+
<li className="flex items-start gap-2">
|
| 716 |
+
<span className="font-bold text-primary">2.</span>
|
| 717 |
+
Upvote requests from other community members
|
| 718 |
+
</li>
|
| 719 |
+
<li className="flex items-start gap-2">
|
| 720 |
+
<span className="font-bold text-primary">3.</span>
|
| 721 |
+
We prioritize requests based on community interest
|
| 722 |
+
</li>
|
| 723 |
+
<li className="flex items-start gap-2">
|
| 724 |
+
<span className="font-bold text-primary">4.</span>
|
| 725 |
+
Models and datasets are published on our Hugging Face page
|
| 726 |
+
</li>
|
| 727 |
+
<li className="flex items-start gap-2">
|
| 728 |
+
<span className="font-bold text-primary">Note:</span>
|
| 729 |
+
We will do our best to fulfill as many requests as possible, but we can’t guarantee anything due to time and money constraints.
|
| 730 |
+
</li>
|
| 731 |
+
</ul>
|
| 732 |
+
</div>
|
| 733 |
+
</CardContent>
|
| 734 |
+
</Card>
|
| 735 |
+
</div>
|
| 736 |
+
</section>
|
| 737 |
+
|
| 738 |
+
<Footer />
|
| 739 |
+
</main>
|
| 740 |
+
);
|
| 741 |
+
}
|
src/app/requests/[type]/[id]/page.tsx
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { useParams, useRouter } from "next/navigation";
|
| 6 |
+
import Navbar from "@/components/Navbar";
|
| 7 |
+
import Footer from "@/components/Footer";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 10 |
+
import { Badge } from "@/components/ui/badge";
|
| 11 |
+
import { Label } from "@/components/ui/label";
|
| 12 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 13 |
+
import { toast } from "@/components/ui/toaster";
|
| 14 |
+
|
| 15 |
+
type RequestStatus = "pending" | "in_progress" | "completed";
|
| 16 |
+
|
| 17 |
+
type DistillationRequest = {
|
| 18 |
+
id: string;
|
| 19 |
+
sourceDataset: string;
|
| 20 |
+
studentModel: string;
|
| 21 |
+
additionalNotes: string;
|
| 22 |
+
upvotes: number;
|
| 23 |
+
createdAt: string;
|
| 24 |
+
status: RequestStatus;
|
| 25 |
+
canEdit?: boolean;
|
| 26 |
+
canDelete?: boolean;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
type DatasetRequest = {
|
| 30 |
+
id: string;
|
| 31 |
+
sourceModel: string;
|
| 32 |
+
datasetSize: string;
|
| 33 |
+
reasoningDepth: string;
|
| 34 |
+
topics: string[];
|
| 35 |
+
additionalNotes: string;
|
| 36 |
+
upvotes: number;
|
| 37 |
+
createdAt: string;
|
| 38 |
+
status: RequestStatus;
|
| 39 |
+
canEdit?: boolean;
|
| 40 |
+
canDelete?: boolean;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
type DiscussionComment = {
|
| 44 |
+
id: string;
|
| 45 |
+
body: string;
|
| 46 |
+
author: string;
|
| 47 |
+
role: "admin" | "user";
|
| 48 |
+
createdAt: string;
|
| 49 |
+
editedAt?: string;
|
| 50 |
+
canEdit?: boolean;
|
| 51 |
+
canDelete?: boolean;
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
type Thread = {
|
| 55 |
+
key: string;
|
| 56 |
+
requestType: "distillation" | "dataset";
|
| 57 |
+
requestId: string;
|
| 58 |
+
comments: DiscussionComment[];
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
function formatDate(dateStr: string) {
|
| 62 |
+
return new Date(dateStr).toLocaleString("en-US", {
|
| 63 |
+
year: "numeric",
|
| 64 |
+
month: "short",
|
| 65 |
+
day: "numeric",
|
| 66 |
+
hour: "numeric",
|
| 67 |
+
minute: "2-digit",
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function StatusBadge({ status }: { status: RequestStatus }) {
|
| 72 |
+
if (status === "completed") return <Badge variant="success">Completed</Badge>;
|
| 73 |
+
if (status === "in_progress") return <Badge variant="warning">In Progress</Badge>;
|
| 74 |
+
return <Badge variant="secondary">Pending</Badge>;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export default function RequestDiscussionPage() {
|
| 78 |
+
const params = useParams<{ type: string; id: string }>();
|
| 79 |
+
const router = useRouter();
|
| 80 |
+
const type = params.type === "dataset" ? "dataset" : "distillation";
|
| 81 |
+
const id = params.id;
|
| 82 |
+
|
| 83 |
+
const [admin, setAdmin] = useState(false);
|
| 84 |
+
const [loading, setLoading] = useState(true);
|
| 85 |
+
|
| 86 |
+
const [request, setRequest] = useState<DistillationRequest | DatasetRequest | null>(null);
|
| 87 |
+
const [thread, setThread] = useState<Thread | null>(null);
|
| 88 |
+
|
| 89 |
+
const [author, setAuthor] = useState("");
|
| 90 |
+
const [commentBody, setCommentBody] = useState("");
|
| 91 |
+
const [submitting, setSubmitting] = useState(false);
|
| 92 |
+
|
| 93 |
+
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
| 94 |
+
const [editingCommentBody, setEditingCommentBody] = useState("");
|
| 95 |
+
const [editingCommentSaving, setEditingCommentSaving] = useState(false);
|
| 96 |
+
|
| 97 |
+
const [editingRequest, setEditingRequest] = useState(false);
|
| 98 |
+
const [editSourceA, setEditSourceA] = useState("");
|
| 99 |
+
const [editSourceB, setEditSourceB] = useState("");
|
| 100 |
+
const [editDatasetSize, setEditDatasetSize] = useState("");
|
| 101 |
+
const [editReasoningDepth, setEditReasoningDepth] = useState("");
|
| 102 |
+
const [editTopics, setEditTopics] = useState("");
|
| 103 |
+
const [editNotes, setEditNotes] = useState("");
|
| 104 |
+
const [savingRequest, setSavingRequest] = useState(false);
|
| 105 |
+
|
| 106 |
+
const title = useMemo(() => {
|
| 107 |
+
if (!request) return "Request";
|
| 108 |
+
if (type === "distillation") {
|
| 109 |
+
const r = request as DistillationRequest;
|
| 110 |
+
return `${r.sourceDataset} → ${r.studentModel}`;
|
| 111 |
+
}
|
| 112 |
+
const r = request as DatasetRequest;
|
| 113 |
+
return `${r.sourceModel} Dataset (${r.datasetSize})`;
|
| 114 |
+
}, [request, type]);
|
| 115 |
+
|
| 116 |
+
useEffect(() => {
|
| 117 |
+
checkAdmin();
|
| 118 |
+
fetchThread();
|
| 119 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 120 |
+
}, [type, id]);
|
| 121 |
+
|
| 122 |
+
async function checkAdmin() {
|
| 123 |
+
try {
|
| 124 |
+
const res = await fetch("/api/admin/me", { cache: "no-store" });
|
| 125 |
+
const data = await res.json();
|
| 126 |
+
setAdmin(Boolean(data?.admin));
|
| 127 |
+
} catch {
|
| 128 |
+
setAdmin(false);
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
async function fetchThread() {
|
| 133 |
+
setLoading(true);
|
| 134 |
+
try {
|
| 135 |
+
const res = await fetch(`/api/requests/${type}/${id}`, { cache: "no-store" });
|
| 136 |
+
const data = await res.json();
|
| 137 |
+
if (!res.ok) {
|
| 138 |
+
toast({ title: "Error", description: data?.error || "Request not found", variant: "destructive" });
|
| 139 |
+
setRequest(null);
|
| 140 |
+
setThread(null);
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
setRequest(data.request);
|
| 144 |
+
setThread(data.thread);
|
| 145 |
+
if (data?.request && !editingRequest) {
|
| 146 |
+
if (type === "distillation") {
|
| 147 |
+
setEditSourceA(data.request.sourceDataset || "");
|
| 148 |
+
setEditSourceB(data.request.studentModel || "");
|
| 149 |
+
} else {
|
| 150 |
+
setEditSourceA(data.request.sourceModel || "");
|
| 151 |
+
setEditDatasetSize(data.request.datasetSize || "");
|
| 152 |
+
setEditReasoningDepth(data.request.reasoningDepth || "");
|
| 153 |
+
setEditTopics(Array.isArray(data.request.topics) ? data.request.topics.join(", ") : "");
|
| 154 |
+
}
|
| 155 |
+
setEditNotes(data.request.additionalNotes || "");
|
| 156 |
+
}
|
| 157 |
+
} catch (error) {
|
| 158 |
+
console.error(error);
|
| 159 |
+
toast({ title: "Error", description: "Failed to load discussion", variant: "destructive" });
|
| 160 |
+
} finally {
|
| 161 |
+
setLoading(false);
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function saveEditedComment() {
|
| 166 |
+
if (!editingCommentId) return;
|
| 167 |
+
const text = editingCommentBody.trim();
|
| 168 |
+
if (!text) {
|
| 169 |
+
toast({ title: "Error", description: "Comment cannot be empty", variant: "destructive" });
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
setEditingCommentSaving(true);
|
| 174 |
+
try {
|
| 175 |
+
const res = await fetch(`/api/requests/${type}/${id}/comments/${editingCommentId}`, {
|
| 176 |
+
method: "PATCH",
|
| 177 |
+
headers: { "Content-Type": "application/json" },
|
| 178 |
+
body: JSON.stringify({ body: text }),
|
| 179 |
+
});
|
| 180 |
+
const data = await res.json();
|
| 181 |
+
if (!res.ok) {
|
| 182 |
+
toast({ title: "Error", description: data?.error || "Failed to edit comment", variant: "destructive" });
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
setEditingCommentId(null);
|
| 186 |
+
setEditingCommentBody("");
|
| 187 |
+
await fetchThread();
|
| 188 |
+
} catch {
|
| 189 |
+
toast({ title: "Error", description: "Failed to edit comment", variant: "destructive" });
|
| 190 |
+
} finally {
|
| 191 |
+
setEditingCommentSaving(false);
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
async function deleteCommentById(commentId: string) {
|
| 196 |
+
if (!confirm("Delete this comment?")) return;
|
| 197 |
+
try {
|
| 198 |
+
const res = await fetch(`/api/requests/${type}/${id}/comments/${commentId}`, {
|
| 199 |
+
method: "DELETE",
|
| 200 |
+
});
|
| 201 |
+
const data = await res.json();
|
| 202 |
+
if (!res.ok) {
|
| 203 |
+
toast({ title: "Error", description: data?.error || "Failed to delete comment", variant: "destructive" });
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
if (editingCommentId === commentId) {
|
| 207 |
+
setEditingCommentId(null);
|
| 208 |
+
setEditingCommentBody("");
|
| 209 |
+
}
|
| 210 |
+
await fetchThread();
|
| 211 |
+
} catch {
|
| 212 |
+
toast({ title: "Error", description: "Failed to delete comment", variant: "destructive" });
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
async function saveRequestEdits() {
|
| 217 |
+
if (!request) return;
|
| 218 |
+
setSavingRequest(true);
|
| 219 |
+
try {
|
| 220 |
+
const payload =
|
| 221 |
+
type === "distillation"
|
| 222 |
+
? {
|
| 223 |
+
sourceDataset: editSourceA.trim(),
|
| 224 |
+
studentModel: editSourceB.trim(),
|
| 225 |
+
additionalNotes: editNotes,
|
| 226 |
+
}
|
| 227 |
+
: {
|
| 228 |
+
sourceModel: editSourceA.trim(),
|
| 229 |
+
datasetSize: editDatasetSize.trim(),
|
| 230 |
+
reasoningDepth: editReasoningDepth.trim(),
|
| 231 |
+
topics: editTopics
|
| 232 |
+
.split(",")
|
| 233 |
+
.map((t) => t.trim())
|
| 234 |
+
.filter(Boolean),
|
| 235 |
+
additionalNotes: editNotes,
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
const res = await fetch(`/api/requests/${type}/${id}`, {
|
| 239 |
+
method: "PATCH",
|
| 240 |
+
headers: { "Content-Type": "application/json" },
|
| 241 |
+
body: JSON.stringify(payload),
|
| 242 |
+
});
|
| 243 |
+
const data = await res.json();
|
| 244 |
+
if (!res.ok) {
|
| 245 |
+
toast({ title: "Error", description: data?.error || "Failed to update request", variant: "destructive" });
|
| 246 |
+
return;
|
| 247 |
+
}
|
| 248 |
+
setEditingRequest(false);
|
| 249 |
+
await fetchThread();
|
| 250 |
+
} catch {
|
| 251 |
+
toast({ title: "Error", description: "Failed to update request", variant: "destructive" });
|
| 252 |
+
} finally {
|
| 253 |
+
setSavingRequest(false);
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
async function deleteRequestSelf() {
|
| 258 |
+
if (!confirm("Delete this request?")) return;
|
| 259 |
+
try {
|
| 260 |
+
const res = await fetch(`/api/requests/${type}/${id}`, { method: "DELETE" });
|
| 261 |
+
const data = await res.json();
|
| 262 |
+
if (!res.ok) {
|
| 263 |
+
toast({ title: "Error", description: data?.error || "Failed to delete request", variant: "destructive" });
|
| 264 |
+
return;
|
| 265 |
+
}
|
| 266 |
+
router.push("/");
|
| 267 |
+
} catch {
|
| 268 |
+
toast({ title: "Error", description: "Failed to delete request", variant: "destructive" });
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
async function submitComment() {
|
| 273 |
+
const text = commentBody.trim();
|
| 274 |
+
if (!text) {
|
| 275 |
+
toast({ title: "Error", description: "Comment cannot be empty", variant: "destructive" });
|
| 276 |
+
return;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
setSubmitting(true);
|
| 280 |
+
try {
|
| 281 |
+
const endpoint = admin
|
| 282 |
+
? `/api/admin/requests/${type}/${id}/comments`
|
| 283 |
+
: `/api/requests/${type}/${id}/comments`;
|
| 284 |
+
|
| 285 |
+
const payload = admin ? { body: text } : { body: text, author };
|
| 286 |
+
|
| 287 |
+
const res = await fetch(endpoint, {
|
| 288 |
+
method: "POST",
|
| 289 |
+
headers: { "Content-Type": "application/json" },
|
| 290 |
+
body: JSON.stringify(payload),
|
| 291 |
+
});
|
| 292 |
+
const data = await res.json();
|
| 293 |
+
if (!res.ok) {
|
| 294 |
+
toast({ title: "Error", description: data?.error || "Failed to comment", variant: "destructive" });
|
| 295 |
+
return;
|
| 296 |
+
}
|
| 297 |
+
setCommentBody("");
|
| 298 |
+
await fetchThread();
|
| 299 |
+
} catch {
|
| 300 |
+
toast({ title: "Error", description: "Failed to comment", variant: "destructive" });
|
| 301 |
+
} finally {
|
| 302 |
+
setSubmitting(false);
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return (
|
| 307 |
+
<main className="min-h-screen bg-background">
|
| 308 |
+
<Navbar />
|
| 309 |
+
|
| 310 |
+
<section className="pt-24 pb-10">
|
| 311 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 312 |
+
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
| 313 |
+
<div>
|
| 314 |
+
<div className="flex items-center gap-2">
|
| 315 |
+
<h1 className="text-2xl font-bold tracking-tight text-foreground">{title}</h1>
|
| 316 |
+
{request ? <StatusBadge status={request.status} /> : null}
|
| 317 |
+
<Badge variant="outline">{type}</Badge>
|
| 318 |
+
</div>
|
| 319 |
+
<p className="mt-1 text-sm text-muted-foreground">
|
| 320 |
+
<Link href="/" className="hover:underline">Home</Link>
|
| 321 |
+
<span className="mx-2">/</span>
|
| 322 |
+
Discussion
|
| 323 |
+
</p>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
|
| 327 |
+
{loading ? (
|
| 328 |
+
<Card>
|
| 329 |
+
<CardContent className="p-6 text-muted-foreground">Loading…</CardContent>
|
| 330 |
+
</Card>
|
| 331 |
+
) : !request ? (
|
| 332 |
+
<Card>
|
| 333 |
+
<CardContent className="p-6 text-muted-foreground">Request not found.</CardContent>
|
| 334 |
+
</Card>
|
| 335 |
+
) : (
|
| 336 |
+
<div className="grid gap-4 lg:grid-cols-3">
|
| 337 |
+
<div className="lg:col-span-1">
|
| 338 |
+
<Card>
|
| 339 |
+
<CardHeader>
|
| 340 |
+
<CardTitle>Request</CardTitle>
|
| 341 |
+
<CardDescription>Details</CardDescription>
|
| 342 |
+
</CardHeader>
|
| 343 |
+
<CardContent className="space-y-3">
|
| 344 |
+
<div className="flex flex-wrap gap-2">
|
| 345 |
+
<Badge variant="secondary">{request.upvotes} upvotes</Badge>
|
| 346 |
+
<Badge variant="outline">Created {formatDate(request.createdAt)}</Badge>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
{type === "distillation" ? (
|
| 350 |
+
<div className="space-y-2">
|
| 351 |
+
<div className="text-sm">
|
| 352 |
+
<span className="text-muted-foreground">Source dataset:</span>{" "}
|
| 353 |
+
<span className="text-foreground">{(request as DistillationRequest).sourceDataset}</span>
|
| 354 |
+
</div>
|
| 355 |
+
<div className="text-sm">
|
| 356 |
+
<span className="text-muted-foreground">Student model:</span>{" "}
|
| 357 |
+
<span className="text-foreground">{(request as DistillationRequest).studentModel}</span>
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
+
) : (
|
| 361 |
+
<div className="space-y-2">
|
| 362 |
+
<div className="text-sm">
|
| 363 |
+
<span className="text-muted-foreground">Source model:</span>{" "}
|
| 364 |
+
<span className="text-foreground">{(request as DatasetRequest).sourceModel}</span>
|
| 365 |
+
</div>
|
| 366 |
+
<div className="text-sm">
|
| 367 |
+
<span className="text-muted-foreground">Dataset size:</span>{" "}
|
| 368 |
+
<span className="text-foreground">{(request as DatasetRequest).datasetSize}</span>
|
| 369 |
+
</div>
|
| 370 |
+
<div className="text-sm">
|
| 371 |
+
<span className="text-muted-foreground">Reasoning depth:</span>{" "}
|
| 372 |
+
<span className="text-foreground">{(request as DatasetRequest).reasoningDepth}</span>
|
| 373 |
+
</div>
|
| 374 |
+
{(request as DatasetRequest).topics?.length ? (
|
| 375 |
+
<div className="flex flex-wrap gap-1">
|
| 376 |
+
{(request as DatasetRequest).topics.map((t) => (
|
| 377 |
+
<Badge key={t} variant="secondary" className="text-xs">
|
| 378 |
+
{t}
|
| 379 |
+
</Badge>
|
| 380 |
+
))}
|
| 381 |
+
</div>
|
| 382 |
+
) : null}
|
| 383 |
+
</div>
|
| 384 |
+
)}
|
| 385 |
+
|
| 386 |
+
{request.additionalNotes ? (
|
| 387 |
+
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
|
| 388 |
+
{request.additionalNotes}
|
| 389 |
+
</div>
|
| 390 |
+
) : null}
|
| 391 |
+
|
| 392 |
+
{(request.canEdit || request.canDelete) && (
|
| 393 |
+
<div className="flex flex-wrap gap-2 pt-2">
|
| 394 |
+
{request.canEdit && (
|
| 395 |
+
<Button
|
| 396 |
+
variant="outline"
|
| 397 |
+
onClick={() => setEditingRequest((v) => !v)}
|
| 398 |
+
>
|
| 399 |
+
{editingRequest ? "Close Edit" : "Edit"}
|
| 400 |
+
</Button>
|
| 401 |
+
)}
|
| 402 |
+
{request.canDelete && (
|
| 403 |
+
<Button variant="destructive" onClick={deleteRequestSelf}>
|
| 404 |
+
Delete
|
| 405 |
+
</Button>
|
| 406 |
+
)}
|
| 407 |
+
</div>
|
| 408 |
+
)}
|
| 409 |
+
|
| 410 |
+
{editingRequest && request.canEdit && (
|
| 411 |
+
<div className="space-y-3 border-t border-border pt-4">
|
| 412 |
+
{type === "distillation" ? (
|
| 413 |
+
<>
|
| 414 |
+
<div className="space-y-2">
|
| 415 |
+
<Label htmlFor="editSourceDataset">Source Dataset</Label>
|
| 416 |
+
<input
|
| 417 |
+
id="editSourceDataset"
|
| 418 |
+
title="Source dataset"
|
| 419 |
+
placeholder="Source dataset"
|
| 420 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 421 |
+
value={editSourceA}
|
| 422 |
+
onChange={(e) => setEditSourceA(e.target.value)}
|
| 423 |
+
/>
|
| 424 |
+
</div>
|
| 425 |
+
<div className="space-y-2">
|
| 426 |
+
<Label htmlFor="editStudentModel">Student Model</Label>
|
| 427 |
+
<input
|
| 428 |
+
id="editStudentModel"
|
| 429 |
+
title="Student model"
|
| 430 |
+
placeholder="Student model"
|
| 431 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 432 |
+
value={editSourceB}
|
| 433 |
+
onChange={(e) => setEditSourceB(e.target.value)}
|
| 434 |
+
/>
|
| 435 |
+
</div>
|
| 436 |
+
</>
|
| 437 |
+
) : (
|
| 438 |
+
<>
|
| 439 |
+
<div className="space-y-2">
|
| 440 |
+
<Label htmlFor="editSourceModel">Source Model</Label>
|
| 441 |
+
<input
|
| 442 |
+
id="editSourceModel"
|
| 443 |
+
title="Source model"
|
| 444 |
+
placeholder="Source model"
|
| 445 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 446 |
+
value={editSourceA}
|
| 447 |
+
onChange={(e) => setEditSourceA(e.target.value)}
|
| 448 |
+
/>
|
| 449 |
+
</div>
|
| 450 |
+
<div className="space-y-2">
|
| 451 |
+
<Label htmlFor="editDatasetSize">Dataset Size</Label>
|
| 452 |
+
<input
|
| 453 |
+
id="editDatasetSize"
|
| 454 |
+
title="Dataset size"
|
| 455 |
+
placeholder="Dataset size"
|
| 456 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 457 |
+
value={editDatasetSize}
|
| 458 |
+
onChange={(e) => setEditDatasetSize(e.target.value)}
|
| 459 |
+
/>
|
| 460 |
+
</div>
|
| 461 |
+
<div className="space-y-2">
|
| 462 |
+
<Label htmlFor="editReasoning">Reasoning Depth</Label>
|
| 463 |
+
<input
|
| 464 |
+
id="editReasoning"
|
| 465 |
+
title="Reasoning depth"
|
| 466 |
+
placeholder="Reasoning depth"
|
| 467 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 468 |
+
value={editReasoningDepth}
|
| 469 |
+
onChange={(e) => setEditReasoningDepth(e.target.value)}
|
| 470 |
+
/>
|
| 471 |
+
</div>
|
| 472 |
+
<div className="space-y-2">
|
| 473 |
+
<Label htmlFor="editTopics">Topics (comma separated)</Label>
|
| 474 |
+
<input
|
| 475 |
+
id="editTopics"
|
| 476 |
+
title="Topics"
|
| 477 |
+
placeholder="Math, Science, Coding"
|
| 478 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 479 |
+
value={editTopics}
|
| 480 |
+
onChange={(e) => setEditTopics(e.target.value)}
|
| 481 |
+
/>
|
| 482 |
+
</div>
|
| 483 |
+
</>
|
| 484 |
+
)}
|
| 485 |
+
|
| 486 |
+
<div className="space-y-2">
|
| 487 |
+
<Label htmlFor="editNotes">Additional Notes</Label>
|
| 488 |
+
<Textarea
|
| 489 |
+
id="editNotes"
|
| 490 |
+
value={editNotes}
|
| 491 |
+
onChange={(e) => setEditNotes(e.target.value)}
|
| 492 |
+
/>
|
| 493 |
+
</div>
|
| 494 |
+
|
| 495 |
+
<div className="flex gap-2">
|
| 496 |
+
<Button onClick={saveRequestEdits} disabled={savingRequest}>
|
| 497 |
+
{savingRequest ? "Saving…" : "Save"}
|
| 498 |
+
</Button>
|
| 499 |
+
<Button
|
| 500 |
+
variant="outline"
|
| 501 |
+
onClick={() => setEditingRequest(false)}
|
| 502 |
+
disabled={savingRequest}
|
| 503 |
+
>
|
| 504 |
+
Cancel
|
| 505 |
+
</Button>
|
| 506 |
+
</div>
|
| 507 |
+
</div>
|
| 508 |
+
)}
|
| 509 |
+
</CardContent>
|
| 510 |
+
</Card>
|
| 511 |
+
</div>
|
| 512 |
+
|
| 513 |
+
<div className="lg:col-span-2">
|
| 514 |
+
<Card>
|
| 515 |
+
<CardHeader>
|
| 516 |
+
<CardTitle>Discussion</CardTitle>
|
| 517 |
+
<CardDescription>
|
| 518 |
+
{thread?.comments?.length ? `${thread.comments.length} comment(s)` : "No comments yet"}
|
| 519 |
+
</CardDescription>
|
| 520 |
+
</CardHeader>
|
| 521 |
+
<CardContent>
|
| 522 |
+
{!thread?.comments?.length ? (
|
| 523 |
+
<div className="text-sm text-muted-foreground">Be the first to comment.</div>
|
| 524 |
+
) : (
|
| 525 |
+
<div className="space-y-3">
|
| 526 |
+
{thread.comments.map((c) => (
|
| 527 |
+
<div
|
| 528 |
+
key={c.id}
|
| 529 |
+
className="rounded-md border border-border bg-card p-4"
|
| 530 |
+
>
|
| 531 |
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
| 532 |
+
<Badge variant={c.role === "admin" ? "default" : "secondary"}>
|
| 533 |
+
{c.role === "admin" ? "TeichAI" : c.author || "Anonymous"}
|
| 534 |
+
</Badge>
|
| 535 |
+
{c.role === "admin" ? <Badge variant="outline">maintainer</Badge> : null}
|
| 536 |
+
<span className="text-xs text-muted-foreground">
|
| 537 |
+
{formatDate(c.createdAt)}
|
| 538 |
+
{c.editedAt ? " · edited" : ""}
|
| 539 |
+
</span>
|
| 540 |
+
<div className="ml-auto flex items-center gap-2">
|
| 541 |
+
{c.canEdit ? (
|
| 542 |
+
<Button
|
| 543 |
+
variant="link"
|
| 544 |
+
className="h-auto px-0 text-xs"
|
| 545 |
+
onClick={() => {
|
| 546 |
+
setEditingCommentId(c.id);
|
| 547 |
+
setEditingCommentBody(c.body);
|
| 548 |
+
}}
|
| 549 |
+
>
|
| 550 |
+
Edit
|
| 551 |
+
</Button>
|
| 552 |
+
) : null}
|
| 553 |
+
{c.canDelete ? (
|
| 554 |
+
<Button
|
| 555 |
+
variant="link"
|
| 556 |
+
className="h-auto px-0 text-xs text-destructive"
|
| 557 |
+
onClick={() => deleteCommentById(c.id)}
|
| 558 |
+
>
|
| 559 |
+
Delete
|
| 560 |
+
</Button>
|
| 561 |
+
) : null}
|
| 562 |
+
</div>
|
| 563 |
+
</div>
|
| 564 |
+
{editingCommentId === c.id ? (
|
| 565 |
+
<div className="space-y-2">
|
| 566 |
+
<Textarea
|
| 567 |
+
value={editingCommentBody}
|
| 568 |
+
onChange={(e) => setEditingCommentBody(e.target.value)}
|
| 569 |
+
/>
|
| 570 |
+
<div className="flex gap-2">
|
| 571 |
+
<Button onClick={saveEditedComment} disabled={editingCommentSaving}>
|
| 572 |
+
{editingCommentSaving ? "Saving…" : "Save"}
|
| 573 |
+
</Button>
|
| 574 |
+
<Button
|
| 575 |
+
variant="outline"
|
| 576 |
+
onClick={() => {
|
| 577 |
+
setEditingCommentId(null);
|
| 578 |
+
setEditingCommentBody("");
|
| 579 |
+
}}
|
| 580 |
+
disabled={editingCommentSaving}
|
| 581 |
+
>
|
| 582 |
+
Cancel
|
| 583 |
+
</Button>
|
| 584 |
+
</div>
|
| 585 |
+
</div>
|
| 586 |
+
) : (
|
| 587 |
+
<div className="whitespace-pre-wrap text-sm text-foreground">{c.body}</div>
|
| 588 |
+
)}
|
| 589 |
+
</div>
|
| 590 |
+
))}
|
| 591 |
+
</div>
|
| 592 |
+
)}
|
| 593 |
+
|
| 594 |
+
<div className="mt-6 border-t border-border pt-4">
|
| 595 |
+
<div className="mb-2 flex items-center justify-between">
|
| 596 |
+
<div className="text-sm font-medium text-foreground">
|
| 597 |
+
{admin ? "Reply as TeichAI" : "Add a comment"}
|
| 598 |
+
</div>
|
| 599 |
+
{admin ? <Badge variant="outline">admin</Badge> : null}
|
| 600 |
+
</div>
|
| 601 |
+
|
| 602 |
+
{!admin && (
|
| 603 |
+
<div className="mb-3">
|
| 604 |
+
<Label htmlFor="author">Name (optional)</Label>
|
| 605 |
+
<input
|
| 606 |
+
id="author"
|
| 607 |
+
className="mt-2 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
| 608 |
+
value={author}
|
| 609 |
+
onChange={(e) => setAuthor(e.target.value)}
|
| 610 |
+
placeholder="Anonymous"
|
| 611 |
+
/>
|
| 612 |
+
</div>
|
| 613 |
+
)}
|
| 614 |
+
|
| 615 |
+
<div>
|
| 616 |
+
<Label htmlFor="comment">Comment</Label>
|
| 617 |
+
<Textarea
|
| 618 |
+
id="comment"
|
| 619 |
+
value={commentBody}
|
| 620 |
+
onChange={(e) => setCommentBody(e.target.value)}
|
| 621 |
+
placeholder={admin ? "Write a reply…" : "Write your comment…"}
|
| 622 |
+
className="mt-2"
|
| 623 |
+
/>
|
| 624 |
+
</div>
|
| 625 |
+
|
| 626 |
+
<div className="mt-3 flex gap-2">
|
| 627 |
+
<Button onClick={submitComment} disabled={submitting}>
|
| 628 |
+
{submitting ? "Posting…" : "Post"}
|
| 629 |
+
</Button>
|
| 630 |
+
<Button
|
| 631 |
+
variant="outline"
|
| 632 |
+
onClick={() => setCommentBody("")}
|
| 633 |
+
disabled={submitting || !commentBody}
|
| 634 |
+
>
|
| 635 |
+
Clear
|
| 636 |
+
</Button>
|
| 637 |
+
</div>
|
| 638 |
+
</div>
|
| 639 |
+
</CardContent>
|
| 640 |
+
</Card>
|
| 641 |
+
</div>
|
| 642 |
+
</div>
|
| 643 |
+
)}
|
| 644 |
+
</div>
|
| 645 |
+
</section>
|
| 646 |
+
|
| 647 |
+
<Footer />
|
| 648 |
+
</main>
|
| 649 |
+
);
|
| 650 |
+
}
|
src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
|
| 3 |
+
export default function Footer() {
|
| 4 |
+
return (
|
| 5 |
+
<footer className="mt-16 bg-background/95 py-10">
|
| 6 |
+
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
| 7 |
+
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
| 8 |
+
<div className="flex items-center gap-6 text-sm">
|
| 9 |
+
<Link href="/" className="font-semibold tracking-tight text-foreground">
|
| 10 |
+
TeichAI
|
| 11 |
+
</Link>
|
| 12 |
+
<a
|
| 13 |
+
href="https://huggingface.co/TeichAI"
|
| 14 |
+
target="_blank"
|
| 15 |
+
rel="noopener noreferrer"
|
| 16 |
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
| 17 |
+
>
|
| 18 |
+
Hugging Face
|
| 19 |
+
</a>
|
| 20 |
+
<a
|
| 21 |
+
href="https://www.teichai.com"
|
| 22 |
+
target="_blank"
|
| 23 |
+
rel="noopener noreferrer"
|
| 24 |
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
| 25 |
+
>
|
| 26 |
+
Website
|
| 27 |
+
</a>
|
| 28 |
+
<a
|
| 29 |
+
href="https://paypal.me/TeichAI"
|
| 30 |
+
target="_blank"
|
| 31 |
+
rel="noopener noreferrer"
|
| 32 |
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
| 33 |
+
>
|
| 34 |
+
Support Us
|
| 35 |
+
</a>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<p className="text-sm text-muted-foreground">
|
| 39 |
+
© {new Date().getFullYear()} TeichAI
|
| 40 |
+
</p>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</footer>
|
| 44 |
+
);
|
| 45 |
+
}
|
src/components/Navbar.tsx
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from "react";
|
| 4 |
+
import { Menu, Sun, Moon } from "lucide-react";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import Image from "next/image";
|
| 7 |
+
import { useRouter } from "next/navigation";
|
| 8 |
+
import { useTheme } from "./ThemeProvider";
|
| 9 |
+
import { Button } from "@/components/ui/button";
|
| 10 |
+
|
| 11 |
+
export default function Navbar() {
|
| 12 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 13 |
+
const { theme, toggleTheme } = useTheme();
|
| 14 |
+
const router = useRouter();
|
| 15 |
+
|
| 16 |
+
const adminHoldTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 17 |
+
const adminGestureTriggeredRef = useRef(false);
|
| 18 |
+
|
| 19 |
+
const clearAdminHoldTimer = () => {
|
| 20 |
+
if (adminHoldTimerRef.current) {
|
| 21 |
+
clearTimeout(adminHoldTimerRef.current);
|
| 22 |
+
adminHoldTimerRef.current = null;
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const onWindowBlur = () => {
|
| 28 |
+
clearAdminHoldTimer();
|
| 29 |
+
adminGestureTriggeredRef.current = false;
|
| 30 |
+
};
|
| 31 |
+
window.addEventListener("blur", onWindowBlur);
|
| 32 |
+
return () => {
|
| 33 |
+
window.removeEventListener("blur", onWindowBlur);
|
| 34 |
+
};
|
| 35 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 36 |
+
}, []);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<nav className="fixed inset-x-0 top-0 z-50 bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/70 shadow-[0_1px_0_rgba(255,255,255,0.03)]">
|
| 40 |
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
|
| 41 |
+
<Link
|
| 42 |
+
href="/"
|
| 43 |
+
className="flex items-center gap-2.5"
|
| 44 |
+
onPointerDown={(e) => {
|
| 45 |
+
if (!e.ctrlKey) return;
|
| 46 |
+
adminGestureTriggeredRef.current = false;
|
| 47 |
+
clearAdminHoldTimer();
|
| 48 |
+
adminHoldTimerRef.current = setTimeout(() => {
|
| 49 |
+
adminGestureTriggeredRef.current = true;
|
| 50 |
+
router.push("/admin");
|
| 51 |
+
}, 3000);
|
| 52 |
+
}}
|
| 53 |
+
onPointerUp={() => {
|
| 54 |
+
clearAdminHoldTimer();
|
| 55 |
+
}}
|
| 56 |
+
onPointerLeave={() => {
|
| 57 |
+
clearAdminHoldTimer();
|
| 58 |
+
}}
|
| 59 |
+
onPointerCancel={() => {
|
| 60 |
+
clearAdminHoldTimer();
|
| 61 |
+
}}
|
| 62 |
+
onClick={(e) => {
|
| 63 |
+
if (adminGestureTriggeredRef.current) {
|
| 64 |
+
e.preventDefault();
|
| 65 |
+
adminGestureTriggeredRef.current = false;
|
| 66 |
+
}
|
| 67 |
+
}}
|
| 68 |
+
>
|
| 69 |
+
<Image
|
| 70 |
+
src="/teich.svg"
|
| 71 |
+
alt="TeichAI Logo"
|
| 72 |
+
width={32}
|
| 73 |
+
height={32}
|
| 74 |
+
className="rounded-lg"
|
| 75 |
+
/>
|
| 76 |
+
<span className="text-lg font-semibold tracking-tight text-foreground">TeichAI Requests</span>
|
| 77 |
+
</Link>
|
| 78 |
+
|
| 79 |
+
<div className="hidden items-center gap-1 md:flex">
|
| 80 |
+
<Button
|
| 81 |
+
onClick={toggleTheme}
|
| 82 |
+
variant="ghost"
|
| 83 |
+
size="icon"
|
| 84 |
+
className="ml-1"
|
| 85 |
+
aria-label="Toggle theme"
|
| 86 |
+
>
|
| 87 |
+
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
| 88 |
+
</Button>
|
| 89 |
+
|
| 90 |
+
<Button asChild className="ml-1">
|
| 91 |
+
<a href="https://huggingface.co/TeichAI" target="_blank" rel="noopener noreferrer">
|
| 92 |
+
HF Hub
|
| 93 |
+
</a>
|
| 94 |
+
</Button>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div className="flex items-center gap-1 md:hidden">
|
| 98 |
+
<Button
|
| 99 |
+
onClick={toggleTheme}
|
| 100 |
+
variant="ghost"
|
| 101 |
+
size="icon"
|
| 102 |
+
aria-label="Toggle theme"
|
| 103 |
+
>
|
| 104 |
+
{theme === "dark" ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
| 105 |
+
</Button>
|
| 106 |
+
|
| 107 |
+
<Button
|
| 108 |
+
variant="ghost"
|
| 109 |
+
size="icon"
|
| 110 |
+
aria-label="Open menu"
|
| 111 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 112 |
+
>
|
| 113 |
+
<Menu className="size-5" />
|
| 114 |
+
</Button>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
{isOpen && (
|
| 119 |
+
<div className="border-t border-border bg-background p-4 md:hidden">
|
| 120 |
+
<Button asChild className="w-full">
|
| 121 |
+
<a href="https://huggingface.co/TeichAI" target="_blank" rel="noopener noreferrer">
|
| 122 |
+
HF Hub
|
| 123 |
+
</a>
|
| 124 |
+
</Button>
|
| 125 |
+
</div>
|
| 126 |
+
)}
|
| 127 |
+
</nav>
|
| 128 |
+
);
|
| 129 |
+
}
|
src/components/Providers.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { ThemeProvider } from "./ThemeProvider";
|
| 4 |
+
import { Toaster } from "./ui/toaster";
|
| 5 |
+
|
| 6 |
+
export function Providers({ children }: { children: React.ReactNode }) {
|
| 7 |
+
return (
|
| 8 |
+
<ThemeProvider>
|
| 9 |
+
{children}
|
| 10 |
+
<Toaster />
|
| 11 |
+
</ThemeProvider>
|
| 12 |
+
);
|
| 13 |
+
}
|
src/components/ThemeProvider.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { createContext, useContext, useEffect, useState } from "react";
|
| 4 |
+
|
| 5 |
+
type Theme = "light" | "dark";
|
| 6 |
+
|
| 7 |
+
interface ThemeContextType {
|
| 8 |
+
theme: Theme;
|
| 9 |
+
toggleTheme: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
| 13 |
+
|
| 14 |
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
| 15 |
+
const [theme, setTheme] = useState<Theme>("dark");
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
const saved = localStorage.getItem("theme") as Theme | null;
|
| 19 |
+
if (saved) {
|
| 20 |
+
setTheme(saved);
|
| 21 |
+
document.documentElement.classList.toggle("dark", saved === "dark");
|
| 22 |
+
}
|
| 23 |
+
}, []);
|
| 24 |
+
|
| 25 |
+
const toggleTheme = () => {
|
| 26 |
+
const newTheme = theme === "dark" ? "light" : "dark";
|
| 27 |
+
setTheme(newTheme);
|
| 28 |
+
localStorage.setItem("theme", newTheme);
|
| 29 |
+
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
| 34 |
+
{children}
|
| 35 |
+
</ThemeContext.Provider>
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function useTheme() {
|
| 40 |
+
const context = useContext(ThemeContext);
|
| 41 |
+
if (!context) {
|
| 42 |
+
throw new Error("useTheme must be used within ThemeProvider");
|
| 43 |
+
}
|
| 44 |
+
return context;
|
| 45 |
+
}
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
const badgeVariants = cva(
|
| 6 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
| 11 |
+
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 12 |
+
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
| 13 |
+
outline: "text-foreground",
|
| 14 |
+
success: "border-transparent bg-green-500/20 text-green-500",
|
| 15 |
+
warning: "border-transparent bg-yellow-500/20 text-yellow-500",
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
defaultVariants: {
|
| 19 |
+
variant: "default",
|
| 20 |
+
},
|
| 21 |
+
}
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
export interface BadgeProps
|
| 25 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
| 26 |
+
VariantProps<typeof badgeVariants> { }
|
| 27 |
+
|
| 28 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 29 |
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export { Badge, badgeVariants };
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const buttonVariants = cva(
|
| 7 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 12 |
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 13 |
+
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
| 14 |
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 15 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 16 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 17 |
+
},
|
| 18 |
+
size: {
|
| 19 |
+
default: "h-10 px-4 py-2",
|
| 20 |
+
sm: "h-9 rounded-md px-3",
|
| 21 |
+
lg: "h-11 rounded-md px-8",
|
| 22 |
+
icon: "h-10 w-10",
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
defaultVariants: {
|
| 26 |
+
variant: "default",
|
| 27 |
+
size: "default",
|
| 28 |
+
},
|
| 29 |
+
}
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
function Button({
|
| 33 |
+
className,
|
| 34 |
+
variant,
|
| 35 |
+
size,
|
| 36 |
+
asChild = false,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<"button"> &
|
| 39 |
+
VariantProps<typeof buttonVariants> & {
|
| 40 |
+
asChild?: boolean;
|
| 41 |
+
}) {
|
| 42 |
+
const Comp = asChild ? Slot : "button";
|
| 43 |
+
return (
|
| 44 |
+
<Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export { Button, buttonVariants };
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 5 |
+
({ className, ...props }, ref) => (
|
| 6 |
+
<div
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-sm", className)}
|
| 9 |
+
{...props}
|
| 10 |
+
/>
|
| 11 |
+
)
|
| 12 |
+
);
|
| 13 |
+
Card.displayName = "Card";
|
| 14 |
+
|
| 15 |
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 16 |
+
({ className, ...props }, ref) => (
|
| 17 |
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
| 18 |
+
)
|
| 19 |
+
);
|
| 20 |
+
CardHeader.displayName = "CardHeader";
|
| 21 |
+
|
| 22 |
+
const CardTitle = React.forwardRef<React.ElementRef<"h3">, React.ComponentPropsWithoutRef<"h3">>(
|
| 23 |
+
({ className, ...props }, ref) => (
|
| 24 |
+
<h3
|
| 25 |
+
ref={ref}
|
| 26 |
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
| 27 |
+
{...props}
|
| 28 |
+
/>
|
| 29 |
+
)
|
| 30 |
+
);
|
| 31 |
+
CardTitle.displayName = "CardTitle";
|
| 32 |
+
|
| 33 |
+
const CardDescription = React.forwardRef<
|
| 34 |
+
HTMLParagraphElement,
|
| 35 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
| 36 |
+
>(({ className, ...props }, ref) => (
|
| 37 |
+
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
| 38 |
+
));
|
| 39 |
+
CardDescription.displayName = "CardDescription";
|
| 40 |
+
|
| 41 |
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 42 |
+
({ className, ...props }, ref) => (
|
| 43 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 44 |
+
)
|
| 45 |
+
);
|
| 46 |
+
CardContent.displayName = "CardContent";
|
| 47 |
+
|
| 48 |
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 49 |
+
({ className, ...props }, ref) => (
|
| 50 |
+
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
| 51 |
+
)
|
| 52 |
+
);
|
| 53 |
+
CardFooter.displayName = "CardFooter";
|
| 54 |
+
|
| 55 |
+
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
src/components/ui/combobox.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import { Check, ChevronsUpDown } from "lucide-react";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
Popover,
|
| 9 |
+
PopoverContent,
|
| 10 |
+
PopoverTrigger,
|
| 11 |
+
} from "@/components/ui/popover";
|
| 12 |
+
import { Input } from "@/components/ui/input";
|
| 13 |
+
|
| 14 |
+
interface ComboboxProps {
|
| 15 |
+
options: { id: string; name: string }[];
|
| 16 |
+
value: string;
|
| 17 |
+
onValueChange: (value: string) => void;
|
| 18 |
+
placeholder?: string;
|
| 19 |
+
searchPlaceholder?: string;
|
| 20 |
+
emptyMessage?: string;
|
| 21 |
+
loading?: boolean;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function Combobox({
|
| 25 |
+
options,
|
| 26 |
+
value,
|
| 27 |
+
onValueChange,
|
| 28 |
+
placeholder = "Select option...",
|
| 29 |
+
searchPlaceholder = "Search...",
|
| 30 |
+
emptyMessage = "No results found.",
|
| 31 |
+
loading = false,
|
| 32 |
+
}: ComboboxProps) {
|
| 33 |
+
const [open, setOpen] = React.useState(false);
|
| 34 |
+
const [search, setSearch] = React.useState("");
|
| 35 |
+
|
| 36 |
+
const filteredOptions = React.useMemo(() => {
|
| 37 |
+
if (!search) return options;
|
| 38 |
+
const lower = search.toLowerCase();
|
| 39 |
+
return options.filter(
|
| 40 |
+
(option) =>
|
| 41 |
+
option.name.toLowerCase().includes(lower) ||
|
| 42 |
+
option.id.toLowerCase().includes(lower)
|
| 43 |
+
);
|
| 44 |
+
}, [options, search]);
|
| 45 |
+
|
| 46 |
+
const selectedOption = options.find((opt) => opt.id === value);
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<Popover open={open} onOpenChange={setOpen}>
|
| 50 |
+
<PopoverTrigger asChild>
|
| 51 |
+
<Button
|
| 52 |
+
variant="outline"
|
| 53 |
+
role="combobox"
|
| 54 |
+
aria-expanded={open}
|
| 55 |
+
className="w-full justify-between font-normal"
|
| 56 |
+
>
|
| 57 |
+
<span className="truncate">
|
| 58 |
+
{selectedOption ? selectedOption.name : placeholder}
|
| 59 |
+
</span>
|
| 60 |
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
| 61 |
+
</Button>
|
| 62 |
+
</PopoverTrigger>
|
| 63 |
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
| 64 |
+
<div className="p-2">
|
| 65 |
+
<Input
|
| 66 |
+
placeholder={searchPlaceholder}
|
| 67 |
+
value={search}
|
| 68 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 69 |
+
className="h-9"
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="max-h-[300px] overflow-y-auto">
|
| 73 |
+
{loading ? (
|
| 74 |
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
| 75 |
+
Loading...
|
| 76 |
+
</div>
|
| 77 |
+
) : filteredOptions.length === 0 ? (
|
| 78 |
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
| 79 |
+
{emptyMessage}
|
| 80 |
+
</div>
|
| 81 |
+
) : (
|
| 82 |
+
<div className="p-1">
|
| 83 |
+
{filteredOptions.map((option) => (
|
| 84 |
+
<button
|
| 85 |
+
key={option.id}
|
| 86 |
+
onClick={() => {
|
| 87 |
+
onValueChange(option.id === value ? "" : option.id);
|
| 88 |
+
setOpen(false);
|
| 89 |
+
setSearch("");
|
| 90 |
+
}}
|
| 91 |
+
className={cn(
|
| 92 |
+
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground",
|
| 93 |
+
value === option.id && "bg-accent"
|
| 94 |
+
)}
|
| 95 |
+
>
|
| 96 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 97 |
+
{value === option.id && <Check className="h-4 w-4" />}
|
| 98 |
+
</span>
|
| 99 |
+
<span className="truncate">{option.name}</span>
|
| 100 |
+
</button>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
</PopoverContent>
|
| 106 |
+
</Popover>
|
| 107 |
+
);
|
| 108 |
+
}
|
src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { }
|
| 5 |
+
|
| 6 |
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
| 7 |
+
({ className, type, ...props }, ref) => {
|
| 8 |
+
return (
|
| 9 |
+
<input
|
| 10 |
+
type={type}
|
| 11 |
+
className={cn(
|
| 12 |
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
ref={ref}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
);
|
| 21 |
+
Input.displayName = "Input";
|
| 22 |
+
|
| 23 |
+
export { Input };
|
src/components/ui/label.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
| 5 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
const labelVariants = cva(
|
| 9 |
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
const Label = React.forwardRef<
|
| 13 |
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
| 14 |
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
| 15 |
+
VariantProps<typeof labelVariants>
|
| 16 |
+
>(({ className, ...props }, ref) => (
|
| 17 |
+
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
| 18 |
+
));
|
| 19 |
+
Label.displayName = LabelPrimitive.Root.displayName;
|
| 20 |
+
|
| 21 |
+
export { Label };
|
src/components/ui/popover.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
const Popover = PopoverPrimitive.Root;
|
| 8 |
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
| 9 |
+
|
| 10 |
+
const PopoverContent = React.forwardRef<
|
| 11 |
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
| 12 |
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
| 13 |
+
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
| 14 |
+
<PopoverPrimitive.Portal>
|
| 15 |
+
<PopoverPrimitive.Content
|
| 16 |
+
ref={ref}
|
| 17 |
+
align={align}
|
| 18 |
+
sideOffset={sideOffset}
|
| 19 |
+
className={cn(
|
| 20 |
+
"z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
</PopoverPrimitive.Portal>
|
| 26 |
+
));
|
| 27 |
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
| 28 |
+
|
| 29 |
+
export { Popover, PopoverTrigger, PopoverContent };
|
src/components/ui/select.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
| 5 |
+
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
const Select = SelectPrimitive.Root;
|
| 9 |
+
const SelectGroup = SelectPrimitive.Group;
|
| 10 |
+
const SelectValue = SelectPrimitive.Value;
|
| 11 |
+
|
| 12 |
+
const SelectTrigger = React.forwardRef<
|
| 13 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
| 14 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
| 15 |
+
>(({ className, children, ...props }, ref) => (
|
| 16 |
+
<SelectPrimitive.Trigger
|
| 17 |
+
ref={ref}
|
| 18 |
+
className={cn(
|
| 19 |
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
| 20 |
+
className
|
| 21 |
+
)}
|
| 22 |
+
{...props}
|
| 23 |
+
>
|
| 24 |
+
{children}
|
| 25 |
+
<SelectPrimitive.Icon asChild>
|
| 26 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 27 |
+
</SelectPrimitive.Icon>
|
| 28 |
+
</SelectPrimitive.Trigger>
|
| 29 |
+
));
|
| 30 |
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
| 31 |
+
|
| 32 |
+
const SelectScrollUpButton = React.forwardRef<
|
| 33 |
+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
| 34 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
| 35 |
+
>(({ className, ...props }, ref) => (
|
| 36 |
+
<SelectPrimitive.ScrollUpButton
|
| 37 |
+
ref={ref}
|
| 38 |
+
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
| 39 |
+
{...props}
|
| 40 |
+
>
|
| 41 |
+
<ChevronUp className="h-4 w-4" />
|
| 42 |
+
</SelectPrimitive.ScrollUpButton>
|
| 43 |
+
));
|
| 44 |
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
| 45 |
+
|
| 46 |
+
const SelectScrollDownButton = React.forwardRef<
|
| 47 |
+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
| 48 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
| 49 |
+
>(({ className, ...props }, ref) => (
|
| 50 |
+
<SelectPrimitive.ScrollDownButton
|
| 51 |
+
ref={ref}
|
| 52 |
+
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
| 53 |
+
{...props}
|
| 54 |
+
>
|
| 55 |
+
<ChevronDown className="h-4 w-4" />
|
| 56 |
+
</SelectPrimitive.ScrollDownButton>
|
| 57 |
+
));
|
| 58 |
+
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
| 59 |
+
|
| 60 |
+
const SelectContent = React.forwardRef<
|
| 61 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
| 62 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
| 63 |
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
| 64 |
+
<SelectPrimitive.Portal>
|
| 65 |
+
<SelectPrimitive.Content
|
| 66 |
+
ref={ref}
|
| 67 |
+
className={cn(
|
| 68 |
+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-card text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 69 |
+
position === "popper" &&
|
| 70 |
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
| 71 |
+
className
|
| 72 |
+
)}
|
| 73 |
+
position={position}
|
| 74 |
+
{...props}
|
| 75 |
+
>
|
| 76 |
+
<SelectScrollUpButton />
|
| 77 |
+
<SelectPrimitive.Viewport
|
| 78 |
+
className={cn(
|
| 79 |
+
"p-1",
|
| 80 |
+
position === "popper" &&
|
| 81 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
| 82 |
+
)}
|
| 83 |
+
>
|
| 84 |
+
{children}
|
| 85 |
+
</SelectPrimitive.Viewport>
|
| 86 |
+
<SelectScrollDownButton />
|
| 87 |
+
</SelectPrimitive.Content>
|
| 88 |
+
</SelectPrimitive.Portal>
|
| 89 |
+
));
|
| 90 |
+
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
| 91 |
+
|
| 92 |
+
const SelectLabel = React.forwardRef<
|
| 93 |
+
React.ElementRef<typeof SelectPrimitive.Label>,
|
| 94 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
| 95 |
+
>(({ className, ...props }, ref) => (
|
| 96 |
+
<SelectPrimitive.Label
|
| 97 |
+
ref={ref}
|
| 98 |
+
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
| 99 |
+
{...props}
|
| 100 |
+
/>
|
| 101 |
+
));
|
| 102 |
+
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
| 103 |
+
|
| 104 |
+
const SelectItem = React.forwardRef<
|
| 105 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
| 106 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
| 107 |
+
>(({ className, children, ...props }, ref) => (
|
| 108 |
+
<SelectPrimitive.Item
|
| 109 |
+
ref={ref}
|
| 110 |
+
className={cn(
|
| 111 |
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 112 |
+
className
|
| 113 |
+
)}
|
| 114 |
+
{...props}
|
| 115 |
+
>
|
| 116 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 117 |
+
<SelectPrimitive.ItemIndicator>
|
| 118 |
+
<Check className="h-4 w-4" />
|
| 119 |
+
</SelectPrimitive.ItemIndicator>
|
| 120 |
+
</span>
|
| 121 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 122 |
+
</SelectPrimitive.Item>
|
| 123 |
+
));
|
| 124 |
+
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
| 125 |
+
|
| 126 |
+
const SelectSeparator = React.forwardRef<
|
| 127 |
+
React.ElementRef<typeof SelectPrimitive.Separator>,
|
| 128 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
| 129 |
+
>(({ className, ...props }, ref) => (
|
| 130 |
+
<SelectPrimitive.Separator
|
| 131 |
+
ref={ref}
|
| 132 |
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
| 133 |
+
{...props}
|
| 134 |
+
/>
|
| 135 |
+
));
|
| 136 |
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
| 137 |
+
|
| 138 |
+
export {
|
| 139 |
+
Select,
|
| 140 |
+
SelectContent,
|
| 141 |
+
SelectGroup,
|
| 142 |
+
SelectItem,
|
| 143 |
+
SelectLabel,
|
| 144 |
+
SelectScrollDownButton,
|
| 145 |
+
SelectScrollUpButton,
|
| 146 |
+
SelectSeparator,
|
| 147 |
+
SelectTrigger,
|
| 148 |
+
SelectValue,
|
| 149 |
+
};
|
src/components/ui/tabs.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
const Tabs = TabsPrimitive.Root;
|
| 8 |
+
|
| 9 |
+
const TabsList = React.forwardRef<
|
| 10 |
+
React.ElementRef<typeof TabsPrimitive.List>,
|
| 11 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
| 12 |
+
>(({ className, ...props }, ref) => (
|
| 13 |
+
<TabsPrimitive.List
|
| 14 |
+
ref={ref}
|
| 15 |
+
className={cn(
|
| 16 |
+
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
));
|
| 22 |
+
TabsList.displayName = TabsPrimitive.List.displayName;
|
| 23 |
+
|
| 24 |
+
const TabsTrigger = React.forwardRef<
|
| 25 |
+
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
| 26 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
| 27 |
+
>(({ className, ...props }, ref) => (
|
| 28 |
+
<TabsPrimitive.Trigger
|
| 29 |
+
ref={ref}
|
| 30 |
+
className={cn(
|
| 31 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
| 32 |
+
className
|
| 33 |
+
)}
|
| 34 |
+
{...props}
|
| 35 |
+
/>
|
| 36 |
+
));
|
| 37 |
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
| 38 |
+
|
| 39 |
+
const TabsContent = React.forwardRef<
|
| 40 |
+
React.ElementRef<typeof TabsPrimitive.Content>,
|
| 41 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
| 42 |
+
>(({ className, ...props }, ref) => (
|
| 43 |
+
<TabsPrimitive.Content
|
| 44 |
+
ref={ref}
|
| 45 |
+
className={cn(
|
| 46 |
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
| 47 |
+
className
|
| 48 |
+
)}
|
| 49 |
+
{...props}
|
| 50 |
+
/>
|
| 51 |
+
));
|
| 52 |
+
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
| 53 |
+
|
| 54 |
+
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
src/components/ui/textarea.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
| 5 |
+
|
| 6 |
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
| 7 |
+
({ className, ...props }, ref) => {
|
| 8 |
+
return (
|
| 9 |
+
<textarea
|
| 10 |
+
className={cn(
|
| 11 |
+
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
ref={ref}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
);
|
| 20 |
+
Textarea.displayName = "Textarea";
|
| 21 |
+
|
| 22 |
+
export { Textarea };
|
src/components/ui/toaster.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as ToastPrimitives from "@radix-ui/react-toast";
|
| 5 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 6 |
+
import { X } from "lucide-react";
|
| 7 |
+
import { cn } from "@/lib/utils";
|
| 8 |
+
|
| 9 |
+
const ToastProvider = ToastPrimitives.Provider;
|
| 10 |
+
|
| 11 |
+
const ToastViewport = React.forwardRef<
|
| 12 |
+
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
| 13 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
| 14 |
+
>(({ className, ...props }, ref) => (
|
| 15 |
+
<ToastPrimitives.Viewport
|
| 16 |
+
ref={ref}
|
| 17 |
+
className={cn(
|
| 18 |
+
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
| 19 |
+
className
|
| 20 |
+
)}
|
| 21 |
+
{...props}
|
| 22 |
+
/>
|
| 23 |
+
));
|
| 24 |
+
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
| 25 |
+
|
| 26 |
+
const toastVariants = cva(
|
| 27 |
+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
| 28 |
+
{
|
| 29 |
+
variants: {
|
| 30 |
+
variant: {
|
| 31 |
+
default: "border bg-background text-foreground",
|
| 32 |
+
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
defaultVariants: {
|
| 36 |
+
variant: "default",
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
const Toast = React.forwardRef<
|
| 42 |
+
React.ElementRef<typeof ToastPrimitives.Root>,
|
| 43 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
| 44 |
+
>(({ className, variant, ...props }, ref) => {
|
| 45 |
+
return (
|
| 46 |
+
<ToastPrimitives.Root
|
| 47 |
+
ref={ref}
|
| 48 |
+
className={cn(toastVariants({ variant }), className)}
|
| 49 |
+
{...props}
|
| 50 |
+
/>
|
| 51 |
+
);
|
| 52 |
+
});
|
| 53 |
+
Toast.displayName = ToastPrimitives.Root.displayName;
|
| 54 |
+
|
| 55 |
+
const ToastAction = React.forwardRef<
|
| 56 |
+
React.ElementRef<typeof ToastPrimitives.Action>,
|
| 57 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
| 58 |
+
>(({ className, ...props }, ref) => (
|
| 59 |
+
<ToastPrimitives.Action
|
| 60 |
+
ref={ref}
|
| 61 |
+
className={cn(
|
| 62 |
+
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
| 63 |
+
className
|
| 64 |
+
)}
|
| 65 |
+
{...props}
|
| 66 |
+
/>
|
| 67 |
+
));
|
| 68 |
+
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
| 69 |
+
|
| 70 |
+
const ToastClose = React.forwardRef<
|
| 71 |
+
React.ElementRef<typeof ToastPrimitives.Close>,
|
| 72 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
| 73 |
+
>(({ className, ...props }, ref) => (
|
| 74 |
+
<ToastPrimitives.Close
|
| 75 |
+
ref={ref}
|
| 76 |
+
className={cn(
|
| 77 |
+
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
| 78 |
+
className
|
| 79 |
+
)}
|
| 80 |
+
toast-close=""
|
| 81 |
+
{...props}
|
| 82 |
+
>
|
| 83 |
+
<X className="h-4 w-4" />
|
| 84 |
+
</ToastPrimitives.Close>
|
| 85 |
+
));
|
| 86 |
+
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
| 87 |
+
|
| 88 |
+
const ToastTitle = React.forwardRef<
|
| 89 |
+
React.ElementRef<typeof ToastPrimitives.Title>,
|
| 90 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
| 91 |
+
>(({ className, ...props }, ref) => (
|
| 92 |
+
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
| 93 |
+
));
|
| 94 |
+
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
| 95 |
+
|
| 96 |
+
const ToastDescription = React.forwardRef<
|
| 97 |
+
React.ElementRef<typeof ToastPrimitives.Description>,
|
| 98 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
| 99 |
+
>(({ className, ...props }, ref) => (
|
| 100 |
+
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
| 101 |
+
));
|
| 102 |
+
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
| 103 |
+
|
| 104 |
+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
| 105 |
+
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
| 106 |
+
|
| 107 |
+
interface ToastState {
|
| 108 |
+
toasts: Array<{
|
| 109 |
+
id: string;
|
| 110 |
+
title?: string;
|
| 111 |
+
description?: string;
|
| 112 |
+
action?: ToastActionElement;
|
| 113 |
+
variant?: "default" | "destructive";
|
| 114 |
+
}>;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const toastState: ToastState = { toasts: [] };
|
| 118 |
+
const listeners: Array<() => void> = [];
|
| 119 |
+
|
| 120 |
+
function dispatch() {
|
| 121 |
+
listeners.forEach((listener) => listener());
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export function toast({
|
| 125 |
+
title,
|
| 126 |
+
description,
|
| 127 |
+
variant = "default",
|
| 128 |
+
}: {
|
| 129 |
+
title?: string;
|
| 130 |
+
description?: string;
|
| 131 |
+
variant?: "default" | "destructive";
|
| 132 |
+
}) {
|
| 133 |
+
const id = Math.random().toString(36).substring(2, 9);
|
| 134 |
+
toastState.toasts.push({ id, title, description, variant });
|
| 135 |
+
dispatch();
|
| 136 |
+
setTimeout(() => {
|
| 137 |
+
toastState.toasts = toastState.toasts.filter((t) => t.id !== id);
|
| 138 |
+
dispatch();
|
| 139 |
+
}, 5000);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function useToastState() {
|
| 143 |
+
const [state, setState] = React.useState(toastState);
|
| 144 |
+
React.useEffect(() => {
|
| 145 |
+
const listener = () => setState({ ...toastState });
|
| 146 |
+
listeners.push(listener);
|
| 147 |
+
return () => {
|
| 148 |
+
const index = listeners.indexOf(listener);
|
| 149 |
+
if (index > -1) listeners.splice(index, 1);
|
| 150 |
+
};
|
| 151 |
+
}, []);
|
| 152 |
+
return state;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function Toaster() {
|
| 156 |
+
const state = useToastState();
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
<ToastProvider>
|
| 160 |
+
{state.toasts.map((t) => (
|
| 161 |
+
<Toast key={t.id} variant={t.variant}>
|
| 162 |
+
<div className="grid gap-1">
|
| 163 |
+
{t.title && <ToastTitle>{t.title}</ToastTitle>}
|
| 164 |
+
{t.description && <ToastDescription>{t.description}</ToastDescription>}
|
| 165 |
+
</div>
|
| 166 |
+
<ToastClose />
|
| 167 |
+
</Toast>
|
| 168 |
+
))}
|
| 169 |
+
<ToastViewport />
|
| 170 |
+
</ToastProvider>
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export {
|
| 175 |
+
type ToastProps,
|
| 176 |
+
type ToastActionElement,
|
| 177 |
+
ToastProvider,
|
| 178 |
+
ToastViewport,
|
| 179 |
+
Toast,
|
| 180 |
+
ToastTitle,
|
| 181 |
+
ToastDescription,
|
| 182 |
+
ToastClose,
|
| 183 |
+
ToastAction,
|
| 184 |
+
Toaster,
|
| 185 |
+
};
|
src/lib/adminAuth.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
import type { NextRequest } from "next/server";
|
| 3 |
+
|
| 4 |
+
export const ADMIN_COOKIE_NAME = "teich_admin";
|
| 5 |
+
|
| 6 |
+
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
|
| 7 |
+
|
| 8 |
+
function getAdminPassword() {
|
| 9 |
+
return process.env.ADMIN_PASSWORD || "";
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function sign(payload: string, secret: string) {
|
| 13 |
+
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function createAdminSessionValue(): string {
|
| 17 |
+
const secret = getAdminPassword();
|
| 18 |
+
const ts = Date.now();
|
| 19 |
+
const nonce = crypto.randomBytes(16).toString("hex");
|
| 20 |
+
const payload = `${ts}:${nonce}`;
|
| 21 |
+
const sig = sign(payload, secret);
|
| 22 |
+
return `${payload}.${sig}`;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function isAdminSessionValue(value: string | undefined | null): boolean {
|
| 26 |
+
if (!value) return false;
|
| 27 |
+
const secret = getAdminPassword();
|
| 28 |
+
if (!secret) return false;
|
| 29 |
+
|
| 30 |
+
const lastDot = value.lastIndexOf(".");
|
| 31 |
+
if (lastDot === -1) return false;
|
| 32 |
+
|
| 33 |
+
const payload = value.slice(0, lastDot);
|
| 34 |
+
const sig = value.slice(lastDot + 1);
|
| 35 |
+
|
| 36 |
+
const expected = sign(payload, secret);
|
| 37 |
+
try {
|
| 38 |
+
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false;
|
| 39 |
+
} catch {
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const [tsStr] = payload.split(":");
|
| 44 |
+
const ts = Number(tsStr);
|
| 45 |
+
if (!Number.isFinite(ts)) return false;
|
| 46 |
+
|
| 47 |
+
const ageSeconds = (Date.now() - ts) / 1000;
|
| 48 |
+
if (ageSeconds < 0 || ageSeconds > SESSION_MAX_AGE_SECONDS) return false;
|
| 49 |
+
|
| 50 |
+
return true;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function isAdminRequest(request: NextRequest): boolean {
|
| 54 |
+
const value = request.cookies.get(ADMIN_COOKIE_NAME)?.value;
|
| 55 |
+
return isAdminSessionValue(value);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function adminCookieOptions() {
|
| 59 |
+
return {
|
| 60 |
+
name: ADMIN_COOKIE_NAME,
|
| 61 |
+
httpOnly: true,
|
| 62 |
+
sameSite: "lax" as const,
|
| 63 |
+
secure: process.env.NODE_ENV === "production",
|
| 64 |
+
path: "/",
|
| 65 |
+
maxAge: SESSION_MAX_AGE_SECONDS,
|
| 66 |
+
};
|
| 67 |
+
}
|
src/lib/store.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { v4 as uuidv4 } from "uuid";
|
| 2 |
+
import fs from "fs";
|
| 3 |
+
import path from "path";
|
| 4 |
+
|
| 5 |
+
export interface DistillationRequest {
|
| 6 |
+
id: string;
|
| 7 |
+
sourceDataset: string;
|
| 8 |
+
studentModel: string;
|
| 9 |
+
additionalNotes: string;
|
| 10 |
+
upvotes: number;
|
| 11 |
+
votedIps: string[];
|
| 12 |
+
ownerId: string;
|
| 13 |
+
createdAt: string;
|
| 14 |
+
status: "pending" | "in_progress" | "completed";
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface DatasetRequest {
|
| 18 |
+
id: string;
|
| 19 |
+
sourceModel: string;
|
| 20 |
+
datasetSize: string;
|
| 21 |
+
reasoningDepth: string;
|
| 22 |
+
topics: string[];
|
| 23 |
+
additionalNotes: string;
|
| 24 |
+
upvotes: number;
|
| 25 |
+
votedIps: string[];
|
| 26 |
+
ownerId: string;
|
| 27 |
+
createdAt: string;
|
| 28 |
+
status: "pending" | "in_progress" | "completed";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export type RequestType = "distillation" | "dataset";
|
| 32 |
+
|
| 33 |
+
export interface DiscussionComment {
|
| 34 |
+
id: string;
|
| 35 |
+
body: string;
|
| 36 |
+
author: string;
|
| 37 |
+
role: "admin" | "user";
|
| 38 |
+
ownerId: string;
|
| 39 |
+
createdAt: string;
|
| 40 |
+
editedAt?: string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface DiscussionThread {
|
| 44 |
+
key: string;
|
| 45 |
+
requestType: RequestType;
|
| 46 |
+
requestId: string;
|
| 47 |
+
comments: DiscussionComment[];
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
interface Store {
|
| 51 |
+
distillationRequests: DistillationRequest[];
|
| 52 |
+
datasetRequests: DatasetRequest[];
|
| 53 |
+
threads: Record<string, DiscussionThread>;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const DATA_DIR = process.env.NODE_ENV === "production" ? "/data" : "./data";
|
| 57 |
+
const DATA_FILE = path.join(DATA_DIR, "requests.json");
|
| 58 |
+
|
| 59 |
+
function ensureDataDir() {
|
| 60 |
+
if (!fs.existsSync(DATA_DIR)) {
|
| 61 |
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function loadStore(): Store {
|
| 66 |
+
ensureDataDir();
|
| 67 |
+
try {
|
| 68 |
+
if (fs.existsSync(DATA_FILE)) {
|
| 69 |
+
const data = fs.readFileSync(DATA_FILE, "utf-8");
|
| 70 |
+
const parsed = JSON.parse(data) as Partial<Store>;
|
| 71 |
+
const parsedThreads = (parsed as any).threads;
|
| 72 |
+
const threads: Record<string, DiscussionThread> =
|
| 73 |
+
parsedThreads && typeof parsedThreads === "object" && !Array.isArray(parsedThreads)
|
| 74 |
+
? Object.fromEntries(
|
| 75 |
+
Object.entries(parsedThreads as Record<string, any>).map(([key, t]) => {
|
| 76 |
+
const requestType: RequestType = t?.requestType === "dataset" ? "dataset" : "distillation";
|
| 77 |
+
const thread: DiscussionThread = {
|
| 78 |
+
key: String(t?.key ?? key),
|
| 79 |
+
requestType,
|
| 80 |
+
requestId: String(t?.requestId ?? ""),
|
| 81 |
+
comments: Array.isArray(t?.comments)
|
| 82 |
+
? t.comments.map((c: any) => ({
|
| 83 |
+
id: String(c?.id ?? uuidv4()),
|
| 84 |
+
body: String(c?.body ?? ""),
|
| 85 |
+
author: String(c?.author ?? (c?.role === "user" ? "Anonymous" : "TeichAI")),
|
| 86 |
+
role: c?.role === "user" ? "user" : "admin",
|
| 87 |
+
ownerId: String(c?.ownerId ?? ""),
|
| 88 |
+
createdAt: String(c?.createdAt ?? new Date().toISOString()),
|
| 89 |
+
editedAt: c?.editedAt ? String(c.editedAt) : undefined,
|
| 90 |
+
}))
|
| 91 |
+
: [],
|
| 92 |
+
};
|
| 93 |
+
return [key, thread] as const;
|
| 94 |
+
})
|
| 95 |
+
)
|
| 96 |
+
: {};
|
| 97 |
+
const store: Store = {
|
| 98 |
+
distillationRequests: Array.isArray(parsed.distillationRequests)
|
| 99 |
+
? (parsed.distillationRequests as any[]).map((r) => ({
|
| 100 |
+
id: String(r.id ?? uuidv4()),
|
| 101 |
+
sourceDataset: String(r.sourceDataset ?? r.teacherModel ?? ""),
|
| 102 |
+
studentModel: String(r.studentModel ?? ""),
|
| 103 |
+
additionalNotes: String(r.additionalNotes ?? ""),
|
| 104 |
+
upvotes: typeof r.upvotes === "number" ? r.upvotes : 0,
|
| 105 |
+
votedIps: Array.isArray(r.votedIps) ? r.votedIps.map(String) : [],
|
| 106 |
+
ownerId: String(r.ownerId ?? ""),
|
| 107 |
+
createdAt: String(r.createdAt ?? new Date().toISOString()),
|
| 108 |
+
status: (r.status === "in_progress" || r.status === "completed") ? r.status : "pending",
|
| 109 |
+
}))
|
| 110 |
+
: [],
|
| 111 |
+
datasetRequests: Array.isArray(parsed.datasetRequests)
|
| 112 |
+
? (parsed.datasetRequests as any[]).map((r) => ({
|
| 113 |
+
id: String(r.id ?? uuidv4()),
|
| 114 |
+
sourceModel: String(r.sourceModel ?? ""),
|
| 115 |
+
datasetSize: String(r.datasetSize ?? "250x"),
|
| 116 |
+
reasoningDepth: String(r.reasoningDepth ?? "high"),
|
| 117 |
+
topics: Array.isArray(r.topics) ? r.topics.map(String) : [],
|
| 118 |
+
additionalNotes: String(r.additionalNotes ?? ""),
|
| 119 |
+
upvotes: typeof r.upvotes === "number" ? r.upvotes : 0,
|
| 120 |
+
votedIps: Array.isArray(r.votedIps) ? r.votedIps.map(String) : [],
|
| 121 |
+
ownerId: String(r.ownerId ?? ""),
|
| 122 |
+
createdAt: String(r.createdAt ?? new Date().toISOString()),
|
| 123 |
+
status: (r.status === "in_progress" || r.status === "completed") ? r.status : "pending",
|
| 124 |
+
}))
|
| 125 |
+
: [],
|
| 126 |
+
threads,
|
| 127 |
+
};
|
| 128 |
+
return store;
|
| 129 |
+
}
|
| 130 |
+
} catch (error) {
|
| 131 |
+
console.error("Error loading store:", error);
|
| 132 |
+
}
|
| 133 |
+
return { distillationRequests: [], datasetRequests: [], threads: {} };
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function saveStore(store: Store) {
|
| 137 |
+
ensureDataDir();
|
| 138 |
+
fs.writeFileSync(DATA_FILE, JSON.stringify(store, null, 2));
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export function getDistillationRequests(): DistillationRequest[] {
|
| 142 |
+
const store = loadStore();
|
| 143 |
+
return store.distillationRequests.sort((a, b) => b.upvotes - a.upvotes);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
export function getDatasetRequests(): DatasetRequest[] {
|
| 147 |
+
const store = loadStore();
|
| 148 |
+
return store.datasetRequests.sort((a, b) => b.upvotes - a.upvotes);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
export function getRequest(type: RequestType, id: string): DistillationRequest | DatasetRequest | null {
|
| 152 |
+
const store = loadStore();
|
| 153 |
+
if (type === "distillation") {
|
| 154 |
+
return store.distillationRequests.find((r) => r.id === id) ?? null;
|
| 155 |
+
}
|
| 156 |
+
return store.datasetRequests.find((r) => r.id === id) ?? null;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
export function updateDistillationRequest(
|
| 160 |
+
id: string,
|
| 161 |
+
updates: Partial<Pick<DistillationRequest, "sourceDataset" | "studentModel" | "additionalNotes">>
|
| 162 |
+
): DistillationRequest | null {
|
| 163 |
+
const store = loadStore();
|
| 164 |
+
const request = store.distillationRequests.find((r) => r.id === id);
|
| 165 |
+
if (!request) return null;
|
| 166 |
+
if (typeof updates.sourceDataset === "string") request.sourceDataset = updates.sourceDataset;
|
| 167 |
+
if (typeof updates.studentModel === "string") request.studentModel = updates.studentModel;
|
| 168 |
+
if (typeof updates.additionalNotes === "string") request.additionalNotes = updates.additionalNotes;
|
| 169 |
+
saveStore(store);
|
| 170 |
+
return request;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
export function updateDatasetRequest(
|
| 174 |
+
id: string,
|
| 175 |
+
updates: Partial<Pick<DatasetRequest, "sourceModel" | "datasetSize" | "reasoningDepth" | "topics" | "additionalNotes">>
|
| 176 |
+
): DatasetRequest | null {
|
| 177 |
+
const store = loadStore();
|
| 178 |
+
const request = store.datasetRequests.find((r) => r.id === id);
|
| 179 |
+
if (!request) return null;
|
| 180 |
+
if (typeof updates.sourceModel === "string") request.sourceModel = updates.sourceModel;
|
| 181 |
+
if (typeof updates.datasetSize === "string") request.datasetSize = updates.datasetSize;
|
| 182 |
+
if (typeof updates.reasoningDepth === "string") request.reasoningDepth = updates.reasoningDepth;
|
| 183 |
+
if (Array.isArray(updates.topics)) request.topics = updates.topics.map(String);
|
| 184 |
+
if (typeof updates.additionalNotes === "string") request.additionalNotes = updates.additionalNotes;
|
| 185 |
+
saveStore(store);
|
| 186 |
+
return request;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
export function updateRequestStatus(
|
| 190 |
+
type: RequestType,
|
| 191 |
+
id: string,
|
| 192 |
+
status: "pending" | "in_progress" | "completed"
|
| 193 |
+
): boolean {
|
| 194 |
+
const store = loadStore();
|
| 195 |
+
if (type === "distillation") {
|
| 196 |
+
const request = store.distillationRequests.find((r) => r.id === id);
|
| 197 |
+
if (!request) return false;
|
| 198 |
+
request.status = status;
|
| 199 |
+
saveStore(store);
|
| 200 |
+
return true;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const request = store.datasetRequests.find((r) => r.id === id);
|
| 204 |
+
if (!request) return false;
|
| 205 |
+
request.status = status;
|
| 206 |
+
saveStore(store);
|
| 207 |
+
return true;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
export function deleteRequest(type: RequestType, id: string): boolean {
|
| 211 |
+
const store = loadStore();
|
| 212 |
+
if (type === "distillation") {
|
| 213 |
+
const before = store.distillationRequests.length;
|
| 214 |
+
store.distillationRequests = store.distillationRequests.filter((r) => r.id !== id);
|
| 215 |
+
if (store.distillationRequests.length === before) return false;
|
| 216 |
+
} else {
|
| 217 |
+
const before = store.datasetRequests.length;
|
| 218 |
+
store.datasetRequests = store.datasetRequests.filter((r) => r.id !== id);
|
| 219 |
+
if (store.datasetRequests.length === before) return false;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const key = `${type}:${id}`;
|
| 223 |
+
if (store.threads[key]) {
|
| 224 |
+
delete store.threads[key];
|
| 225 |
+
}
|
| 226 |
+
saveStore(store);
|
| 227 |
+
return true;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
export function getThread(type: RequestType, id: string): DiscussionThread {
|
| 231 |
+
const store = loadStore();
|
| 232 |
+
const key = `${type}:${id}`;
|
| 233 |
+
const existing = store.threads[key];
|
| 234 |
+
if (existing && existing.requestType === type && existing.requestId === id) {
|
| 235 |
+
return existing;
|
| 236 |
+
}
|
| 237 |
+
const thread: DiscussionThread = {
|
| 238 |
+
key,
|
| 239 |
+
requestType: type,
|
| 240 |
+
requestId: id,
|
| 241 |
+
comments: [],
|
| 242 |
+
};
|
| 243 |
+
store.threads[key] = thread;
|
| 244 |
+
saveStore(store);
|
| 245 |
+
return thread;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
export function addAdminComment(type: RequestType, id: string, body: string, ownerId: string): DiscussionComment {
|
| 249 |
+
const store = loadStore();
|
| 250 |
+
const key = `${type}:${id}`;
|
| 251 |
+
const thread = store.threads[key] ?? {
|
| 252 |
+
key,
|
| 253 |
+
requestType: type,
|
| 254 |
+
requestId: id,
|
| 255 |
+
comments: [],
|
| 256 |
+
};
|
| 257 |
+
const comment: DiscussionComment = {
|
| 258 |
+
id: uuidv4(),
|
| 259 |
+
body,
|
| 260 |
+
author: "TeichAI",
|
| 261 |
+
role: "admin",
|
| 262 |
+
ownerId,
|
| 263 |
+
createdAt: new Date().toISOString(),
|
| 264 |
+
};
|
| 265 |
+
thread.comments.push(comment);
|
| 266 |
+
store.threads[key] = thread;
|
| 267 |
+
saveStore(store);
|
| 268 |
+
return comment;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
export function addUserComment(
|
| 272 |
+
type: RequestType,
|
| 273 |
+
id: string,
|
| 274 |
+
body: string,
|
| 275 |
+
author: string | undefined,
|
| 276 |
+
ownerId: string
|
| 277 |
+
): DiscussionComment {
|
| 278 |
+
const store = loadStore();
|
| 279 |
+
const key = `${type}:${id}`;
|
| 280 |
+
const thread = store.threads[key] ?? {
|
| 281 |
+
key,
|
| 282 |
+
requestType: type,
|
| 283 |
+
requestId: id,
|
| 284 |
+
comments: [],
|
| 285 |
+
};
|
| 286 |
+
const comment: DiscussionComment = {
|
| 287 |
+
id: uuidv4(),
|
| 288 |
+
body,
|
| 289 |
+
author: author?.trim() ? author.trim() : "Anonymous",
|
| 290 |
+
role: "user",
|
| 291 |
+
ownerId,
|
| 292 |
+
createdAt: new Date().toISOString(),
|
| 293 |
+
};
|
| 294 |
+
thread.comments.push(comment);
|
| 295 |
+
store.threads[key] = thread;
|
| 296 |
+
saveStore(store);
|
| 297 |
+
return comment;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
export function updateComment(
|
| 301 |
+
type: RequestType,
|
| 302 |
+
requestId: string,
|
| 303 |
+
commentId: string,
|
| 304 |
+
body: string
|
| 305 |
+
): DiscussionComment | null {
|
| 306 |
+
const store = loadStore();
|
| 307 |
+
const key = `${type}:${requestId}`;
|
| 308 |
+
const thread = store.threads[key];
|
| 309 |
+
if (!thread) return null;
|
| 310 |
+
const comment = thread.comments.find((c) => c.id === commentId);
|
| 311 |
+
if (!comment) return null;
|
| 312 |
+
comment.body = body;
|
| 313 |
+
comment.editedAt = new Date().toISOString();
|
| 314 |
+
saveStore(store);
|
| 315 |
+
return comment;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
export function deleteComment(
|
| 319 |
+
type: RequestType,
|
| 320 |
+
requestId: string,
|
| 321 |
+
commentId: string
|
| 322 |
+
): boolean {
|
| 323 |
+
const store = loadStore();
|
| 324 |
+
const key = `${type}:${requestId}`;
|
| 325 |
+
const thread = store.threads[key];
|
| 326 |
+
if (!thread) return false;
|
| 327 |
+
const before = thread.comments.length;
|
| 328 |
+
thread.comments = thread.comments.filter((c) => c.id !== commentId);
|
| 329 |
+
if (thread.comments.length === before) return false;
|
| 330 |
+
store.threads[key] = thread;
|
| 331 |
+
saveStore(store);
|
| 332 |
+
return true;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
export function addDistillationRequest(
|
| 336 |
+
request: Omit<DistillationRequest, "id" | "upvotes" | "votedIps" | "createdAt" | "status">
|
| 337 |
+
): DistillationRequest {
|
| 338 |
+
const store = loadStore();
|
| 339 |
+
const newRequest: DistillationRequest = {
|
| 340 |
+
...request,
|
| 341 |
+
id: uuidv4(),
|
| 342 |
+
upvotes: 0,
|
| 343 |
+
votedIps: [],
|
| 344 |
+
createdAt: new Date().toISOString(),
|
| 345 |
+
status: "pending",
|
| 346 |
+
};
|
| 347 |
+
store.distillationRequests.push(newRequest);
|
| 348 |
+
saveStore(store);
|
| 349 |
+
return newRequest;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
export function addDatasetRequest(
|
| 353 |
+
request: Omit<DatasetRequest, "id" | "upvotes" | "votedIps" | "createdAt" | "status">
|
| 354 |
+
): DatasetRequest {
|
| 355 |
+
const store = loadStore();
|
| 356 |
+
const newRequest: DatasetRequest = {
|
| 357 |
+
...request,
|
| 358 |
+
id: uuidv4(),
|
| 359 |
+
upvotes: 0,
|
| 360 |
+
votedIps: [],
|
| 361 |
+
createdAt: new Date().toISOString(),
|
| 362 |
+
status: "pending",
|
| 363 |
+
};
|
| 364 |
+
store.datasetRequests.push(newRequest);
|
| 365 |
+
saveStore(store);
|
| 366 |
+
return newRequest;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
export function upvoteDistillation(
|
| 370 |
+
id: string,
|
| 371 |
+
ip: string
|
| 372 |
+
): { success: boolean; upvotes: number; action?: "upvoted" | "unvoted" } {
|
| 373 |
+
const store = loadStore();
|
| 374 |
+
const request = store.distillationRequests.find((r) => r.id === id);
|
| 375 |
+
if (!request) {
|
| 376 |
+
return { success: false, upvotes: 0 };
|
| 377 |
+
}
|
| 378 |
+
if (request.votedIps.includes(ip)) {
|
| 379 |
+
request.votedIps = request.votedIps.filter((v) => v !== ip);
|
| 380 |
+
request.upvotes = Math.max(0, request.upvotes - 1);
|
| 381 |
+
saveStore(store);
|
| 382 |
+
return { success: true, upvotes: request.upvotes, action: "unvoted" };
|
| 383 |
+
}
|
| 384 |
+
request.upvotes += 1;
|
| 385 |
+
request.votedIps.push(ip);
|
| 386 |
+
saveStore(store);
|
| 387 |
+
return { success: true, upvotes: request.upvotes, action: "upvoted" };
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
export function upvoteDataset(
|
| 391 |
+
id: string,
|
| 392 |
+
ip: string
|
| 393 |
+
): { success: boolean; upvotes: number; action?: "upvoted" | "unvoted" } {
|
| 394 |
+
const store = loadStore();
|
| 395 |
+
const request = store.datasetRequests.find((r) => r.id === id);
|
| 396 |
+
if (!request) {
|
| 397 |
+
return { success: false, upvotes: 0 };
|
| 398 |
+
}
|
| 399 |
+
if (request.votedIps.includes(ip)) {
|
| 400 |
+
request.votedIps = request.votedIps.filter((v) => v !== ip);
|
| 401 |
+
request.upvotes = Math.max(0, request.upvotes - 1);
|
| 402 |
+
saveStore(store);
|
| 403 |
+
return { success: true, upvotes: request.upvotes, action: "unvoted" };
|
| 404 |
+
}
|
| 405 |
+
request.upvotes += 1;
|
| 406 |
+
request.votedIps.push(ip);
|
| 407 |
+
saveStore(store);
|
| 408 |
+
return { success: true, upvotes: request.upvotes, action: "upvoted" };
|
| 409 |
+
}
|
src/lib/userIdentity.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
import type { NextRequest } from "next/server";
|
| 3 |
+
|
| 4 |
+
export const USER_COOKIE_NAME = "teich_uid";
|
| 5 |
+
|
| 6 |
+
const USER_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
| 7 |
+
|
| 8 |
+
export function getOrCreateUserId(request: NextRequest): {
|
| 9 |
+
userId: string;
|
| 10 |
+
shouldSetCookie: boolean;
|
| 11 |
+
} {
|
| 12 |
+
const existing = request.cookies.get(USER_COOKIE_NAME)?.value;
|
| 13 |
+
if (existing) {
|
| 14 |
+
return { userId: existing, shouldSetCookie: false };
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const userId = crypto.randomUUID();
|
| 18 |
+
return { userId, shouldSetCookie: true };
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function userCookieOptions() {
|
| 22 |
+
return {
|
| 23 |
+
name: USER_COOKIE_NAME,
|
| 24 |
+
httpOnly: true,
|
| 25 |
+
sameSite: "lax" as const,
|
| 26 |
+
secure: process.env.NODE_ENV === "production",
|
| 27 |
+
path: "/",
|
| 28 |
+
maxAge: USER_COOKIE_MAX_AGE_SECONDS,
|
| 29 |
+
};
|
| 30 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ClassValue, clsx } from "clsx";
|
| 2 |
+
import { twMerge } from "tailwind-merge";
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
tailwind.config.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
darkMode: "class",
|
| 5 |
+
content: [
|
| 6 |
+
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
| 7 |
+
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
| 8 |
+
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
| 9 |
+
],
|
| 10 |
+
theme: {
|
| 11 |
+
extend: {
|
| 12 |
+
colors: {
|
| 13 |
+
background: "var(--background)",
|
| 14 |
+
foreground: "var(--foreground)",
|
| 15 |
+
card: "var(--card)",
|
| 16 |
+
"card-foreground": "var(--card-foreground)",
|
| 17 |
+
popover: "var(--card)",
|
| 18 |
+
"popover-foreground": "var(--card-foreground)",
|
| 19 |
+
primary: "var(--accent)",
|
| 20 |
+
"primary-foreground": "#ffffff",
|
| 21 |
+
secondary: "var(--muted)",
|
| 22 |
+
"secondary-foreground": "var(--foreground)",
|
| 23 |
+
muted: "var(--muted)",
|
| 24 |
+
"muted-foreground": "var(--muted-foreground)",
|
| 25 |
+
accent: "var(--accent-light)",
|
| 26 |
+
"accent-foreground": "var(--accent)",
|
| 27 |
+
destructive: "#ef4444",
|
| 28 |
+
"destructive-foreground": "#ffffff",
|
| 29 |
+
border: "var(--border)",
|
| 30 |
+
input: "var(--border)",
|
| 31 |
+
ring: "var(--accent)",
|
| 32 |
+
},
|
| 33 |
+
borderRadius: {
|
| 34 |
+
lg: "0.75rem",
|
| 35 |
+
md: "0.5rem",
|
| 36 |
+
sm: "0.25rem",
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
},
|
| 40 |
+
plugins: [],
|
| 41 |
+
} satisfies Config;
|
tsconfig.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"lib": [
|
| 4 |
+
"dom",
|
| 5 |
+
"dom.iterable",
|
| 6 |
+
"esnext"
|
| 7 |
+
],
|
| 8 |
+
"allowJs": true,
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
"strict": true,
|
| 11 |
+
"noEmit": true,
|
| 12 |
+
"esModuleInterop": true,
|
| 13 |
+
"module": "esnext",
|
| 14 |
+
"moduleResolution": "bundler",
|
| 15 |
+
"resolveJsonModule": true,
|
| 16 |
+
"isolatedModules": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
"incremental": true,
|
| 19 |
+
"plugins": [
|
| 20 |
+
{
|
| 21 |
+
"name": "next"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"paths": {
|
| 25 |
+
"@/*": [
|
| 26 |
+
"./src/*"
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
"target": "ES2017"
|
| 30 |
+
},
|
| 31 |
+
"include": [
|
| 32 |
+
"next-env.d.ts",
|
| 33 |
+
"**/*.ts",
|
| 34 |
+
"**/*.tsx",
|
| 35 |
+
".next/types/**/*.ts",
|
| 36 |
+
".next/dev/types/**/*.ts"
|
| 37 |
+
],
|
| 38 |
+
"exclude": [
|
| 39 |
+
"node_modules"
|
| 40 |
+
]
|
| 41 |
+
}
|