FauziIsyrinApridal commited on
Commit
17fabdd
·
1 Parent(s): f51ddc8
This view is limited to 50 files because it contains too many changes.   See raw diff
.env.local.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ NEXT_PUBLIC_SUPABASE_URL=
2
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=
3
+ NEXT_PUBLIC_SUPABASE_SERVICE_KEY=
4
+ NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET=
5
+ CRON_SECRET=
.env.production ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ NEXT_PUBLIC_SUPABASE_URL=https://dohsfmrbydlxkobwhblt.supabase.co
2
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRvaHNmbXJieWRseGtvYndoYmx0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDM3NDAsImV4cCI6MjA1NjgxOTc0MH0.wtHPnFJuhgGQHl6wLzx8ztTHpmn1OwFMUYOUBaE06ls
3
+ NEXT_PUBLIC_SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRvaHNmbXJieWRseGtvYndoYmx0Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0Mzc0MCwiZXhwIjoyMDU2ODE5NzQwfQ.oa881BvgxIJcffbEw_N00LIc9-Oj30IBsD-3y2AqIjM
4
+ NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET=pnp-bot-storage
5
+ CRON_SECRET="fLXB3uzAfSgjrMxg3NxsprqYuPk63+F4ZlUZXlv2fE="
.eslintrc.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "extends": "next/core-web-vitals"
3
+ }
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ .env.local
5
+ .env.development.local
6
+ .env.test.local
7
+ .env.production.local
8
+ node_modules
.prettierrc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "plugins": ["prettier-plugin-tailwindcss"]
3
+ }
Dockerfile ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gunakan Python base image dengan Debian
2
+ FROM python:3.11-slim AS base
3
+
4
+ # Install curl dan Node.js 18 (versi LTS yang stabil)
5
+ RUN apt-get update && \
6
+ apt-get install -y curl gnupg build-essential && \
7
+ curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
8
+ apt-get install -y nodejs && \
9
+ apt-get clean && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Set working directory
12
+ WORKDIR /app
13
+
14
+ # Copy dan install dependencies Node.js
15
+ COPY package*.json ./
16
+ RUN npm install
17
+
18
+ # Copy semua file project ke container
19
+ COPY . .
20
+
21
+ # Install dependencies Python
22
+ COPY requirements.txt .
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
+
25
+ # Build Next.js app
26
+ RUN npm run build
27
+
28
+ # Expose port yang digunakan Next.js
29
+ EXPOSE 7860
30
+
31
+ # Jalankan Next.js app (mode production)
32
+ CMD ["npm", "start"]
33
+
34
+
35
+
36
+ # Dockerfile Lama
37
+ # Node image
38
+ # FROM node:22.2-alpine
39
+
40
+ # # Set workdir
41
+ # WORKDIR /app
42
+ # COPY package*.json ./
43
+
44
+ # # Install dependencies
45
+ # RUN npm install
46
+ # COPY . .
47
+
48
+ # # Build app
49
+ # RUN npm run build
50
+
51
+ # EXPOSE 3000
52
+
53
+ # # Start app
54
+ # CMD ["npm", "start"]
README.md CHANGED
@@ -10,3 +10,37 @@ short_description: Admin Chatbot PNP
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
13
+
14
+ ### Chatbot Admin App
15
+
16
+ #### Technologies
17
+
18
+ - Javascript/Typescript
19
+ - Next Js
20
+ - React
21
+ - Supabase (Database and Authentication)
22
+ - shadcn/ui
23
+
24
+ #### Install dependencies
25
+
26
+ ```bash
27
+ npm install
28
+ ```
29
+
30
+ #### Setup env
31
+
32
+ Create env using env.example file
33
+
34
+ ```bash
35
+ cp .env.local.example .env.local
36
+ ```
37
+
38
+ Fill the variable with your own API keys from https://supabase.com/
39
+
40
+ #### Running
41
+
42
+ ```bash
43
+ npm run dev
44
+ ```
45
+
46
+ #
app/(auth)/layout.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import "../globals.css";
2
+
3
+ export default function AuthLayout({
4
+ children,
5
+ }: Readonly<{
6
+ children: React.ReactNode;
7
+ }>) {
8
+ return <div className="mx-auto w-full">{children}</div>;
9
+ }
app/(auth)/login/page.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import SignInForm from "@/components/SignInForm";
2
+ import Image from "next/image";
3
+ import PnpLogo from "../../../public/logo-pnp.webp";
4
+ import { redirect } from "next/navigation";
5
+ import { createClient } from "@/utils/supabase/server";
6
+
7
+ export default async function Login() {
8
+ const supabase = createClient();
9
+
10
+ const {
11
+ data: { session },
12
+ } = await supabase.auth.getSession();
13
+
14
+ if (session) {
15
+ return redirect("/");
16
+ }
17
+ return (
18
+ <div className="flex min-h-screen flex-col items-center justify-center">
19
+ <div className="w-10/12 rounded-md border p-5 text-center shadow-md sm:w-8/12 md:w-6/12 lg:w-4/12 xl:w-3/12 2xl:w-3/12">
20
+ <div className="flex justify-center py-5">
21
+ <Image
22
+ src={PnpLogo}
23
+ width={150}
24
+ height={150}
25
+ alt="Logo Politeknik Negeri Padang"
26
+ />
27
+ </div>
28
+ <h1 className="pb-3 text-xl font-semibold">Login</h1>
29
+ <p className="mb-5 text-sm">
30
+ Selamat Datang di Situs Manajemen Data Chatbot Politeknik Negeri
31
+ Padang
32
+ </p>
33
+ <SignInForm />
34
+ </div>
35
+ </div>
36
+ );
37
+ }
app/(main)/components/ExampleDoc.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FileTable.tsx
2
+ "use client";
3
+ import { Button } from "@/components/ui/button";
4
+ import { createClient } from "@/utils/supabase/client";
5
+ import { FileCheck } from "lucide-react";
6
+
7
+ export default function ExampleDoc() {
8
+ const supabase = createClient();
9
+
10
+ const inspectItem = () => {
11
+ const { data } = supabase.storage
12
+ .from("example")
13
+ .getPublicUrl("example.pdf");
14
+ window.open(`${data.publicUrl}`, "_blank");
15
+ };
16
+
17
+ return (
18
+ <Button onClick={inspectItem} asChild>
19
+ <div className="group inline-flex rounded-lg border border-slate-400 bg-slate-100 px-5 py-3 shadow-sm hover:bg-slate-200">
20
+ <div className="flex gap-2">
21
+ <FileCheck
22
+ size={20}
23
+ className="text-red-500 group-hover:text-red-700"
24
+ />
25
+ <h1 className="text-sm text-slate-600">Doc Example V1</h1>
26
+ </div>
27
+ </div>
28
+ </Button>
29
+ );
30
+ }
app/(main)/components/RagDashboard.tsx ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import FileTable from "../../../components/FileTable";
4
+ import SemesterFilter from "../../../components/SemesterFilter";
5
+ import { createClient } from "@/utils/supabase/client";
6
+ import Link from "next/link";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuTrigger,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ } from "@radix-ui/react-dropdown-menu";
14
+ import { ChevronDown, RefreshCw } from "lucide-react";
15
+ import { toast } from "@/hooks/use-toast";
16
+
17
+ interface FileData {
18
+ name: string;
19
+ id: string;
20
+ created_at: string;
21
+ updated_at: string;
22
+ last_accessed_at: string;
23
+ metadata: {
24
+ eTag: string;
25
+ size: number;
26
+ mimetype: string;
27
+ cacheControl: string;
28
+ lastModified: string;
29
+ contentLength: number;
30
+ httpStatusCode: number;
31
+ };
32
+ }
33
+
34
+ export default function RagDashboard() {
35
+ const supabase = createClient();
36
+ const [ragData, setRagData] = useState<FileData[]>([]);
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const [isRefreshing, setIsRefreshing] = useState(false);
39
+ const [semesterFilter, setSemesterFilter] = useState("all");
40
+ const [showingFilteredResults, setShowingFilteredResults] = useState(false);
41
+
42
+ useEffect(() => {
43
+ fetchData();
44
+ }, []);
45
+
46
+ const fetchData = async () => {
47
+ setIsRefreshing(true);
48
+ try {
49
+ const res = await supabase.storage.from("pnp-bot-storage").list("", {
50
+ limit: 1000,
51
+ offset: 0,
52
+ sortBy: { column: "name", order: "asc" },
53
+ });
54
+
55
+ if (res.data) {
56
+ setRagData(
57
+ res.data.map((file: any) => ({
58
+ ...file,
59
+ metadata: {
60
+ eTag: file.metadata?.eTag ?? "",
61
+ size: file.metadata?.size ?? 0,
62
+ mimetype: file.metadata?.mimetype ?? "",
63
+ cacheControl: file.metadata?.cacheControl ?? "",
64
+ lastModified: file.metadata?.lastModified ?? "",
65
+ contentLength: file.metadata?.contentLength ?? 0,
66
+ httpStatusCode: file.metadata?.httpStatusCode ?? 0,
67
+ },
68
+ })),
69
+ );
70
+ } else if (res.error) {
71
+ throw new Error(res.error.message);
72
+ }
73
+ } catch (error) {
74
+ console.error("Error fetching data:", error);
75
+ toast({
76
+ title: "Error",
77
+ description: "Failed to load data. Please try again.",
78
+ variant: "destructive",
79
+ });
80
+ } finally {
81
+ setIsRefreshing(false);
82
+ }
83
+ };
84
+
85
+ const handleSemesterFilterChange = (semesterId: string) => {
86
+ setSemesterFilter(semesterId);
87
+ setShowingFilteredResults(semesterId !== "all");
88
+ };
89
+
90
+ const isInSemester = (date: string, semesterId: string) => {
91
+ if (semesterId === "all") return true;
92
+
93
+ const [type, academicYear] = semesterId.split("-");
94
+ const [startYear, endYear] = academicYear.split("/");
95
+ const docDate = new Date(date);
96
+ const docMonth = docDate.getMonth() + 1;
97
+ const docYear = docDate.getFullYear();
98
+
99
+ if (type === "odd") {
100
+ return (
101
+ (docYear === parseInt(startYear) && docMonth >= 9 && docMonth <= 12) ||
102
+ (docYear === parseInt(endYear) && docMonth === 1)
103
+ );
104
+ } else {
105
+ return docYear === parseInt(endYear) && docMonth >= 2 && docMonth <= 8;
106
+ }
107
+ };
108
+
109
+ const filteredData =
110
+ semesterFilter === "all"
111
+ ? ragData
112
+ : ragData.filter((file) => isInSemester(file.created_at, semesterFilter));
113
+
114
+ const handleStartScraping = async (type = "all") => {
115
+ setIsLoading(true);
116
+ try {
117
+ const response = await fetch("/api/start-scraping", {
118
+ method: "POST",
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ },
122
+ body: JSON.stringify({ type }),
123
+ });
124
+
125
+ if (!response.ok) {
126
+ const errorData = await response.json();
127
+ throw new Error(errorData.error || "Scraping failed");
128
+ }
129
+
130
+ const result = await response.json();
131
+
132
+ toast({
133
+ title: "Success",
134
+ description: result.message,
135
+ variant: "default",
136
+ duration: 2000,
137
+ });
138
+
139
+ setTimeout(fetchData, 5000);
140
+ } catch (error) {
141
+ console.error("Scraping error:", error);
142
+ let errorMessage = "Failed to run scraping";
143
+ if (error instanceof Error) {
144
+ errorMessage = error.message.includes("timeout")
145
+ ? "Scraping took too long to complete"
146
+ : error.message;
147
+ }
148
+
149
+ toast({
150
+ title: "Error",
151
+ description: errorMessage,
152
+ variant: "destructive",
153
+ });
154
+ } finally {
155
+ setIsLoading(false);
156
+ }
157
+ };
158
+
159
+ const scraperTypeNames = {
160
+ all: "All Data",
161
+ dosen: "Data Dosen",
162
+ jadwal: "Data Jadwal",
163
+ jurusan: "Data Jurusan",
164
+ pnp: "Data PNP",
165
+ };
166
+
167
+ return (
168
+ <div className="container mx-auto px-4 py-8">
169
+ <div className="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
170
+ <div>
171
+ <h1 className="text-2xl font-bold">Documents</h1>
172
+ <p className="text-muted-foreground">
173
+ {ragData.length} documents available
174
+ {showingFilteredResults && ` (${filteredData.length} filtered)`}
175
+ </p>
176
+ </div>
177
+
178
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
179
+ <Button
180
+ variant="outline"
181
+ size="sm"
182
+ onClick={fetchData}
183
+ disabled={isRefreshing}
184
+ className="flex items-center gap-2"
185
+ >
186
+ <RefreshCw
187
+ className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
188
+ />
189
+ {isRefreshing ? "Refreshing..." : "Refresh"}
190
+ </Button>
191
+
192
+ <SemesterFilter onFilterChange={handleSemesterFilterChange} />
193
+
194
+ <DropdownMenu>
195
+ <DropdownMenuTrigger asChild>
196
+ <Button
197
+ disabled={isLoading}
198
+ className="flex gap-2 bg-orange-600 hover:bg-orange-800 sm:me-2"
199
+ >
200
+ {isLoading ? "Scraping..." : "Start Scraping"}
201
+ <ChevronDown className="h-4 w-4" />
202
+ </Button>
203
+ </DropdownMenuTrigger>
204
+
205
+ <DropdownMenuContent
206
+ align="end"
207
+ className="z-50 min-w-[160px] rounded-md border bg-popover shadow-lg"
208
+ >
209
+ {Object.entries(scraperTypeNames).map(([type, name]) => (
210
+ <DropdownMenuItem
211
+ key={type}
212
+ className="cursor-pointer px-4 py-2 text-sm hover:bg-accent"
213
+ onSelect={() => handleStartScraping(type)}
214
+ >
215
+ {name}
216
+ </DropdownMenuItem>
217
+ ))}
218
+ </DropdownMenuContent>
219
+ </DropdownMenu>
220
+
221
+ <Button asChild className="bg-orange-600 hover:bg-orange-800">
222
+ <Link href={"/upload-document"}>Upload Document</Link>
223
+ </Button>
224
+ </div>
225
+ </div>
226
+
227
+ <div className="rounded-lg border">
228
+ {filteredData.length === 0 && showingFilteredResults ? (
229
+ <div className="p-8 text-center">
230
+ <p className="text-muted-foreground">
231
+ No documents match your filter.
232
+ </p>
233
+ <button
234
+ onClick={() => handleSemesterFilterChange("all")}
235
+ className="mt-2 text-orange-600 hover:underline"
236
+ >
237
+ Show all documents
238
+ </button>
239
+ </div>
240
+ ) : filteredData.length === 0 && !isRefreshing ? (
241
+ <div className="p-8 text-center">
242
+ <p className="text-muted-foreground">No documents available.</p>
243
+ <p className="mt-2 text-sm text-muted-foreground">
244
+ Please upload documents or run scraping.
245
+ </p>
246
+ </div>
247
+ ) : (
248
+ <FileTable fetchData={fetchData} ragData={filteredData} />
249
+ )}
250
+ </div>
251
+ </div>
252
+ );
253
+ }
app/(main)/layout.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Footer } from "@/components/Footer";
2
+ import "../globals.css";
3
+ import { Navbar } from "@/components/Navbar";
4
+ import { Toaster } from "@/components/ui/toaster";
5
+
6
+ export default function HomeLayout({
7
+ children,
8
+ }: Readonly<{
9
+ children: React.ReactNode;
10
+ }>) {
11
+ return (
12
+ <div className="mx-auto w-full">
13
+ <Navbar />
14
+ <div className="mx-auto min-h-screen w-10/12 px-2">{children}</div>
15
+ <Toaster />
16
+ <Footer />
17
+ </div>
18
+ );
19
+ }
app/(main)/page.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import RagDashboard from "@/app/(main)/components/RagDashboard";
2
+ import { createClient } from "@/utils/supabase/server";
3
+ import { revalidatePath } from "next/cache";
4
+ import { redirect } from "next/navigation";
5
+
6
+ export default async function Home() {
7
+ const supabase = createClient();
8
+
9
+ const {
10
+ data: { session },
11
+ } = await supabase.auth.getSession();
12
+
13
+ if (!session) {
14
+ revalidatePath("/login", "page");
15
+ redirect("/login");
16
+ }
17
+
18
+ return (
19
+ <div>
20
+ <RagDashboard />
21
+ </div>
22
+ );
23
+ }
app/(main)/upload-document/page.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { UploadForm } from "@/components/UploadForm";
2
+ import { createClient } from "@/utils/supabase/server";
3
+ import { revalidatePath } from "next/cache";
4
+ import { redirect } from "next/navigation";
5
+ import ExampleDoc from "../components/ExampleDoc";
6
+
7
+ export default async function UploadDocument() {
8
+ const supabase = createClient();
9
+
10
+ const {
11
+ data: { session },
12
+ } = await supabase.auth.getSession();
13
+
14
+ if (!session) {
15
+ revalidatePath("/login", "page");
16
+ redirect("/login");
17
+ }
18
+
19
+ return (
20
+ <div className="mx-auto w-full md:w-3/4">
21
+ <h1 className="font-semibold">Upload Document</h1>
22
+ <div className="my-5 flex w-full rounded-md border border-slate-200 bg-slate-50 p-5">
23
+ <UploadForm />
24
+ </div>
25
+ <div className="mx-2">
26
+ <h1 className="font-semibold underline underline-offset-4">
27
+ Document Structure Guide
28
+ </h1>
29
+ <ol className="mt-5 list-decimal space-y-2 px-5">
30
+ <li>
31
+ Title And Metadata
32
+ <ul className="list-disc px-5">
33
+ <li>
34
+ Each document must start with a clear and concise title that
35
+ reflects the content of the document.
36
+ </li>
37
+ <li>
38
+ Include metadata such as author, date of creation, and keywords
39
+ for easier retrieval.{" "}
40
+ <span className="text-green-400">(optional)</span>
41
+ </li>
42
+ </ul>
43
+ </li>
44
+ <li>
45
+ Content Organization
46
+ <ul className="list-disc px-5">
47
+ <li>
48
+ The document should be organized into distinct sections, each
49
+ focusing on a single context or topic.
50
+ </li>
51
+ <li>
52
+ Each section must contain exactly one paragraph per context.
53
+ </li>
54
+ </ul>
55
+ </li>
56
+ <li>
57
+ Paragraph Formatting
58
+ <ul className="list-disc px-5">
59
+ <li>
60
+ Each paragraph should be concise, ideally between 50 to 150
61
+ words.
62
+ </li>
63
+ <li>
64
+ Paragraphs should be self-contained and contextually complete,
65
+ providing sufficient information on the topic without relying on
66
+ external references.
67
+ </li>
68
+ <li>
69
+ There must be one blank line between each paragraph to clearly
70
+ delineate contexts.
71
+ </li>
72
+ </ul>
73
+ </li>
74
+ <li>
75
+ Language and Style
76
+ <ul className="list-disc px-5">
77
+ <li>
78
+ Use clear and unambiguous language suitable for the intended
79
+ audience.
80
+ </li>
81
+ <li>
82
+ Avoid jargon unless it is defined within the paragraph or
83
+ commonly understood by the target audience.
84
+ </li>
85
+ <li>
86
+ Ensure proper grammar, punctuation, and spelling throughout the
87
+ document.
88
+ </li>
89
+ </ul>
90
+ </li>
91
+ <li>
92
+ Content Requirements
93
+ <ul className="list-disc px-5">
94
+ <li>
95
+ Information must be accurate, up-to-date, and relevant to the
96
+ topic.
97
+ </li>
98
+ <li>
99
+ Each paragraph should aim to answer a specific question or
100
+ address a particular aspect of the context.
101
+ </li>
102
+ <li>
103
+ Include any necessary explanations, examples, or evidence within
104
+ the paragraph to enhance understanding.
105
+ </li>
106
+ </ul>
107
+ </li>
108
+ </ol>
109
+ {/* <h1 className="mb-3 mt-5 font-semibold underline underline-offset-4">
110
+ Document Example :
111
+ </h1>
112
+ <ExampleDoc /> */}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
app/api/cron-scraping/route.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // File: app/api/cron-scraping/route.ts
2
+ import { spawn } from "child_process";
3
+ import { NextResponse } from "next/server";
4
+
5
+ const VALID_SCRAPERS = ["dosen", "jadwal", "jurusan", "pnp", "all"];
6
+ const CRON_SECRET =
7
+ process.env.CRON_SECRET || "fLXB3uzAfSgjrMxg3NxsprqYuPk63+F41zUI2Xlv2fE=";
8
+
9
+ export async function GET(request: Request) {
10
+ try {
11
+ const url = new URL(request.url);
12
+ const authHeader = request.headers.get("Authorization");
13
+ const token = authHeader?.startsWith("Bearer ")
14
+ ? authHeader.substring(7)
15
+ : url.searchParams.get("key");
16
+
17
+ // Validasi token
18
+ if (token !== CRON_SECRET) {
19
+ console.log("[Scraping] Unauthorized access attempt");
20
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
21
+ }
22
+
23
+ // Ambil tipe scraper
24
+ const scraperType = url.searchParams.get("type") || "all";
25
+ if (!VALID_SCRAPERS.includes(scraperType)) {
26
+ return NextResponse.json(
27
+ {
28
+ error: `Invalid scraper type. Must be one of: ${VALID_SCRAPERS.join(", ")}`,
29
+ },
30
+ { status: 400 },
31
+ );
32
+ }
33
+
34
+ // Tentukan nama script
35
+ let commandArgs: string[] = [];
36
+ switch (scraperType) {
37
+ case "dosen":
38
+ commandArgs = ["dosen_scrap.py"];
39
+ break;
40
+ case "jadwal":
41
+ commandArgs = ["jadwal_scrap.py"];
42
+ break;
43
+ case "jurusan":
44
+ commandArgs = ["jurusan_scrap.py"];
45
+ break;
46
+ case "pnp":
47
+ commandArgs = ["pnp_scrap.py"];
48
+ break;
49
+ case "all":
50
+ commandArgs = ["run_all.py"]; // Buat file khusus jika ingin sekaligus
51
+ break;
52
+ }
53
+
54
+ // Jalankan proses secara terpisah
55
+ const child = spawn("python3", commandArgs, {
56
+ cwd: "scrapping",
57
+ detached: true,
58
+ stdio: "ignore",
59
+ env: {
60
+ ...process.env,
61
+ SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
62
+ SUPABASE_KEY: process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY,
63
+ SUPABASE_BUCKET: process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET,
64
+ },
65
+ });
66
+
67
+ child.unref();
68
+
69
+ return NextResponse.json({
70
+ success: true,
71
+ message: `Scraping ${scraperType} started in background`,
72
+ startedAt: new Date().toISOString(),
73
+ });
74
+ } catch (error: any) {
75
+ console.error("[Scraping] Unexpected error:", error);
76
+ return NextResponse.json(
77
+ {
78
+ success: false,
79
+ error: "Failed to start scraping",
80
+ details: error instanceof Error ? error.message : String(error),
81
+ },
82
+ { status: 500 },
83
+ );
84
+ }
85
+ }
app/api/start-scraping/route.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { exec } from "child_process";
2
+ import { NextResponse } from "next/server";
3
+ import { promisify } from "util";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ const VALID_SCRAPERS = ["dosen", "jadwal", "jurusan", "pnp", "all"];
8
+
9
+ export async function POST(request: Request) {
10
+ try {
11
+ const body = await request.json().catch(() => ({}));
12
+ const scraperType = body.type || "all";
13
+
14
+ if (!VALID_SCRAPERS.includes(scraperType)) {
15
+ return NextResponse.json(
16
+ {
17
+ error: `Invalid scraper type. Must be one of: ${VALID_SCRAPERS.join(", ")}`,
18
+ },
19
+ { status: 400 },
20
+ );
21
+ }
22
+
23
+ // Set working directory to the scraper folder
24
+ const baseCommand = "cd scrapping &&";
25
+ let command;
26
+
27
+ switch (scraperType) {
28
+ case "dosen":
29
+ command = `${baseCommand} python3 dosen_scrap.py`;
30
+ break;
31
+ case "jadwal":
32
+ command = `${baseCommand} python3 jadwal_scrap.py`;
33
+ break;
34
+ case "jurusan":
35
+ command = `${baseCommand} python3 jurusan_scrap.py`;
36
+ break;
37
+ case "pnp":
38
+ command = `${baseCommand} python3 pnp_scrap.py`;
39
+ break;
40
+ case "all":
41
+ default:
42
+ command = `${baseCommand} python3 run_all.py`;
43
+ break;
44
+ }
45
+
46
+ console.log(`[Scraping] Starting process: ${command}`);
47
+
48
+ // Increased timeout to 15 minutes
49
+ const { stdout, stderr } = await execAsync(command, {
50
+ timeout: 900000,
51
+ maxBuffer: 1024 * 1024 * 5, // 5MB buffer
52
+ });
53
+
54
+ console.log("[Scraping] Process completed:", {
55
+ stdout: stdout,
56
+ stderr: stderr,
57
+ });
58
+
59
+ // Only treat as error if stderr contains actual error messages
60
+ if (stderr && !stderr.includes("INFO:") && !stderr.includes("DEBUG:")) {
61
+ console.error("[Scraping] Error output:", stderr);
62
+ return NextResponse.json(
63
+ {
64
+ success: false,
65
+ error: "Scraping process completed with errors",
66
+ details: stderr,
67
+ },
68
+ { status: 500 },
69
+ );
70
+ }
71
+
72
+ return NextResponse.json({
73
+ success: true,
74
+ message: `Scraping ${scraperType} completed successfully`,
75
+ output: stdout,
76
+ warnings: stderr || null,
77
+ finishedAt: new Date().toISOString(),
78
+ });
79
+ } catch (error: any) {
80
+ console.error("[Scraping] Failed to execute:", error);
81
+
82
+ let errorMessage = "Scraping process failed";
83
+ if (error.code === "ETIMEDOUT") {
84
+ errorMessage = "Scraping process timed out (took too long)";
85
+ } else if (error.killed) {
86
+ errorMessage = "Scraping process was terminated";
87
+ }
88
+
89
+ return NextResponse.json(
90
+ {
91
+ success: false,
92
+ error: errorMessage,
93
+ details: error instanceof Error ? error.message : String(error),
94
+ },
95
+ { status: 500 },
96
+ );
97
+ }
98
+ }
app/error/page.tsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export default function ErrorPage() {
2
+ return <p>Sorry, something went wrong</p>;
3
+ }
app/favicon.ico ADDED
app/globals.css ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 222.2 84% 4.9%;
9
+
10
+ --card: 0 0% 100%;
11
+ --card-foreground: 222.2 84% 4.9%;
12
+
13
+ --popover: 0 0% 100%;
14
+ --popover-foreground: 222.2 84% 4.9%;
15
+
16
+ --primary: 222.2 47.4% 11.2%;
17
+ --primary-foreground: 210 40% 98%;
18
+
19
+ --secondary: 210 40% 96.1%;
20
+ --secondary-foreground: 222.2 47.4% 11.2%;
21
+
22
+ --muted: 210 40% 96.1%;
23
+ --muted-foreground: 215.4 16.3% 46.9%;
24
+
25
+ --accent: 210 40% 96.1%;
26
+ --accent-foreground: 222.2 47.4% 11.2%;
27
+
28
+ --destructive: 0 84.2% 60.2%;
29
+ --destructive-foreground: 210 40% 98%;
30
+
31
+ --border: 214.3 31.8% 91.4%;
32
+ --input: 214.3 31.8% 91.4%;
33
+ --ring: 222.2 84% 4.9%;
34
+
35
+ --radius: 0.5rem;
36
+ }
37
+
38
+ .dark {
39
+ --background: 222.2 84% 4.9%;
40
+ --foreground: 210 40% 98%;
41
+
42
+ --card: 222.2 84% 4.9%;
43
+ --card-foreground: 210 40% 98%;
44
+
45
+ --popover: 222.2 84% 4.9%;
46
+ --popover-foreground: 210 40% 98%;
47
+
48
+ --primary: 210 40% 98%;
49
+ --primary-foreground: 222.2 47.4% 11.2%;
50
+
51
+ --secondary: 217.2 32.6% 17.5%;
52
+ --secondary-foreground: 210 40% 98%;
53
+
54
+ --muted: 217.2 32.6% 17.5%;
55
+ --muted-foreground: 215 20.2% 65.1%;
56
+
57
+ --accent: 217.2 32.6% 17.5%;
58
+ --accent-foreground: 210 40% 98%;
59
+
60
+ --destructive: 0 62.8% 30.6%;
61
+ --destructive-foreground: 210 40% 98%;
62
+
63
+ --border: 217.2 32.6% 17.5%;
64
+ --input: 217.2 32.6% 17.5%;
65
+ --ring: 212.7 26.8% 83.9%;
66
+ }
67
+ }
68
+
69
+ @layer base {
70
+ * {
71
+ @apply border-border;
72
+ }
73
+ body {
74
+ @apply bg-background text-foreground;
75
+ }
76
+ }
app/layout.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({ subsets: ["latin"] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "PNP-Bot Admin",
9
+ description: " Situs Manajemen Data Chatbot Politeknik Negeri Padang",
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: Readonly<{
15
+ children: React.ReactNode;
16
+ }>) {
17
+ return (
18
+ <html lang="en">
19
+ <body className={inter.className}>{children}</body>
20
+ </html>
21
+ );
22
+ }
components.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "app/globals.css",
9
+ "baseColor": "slate",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils"
16
+ }
17
+ }
components/FileTable.tsx ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import {
4
+ Table,
5
+ TableBody,
6
+ TableCell,
7
+ TableHead,
8
+ TableHeader,
9
+ TableRow,
10
+ } from "@/components/ui/table";
11
+ import { Button } from "@/components/ui/button";
12
+ import { DownloadIcon, EyeOpenIcon, TrashIcon } from "@radix-ui/react-icons";
13
+ import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
14
+ import { formatDatetime } from "@/utils/formatDatetime";
15
+ import { createClient } from "@/utils/supabase/client";
16
+ import { toast } from "@/hooks/use-toast";
17
+
18
+ interface FileTableProps {
19
+ fetchData: () => Promise<void>;
20
+ ragData: any[];
21
+ }
22
+
23
+ export default function FileTable({ fetchData, ragData }: FileTableProps) {
24
+ const supabase = createClient();
25
+ const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({});
26
+ const [currentPage, setCurrentPage] = useState(1);
27
+ const [sortedData, setSortedData] = useState<any[]>([]);
28
+ const itemsPerPage = 10; // Adjust as needed
29
+ const pagesToShow = 2; // Number of page buttons to display at once
30
+
31
+ useEffect(() => {
32
+ // Sort data by created_at in descending order (newest first)
33
+ if (ragData && ragData.length > 0) {
34
+ const sorted = [...ragData].sort((a, b) => {
35
+ return (
36
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
37
+ );
38
+ });
39
+ setSortedData(sorted);
40
+ } else {
41
+ setSortedData([]);
42
+ }
43
+ }, [ragData]);
44
+
45
+ const deleteItem = async (fileName: string) => {
46
+ const confirmed = window.confirm(
47
+ `Yakin ingin menghapus file "${fileName}"?`,
48
+ );
49
+ if (!confirmed) return;
50
+
51
+ try {
52
+ setLoadingMap((prev) => ({ ...prev, [fileName]: true }));
53
+
54
+ const { error } = await supabase.storage
55
+ .from("pnp-bot-storage")
56
+ .remove([fileName]);
57
+
58
+ if (error) {
59
+ toast({
60
+ title: "Gagal menghapus file",
61
+ description: error.message,
62
+ variant: "destructive",
63
+ });
64
+ } else {
65
+ toast({
66
+ title: "File berhasil dihapus",
67
+ description: `File "${fileName}" telah dihapus.`,
68
+ });
69
+ fetchData(); // refresh daftar file
70
+ }
71
+ } catch (err) {
72
+ console.error("Gagal menghapus:", err);
73
+ toast({
74
+ title: "Terjadi kesalahan",
75
+ description: "Tidak dapat menghapus file.",
76
+ variant: "destructive",
77
+ });
78
+ } finally {
79
+ setLoadingMap((prev) => ({ ...prev, [fileName]: false }));
80
+ }
81
+ };
82
+
83
+ // Lihat File (Open in New Tab)
84
+ const inspectItem = (fileName: string) => {
85
+ const { data } = supabase.storage
86
+ .from("pnp-bot-storage")
87
+ .getPublicUrl(fileName);
88
+
89
+ if (!data?.publicUrl) {
90
+ toast({
91
+ title: "Gagal membuka file",
92
+ description: `File "${fileName}" tidak memiliki URL publik.`,
93
+ variant: "destructive",
94
+ });
95
+ return;
96
+ }
97
+
98
+ window.open(data.publicUrl, "_blank");
99
+ };
100
+
101
+ // Unduh File
102
+ const downloadItem = async (fileName: string) => {
103
+ try {
104
+ // Retrieve the file as a blob using the download method
105
+ const { data, error } = await supabase.storage
106
+ .from("pnp-bot-storage") // Use your bucket name
107
+ .download(fileName);
108
+
109
+ if (error) {
110
+ toast({
111
+ title: "Gagal mengunduh file",
112
+ description: error.message || "Terjadi kesalahan saat mengunduh.",
113
+ variant: "destructive",
114
+ });
115
+ return;
116
+ }
117
+
118
+ // Create a link element to download the file
119
+ const url = URL.createObjectURL(data);
120
+ const link = document.createElement("a");
121
+ link.href = url;
122
+ link.download = fileName;
123
+
124
+ // Programmatically trigger the download
125
+ document.body.appendChild(link);
126
+ link.click();
127
+ document.body.removeChild(link);
128
+
129
+ // Clean up the object URL
130
+ URL.revokeObjectURL(url);
131
+
132
+ toast({
133
+ title: "Unduh berhasil",
134
+ description: `File "${fileName}" berhasil diunduh.`,
135
+ duration: 2000,
136
+ });
137
+ } catch (err) {
138
+ console.error("Gagal mengunduh:", err);
139
+ toast({
140
+ title: "Terjadi kesalahan",
141
+ description: "Tidak dapat mengunduh file.",
142
+ variant: "destructive",
143
+ });
144
+ }
145
+ };
146
+
147
+ // Calculate pagination
148
+ const totalPages = Math.ceil(sortedData.length / itemsPerPage);
149
+ const paginatedData = sortedData.slice(
150
+ (currentPage - 1) * itemsPerPage,
151
+ currentPage * itemsPerPage,
152
+ );
153
+
154
+ const goToNextPage = () => {
155
+ if (currentPage < totalPages) {
156
+ setCurrentPage(currentPage + 1);
157
+ }
158
+ };
159
+
160
+ const goToPrevPage = () => {
161
+ if (currentPage > 1) {
162
+ setCurrentPage(currentPage - 1);
163
+ }
164
+ };
165
+
166
+ const goToPage = (page: number) => {
167
+ setCurrentPage(page);
168
+ };
169
+
170
+ // Calculate the range of page numbers to display
171
+ const startPage = Math.max(1, currentPage - Math.floor(pagesToShow / 2));
172
+ const endPage = Math.min(totalPages, startPage + pagesToShow - 1);
173
+ const pageNumbers = Array.from(
174
+ { length: endPage - startPage + 1 },
175
+ (_, index) => startPage + index,
176
+ );
177
+
178
+ return (
179
+ <div className="space-y-4">
180
+ <Table>
181
+ <TableHeader className="bg-slate-100">
182
+ <TableRow>
183
+ <TableHead className="text-center">#</TableHead>
184
+ <TableHead className="min-w-[240px]">Name</TableHead>
185
+ <TableHead>Uploaded At</TableHead>
186
+ <TableHead>File Size</TableHead>
187
+ <TableHead></TableHead>
188
+ </TableRow>
189
+ </TableHeader>
190
+ <TableBody>
191
+ {paginatedData && paginatedData.length > 0 ? (
192
+ paginatedData.map((item: any, index: number) => (
193
+ <TableRow key={index}>
194
+ <TableCell className="text-center">
195
+ {(currentPage - 1) * itemsPerPage + index + 1}
196
+ </TableCell>
197
+ <TableCell className="min-w-[240px] font-medium">
198
+ {item.name}
199
+ </TableCell>
200
+ <TableCell>{formatDatetime(item.created_at)}</TableCell>
201
+ <TableCell>{item.metadata.size}</TableCell>
202
+ <TableCell className="flex justify-center gap-2">
203
+ <Button
204
+ variant={"secondary"}
205
+ className="hover:bg-neutral-500 hover:text-white"
206
+ onClick={() => inspectItem(item.name)}
207
+ >
208
+ <EyeOpenIcon className="h-4 w-4" />
209
+ </Button>
210
+ <Button
211
+ variant="outline"
212
+ className="hover:bg-blue-600 hover:text-white"
213
+ onClick={() => downloadItem(item.name)}
214
+ >
215
+ <DownloadIcon className="h-4 w-4" />
216
+ </Button>
217
+ <Button
218
+ variant={"destructive"}
219
+ className="hover:bg-red-800"
220
+ onClick={() => deleteItem(item.name)}
221
+ >
222
+ {loadingMap[item.name] ? (
223
+ <Loader2 className="h-4 w-4 animate-spin" />
224
+ ) : (
225
+ <TrashIcon className="h-4 w-4" />
226
+ )}
227
+ </Button>
228
+ </TableCell>
229
+ </TableRow>
230
+ ))
231
+ ) : (
232
+ <TableRow>
233
+ <TableCell colSpan={5} className="py-4 text-center">
234
+ No Data Available
235
+ </TableCell>
236
+ </TableRow>
237
+ )}
238
+ </TableBody>
239
+ </Table>
240
+
241
+ {/* Pagination Controls */}
242
+ {sortedData.length > 0 && (
243
+ <div className="flex items-center justify-between px-4 py-3">
244
+ <div className="text-sm text-muted-foreground">
245
+ Showing{" "}
246
+ <span className="font-medium">
247
+ {(currentPage - 1) * itemsPerPage + 1}
248
+ </span>{" "}
249
+ to{" "}
250
+ <span className="font-medium">
251
+ {Math.min(currentPage * itemsPerPage, sortedData.length)}
252
+ </span>{" "}
253
+ of <span className="font-medium">{sortedData.length}</span> files
254
+ </div>
255
+ <div className="flex items-center space-x-2">
256
+ <Button
257
+ variant="outline"
258
+ size="sm"
259
+ onClick={goToPrevPage}
260
+ disabled={currentPage === 1}
261
+ className="px-3"
262
+ >
263
+ <ChevronLeft className="h-4 w-4" />
264
+ </Button>
265
+
266
+ {/* Always show first page */}
267
+ <Button
268
+ variant="outline"
269
+ size="sm"
270
+ onClick={() => goToPage(1)}
271
+ className={currentPage === 1 ? "bg-blue-500 text-white" : ""}
272
+ disabled={currentPage === 1}
273
+ >
274
+ 1
275
+ </Button>
276
+
277
+ {/* Show "..." if current page is far from start */}
278
+ {currentPage > 3 && <span className="px-2">...</span>}
279
+
280
+ {/* Dynamic page numbers (middle range) */}
281
+ <div className="flex space-x-1">
282
+ {Array.from({ length: Math.min(3, totalPages - 2) }, (_, i) => {
283
+ let page;
284
+ if (currentPage <= 2)
285
+ page = i + 2; // Near start: 2, 3, 4
286
+ else if (currentPage >= totalPages - 1)
287
+ page = totalPages - 2 + i; // Near end
288
+ else page = currentPage - 1 + i; // Middle range
289
+
290
+ if (page > 1 && page < totalPages) {
291
+ return (
292
+ <Button
293
+ key={page}
294
+ variant="outline"
295
+ size="sm"
296
+ className={
297
+ currentPage === page ? "bg-blue-500 text-white" : ""
298
+ }
299
+ onClick={() => goToPage(page)}
300
+ >
301
+ {page}
302
+ </Button>
303
+ );
304
+ }
305
+ return null;
306
+ })}
307
+ </div>
308
+
309
+ {/* Show "..." if current page is far from end */}
310
+ {currentPage < totalPages - 2 && <span className="px-2">...</span>}
311
+
312
+ {/* Always show last page (if different from first) */}
313
+ {totalPages > 1 && (
314
+ <Button
315
+ variant="outline"
316
+ size="sm"
317
+ onClick={() => goToPage(totalPages)}
318
+ disabled={currentPage === totalPages}
319
+ className={
320
+ currentPage === totalPages ? "bg-blue-500 text-white" : ""
321
+ }
322
+ >
323
+ {totalPages}
324
+ </Button>
325
+ )}
326
+
327
+ <Button
328
+ variant="outline"
329
+ size="sm"
330
+ onClick={goToNextPage}
331
+ disabled={currentPage === totalPages || totalPages === 0}
332
+ className="px-3"
333
+ >
334
+ <ChevronRight className="h-4 w-4" />
335
+ </Button>
336
+ </div>
337
+ </div>
338
+ )}
339
+ </div>
340
+ );
341
+ }
components/Footer.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import * as React from "react";
3
+
4
+ export function Footer() {
5
+ return (
6
+ <div className="mt-10 flex w-full items-center justify-center border-t bg-orange-600 py-5">
7
+ <h1 className="text-center text-sm font-semibold text-white">
8
+ PNP Bot est. 2025
9
+ </h1>
10
+ </div>
11
+ );
12
+ }
components/Navbar.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import * as React from "react";
3
+ import PnpLogo from "../public/logo-pnp-small.webp";
4
+ import Image from "next/image";
5
+ import SignOutButton from "./SignOutButton";
6
+ import Link from "next/link";
7
+
8
+ export function Navbar() {
9
+ return (
10
+ <div className="mb-5 flex w-full border-b bg-slate-50 py-5">
11
+ <div className="mx-auto flex w-10/12 items-center justify-between">
12
+ <Link href="/">
13
+ <div className="flex items-center gap-2">
14
+ <Image
15
+ src={PnpLogo}
16
+ width={30}
17
+ height={30}
18
+ alt="Logo Teknologi Informasi"
19
+ />
20
+ <h1 className="font-semibold">PNP-Bot</h1>
21
+ </div>
22
+ </Link>
23
+ <SignOutButton />
24
+ </div>
25
+ </div>
26
+ );
27
+ }
components/SemesterFilter.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { ChevronDown, Filter, Calendar } from "lucide-react";
3
+
4
+ // Define the props interface for the component
5
+ interface SemesterFilterProps {
6
+ onFilterChange?: (semesterId: string) => void; // Make it optional with ?
7
+ }
8
+
9
+ // Define the semester option type
10
+ interface SemesterOption {
11
+ id: string;
12
+ label: string;
13
+ description: string;
14
+ }
15
+
16
+ export default function SemesterFilter({
17
+ onFilterChange,
18
+ }: SemesterFilterProps) {
19
+ const [semesterFilter, setSemesterFilter] = useState("all");
20
+ const [isFilterOpen, setIsFilterOpen] = useState(false);
21
+
22
+ // Get current year
23
+ const currentYear = new Date().getFullYear();
24
+
25
+ // Generate academic years (e.g., 2023/2024, 2024/2025)
26
+ const generateAcademicYears = (
27
+ startYear: number,
28
+ endYear: number,
29
+ ): string[] => {
30
+ const years: string[] = [];
31
+ for (let year = startYear; year <= endYear; year++) {
32
+ years.push(`${year}/${year + 1}`);
33
+ }
34
+ return years.reverse(); // Most recent first okay
35
+ };
36
+
37
+ const academicYears = generateAcademicYears(currentYear - 3, currentYear);
38
+
39
+ // Generate semester options
40
+ const semesterOptions: SemesterOption[] = [];
41
+ academicYears.forEach((academicYear) => {
42
+ const [startYear, endYear] = academicYear.split("/");
43
+ // Odd semester (September startYear - January endYear)
44
+ semesterOptions.push({
45
+ id: `odd-${academicYear}`,
46
+ label: `Ganjil ${academicYear}`,
47
+ description: `September ${startYear} - January ${endYear}`,
48
+ });
49
+
50
+ // Even semester (February - August of endYear)
51
+ semesterOptions.push({
52
+ id: `even-${academicYear}`,
53
+ label: `Genap ${academicYear}`,
54
+ description: `February - August ${endYear}`,
55
+ });
56
+ });
57
+
58
+ // Check if a document falls within a specific semester
59
+ const isInSemester = (date: string, semesterId: string) => {
60
+ if (semesterId === "all") return true;
61
+
62
+ const [type, academicYear] = semesterId.split("-");
63
+ const [startYear, endYear] = academicYear.split("/");
64
+ const docDate = new Date(date);
65
+ const docMonth = docDate.getMonth() + 1; // 1-12
66
+ const docYear = docDate.getFullYear();
67
+
68
+ if (type === "odd") {
69
+ // Odd semester: September (9) - January (1) of next year
70
+ return (
71
+ (docYear === parseInt(startYear) && docMonth >= 9 && docMonth <= 12) ||
72
+ (docYear === parseInt(endYear) && docMonth === 1)
73
+ );
74
+ } else {
75
+ // Even semester: February (2) - August (8)
76
+ return docYear === parseInt(endYear) && docMonth >= 2 && docMonth <= 8;
77
+ }
78
+ };
79
+
80
+ const handleFilterClick = () => {
81
+ setIsFilterOpen(!isFilterOpen);
82
+ };
83
+
84
+ const handleSemesterSelect = (semesterId: string) => {
85
+ setSemesterFilter(semesterId);
86
+ setIsFilterOpen(false);
87
+
88
+ // Call the onFilterChange prop if it exists
89
+ if (onFilterChange) {
90
+ onFilterChange(semesterId);
91
+ }
92
+ };
93
+
94
+ // Get display text for current filter
95
+ const getCurrentFilterText = () => {
96
+ if (semesterFilter === "all") return "All Semesters";
97
+
98
+ const selectedSemester = semesterOptions.find(
99
+ (option) => option.id === semesterFilter,
100
+ );
101
+ return selectedSemester ? selectedSemester.label : "All Semesters";
102
+ };
103
+
104
+ return (
105
+ <div className="relative">
106
+ {/* Filter Button */}
107
+ <button
108
+ onClick={handleFilterClick}
109
+ className="flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm hover:bg-gray-50"
110
+ >
111
+ <Filter className="h-4 w-4" />
112
+ <Calendar className="h-4 w-4" />
113
+ <span>{getCurrentFilterText()}</span>
114
+ <ChevronDown className="h-4 w-4" />
115
+ </button>
116
+
117
+ {/* Dropdown Menu */}
118
+ {isFilterOpen && (
119
+ <div className="absolute z-50 mt-2 min-w-[240px] rounded-md border border-gray-200 bg-white shadow-lg">
120
+ <div className="py-1">
121
+ {/* All option */}
122
+ <div
123
+ onClick={() => handleSemesterSelect("all")}
124
+ className={`cursor-pointer px-4 py-2 hover:bg-gray-100 ${
125
+ semesterFilter === "all" ? "bg-blue-50 text-blue-600" : ""
126
+ }`}
127
+ >
128
+ All Semesters
129
+ </div>
130
+
131
+ {/* Divider */}
132
+ <div className="my-1 border-t border-gray-200"></div>
133
+
134
+ {/* Academic Years and Semesters */}
135
+ {academicYears.map((year, yearIndex) => (
136
+ <div key={year}>
137
+ {yearIndex > 0 && (
138
+ <div className="my-1 border-t border-gray-200"></div>
139
+ )}
140
+ <div className="px-4 py-2 text-xs font-semibold text-gray-500">
141
+ Academic Year {year}
142
+ </div>
143
+
144
+ {/* Odd Semester */}
145
+ <div
146
+ onClick={() => handleSemesterSelect(`odd-${year}`)}
147
+ className={`cursor-pointer px-4 py-2 hover:bg-gray-100 ${
148
+ semesterFilter === `odd-${year}`
149
+ ? "bg-blue-50 text-blue-600"
150
+ : ""
151
+ }`}
152
+ >
153
+ <div className="font-medium">Ganjil {year}</div>
154
+ <div className="text-xs text-gray-500">
155
+ September - January
156
+ </div>
157
+ </div>
158
+
159
+ {/* Even Semester */}
160
+ <div
161
+ onClick={() => handleSemesterSelect(`even-${year}`)}
162
+ className={`cursor-pointer px-4 py-2 hover:bg-gray-100 ${
163
+ semesterFilter === `even-${year}`
164
+ ? "bg-blue-50 text-blue-600"
165
+ : ""
166
+ }`}
167
+ >
168
+ <div className="font-medium">Genap {year}</div>
169
+ <div className="text-xs text-gray-500">February - August</div>
170
+ </div>
171
+ </div>
172
+ ))}
173
+ </div>
174
+ </div>
175
+ )}
176
+ </div>
177
+ );
178
+ }
components/SignInForm.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { zodResolver } from "@hookform/resolvers/zod";
3
+ import { SubmitHandler, useForm } from "react-hook-form";
4
+ import { z } from "zod";
5
+ import { Input } from "./ui/input";
6
+ import { Button } from "./ui/button";
7
+ import { login } from "@/utils/signIn";
8
+ import { Label } from "./ui/label";
9
+
10
+ const schema = z.object({
11
+ email: z.string().email(),
12
+ password: z.string().min(8),
13
+ });
14
+
15
+ type FormFields = z.infer<typeof schema>;
16
+
17
+ const SignInForm = () => {
18
+ const {
19
+ register,
20
+ handleSubmit,
21
+ setError,
22
+ formState: { errors, isSubmitting },
23
+ } = useForm<FormFields>({
24
+ defaultValues: {},
25
+ resolver: zodResolver(schema),
26
+ });
27
+
28
+ const onSubmit: SubmitHandler<FormFields> = async (data) => {
29
+ try {
30
+ await login(data.email, data.password);
31
+ } catch (error) {
32
+ setError("root", {
33
+ message: "Invalid email or password",
34
+ });
35
+ }
36
+ };
37
+
38
+ return (
39
+ <>
40
+ <form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
41
+ <Label htmlFor="email" className="text-left">
42
+ Email
43
+ </Label>
44
+ <Input
45
+ {...register("email")}
46
+ placeholder="Email"
47
+ id="email"
48
+ type="email"
49
+ />
50
+ {errors.email && (
51
+ <div className="text-left text-xs text-red-500">
52
+ {errors.email.message}
53
+ </div>
54
+ )}
55
+ <Label htmlFor="password" className="text-left">
56
+ Password
57
+ </Label>
58
+ <Input
59
+ {...register("password")}
60
+ placeholder="Password"
61
+ type="password"
62
+ id="password"
63
+ />
64
+ {errors.password && (
65
+ <div className="text-left text-xs text-red-500">
66
+ {errors.password.message}
67
+ </div>
68
+ )}
69
+ <Button
70
+ disabled={isSubmitting}
71
+ type="submit"
72
+ className="mt-4 bg-orange-600 hover:bg-orange-800"
73
+ >
74
+ {isSubmitting ? "Loading..." : "Login"}
75
+ </Button>
76
+ {errors.root && (
77
+ <div className="text-xs text-red-500">{errors.root.message}</div>
78
+ )}
79
+ </form>
80
+ </>
81
+ );
82
+ };
83
+
84
+ export default SignInForm;
components/SignOutButton.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { signOut } from "@/utils/signOut";
3
+ import { Button } from "./ui/button";
4
+ import { ExitIcon } from "@radix-ui/react-icons";
5
+ import { useState } from "react";
6
+ import { Loader2 } from "lucide-react";
7
+
8
+ const SignOutButton = () => {
9
+ const [isLoading, setIsLoading] = useState<boolean>(false);
10
+ async function handleSignOut() {
11
+ try {
12
+ setIsLoading(true);
13
+ await signOut();
14
+ setIsLoading(false);
15
+ } catch (error) {}
16
+ }
17
+
18
+ return (
19
+ <>
20
+ <Button
21
+ className="hover:bg-slate-100"
22
+ variant={"ghost"}
23
+ onClick={handleSignOut}
24
+ >
25
+ {isLoading ? (
26
+ <>
27
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
28
+ Logging Out
29
+ </>
30
+ ) : (
31
+ <>
32
+ <ExitIcon className="mr-2 h-4 w-4" />
33
+ Log Out
34
+ </>
35
+ )}
36
+ </Button>
37
+ </>
38
+ );
39
+ };
40
+
41
+ export default SignOutButton;
components/UploadForm.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogClose,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from "@/components/ui/dialog";
14
+ import { Input } from "@/components/ui/input";
15
+ import { Label } from "@/components/ui/label";
16
+ import { useForm, SubmitHandler } from "react-hook-form";
17
+ import { createClient } from "@/utils/supabase/client";
18
+ import { useRouter } from "next/navigation";
19
+
20
+ type FormValues = {
21
+ file: FileList;
22
+ };
23
+
24
+ export function UploadForm() {
25
+ const supabase = createClient();
26
+ const router = useRouter();
27
+ const {
28
+ register,
29
+ handleSubmit,
30
+ reset,
31
+ formState: { errors, isSubmitting },
32
+ } = useForm<FormValues>();
33
+
34
+ const onSubmit: SubmitHandler<FormValues> = async (data) => {
35
+ const selectedFile = data.file[0];
36
+ try {
37
+ await supabase.storage
38
+ .from("pnp-bot-storage")
39
+ .upload(`${selectedFile.name}`, selectedFile, {
40
+ cacheControl: "3600",
41
+ upsert: false,
42
+ });
43
+ reset();
44
+ router.replace("/");
45
+ } catch (error) {
46
+ console.error("Upload error:", error);
47
+ }
48
+ };
49
+
50
+ return (
51
+ <form onSubmit={handleSubmit(onSubmit)} className="w-full">
52
+ <div className="mb-3">
53
+ <Input
54
+ placeholder="Upload file"
55
+ {...register("file", { required: true })}
56
+ id="upload_file"
57
+ type="file"
58
+ accept=".pdf, .doc, .docx, .txt"
59
+ className="mb-1 bg-white"
60
+ />
61
+ </div>
62
+ <div className="flex justify-end">
63
+ <Button
64
+ disabled={isSubmitting}
65
+ type="submit"
66
+ className="bg-orange-600 hover:bg-orange-800"
67
+ >
68
+ {isSubmitting ? "Loading..." : "Submit"}
69
+ </Button>
70
+ </div>
71
+ </form>
72
+ );
73
+ }
components/ui/button.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16
+ outline:
17
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20
+ ghost: "hover:bg-accent hover:text-accent-foreground",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2",
25
+ sm: "h-8 rounded-md px-3 text-xs",
26
+ lg: "h-10 rounded-md px-8",
27
+ icon: "h-9 w-9",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ }
35
+ )
36
+
37
+ export interface ButtonProps
38
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39
+ VariantProps<typeof buttonVariants> {
40
+ asChild?: boolean
41
+ }
42
+
43
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
45
+ const Comp = asChild ? Slot : "button"
46
+ return (
47
+ <Comp
48
+ className={cn(buttonVariants({ variant, size, className }))}
49
+ ref={ref}
50
+ {...props}
51
+ />
52
+ )
53
+ }
54
+ )
55
+ Button.displayName = "Button"
56
+
57
+ export { Button, buttonVariants }
components/ui/dialog.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
5
+ import { Cross2Icon } from "@radix-ui/react-icons"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Dialog = DialogPrimitive.Root
10
+
11
+ const DialogTrigger = DialogPrimitive.Trigger
12
+
13
+ const DialogPortal = DialogPrimitive.Portal
14
+
15
+ const DialogClose = DialogPrimitive.Close
16
+
17
+ const DialogOverlay = React.forwardRef<
18
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
19
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
+ >(({ className, ...props }, ref) => (
21
+ <DialogPrimitive.Overlay
22
+ ref={ref}
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ ))
30
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31
+
32
+ const DialogContent = React.forwardRef<
33
+ React.ElementRef<typeof DialogPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35
+ >(({ className, children, ...props }, ref) => (
36
+ <DialogPortal>
37
+ <DialogOverlay />
38
+ <DialogPrimitive.Content
39
+ ref={ref}
40
+ className={cn(
41
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48
+ <Cross2Icon className="h-4 w-4" />
49
+ <span className="sr-only">Close</span>
50
+ </DialogPrimitive.Close>
51
+ </DialogPrimitive.Content>
52
+ </DialogPortal>
53
+ ))
54
+ DialogContent.displayName = DialogPrimitive.Content.displayName
55
+
56
+ const DialogHeader = ({
57
+ className,
58
+ ...props
59
+ }: React.HTMLAttributes<HTMLDivElement>) => (
60
+ <div
61
+ className={cn(
62
+ "flex flex-col space-y-1.5 text-center sm:text-left",
63
+ className
64
+ )}
65
+ {...props}
66
+ />
67
+ )
68
+ DialogHeader.displayName = "DialogHeader"
69
+
70
+ const DialogFooter = ({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) => (
74
+ <div
75
+ className={cn(
76
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
77
+ className
78
+ )}
79
+ {...props}
80
+ />
81
+ )
82
+ DialogFooter.displayName = "DialogFooter"
83
+
84
+ const DialogTitle = React.forwardRef<
85
+ React.ElementRef<typeof DialogPrimitive.Title>,
86
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
87
+ >(({ className, ...props }, ref) => (
88
+ <DialogPrimitive.Title
89
+ ref={ref}
90
+ className={cn(
91
+ "text-lg font-semibold leading-none tracking-tight",
92
+ className
93
+ )}
94
+ {...props}
95
+ />
96
+ ))
97
+ DialogTitle.displayName = DialogPrimitive.Title.displayName
98
+
99
+ const DialogDescription = React.forwardRef<
100
+ React.ElementRef<typeof DialogPrimitive.Description>,
101
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
102
+ >(({ className, ...props }, ref) => (
103
+ <DialogPrimitive.Description
104
+ ref={ref}
105
+ className={cn("text-sm text-muted-foreground", className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ DialogDescription.displayName = DialogPrimitive.Description.displayName
110
+
111
+ export {
112
+ Dialog,
113
+ DialogPortal,
114
+ DialogOverlay,
115
+ DialogTrigger,
116
+ DialogClose,
117
+ DialogContent,
118
+ DialogHeader,
119
+ DialogFooter,
120
+ DialogTitle,
121
+ DialogDescription,
122
+ }
components/ui/input.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ export interface InputProps
6
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
7
+
8
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(
9
+ ({ className, type, ...props }, ref) => {
10
+ return (
11
+ <input
12
+ type={type}
13
+ className={cn(
14
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
15
+ className
16
+ )}
17
+ ref={ref}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+ )
23
+ Input.displayName = "Input"
24
+
25
+ export { Input }
components/ui/label.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const labelVariants = cva(
10
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11
+ )
12
+
13
+ const Label = React.forwardRef<
14
+ React.ElementRef<typeof LabelPrimitive.Root>,
15
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
16
+ VariantProps<typeof labelVariants>
17
+ >(({ className, ...props }, ref) => (
18
+ <LabelPrimitive.Root
19
+ ref={ref}
20
+ className={cn(labelVariants(), className)}
21
+ {...props}
22
+ />
23
+ ))
24
+ Label.displayName = LabelPrimitive.Root.displayName
25
+
26
+ export { Label }
components/ui/navigation-menu.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { ChevronDownIcon } from "@radix-ui/react-icons"
3
+ import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
4
+ import { cva } from "class-variance-authority"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const NavigationMenu = React.forwardRef<
9
+ React.ElementRef<typeof NavigationMenuPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
11
+ >(({ className, children, ...props }, ref) => (
12
+ <NavigationMenuPrimitive.Root
13
+ ref={ref}
14
+ className={cn(
15
+ "relative z-10 flex max-w-max flex-1 items-center justify-center",
16
+ className
17
+ )}
18
+ {...props}
19
+ >
20
+ {children}
21
+ <NavigationMenuViewport />
22
+ </NavigationMenuPrimitive.Root>
23
+ ))
24
+ NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
25
+
26
+ const NavigationMenuList = React.forwardRef<
27
+ React.ElementRef<typeof NavigationMenuPrimitive.List>,
28
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
29
+ >(({ className, ...props }, ref) => (
30
+ <NavigationMenuPrimitive.List
31
+ ref={ref}
32
+ className={cn(
33
+ "group flex flex-1 list-none items-center justify-center space-x-1",
34
+ className
35
+ )}
36
+ {...props}
37
+ />
38
+ ))
39
+ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
40
+
41
+ const NavigationMenuItem = NavigationMenuPrimitive.Item
42
+
43
+ const navigationMenuTriggerStyle = cva(
44
+ "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
45
+ )
46
+
47
+ const NavigationMenuTrigger = React.forwardRef<
48
+ React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
49
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
50
+ >(({ className, children, ...props }, ref) => (
51
+ <NavigationMenuPrimitive.Trigger
52
+ ref={ref}
53
+ className={cn(navigationMenuTriggerStyle(), "group", className)}
54
+ {...props}
55
+ >
56
+ {children}{" "}
57
+ <ChevronDownIcon
58
+ className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
59
+ aria-hidden="true"
60
+ />
61
+ </NavigationMenuPrimitive.Trigger>
62
+ ))
63
+ NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
64
+
65
+ const NavigationMenuContent = React.forwardRef<
66
+ React.ElementRef<typeof NavigationMenuPrimitive.Content>,
67
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
68
+ >(({ className, ...props }, ref) => (
69
+ <NavigationMenuPrimitive.Content
70
+ ref={ref}
71
+ className={cn(
72
+ "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
73
+ className
74
+ )}
75
+ {...props}
76
+ />
77
+ ))
78
+ NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
79
+
80
+ const NavigationMenuLink = NavigationMenuPrimitive.Link
81
+
82
+ const NavigationMenuViewport = React.forwardRef<
83
+ React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
84
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
85
+ >(({ className, ...props }, ref) => (
86
+ <div className={cn("absolute left-0 top-full flex justify-center")}>
87
+ <NavigationMenuPrimitive.Viewport
88
+ className={cn(
89
+ "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
90
+ className
91
+ )}
92
+ ref={ref}
93
+ {...props}
94
+ />
95
+ </div>
96
+ ))
97
+ NavigationMenuViewport.displayName =
98
+ NavigationMenuPrimitive.Viewport.displayName
99
+
100
+ const NavigationMenuIndicator = React.forwardRef<
101
+ React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
102
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
103
+ >(({ className, ...props }, ref) => (
104
+ <NavigationMenuPrimitive.Indicator
105
+ ref={ref}
106
+ className={cn(
107
+ "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
108
+ className
109
+ )}
110
+ {...props}
111
+ >
112
+ <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
113
+ </NavigationMenuPrimitive.Indicator>
114
+ ))
115
+ NavigationMenuIndicator.displayName =
116
+ NavigationMenuPrimitive.Indicator.displayName
117
+
118
+ export {
119
+ navigationMenuTriggerStyle,
120
+ NavigationMenu,
121
+ NavigationMenuList,
122
+ NavigationMenuItem,
123
+ NavigationMenuContent,
124
+ NavigationMenuTrigger,
125
+ NavigationMenuLink,
126
+ NavigationMenuIndicator,
127
+ NavigationMenuViewport,
128
+ }
components/ui/table.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Table = React.forwardRef<
6
+ HTMLTableElement,
7
+ React.HTMLAttributes<HTMLTableElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div className="relative w-full overflow-auto">
10
+ <table
11
+ ref={ref}
12
+ className={cn("w-full caption-bottom text-sm", className)}
13
+ {...props}
14
+ />
15
+ </div>
16
+ ))
17
+ Table.displayName = "Table"
18
+
19
+ const TableHeader = React.forwardRef<
20
+ HTMLTableSectionElement,
21
+ React.HTMLAttributes<HTMLTableSectionElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
24
+ ))
25
+ TableHeader.displayName = "TableHeader"
26
+
27
+ const TableBody = React.forwardRef<
28
+ HTMLTableSectionElement,
29
+ React.HTMLAttributes<HTMLTableSectionElement>
30
+ >(({ className, ...props }, ref) => (
31
+ <tbody
32
+ ref={ref}
33
+ className={cn("[&_tr:last-child]:border-0", className)}
34
+ {...props}
35
+ />
36
+ ))
37
+ TableBody.displayName = "TableBody"
38
+
39
+ const TableFooter = React.forwardRef<
40
+ HTMLTableSectionElement,
41
+ React.HTMLAttributes<HTMLTableSectionElement>
42
+ >(({ className, ...props }, ref) => (
43
+ <tfoot
44
+ ref={ref}
45
+ className={cn(
46
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
47
+ className
48
+ )}
49
+ {...props}
50
+ />
51
+ ))
52
+ TableFooter.displayName = "TableFooter"
53
+
54
+ const TableRow = React.forwardRef<
55
+ HTMLTableRowElement,
56
+ React.HTMLAttributes<HTMLTableRowElement>
57
+ >(({ className, ...props }, ref) => (
58
+ <tr
59
+ ref={ref}
60
+ className={cn(
61
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
62
+ className
63
+ )}
64
+ {...props}
65
+ />
66
+ ))
67
+ TableRow.displayName = "TableRow"
68
+
69
+ const TableHead = React.forwardRef<
70
+ HTMLTableCellElement,
71
+ React.ThHTMLAttributes<HTMLTableCellElement>
72
+ >(({ className, ...props }, ref) => (
73
+ <th
74
+ ref={ref}
75
+ className={cn(
76
+ "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
77
+ className
78
+ )}
79
+ {...props}
80
+ />
81
+ ))
82
+ TableHead.displayName = "TableHead"
83
+
84
+ const TableCell = React.forwardRef<
85
+ HTMLTableCellElement,
86
+ React.TdHTMLAttributes<HTMLTableCellElement>
87
+ >(({ className, ...props }, ref) => (
88
+ <td
89
+ ref={ref}
90
+ className={cn(
91
+ "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
92
+ className
93
+ )}
94
+ {...props}
95
+ />
96
+ ))
97
+ TableCell.displayName = "TableCell"
98
+
99
+ const TableCaption = React.forwardRef<
100
+ HTMLTableCaptionElement,
101
+ React.HTMLAttributes<HTMLTableCaptionElement>
102
+ >(({ className, ...props }, ref) => (
103
+ <caption
104
+ ref={ref}
105
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ TableCaption.displayName = "TableCaption"
110
+
111
+ export {
112
+ Table,
113
+ TableHeader,
114
+ TableBody,
115
+ TableFooter,
116
+ TableHead,
117
+ TableRow,
118
+ TableCell,
119
+ TableCaption,
120
+ }
components/ui/textarea.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ export interface TextareaProps
6
+ extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
7
+
8
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
9
+ ({ className, ...props }, ref) => {
10
+ return (
11
+ <textarea
12
+ className={cn(
13
+ "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
14
+ className
15
+ )}
16
+ ref={ref}
17
+ {...props}
18
+ />
19
+ )
20
+ }
21
+ )
22
+ Textarea.displayName = "Textarea"
23
+
24
+ export { Textarea }
components/ui/toast.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { cn } from "@/lib/utils"
7
+ import { Cross2Icon } from "@radix-ui/react-icons"
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-2 overflow-hidden rounded-md border p-4 pr-6 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:
33
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ },
39
+ }
40
+ )
41
+
42
+ const Toast = React.forwardRef<
43
+ React.ElementRef<typeof ToastPrimitives.Root>,
44
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
45
+ VariantProps<typeof toastVariants>
46
+ >(({ className, variant, ...props }, ref) => {
47
+ return (
48
+ <ToastPrimitives.Root
49
+ ref={ref}
50
+ className={cn(toastVariants({ variant }), className)}
51
+ {...props}
52
+ />
53
+ )
54
+ })
55
+ Toast.displayName = ToastPrimitives.Root.displayName
56
+
57
+ const ToastAction = React.forwardRef<
58
+ React.ElementRef<typeof ToastPrimitives.Action>,
59
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
60
+ >(({ className, ...props }, ref) => (
61
+ <ToastPrimitives.Action
62
+ ref={ref}
63
+ className={cn(
64
+ "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring 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",
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ ))
70
+ ToastAction.displayName = ToastPrimitives.Action.displayName
71
+
72
+ const ToastClose = React.forwardRef<
73
+ React.ElementRef<typeof ToastPrimitives.Close>,
74
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
75
+ >(({ className, ...props }, ref) => (
76
+ <ToastPrimitives.Close
77
+ ref={ref}
78
+ className={cn(
79
+ "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 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",
80
+ className
81
+ )}
82
+ toast-close=""
83
+ {...props}
84
+ >
85
+ <Cross2Icon className="h-4 w-4" />
86
+ </ToastPrimitives.Close>
87
+ ))
88
+ ToastClose.displayName = ToastPrimitives.Close.displayName
89
+
90
+ const ToastTitle = React.forwardRef<
91
+ React.ElementRef<typeof ToastPrimitives.Title>,
92
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
93
+ >(({ className, ...props }, ref) => (
94
+ <ToastPrimitives.Title
95
+ ref={ref}
96
+ className={cn("text-sm font-semibold [&+div]:text-xs", className)}
97
+ {...props}
98
+ />
99
+ ))
100
+ ToastTitle.displayName = ToastPrimitives.Title.displayName
101
+
102
+ const ToastDescription = React.forwardRef<
103
+ React.ElementRef<typeof ToastPrimitives.Description>,
104
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
105
+ >(({ className, ...props }, ref) => (
106
+ <ToastPrimitives.Description
107
+ ref={ref}
108
+ className={cn("text-sm opacity-90", className)}
109
+ {...props}
110
+ />
111
+ ))
112
+ ToastDescription.displayName = ToastPrimitives.Description.displayName
113
+
114
+ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
115
+
116
+ type ToastActionElement = React.ReactElement<typeof ToastAction>
117
+
118
+ export {
119
+ type ToastProps,
120
+ type ToastActionElement,
121
+ ToastProvider,
122
+ ToastViewport,
123
+ Toast,
124
+ ToastTitle,
125
+ ToastDescription,
126
+ ToastClose,
127
+ ToastAction,
128
+ }
components/ui/toaster.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useToast } from "@/hooks/use-toast";
4
+ import {
5
+ Toast,
6
+ ToastClose,
7
+ ToastDescription,
8
+ ToastProvider,
9
+ ToastTitle,
10
+ ToastViewport,
11
+ } from "@/components/ui/toast";
12
+
13
+ export function Toaster() {
14
+ const { toasts } = useToast();
15
+
16
+ return (
17
+ <ToastProvider>
18
+ {toasts.map(function ({ id, title, description, action, ...props }) {
19
+ return (
20
+ <Toast key={id} {...props}>
21
+ <div className="grid gap-1">
22
+ {title && <ToastTitle>{title}</ToastTitle>}
23
+ {description && (
24
+ <ToastDescription>{description}</ToastDescription>
25
+ )}
26
+ </div>
27
+ {action}
28
+ <ToastClose />
29
+ </Toast>
30
+ );
31
+ })}
32
+ <ToastViewport />
33
+ </ToastProvider>
34
+ );
35
+ }
database.types.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Json =
2
+ | string
3
+ | number
4
+ | boolean
5
+ | null
6
+ | { [key: string]: Json | undefined }
7
+ | Json[]
8
+
9
+ export type Database = {
10
+ public: {
11
+ Tables: {
12
+ user: {
13
+ Row: {
14
+ created_at: string
15
+ email: string | null
16
+ id: number
17
+ name: string | null
18
+ password: string | null
19
+ position: string | null
20
+ updated_at: string | null
21
+ }
22
+ Insert: {
23
+ created_at?: string
24
+ email?: string | null
25
+ id?: number
26
+ name?: string | null
27
+ password?: string | null
28
+ position?: string | null
29
+ updated_at?: string | null
30
+ }
31
+ Update: {
32
+ created_at?: string
33
+ email?: string | null
34
+ id?: number
35
+ name?: string | null
36
+ password?: string | null
37
+ position?: string | null
38
+ updated_at?: string | null
39
+ }
40
+ Relationships: []
41
+ }
42
+ }
43
+ Views: {
44
+ [_ in never]: never
45
+ }
46
+ Functions: {
47
+ [_ in never]: never
48
+ }
49
+ Enums: {
50
+ [_ in never]: never
51
+ }
52
+ CompositeTypes: {
53
+ [_ in never]: never
54
+ }
55
+ }
56
+ }
57
+
58
+ type PublicSchema = Database[Extract<keyof Database, "public">]
59
+
60
+ export type Tables<
61
+ PublicTableNameOrOptions extends
62
+ | keyof (PublicSchema["Tables"] & PublicSchema["Views"])
63
+ | { schema: keyof Database },
64
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
65
+ ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
66
+ Database[PublicTableNameOrOptions["schema"]]["Views"])
67
+ : never = never,
68
+ > = PublicTableNameOrOptions extends { schema: keyof Database }
69
+ ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
70
+ Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
71
+ Row: infer R
72
+ }
73
+ ? R
74
+ : never
75
+ : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
76
+ PublicSchema["Views"])
77
+ ? (PublicSchema["Tables"] &
78
+ PublicSchema["Views"])[PublicTableNameOrOptions] extends {
79
+ Row: infer R
80
+ }
81
+ ? R
82
+ : never
83
+ : never
84
+
85
+ export type TablesInsert<
86
+ PublicTableNameOrOptions extends
87
+ | keyof PublicSchema["Tables"]
88
+ | { schema: keyof Database },
89
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
90
+ ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
91
+ : never = never,
92
+ > = PublicTableNameOrOptions extends { schema: keyof Database }
93
+ ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
94
+ Insert: infer I
95
+ }
96
+ ? I
97
+ : never
98
+ : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
99
+ ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
100
+ Insert: infer I
101
+ }
102
+ ? I
103
+ : never
104
+ : never
105
+
106
+ export type TablesUpdate<
107
+ PublicTableNameOrOptions extends
108
+ | keyof PublicSchema["Tables"]
109
+ | { schema: keyof Database },
110
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
111
+ ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
112
+ : never = never,
113
+ > = PublicTableNameOrOptions extends { schema: keyof Database }
114
+ ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
115
+ Update: infer U
116
+ }
117
+ ? U
118
+ : never
119
+ : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
120
+ ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
121
+ Update: infer U
122
+ }
123
+ ? U
124
+ : never
125
+ : never
126
+
127
+ export type Enums<
128
+ PublicEnumNameOrOptions extends
129
+ | keyof PublicSchema["Enums"]
130
+ | { schema: keyof Database },
131
+ EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
132
+ ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
133
+ : never = never,
134
+ > = PublicEnumNameOrOptions extends { schema: keyof Database }
135
+ ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
136
+ : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
137
+ ? PublicSchema["Enums"][PublicEnumNameOrOptions]
138
+ : never
hooks/use-toast.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ // Inspired by react-hot-toast library
4
+ import * as React from "react"
5
+
6
+ import type {
7
+ ToastActionElement,
8
+ ToastProps,
9
+ } from "@/components/ui/toast"
10
+
11
+ const TOAST_LIMIT = 1
12
+ const TOAST_REMOVE_DELAY = 1000000
13
+
14
+ type ToasterToast = ToastProps & {
15
+ id: string
16
+ title?: React.ReactNode
17
+ description?: React.ReactNode
18
+ action?: ToastActionElement
19
+ }
20
+
21
+ const actionTypes = {
22
+ ADD_TOAST: "ADD_TOAST",
23
+ UPDATE_TOAST: "UPDATE_TOAST",
24
+ DISMISS_TOAST: "DISMISS_TOAST",
25
+ REMOVE_TOAST: "REMOVE_TOAST",
26
+ } as const
27
+
28
+ let count = 0
29
+
30
+ function genId() {
31
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
32
+ return count.toString()
33
+ }
34
+
35
+ type ActionType = typeof actionTypes
36
+
37
+ type Action =
38
+ | {
39
+ type: ActionType["ADD_TOAST"]
40
+ toast: ToasterToast
41
+ }
42
+ | {
43
+ type: ActionType["UPDATE_TOAST"]
44
+ toast: Partial<ToasterToast>
45
+ }
46
+ | {
47
+ type: ActionType["DISMISS_TOAST"]
48
+ toastId?: ToasterToast["id"]
49
+ }
50
+ | {
51
+ type: ActionType["REMOVE_TOAST"]
52
+ toastId?: ToasterToast["id"]
53
+ }
54
+
55
+ interface State {
56
+ toasts: ToasterToast[]
57
+ }
58
+
59
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
60
+
61
+ const addToRemoveQueue = (toastId: string) => {
62
+ if (toastTimeouts.has(toastId)) {
63
+ return
64
+ }
65
+
66
+ const timeout = setTimeout(() => {
67
+ toastTimeouts.delete(toastId)
68
+ dispatch({
69
+ type: "REMOVE_TOAST",
70
+ toastId: toastId,
71
+ })
72
+ }, TOAST_REMOVE_DELAY)
73
+
74
+ toastTimeouts.set(toastId, timeout)
75
+ }
76
+
77
+ export const reducer = (state: State, action: Action): State => {
78
+ switch (action.type) {
79
+ case "ADD_TOAST":
80
+ return {
81
+ ...state,
82
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83
+ }
84
+
85
+ case "UPDATE_TOAST":
86
+ return {
87
+ ...state,
88
+ toasts: state.toasts.map((t) =>
89
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
90
+ ),
91
+ }
92
+
93
+ case "DISMISS_TOAST": {
94
+ const { toastId } = action
95
+
96
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
97
+ // but I'll keep it here for simplicity
98
+ if (toastId) {
99
+ addToRemoveQueue(toastId)
100
+ } else {
101
+ state.toasts.forEach((toast) => {
102
+ addToRemoveQueue(toast.id)
103
+ })
104
+ }
105
+
106
+ return {
107
+ ...state,
108
+ toasts: state.toasts.map((t) =>
109
+ t.id === toastId || toastId === undefined
110
+ ? {
111
+ ...t,
112
+ open: false,
113
+ }
114
+ : t
115
+ ),
116
+ }
117
+ }
118
+ case "REMOVE_TOAST":
119
+ if (action.toastId === undefined) {
120
+ return {
121
+ ...state,
122
+ toasts: [],
123
+ }
124
+ }
125
+ return {
126
+ ...state,
127
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
128
+ }
129
+ }
130
+ }
131
+
132
+ const listeners: Array<(state: State) => void> = []
133
+
134
+ let memoryState: State = { toasts: [] }
135
+
136
+ function dispatch(action: Action) {
137
+ memoryState = reducer(memoryState, action)
138
+ listeners.forEach((listener) => {
139
+ listener(memoryState)
140
+ })
141
+ }
142
+
143
+ type Toast = Omit<ToasterToast, "id">
144
+
145
+ function toast({ ...props }: Toast) {
146
+ const id = genId()
147
+
148
+ const update = (props: ToasterToast) =>
149
+ dispatch({
150
+ type: "UPDATE_TOAST",
151
+ toast: { ...props, id },
152
+ })
153
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154
+
155
+ dispatch({
156
+ type: "ADD_TOAST",
157
+ toast: {
158
+ ...props,
159
+ id,
160
+ open: true,
161
+ onOpenChange: (open) => {
162
+ if (!open) dismiss()
163
+ },
164
+ },
165
+ })
166
+
167
+ return {
168
+ id: id,
169
+ dismiss,
170
+ update,
171
+ }
172
+ }
173
+
174
+ function useToast() {
175
+ const [state, setState] = React.useState<State>(memoryState)
176
+
177
+ React.useEffect(() => {
178
+ listeners.push(setState)
179
+ return () => {
180
+ const index = listeners.indexOf(setState)
181
+ if (index > -1) {
182
+ listeners.splice(index, 1)
183
+ }
184
+ }
185
+ }, [state])
186
+
187
+ return {
188
+ ...state,
189
+ toast,
190
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191
+ }
192
+ }
193
+
194
+ export { useToast, toast }
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
+ }
middleware.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from "next/server";
2
+ import { updateSession } from "@/utils/supabase/middleware";
3
+
4
+ export async function middleware(request: NextRequest) {
5
+ return await updateSession(request);
6
+ }
7
+
8
+ export const config = {
9
+ matcher: [
10
+ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
11
+ ],
12
+ };
next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/basic-features/typescript for more information.
next.config.mjs ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {};
3
+
4
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "admin-ui",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "@hookform/resolvers": "^3.4.2",
13
+ "@radix-ui/react-dialog": "^1.0.5",
14
+ "@radix-ui/react-dropdown-menu": "^2.1.7",
15
+ "@radix-ui/react-icons": "^1.3.0",
16
+ "@radix-ui/react-label": "^2.0.2",
17
+ "@radix-ui/react-navigation-menu": "^1.1.4",
18
+ "@radix-ui/react-slot": "^1.0.2",
19
+ "@radix-ui/react-toast": "^1.2.7",
20
+ "@supabase/ssr": "^0.3.0",
21
+ "class-variance-authority": "^0.7.0",
22
+ "clsx": "^2.1.1",
23
+ "jose": "^5.4.0",
24
+ "lucide-react": "^0.383.0",
25
+ "next": "14.2.3",
26
+ "react": "^18",
27
+ "react-dom": "^18",
28
+ "react-hook-form": "^7.51.5",
29
+ "sharp": "^0.34.1",
30
+ "tailwind-merge": "^2.3.0",
31
+ "tailwindcss-animate": "^1.0.7",
32
+ "zod": "^3.23.8"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20",
36
+ "@types/react": "^18",
37
+ "@types/react-dom": "^18",
38
+ "eslint": "^8",
39
+ "eslint-config-next": "14.2.3",
40
+ "postcss": "^8",
41
+ "prettier": "^3.3.0",
42
+ "prettier-plugin-tailwindcss": "^0.6.1",
43
+ "supabase": "^1.172.2",
44
+ "tailwindcss": "^3.4.1",
45
+ "typescript": "^5"
46
+ }
47
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ },
6
+ };
7
+
8
+ export default config;
public/logo-pnp-small.webp ADDED
public/logo-pnp.webp ADDED
public/next.svg ADDED
public/vercel.svg ADDED