armand0e commited on
Commit
101ebaa
·
0 Parent(s):
Files changed (49) hide show
  1. .dockerignore +8 -0
  2. .gitattributes +35 -0
  3. .gitignore +35 -0
  4. Dockerfile +55 -0
  5. README copy.md +48 -0
  6. README.md +11 -0
  7. next.config.ts +7 -0
  8. package.json +42 -0
  9. postcss.config.mjs +7 -0
  10. public/.gitkeep +0 -0
  11. public/teich.svg +11 -0
  12. src/app/admin/page.tsx +391 -0
  13. src/app/api/admin/login/route.ts +28 -0
  14. src/app/api/admin/logout/route.ts +13 -0
  15. src/app/api/admin/me/route.ts +6 -0
  16. src/app/api/admin/requests/[type]/[id]/comments/route.ts +43 -0
  17. src/app/api/admin/requests/[type]/[id]/route.ts +63 -0
  18. src/app/api/dataset/route.ts +83 -0
  19. src/app/api/distillation/route.ts +81 -0
  20. src/app/api/openrouter-models/route.ts +52 -0
  21. src/app/api/requests/[type]/[id]/comments/[commentId]/route.ts +106 -0
  22. src/app/api/requests/[type]/[id]/comments/route.ts +46 -0
  23. src/app/api/requests/[type]/[id]/route.ts +156 -0
  24. src/app/api/teichai-datasets/route.ts +52 -0
  25. src/app/globals.css +73 -0
  26. src/app/layout.tsx +26 -0
  27. src/app/page.tsx +741 -0
  28. src/app/requests/[type]/[id]/page.tsx +650 -0
  29. src/components/Footer.tsx +45 -0
  30. src/components/Navbar.tsx +129 -0
  31. src/components/Providers.tsx +13 -0
  32. src/components/ThemeProvider.tsx +45 -0
  33. src/components/ui/badge.tsx +32 -0
  34. src/components/ui/button.tsx +48 -0
  35. src/components/ui/card.tsx +55 -0
  36. src/components/ui/combobox.tsx +108 -0
  37. src/components/ui/input.tsx +23 -0
  38. src/components/ui/label.tsx +21 -0
  39. src/components/ui/popover.tsx +29 -0
  40. src/components/ui/select.tsx +149 -0
  41. src/components/ui/tabs.tsx +54 -0
  42. src/components/ui/textarea.tsx +22 -0
  43. src/components/ui/toaster.tsx +185 -0
  44. src/lib/adminAuth.ts +67 -0
  45. src/lib/store.ts +409 -0
  46. src/lib/userIdentity.ts +30 -0
  47. src/lib/utils.ts +6 -0
  48. tailwind.config.ts +41 -0
  49. 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
+ &copy; {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
+ }