Spaces:
Sleeping
Sleeping
FauziIsyrinApridal
commited on
Commit
·
17fabdd
1
Parent(s):
f51ddc8
init
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env.local.example +5 -0
- .env.production +5 -0
- .eslintrc.json +3 -0
- .gitignore +8 -0
- .prettierrc +3 -0
- Dockerfile +54 -0
- README.md +34 -0
- app/(auth)/layout.tsx +9 -0
- app/(auth)/login/page.tsx +37 -0
- app/(main)/components/ExampleDoc.tsx +30 -0
- app/(main)/components/RagDashboard.tsx +253 -0
- app/(main)/layout.tsx +19 -0
- app/(main)/page.tsx +23 -0
- app/(main)/upload-document/page.tsx +116 -0
- app/api/cron-scraping/route.ts +85 -0
- app/api/start-scraping/route.ts +98 -0
- app/error/page.tsx +3 -0
- app/favicon.ico +0 -0
- app/globals.css +76 -0
- app/layout.tsx +22 -0
- components.json +17 -0
- components/FileTable.tsx +341 -0
- components/Footer.tsx +12 -0
- components/Navbar.tsx +27 -0
- components/SemesterFilter.tsx +178 -0
- components/SignInForm.tsx +84 -0
- components/SignOutButton.tsx +41 -0
- components/UploadForm.tsx +73 -0
- components/ui/button.tsx +57 -0
- components/ui/dialog.tsx +122 -0
- components/ui/input.tsx +25 -0
- components/ui/label.tsx +26 -0
- components/ui/navigation-menu.tsx +128 -0
- components/ui/table.tsx +120 -0
- components/ui/textarea.tsx +24 -0
- components/ui/toast.tsx +128 -0
- components/ui/toaster.tsx +35 -0
- database.types.ts +138 -0
- hooks/use-toast.ts +194 -0
- lib/utils.ts +6 -0
- middleware.ts +12 -0
- next-env.d.ts +5 -0
- next.config.mjs +4 -0
- package-lock.json +0 -0
- package.json +47 -0
- postcss.config.mjs +8 -0
- public/logo-pnp-small.webp +0 -0
- public/logo-pnp.webp +0 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
.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
|