first commit

This commit is contained in:
Muhammad Rizal Fahlevi 2026-08-26 15:26:04 +07:00
commit 8336243ad5
82 changed files with 6264 additions and 0 deletions

190
be-nlp/api/index.py Normal file
View File

@ -0,0 +1,190 @@
import os
import re
import string
import joblib
import requests
import nltk
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from nltk.tokenize import word_tokenize
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
# ---------------------------------------------------------
# 1. KONFIGURASI ENVIRONMENT & VERCEL BLOB
# ---------------------------------------------------------
BLOB_TOKEN = os.environ.get("BLOB_READ_WRITE_TOKEN", "vercel_blob_rw_rtoKzdsaGXwvNykn_6VpBFIALjlA8NPCMtwJmUVTOzisxfe")
MODEL_FILENAME = "model_sentiment.pkl"
MODEL_PATH = f"/tmp/{MODEL_FILENAME}"
# ---------------------------------------------------------
# 2. KONFIGURASI NLTK UNTUK VERCEL (SERVERLESS)
# ---------------------------------------------------------
nltk_data_path = '/tmp/nltk_data'
os.makedirs(nltk_data_path, exist_ok=True)
nltk.data.path.append(nltk_data_path)
try:
nltk.data.find('tokenizers/punkt')
except LookupError:
nltk.download('punkt', download_dir=nltk_data_path)
try:
nltk.data.find('tokenizers/punkt_tab')
except LookupError:
nltk.download('punkt_tab', download_dir=nltk_data_path)
# ---------------------------------------------------------
# 3. INISIALISASI SASTRAWI GLOBAL (PENCEGAH TIMEOUT VERCEL)
# ---------------------------------------------------------
factory = StemmerFactory()
global_stemmer = factory.create_stemmer()
app = FastAPI(title="Sentiment Analysis API")
# ---------------------------------------------------------
# 4. STATE MODEL GLOBAL
# ---------------------------------------------------------
model_state = {
"is_loaded": False,
"vectorizer": None,
"classifier": None,
"metrics": None,
"slangwords": {},
"stopwords": set(),
"data": None
}
class PredictRequest(BaseModel):
texts: list[str]
# ---------------------------------------------------------
# 5. FUNGSI UNDUH & MUAT MODEL DARI VERCEL BLOB
# ---------------------------------------------------------
def load_model_from_blob():
try:
headers = {"Authorization": f"Bearer {BLOB_TOKEN}"}
list_url = "https://blob.vercel-storage.com"
response = requests.get(list_url, headers=headers)
response.raise_for_status()
blobs = response.json().get('blobs', [])
pkl_url = next((b['url'] for b in blobs if b['pathname'].endswith(MODEL_FILENAME)), None)
if not pkl_url:
raise Exception(f"File {MODEL_FILENAME} tidak ditemukan di Vercel Blob.")
pkl_response = requests.get(pkl_url)
pkl_response.raise_for_status()
with open(MODEL_PATH, 'wb') as f:
f.write(pkl_response.content)
loaded_data = joblib.load(MODEL_PATH)
model_state["vectorizer"] = loaded_data['vectorizer']
model_state["classifier"] = loaded_data['classifier']
model_state["metrics"] = loaded_data['metrics']
model_state["slangwords"] = loaded_data['preprocessing_assets']['slangwords']
model_state["stopwords"] = loaded_data['preprocessing_assets']['stopwords']
model_state["data"] = loaded_data.get('data')
model_state["is_loaded"] = True
print("Model berhasil dimuat dari Vercel Blob!")
except Exception as e:
print(f"Gagal memuat model: {e}")
raise e
# ---------------------------------------------------------
# 6. PIPELINE PRAPEMROSESAN
# ---------------------------------------------------------
def cleaningText(text):
text = re.sub(r'@[A-Za-z0-9]+', ' ', text)
text = re.sub(r'#[A-Za-z0-9]+', ' ', text)
text = re.sub(r'RT[\s]', ' ', text)
text = re.sub(r"http\S+", ' ', text)
text = re.sub(r'[0-9]+', ' ', text)
text = re.sub(r'[^\w\s]', ' ', text)
text = text.replace('\n', ' ')
text = text.translate(str.maketrans('', '', string.punctuation))
return text.strip(' ')
def casefoldingText(text):
return text.lower()
def fast_fix_slangwords(text):
pattern = re.compile(r'\b\w+\b')
return pattern.sub(lambda x: model_state['slangwords'].get(x.group(), x.group()), text)
def fast_filteringText(text):
return [txt for txt in text if txt not in model_state['stopwords']]
def stemmingText(text_list):
return global_stemmer.stem(' '.join(text_list))
def fast_preprocess_pipeline(text):
text = cleaningText(text)
text = casefoldingText(text)
text = fast_fix_slangwords(text)
text = word_tokenize(text)
text = fast_filteringText(text)
text = stemmingText(text)
return text
# ---------------------------------------------------------
# 7. ENDPOINTS
# ---------------------------------------------------------
@app.get("/")
def read_root():
return {
"status": "Online",
"message": "API Sentimen Fast API siap digunakan.",
"model_loaded": model_state["is_loaded"]
}
@app.get("/info")
def get_info():
if not model_state["is_loaded"]:
raise HTTPException(status_code=503, detail="Model belum dimuat. Silakan akses POST /reload-model.")
return {
"status": "success",
"data": model_state["data"]
}
@app.get("/metrics")
def get_metrics():
if not model_state["is_loaded"]:
raise HTTPException(status_code=503, detail="Model belum dimuat. Silakan akses POST /reload-model.")
return {
"status": "success",
"metrics": model_state["metrics"]
}
@app.post("/reload-model")
def reload_model():
try:
load_model_from_blob()
return {"status": "success", "message": "Model berhasil diunduh ulang dan dimuat."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Gagal memuat model: {str(e)}")
@app.post("/predict")
def predict(req: PredictRequest):
if not model_state["is_loaded"]:
raise HTTPException(status_code=503, detail="Model belum dimuat. Silakan akses POST /reload-model.")
results = []
try:
clean_texts = [fast_preprocess_pipeline(t) for t in req.texts]
vectorized_texts = model_state["vectorizer"].transform(clean_texts)
predictions = model_state["classifier"].predict(vectorized_texts)
for pred in predictions:
results.append({
"sentiment": pred.item()
})
return results
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error saat prediksi: {str(e)}")

8
be-nlp/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi==0.104.1
pydantic==2.5.2
joblib==1.3.2
scikit-learn
nltk==3.8.1
regex==2023.10.3
Sastrawi==1.0.1
requests==2.31.0

14
be-nlp/vercel.json Normal file
View File

@ -0,0 +1,14 @@
{
"builds": [
{
"src": "api/index.py",
"use": "@vercel/python"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "api/index.py"
}
]
}

43
fe-nlp/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

36
fe-nlp/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

1868
fe-nlp/bun.lock Normal file

File diff suppressed because it is too large Load Diff

25
fe-nlp/components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-vega",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

18
fe-nlp/eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

14
fe-nlp/next.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/api/backend/:path*',
destination: 'https://be-nlp.vercel.app/:path*',
},
];
},
};
export default nextConfig;

63
fe-nlp/package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "nlp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate",
"vercel-build": "prisma generate && prisma migrate deploy && next build"
},
"prisma": {
"seed": "bun prisma/seed.ts"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
"@tanstack/react-query": "^5.101.4",
"@types/pg": "^8.20.3",
"@vercel/blob": "^2.6.1",
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"jose": "^6.2.8",
"lucide-react": "^1.28.0",
"next": "16.3.0",
"pg": "^8.22.0",
"prisma": "^7.9.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"shadcn": "^4.16.1",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.5",
"tw-animate-css": "^1.4.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"tailwindcss": "^4",
"typescript": "^5"
},
"packageManager": "bun@1.3.9",
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
]
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

13
fe-nlp/prisma.config.ts Normal file
View File

@ -0,0 +1,13 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "bun ./prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "Model" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Model_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,31 @@
/*
Warnings:
- You are about to drop the `Model` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "Model";
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ActiveModel" (
"id" INTEGER NOT NULL DEFAULT 1,
"name" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActiveModel_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the `ActiveModel` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "ActiveModel";

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -0,0 +1,21 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(uuid())
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

32
fe-nlp/prisma/seed.ts Normal file
View File

@ -0,0 +1,32 @@
import { prisma } from '../src/lib/prisma';
import bcrypt from 'bcryptjs';
async function main() {
console.log('Seeding admin user...');
const email = 'admin@gmail.com';
const plainPassword = 'admin';
const hashedPassword = await bcrypt.hash(plainPassword, 10);
const admin = await prisma.user.upsert({
where: { email },
update: {
password: hashedPassword,
},
create: {
email,
password: hashedPassword,
},
});
console.log('Admin user seeded:', admin.email);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

1
fe-nlp/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
fe-nlp/public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
fe-nlp/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
fe-nlp/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
fe-nlp/public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Loader2, Eye, EyeOff } from 'lucide-react';
import axios from 'axios';
import Link from 'next/link';
export default function LoginPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMsg('');
setIsLoading(true);
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
// Login API
await axios.post('/api/auth/login', { email, password });
// Jika login berhasil, panggil reload-model
try {
await axios.post('/api/backend/reload-model');
} catch (e) {
console.warn('Gagal memanggil /reload-model, tapi akan tetap dilanjutkan.', e);
}
alert('Login berhasil!');
// Arahkan ke dashboard
router.push('/dashboard');
} catch (err: any) {
setErrorMsg(err.response?.data?.message || 'Terjadi kesalahan saat login.');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-screen w-full items-center justify-center bg-muted/40">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Login Admin</CardTitle>
<CardDescription>
Masukkan email dan password Anda untuk masuk ke sistem.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="admin@gmail.com"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
required
disabled={isLoading}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
disabled={isLoading}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">Toggle password visibility</span>
</Button>
</div>
</div>
{errorMsg && (
<p className="text-sm font-medium text-destructive">
{errorMsg}
</p>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Memproses...
</>
) : (
'Login'
)}
</Button>
<div className="text-center text-sm mt-4">
<Link href="/" className="text-muted-foreground hover:text-primary transition-colors">
&larr; Kembali ke halaman utama
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,64 @@
"use client";
import { useModelMetrics } from "../../../hooks/use-model-metrics"
import { useModelInfo } from "../../../hooks/use-model-info"
import { parseLabelInsights } from "../../../utils/label-parser"
import { MetricsCards } from "../../../components/dashboard/metrics-cards"
import { DatasetChart } from "../../../components/dashboard/dataset-chart"
import { InsightsPanel } from "../../../components/dashboard/insights-panel"
import { ModelReloadCard } from "../../../components/dashboard/model-reload-card"
import { Skeleton } from "@/components/ui/skeleton"
import { AlertCircle } from "lucide-react"
export default function DashboardPage() {
const { data: metricsData, isLoading: isMetricsLoading, isError: isMetricsError } = useModelMetrics();
const { data: infoData, isLoading: isInfoLoading, isError: isInfoError } = useModelInfo();
if (isMetricsLoading || isInfoLoading) {
return (
<div className="flex flex-col space-y-6 p-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-80 w-full rounded-xl" />
<Skeleton className="h-80 w-full rounded-xl" />
</div>
</div>
);
}
if (isMetricsError || isInfoError || !metricsData || !infoData) {
return (
<div className="flex flex-col items-center justify-center h-full space-y-4 p-8 text-center text-muted-foreground">
<AlertCircle className="h-12 w-12 text-destructive" />
<p>Gagal memuat data dari server backend Vercel. Pastikan server sedang berjalan atau coba reload model.</p>
</div>
);
}
const insight = parseLabelInsights(infoData.data);
return (
<div className="flex flex-col space-y-6 p-6">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Kinerja Model Naive Bayes</h2>
<p className="text-muted-foreground">
Visualisasi hasil evaluasi performa model dan distribusi dataset sentimen.
</p>
</div>
<MetricsCards metrics={metricsData.metrics} />
<DatasetChart info={infoData.data} />
<div className="grid gap-4 md:grid-cols-2">
<InsightsPanel insight={insight} />
<ModelReloadCard />
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useActiveModel } from "../../../hooks/use-blob-model";
import { ActiveModelInfo } from "../../../components/input-model/active-model-info";
import { ModelUploadForm } from "../../../components/input-model/model-upload-form";
import { Loader2 } from "lucide-react";
export default function InputModelPage() {
const { data: activeModel, isLoading, isError } = useActiveModel();
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] space-y-4 text-center">
<p className="text-destructive font-medium">Gagal mengambil informasi model dari Vercel Blob.</p>
</div>
);
}
const hasExistingModel = !!activeModel;
return (
<div className="flex flex-col space-y-6 p-6 max-w-4xl">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Input Model</h2>
<p className="text-muted-foreground">
Kelola file model Naive Bayes yang digunakan oleh sistem untuk menganalisis sentimen.
</p>
</div>
<div className="flex flex-col space-y-6 mt-4">
{hasExistingModel && activeModel && (
<ActiveModelInfo model={activeModel} />
)}
<ModelUploadForm hasExistingModel={hasExistingModel} />
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import { SidebarProvider } from "@/components/ui/sidebar"
import { AppSidebar } from "../../components/layout/app-sidebar"
import { AppHeader } from "../../components/layout/app-header"
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from "react";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider>
<div className="flex min-h-screen w-full bg-muted/20">
<AppSidebar />
<div className="flex w-full flex-col">
<AppHeader />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
</SidebarProvider>
</QueryClientProvider>
)
}

View File

@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { signJwt } from '@/lib/jwt';
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json({ message: 'Email dan password harus diisi.' }, { status: 400 });
}
// Cari user
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json({ message: 'Kredensial tidak valid.' }, { status: 401 });
}
// Verifikasi password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json({ message: 'Kredensial tidak valid.' }, { status: 401 });
}
// Buat JWT Token
const token = await signJwt({ id: user.id, email: user.email });
// Set cookie
const response = NextResponse.json({ message: 'Login berhasil.', user: { email: user.email } }, { status: 200 });
// Cookie valid 24 jam (86400 detik)
response.cookies.set({
name: 'auth-token',
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24, // 24 hours
});
return response;
} catch {
return NextResponse.json({ message: 'Terjadi kesalahan pada server.' }, { status: 500 });
}
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ message: 'Logout berhasil.' }, { status: 200 });
// Hapus cookie
response.cookies.delete('auth-token');
return response;
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { list, put, del } from '@vercel/blob';
export async function GET() {
try {
const { blobs } = await list();
// Jika tidak ada model, return null
if (blobs.length === 0) {
return NextResponse.json({ status: 'success', data: null });
}
// Return model pertama (satu-satunya file yang diizinkan)
return NextResponse.json({ status: 'success', data: blobs[0] });
} catch {
return NextResponse.json({ status: 'error', message: 'Gagal mengambil data model.' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ status: 'error', message: 'File tidak ditemukan.' }, { status: 400 });
}
// Cek model lama dan hapus jika ada
const { blobs } = await list();
if (blobs.length > 0) {
// Hapus semua blob sebelumnya untuk memastikan hanya ada 1 file
await Promise.all(blobs.map(blob => del(blob.url)));
}
// Upload model baru dengan nama statis
const blob = await put('model_sentiment.pkl', file, { access: 'public' });
return NextResponse.json({ status: 'success', data: blob });
} catch {
return NextResponse.json({ status: 'error', message: 'Gagal mengunggah model.' }, { status: 500 });
}
}

BIN
fe-nlp/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

130
fe-nlp/src/app/globals.css Normal file
View File

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

32
fe-nlp/src/app/layout.tsx Normal file
View File

@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

31
fe-nlp/src/app/page.tsx Normal file
View File

@ -0,0 +1,31 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { HeroSection } from '@/components/landing-page/hero-section';
import { ContextSection } from '@/components/landing-page/context-section';
import { EducationSection } from '@/components/landing-page/education-section';
import { Footer } from '@/components/landing-page/footer';
import { PublicNavbar } from '@/components/layout/public-navbar';
import { cookies } from 'next/headers';
export default async function Home() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token');
const isLoggedIn = !!token;
return (
<div className="min-h-screen flex flex-col font-sans bg-background text-foreground">
{/* Header / Navbar */}
<PublicNavbar isLoggedIn={isLoggedIn} />
{/* Main Content */}
<main className="flex-1 flex flex-col items-center">
<HeroSection />
<ContextSection />
<EducationSection />
</main>
{/* Footer */}
<Footer />
</div>
);
}

View File

@ -0,0 +1,25 @@
import { cookies } from 'next/headers';
import { PublicNavbar } from '@/components/layout/public-navbar';
import { Footer } from '@/components/landing-page/footer';
import { PlaygroundSection } from '@/components/landing-page/playground/playground-section';
export default async function PlaygroundPage() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token');
const isLoggedIn = !!token;
return (
<div className="min-h-screen flex flex-col font-sans bg-background text-foreground">
{/* Header / Navbar */}
<PublicNavbar isLoggedIn={isLoggedIn} />
{/* Main Content */}
<main className="flex-1 flex flex-col items-center">
<PlaygroundSection />
</main>
{/* Footer */}
<Footer />
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { DatasetInfo } from "../../types/model"
interface DatasetChartProps {
info: DatasetInfo;
}
const COLORS = ['#ef4444', '#22c55e', '#3b82f6', '#f59e0b', '#8b5cf6'];
export function DatasetChart({ info }: DatasetChartProps) {
// Prepare data for Pie Chart (Label Distribution)
const labelData = Object.keys(info.label_size).map((key, index) => ({
name: `Label ${key}`,
value: info.label_size[key],
color: COLORS[index % COLORS.length]
}));
// Prepare data for Bar Chart (Train vs Test)
const splitData = [
{ name: 'Data Latih (Train)', value: info.train_size, fill: '#3b82f6' },
{ name: 'Data Uji (Test)', value: info.test_size, fill: '#f59e0b' }
];
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Distribusi Label Sentimen</CardTitle>
<CardDescription>Porsi jumlah sampel untuk setiap label pada dataset</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={labelData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{labelData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<RechartsTooltip formatter={(value) => [`${value} sampel`, 'Jumlah']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pembagian Dataset (Train vs Test)</CardTitle>
<CardDescription>Rasio data yang digunakan untuk melatih dan menguji model</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={splitData} margin={{ top: 20, right: 30, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" />
<YAxis />
<RechartsTooltip cursor={{fill: 'transparent'}} formatter={(value) => [`${value} sampel`, 'Jumlah']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{splitData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { SummaryInsight } from "../../types/model"
import { Info } from "lucide-react"
interface InsightsPanelProps {
insight: SummaryInsight;
}
export function InsightsPanel({ insight }: InsightsPanelProps) {
const getBadgeColor = (sentiment: string) => {
if (sentiment === 'Positif') return 'bg-green-500 hover:bg-green-600';
if (sentiment === 'Negatif') return 'bg-red-500 hover:bg-red-600';
return 'bg-blue-500 hover:bg-blue-600';
};
return (
<Card className="bg-primary/5 border-primary/20">
<CardHeader>
<div className="flex items-center gap-2">
<Info className="h-5 w-5 text-primary" />
<CardTitle>Kesimpulan Analisis Dataset</CardTitle>
</div>
<CardDescription>Rangkuman dari distribusi data dan karakteristik dataset sentimen.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 rounded-lg bg-background border shadow-sm">
<p className="text-sm font-medium leading-relaxed">
{insight.conclusionText}
</p>
<div className="mt-3">
<Badge className={getBadgeColor(insight.dominantSentiment)}>
Dominan {insight.dominantSentiment}
</Badge>
</div>
</div>
<div className="grid gap-2 text-sm text-muted-foreground mt-4">
<p> Total sampel data yang dianalisis: <strong className="text-foreground">{insight.totalData}</strong> sampel.</p>
<p> Komposisi Data Latih (Train) sebesar <strong className="text-foreground">{insight.trainRatioPercentage.toFixed(1)}%</strong> dan Data Uji (Test) sebesar <strong className="text-foreground">{insight.testRatioPercentage.toFixed(1)}%</strong>.</p>
<div className="mt-2 space-y-1">
<p className="font-semibold text-foreground">Rincian Per Label:</p>
{insight.insightsList.map((text, idx) => (
<p key={idx}>- {text}</p>
))}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { ModelMetrics } from "../../types/model"
import { calculateMetricsDerivatives } from "../../utils/metrics-calculator"
interface MetricsCardsProps {
metrics: ModelMetrics;
}
export function MetricsCards({ metrics }: MetricsCardsProps) {
const derived = calculateMetricsDerivatives(metrics);
const cards = [
{ title: "Accuracy", value: derived.accuracyPercentage, description: "Ketepatan prediksi keseluruhan" },
{ title: "Precision", value: derived.precisionPercentage, description: "Ketepatan prediksi sentimen" },
{ title: "Recall", value: derived.recallPercentage, description: "Sensitivitas model" },
{ title: "F1-Score", value: derived.f1ScorePercentage, description: "Keseimbangan Precision & Recall" },
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{card.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value.toFixed(2)}%</div>
<p className="text-xs text-muted-foreground mb-3">
{card.description}
</p>
<Progress value={card.value} className="h-2" />
</CardContent>
</Card>
))}
<Card className="md:col-span-2 lg:col-span-4 bg-muted/50 border-destructive/20">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium text-destructive">Error Rate</CardTitle>
</CardHeader>
<CardContent className="pb-3">
<div className="text-lg font-bold text-destructive">{derived.errorRatePercentage.toFixed(2)}%</div>
<p className="text-xs text-muted-foreground">
Tingkat kesalahan prediksi dari model Naive Bayes.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,54 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useReloadModel } from "../../hooks/use-reload-model"
import { useDashboardStore } from "../../store/use-dashboard-store"
import { RefreshCw, Server } from "lucide-react"
export function ModelReloadCard() {
const { mutate: reloadModel, isPending } = useReloadModel();
const lastReloadedAt = useDashboardStore((state) => state.lastReloadedAt);
const handleReload = () => {
reloadModel(undefined, {
onSuccess: (data) => {
alert(data.message || "Model berhasil diunduh ulang dan dimuat ke RAM Vercel.");
window.location.reload();
},
onError: () => {
alert("Terjadi kesalahan saat memuat ulang model.");
}
});
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Status Model di Vercel RAM
</CardTitle>
<CardDescription>
Model Naive Bayes saat ini dimuat dan di-cache di dalam memori server untuk mempercepat waktu prediksi.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="text-sm">
<p className="text-muted-foreground">Waktu Terakhir Reload:</p>
<p className="font-medium">
{lastReloadedAt ? lastReloadedAt.toLocaleString('id-ID') : 'Belum direload pada sesi ini'}
</p>
</div>
<Button
onClick={handleReload}
disabled={isPending}
className="w-full sm:w-auto"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isPending ? 'animate-spin' : ''}`} />
{isPending ? 'Memuat ulang...' : 'Reload Model RAM'}
</Button>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,57 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Download, FileIcon } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { BlobModelData } from "../../services/blob-service";
interface ActiveModelInfoProps {
model: BlobModelData;
}
export function ActiveModelInfo({ model }: ActiveModelInfoProps) {
const sizeInMB = (model.size / (1024 * 1024)).toFixed(2);
const uploadDate = new Date(model.uploadedAt).toLocaleString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return (
<Card className="mb-6 border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileIcon className="h-5 w-5 text-primary" />
Model Aktif Saat Ini
</CardTitle>
<CardDescription>
Berikut adalah detail file model sentimen analisis yang sedang digunakan.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground font-medium">Nama File</p>
<p className="font-semibold break-all">{model.pathname}</p>
</div>
<div>
<p className="text-muted-foreground font-medium">Ukuran</p>
<p className="font-semibold">{sizeInMB} MB</p>
</div>
<div>
<p className="text-muted-foreground font-medium">Waktu Diunggah</p>
<p className="font-semibold">{uploadDate}</p>
</div>
</div>
<div className="pt-2">
<a href={model.downloadUrl} download className={buttonVariants({ variant: "outline", className: "gap-2" })}>
<Download className="h-4 w-4" />
Unduh File Model
</a>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,89 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { UploadCloud, Loader2 } from "lucide-react";
import { useUploadModel } from "../../hooks/use-blob-model";
interface ModelUploadFormProps {
hasExistingModel: boolean;
}
export function ModelUploadForm({ hasExistingModel }: ModelUploadFormProps) {
const [file, setFile] = useState<File | null>(null);
const { mutate: uploadModel, isPending } = useUploadModel();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setFile(e.target.files[0]);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
uploadModel(file, {
onSuccess: () => {
alert("Model berhasil diunggah dan direload!");
setFile(null); // Reset form
// Reset file input element
const fileInput = document.getElementById("model-file") as HTMLInputElement;
if (fileInput) fileInput.value = "";
},
onError: () => {
alert("Terjadi kesalahan saat mengunggah model. Silakan coba lagi.");
}
});
};
return (
<Card>
<CardHeader>
<CardTitle>{hasExistingModel ? "Ganti Model" : "Unggah Model Baru"}</CardTitle>
<CardDescription>
{hasExistingModel
? "Pilih file .pkl baru untuk menggantikan model yang saat ini aktif. Model lama akan dihapus."
: "Silakan unggah file model .pkl Anda untuk digunakan oleh sistem."}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-1 space-y-1">
<Input
id="model-file"
type="file"
accept=".pkl"
onChange={handleFileChange}
disabled={isPending}
className="cursor-pointer"
/>
<p className="text-[0.8rem] text-muted-foreground">
* File ini akan secara otomatis disimpan di server dengan nama <strong>model_sentiment.pkl</strong>
</p>
</div>
<Button type="submit" disabled={!file || isPending} className="min-w-[140px]">
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Mengunggah...
</>
) : (
<>
<UploadCloud className="mr-2 h-4 w-4" />
Upload
</>
)}
</Button>
</div>
{file && (
<p className="text-sm text-muted-foreground">
File terpilih: <span className="font-medium text-foreground">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
</p>
)}
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,23 @@
export function ContextSection() {
return (
<section className="w-full py-12 md:py-24 lg:py-32 bg-muted/50 flex flex-col items-center">
<div className="container px-4 md:px-6">
<div className="mx-auto max-w-4xl space-y-8 text-center">
<div className="space-y-4">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl">
Mengenal Skolla: Ruang Belajar Digital Masa Kini.
</h2>
<div className="space-y-6 text-muted-foreground md:text-lg/relaxed leading-relaxed text-left">
<p>
Skolla adalah platform EdTech (Edukasi Teknologi) atau aplikasi bimbingan belajar online yang dirancang untuk membantu siswa SD hingga SMA, dan juga para alumni. Lewat Skolla, ribuan siswa belajar secara interaktif untuk persiapan ujian sekolah hingga tes masuk Perguruan Tinggi Negeri (UTBK).
</p>
<p>
Dengan fitur yang begitu beragam, pengguna tentu memiliki berbagai pengalamanada yang sangat puas, namun ada juga yang mungkin menemukan kendala. Di sinilah sistem Machine Learning (ML) yang ada di web ini bekerja: Membaca dan menyimpulkan jutaan pendapat tersebut secara otomatis dalam hitungan detik.
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,65 @@
import { Brain, Filter, PieChart } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function EducationSection() {
return (
<section className="w-full py-12 md:py-24 lg:py-32 flex flex-col items-center">
<div className="container px-4 md:px-6">
<div className="mx-auto max-w-5xl space-y-12">
<div className="space-y-4 text-center">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl">
Di Balik Layar: Bagaimana ML 'Membaca' Perasaan?
</h2>
<p className="mx-auto max-w-[800px] text-muted-foreground md:text-lg/relaxed">
Mesin tidak mengerti emosi seperti manusia. Bagi ML, kalimatmu hanyalah sekumpulan kata. Namun, dengan metode yang disebut Naive Bayes, sistem ini dilatih untuk mengenali 'pola kata' dari ribuan ulasan sebelumnya. Mari kita lihat prosesnya secara sederhana:
</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Filter className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl">1. Text Preprocessing</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Kalimat akan dibersihkan dari simbol, tanda baca, dan kata-kata yang tidak memiliki makna (stop words), kemudian dikembalikan ke kata dasarnya (stemming).
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Brain className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl">2. Menghitung Probabilitas</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Sistem mengevaluasi setiap kata berdasarkan data latih (dataset) yang sudah ada, menghitung seberapa sering kata tersebut muncul dalam sentimen positif atau negatif.
</p>
</CardContent>
</Card>
<Card className="sm:col-span-2 lg:col-span-1">
<CardHeader className="flex flex-row items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<PieChart className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl">3. Klasifikasi Akhir</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Berdasarkan kalkulasi Theorema Bayes, ML akan menentukan apakah kalimat tersebut secara keseluruhan lebih condong ke arah sentimen positif atau negatif.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,41 @@
import { AtSign, Mail } from 'lucide-react';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="w-full border-t bg-background py-8 md:py-12">
<div className="container px-4 md:px-6 flex flex-col items-center">
<div className="flex flex-col md:flex-row justify-between w-full max-w-5xl gap-8">
<div className="space-y-4">
<h3 className="text-lg font-semibold">Analisis Sentimen Skolla</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Membaca dan menyimpulkan jutaan pendapat pengguna secara otomatis dalam hitungan detik menggunakan ML.
</p>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Hubungi Kami</h3>
<div className="flex flex-col space-y-2 text-sm text-muted-foreground">
<a href="mailto:admin@gmail.com" className="flex items-center gap-2 hover:text-foreground transition-colors">
<Mail className="h-4 w-4" />
admin@gmail.com
</a>
<a href="https://instagram.com/admin" target="_blank" rel="noreferrer" className="flex items-center gap-2 hover:text-foreground transition-colors">
<AtSign className="h-4 w-4" />
@admin
</a>
</div>
</div>
</div>
<div className="w-full max-w-5xl mt-8 pt-8 border-t flex flex-col sm:flex-row justify-between items-center gap-4 text-xs text-muted-foreground">
<p>© {currentYear} Analisis Sentimen Skolla. Semua hak cipta dilindungi.</p>
<p>
Sumber data: <a href="https://skolla.online/" target="_blank" rel="noreferrer" className="underline hover:text-foreground">skolla.online</a>
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,31 @@
import { Button } from '@/components/ui/button';
import Link from 'next/link';
export function HeroSection() {
return (
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 flex flex-col items-center justify-center text-center">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-4">
<div className="space-y-2 max-w-3xl">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl">
Baca Perasaan Pengguna Skolla dalam Hitungan Detik.
</h1>
<p className="mx-auto max-w-[700px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed mt-4">
Ribuan orang telah menggunakan Skolla. Melalui simulasi ML sederhana ini, kamu bisa melihat bagaimana komputer dilatih untuk memahami dan mengelompokkan ribuan feedback pengguna secara otomatis.
</p>
</div>
<div className="space-y-2 mt-8">
<Link href="/playground">
<Button size="lg" className="h-12 px-8 text-base font-medium">
Coba Analisis Sekarang
</Button>
</Link>
<p className="text-xs text-muted-foreground mt-2">
Tanpa perlu login. Coba gratis sekarang.
</p>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,288 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Loader2, Upload, FileDown, Download } from 'lucide-react';
import { sentimentApi } from '@/services/api';
import { readTextsFromFile, downloadResultsAsExcel, downloadTemplateExcel } from '@/utils/excelUtils';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
export function PlaygroundSection() {
const [textInput, setTextInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [results, setResults] = useState<any[]>([]);
// Mapping Sentimen dinamis dari backend
const [sentimentMap, setSentimentMap] = useState<Record<string, string>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchInfo = async () => {
try {
const info = await sentimentApi.getInfo();
const labelSize = info.data?.label_size || {};
// Ekstrak keys dan konversi ke integer
const keys = Object.keys(labelSize).map(Number).sort((a, b) => a - b);
if (keys.length > 0) {
const minKey = keys[0];
const maxKey = keys[keys.length - 1];
const newMap: Record<string, string> = {};
keys.forEach(k => {
if (k === minKey) newMap[k] = 'Negatif';
else if (k === maxKey) newMap[k] = 'Positif';
else newMap[k] = 'Netral';
});
setSentimentMap(newMap);
return newMap;
}
} catch (error) {
console.error('Gagal mengambil info model:', error);
}
return null;
};
useEffect(() => {
fetchInfo();
}, []);
const getSentimentLabel = (value: number, currentMap: Record<string, string>) => {
if (value === undefined || value === null) return 'Tidak Diketahui';
// Gunakan mapping dari backend jika ada
if (currentMap && currentMap[String(value)]) {
return currentMap[String(value)];
}
// Fallback default jika mapping backend belum/gagal dimuat
if (value === 0) return 'Negatif';
if (value === 1 || value === 2) return 'Positif';
return 'Tidak Diketahui';
};
const handlePredictSingle = async () => {
if (!textInput.trim()) return;
setIsLoading(true);
try {
let activeMap = sentimentMap;
if (Object.keys(activeMap).length === 0) {
const loadedMap = await fetchInfo();
if (loadedMap) activeMap = loadedMap;
}
const response = await sentimentApi.predict([textInput]);
if (response && response.length > 0) {
setResults([
{
Teks: textInput,
Sentimen: getSentimentLabel(response[0].sentiment, activeMap)
}
]);
}
} catch (error: any) {
console.error('Gagal memprediksi:', error);
if (error.response?.status === 500 || error.response?.status === 503) {
alert('Server Machine Learning sedang bermasalah (Error 500/503). Silakan coba beberapa saat lagi.');
} else {
alert('Terjadi kesalahan saat memproses permintaan.');
}
} finally {
setIsLoading(false);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsLoading(true);
setResults([]);
setTextInput('');
try {
const texts = await readTextsFromFile(file);
if (texts.length === 0) {
alert('File kosong atau tidak valid.');
setIsLoading(false);
return;
}
let activeMap = sentimentMap;
if (Object.keys(activeMap).length === 0) {
const loadedMap = await fetchInfo();
if (loadedMap) activeMap = loadedMap;
}
const response = await sentimentApi.predict(texts);
const combinedResults = texts.map((text, idx) => ({
Teks: text,
Sentimen: getSentimentLabel(response[idx]?.sentiment, activeMap)
}));
setResults(combinedResults);
} catch (error: any) {
console.error('Gagal memproses file:', error);
if (error.response?.status === 500 || error.response?.status === 503) {
alert('Server Machine Learning sedang bermasalah (Error 500/503). Silakan coba beberapa saat lagi.');
} else {
alert('Terjadi kesalahan saat memproses file.');
}
} finally {
setIsLoading(false);
// Reset input file
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const applyRecommendation = (text: string) => {
setTextInput(text);
};
return (
<section className="w-full py-12 md:py-24 flex flex-col items-center">
<div className="container px-4 md:px-6 max-w-4xl space-y-8">
{/* Header Section */}
<div className="text-center space-y-4">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Uji Kalimatmu di Sini</h1>
<p className="mx-auto max-w-[700px] text-muted-foreground md:text-lg">
Ketik ulasan, keluhan, atau pujian tentang aplikasi belajar Skolla di dalam kotak ini.
Model Machine Learning (Naive Bayes) yang kami latih akan langsung menebak apakah kalimatmu bersentimen Positif atau Negatif.
</p>
</div>
{/* Input Area */}
<Card>
<CardHeader>
<CardTitle>Coba Prediksi Sentimen</CardTitle>
<CardDescription>
Masukkan teks secara manual atau import dari file Excel/TXT.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2">
<Textarea
placeholder="Ketikkan ulasan Anda di sini..."
className="min-h-[120px] resize-y"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
disabled={isLoading}
/>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<span className="text-sm font-medium text-muted-foreground self-center mr-2">Rekomendasi ulasan:</span>
<Button variant="outline" size="sm" onClick={() => applyRecommendation("Aplikasi skolla sangat membantu belajar UTBK!")} disabled={isLoading}>
Pujian
</Button>
<Button variant="outline" size="sm" onClick={() => applyRecommendation("Sering lag kalau buka video pembelajaran, tolong diperbaiki.")} disabled={isLoading}>
Keluhan
</Button>
</div>
</CardContent>
<CardFooter className="flex flex-col sm:flex-row justify-between gap-4 border-t bg-muted/20 p-6">
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<input
type="file"
accept=".xlsx, .xls, .txt"
className="hidden"
ref={fileInputRef}
onChange={handleFileUpload}
/>
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={isLoading} className="w-full sm:w-auto">
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
Import Excel / TXT
</Button>
<Button variant="ghost" size="sm" onClick={downloadTemplateExcel} disabled={isLoading}>
<FileDown className="mr-2 h-4 w-4" />
Template
</Button>
</div>
<Button onClick={handlePredictSingle} disabled={isLoading || (!textInput.trim() && results.length === 0)} className="w-full sm:w-auto">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Memproses Teks...
</>
) : (
'Mulai Analisis'
)}
</Button>
</CardFooter>
</Card>
{/* Result Area */}
{results.length > 0 && !isLoading && (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle>Hasil Prediksi</CardTitle>
<CardDescription>
Ditemukan {results.length} hasil analisis sentimen.
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => downloadResultsAsExcel(results)}>
<Download className="mr-2 h-4 w-4" />
Unduh Hasil
</Button>
</CardHeader>
<CardContent>
{results.length === 1 ? (
<div className="flex flex-col items-center justify-center p-6 bg-background rounded-lg border shadow-sm">
<span className="text-muted-foreground mb-2">Sentimen:</span>
<span className={`text-3xl font-bold ${
results[0].Sentimen === 'Positif' ? 'text-green-600' :
results[0].Sentimen === 'Negatif' ? 'text-red-600' : 'text-yellow-600'
}`}>
{results[0].Sentimen}
</span>
</div>
) : (
<div className="rounded-md border bg-background max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/3">Teks Ulasan</TableHead>
<TableHead>Sentimen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((row, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{row.Teks}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
row.Sentimen === 'Positif' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
row.Sentimen === 'Negatif' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}>
{row.Sentimen}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,12 @@
import { SidebarTrigger } from "@/components/ui/sidebar"
export function AppHeader() {
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex flex-1 items-center gap-2">
<h1 className="text-sm font-semibold">Dashboard Sentimen</h1>
</div>
</header>
)
}

View File

@ -0,0 +1,78 @@
"use client"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { LayoutDashboard, BrainCircuit, LogOut } from "lucide-react"
import axios from "axios"
import { useRouter } from "next/navigation"
// Menu items.
const items = [
{
title: "Dashboard",
url: "/dashboard",
icon: LayoutDashboard,
},
{
title: "Input Model",
url: "/input-model",
icon: BrainCircuit,
},
]
export function AppSidebar() {
const router = useRouter();
const handleLogout = async () => {
try {
await axios.post('/api/auth/logout');
router.push('/login');
} catch (error) {
console.error('Logout error:', error);
router.push('/login');
}
};
return (
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Analisis Sentimen</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton>
<a href={item.url} className="flex items-center gap-2">
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleLogout} className="text-destructive hover:text-destructive hover:bg-destructive/10">
<LogOut />
<span>Logout</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}

View File

@ -0,0 +1,47 @@
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { usePathname } from 'next/navigation';
interface PublicNavbarProps {
isLoggedIn?: boolean;
}
export function PublicNavbar({ isLoggedIn = false }: PublicNavbarProps) {
const pathname = usePathname();
const isPlayground = pathname === '/playground';
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
<Link href="/" className="flex items-center gap-2 cursor-pointer">
<span className="text-xl font-bold tracking-tight text-primary">Skolla<span className="text-foreground">Sentiment</span></span>
</Link>
<nav className="flex items-center gap-4">
{isPlayground && (
<Link href="/">
<Button variant="ghost" size="sm">
Beranda
</Button>
</Link>
)}
{!isLoggedIn && (
<Link href="/login">
<Button variant="outline" size="sm">
Login Admin
</Button>
</Link>
)}
{!isPlayground && (
<Link href="/playground">
<Button size="sm">
Mulai Analisis
</Button>
</Link>
)}
</nav>
</div>
</header>
);
}

View File

@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-normal font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1.5 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-heading font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,723 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,234 @@
"use client"
import * as React from "react"
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const toast = ToastPrimitive.createToastManager()
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
return <ToastPrimitive.Provider {...props} />
}
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
}
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
"pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
className
)}
{...props}
/>
)
}
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
className
)}
{...props}
/>
)
}
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
return (
<ToastPrimitive.Content
data-slot="toast-content"
className={cn(
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
className
)}
{...props}
/>
)
}
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
return (
<ToastPrimitive.Title
data-slot="toast-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
)
}
function ToastDescription({
className,
...props
}: ToastPrimitive.Description.Props) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function ToastAction({
className,
render = <Button variant="outline" size="sm" />,
...props
}: ToastPrimitive.Action.Props) {
return (
<ToastPrimitive.Action
data-slot="toast-action"
render={render}
className={cn("shrink-0", className)}
{...props}
/>
)
}
function ToastClose({
className,
children,
render = <Button variant="ghost" size="icon-sm" />,
...props
}: ToastPrimitive.Close.Props) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close toast"
render={render}
className={cn(
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<XIcon aria-hidden="true" />
)}
</ToastPrimitive.Close>
)
}
function ToastIcon({ type }: { type: string | undefined }) {
let icon: React.ReactNode = null
if (type === "success") {
icon = (
<CircleCheckIcon aria-hidden="true" />
)
}
if (type === "info") {
icon = (
<InfoIcon aria-hidden="true" />
)
}
if (type === "warning") {
icon = (
<TriangleAlertIcon aria-hidden="true" />
)
}
if (type === "error") {
icon = (
<OctagonXIcon className="text-destructive" aria-hidden="true" />
)
}
if (type === "loading") {
icon = (
<Loader2Icon className="animate-spin" aria-hidden="true" />
)
}
if (!icon) {
return null
}
return (
<span
data-slot="toast-icon"
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
>
{icon}
</span>
)
}
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager()
return toasts.map((toastItem) => (
<Toast key={toastItem.id} toast={toastItem}>
<ToastContent>
<ToastIcon type={toastItem.type} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<ToastTitle />
<ToastDescription />
</div>
<ToastAction />
<ToastClose />
</ToastContent>
</Toast>
))
}
function Toaster({
children,
toastManager = toast,
...props
}: ToastPrimitive.Provider.Props) {
return (
<ToastProvider toastManager={toastManager} {...props}>
{children}
<ToastPortal>
<ToastViewport>
<ToastList />
</ToastViewport>
</ToastPortal>
</ToastProvider>
)
}
const createToastManager = ToastPrimitive.createToastManager
const useToastManager = ToastPrimitive.useToastManager
export {
Toaster,
Toast,
ToastAction,
ToastClose,
ToastContent,
ToastDescription,
ToastPortal,
ToastProvider,
ToastTitle,
ToastViewport,
createToastManager,
toast,
useToastManager,
}

View File

@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,34 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getActiveModel, uploadModel } from '../services/blob-service';
import { reloadModel } from '../services/model-service';
export const useActiveModel = () => {
return useQuery({
queryKey: ['active-blob-model'],
queryFn: getActiveModel,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: true,
});
};
export const useUploadModel = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => uploadModel(file),
onSuccess: async () => {
// Refresh model info
queryClient.invalidateQueries({ queryKey: ['active-blob-model'] });
// Auto-reload backend model
try {
await reloadModel();
// optionally invalidate metrics/info if they are loaded
queryClient.invalidateQueries({ queryKey: ['metrics'] });
queryClient.invalidateQueries({ queryKey: ['info'] });
} catch (error) {
console.error("Failed to automatically reload the model on the backend.", error);
}
},
});
};

View File

@ -0,0 +1,20 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { fetchInfo } from '../services/model-service';
export const useModelInfo = () => {
return useQuery({
queryKey: ['info'],
queryFn: fetchInfo,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
};

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { fetchMetrics } from '../services/model-service';
export const useModelMetrics = () => {
return useQuery({
queryKey: ['metrics'],
queryFn: fetchMetrics,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
};

View File

@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { reloadModel } from '../services/model-service';
import { useDashboardStore } from '../store/use-dashboard-store';
export const useReloadModel = () => {
const queryClient = useQueryClient();
const setLastReloadedAt = useDashboardStore((state) => state.setLastReloadedAt);
return useMutation({
mutationFn: reloadModel,
onSuccess: () => {
// Invalidate queries to refresh data on screen if needed
queryClient.invalidateQueries({ queryKey: ['metrics'] });
queryClient.invalidateQueries({ queryKey: ['info'] });
// Update global state
setLastReloadedAt(new Date());
},
});
};

26
fe-nlp/src/lib/jwt.ts Normal file
View File

@ -0,0 +1,26 @@
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
export const getJwtSecretKey = () => {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length === 0) {
throw new Error('Variabel environment JWT_SECRET tidak diatur.');
}
return new TextEncoder().encode(secret);
};
export async function signJwt(payload: JWTPayload, expiresIn: string | number = '24h') {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(getJwtSecretKey());
}
export async function verifyJwt(token: string) {
try {
const { payload } = await jwtVerify(token, getJwtSecretKey());
return payload;
} catch {
return null;
}
}

18
fe-nlp/src/lib/prisma.ts Normal file
View File

@ -0,0 +1,18 @@
import { PrismaClient } from '../generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const connectionString = `${process.env.DATABASE_URL}`;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ adapter });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

6
fe-nlp/src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

51
fe-nlp/src/proxy.ts Normal file
View File

@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyJwt } from './lib/jwt';
// Rute yang perlu diproteksi
const protectedRoutes = ['/dashboard', '/input-model'];
// Rute yang tidak boleh diakses kalau sudah login
const publicOnlyRoutes = ['/login'];
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtectedRoute = protectedRoutes.some(
(route) => pathname.startsWith(route)
);
const isPublicOnlyRoute = publicOnlyRoutes.some((route) => pathname.startsWith(route));
// Jika halaman butuh proteksi
if (isProtectedRoute) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const payload = await verifyJwt(token);
if (!payload) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('auth-token'); // clear invalid token
return response;
}
}
// Jika user sudah login tapi coba buka /login
if (isPublicOnlyRoute) {
const token = request.cookies.get('auth-token')?.value;
if (token) {
const payload = await verifyJwt(token);
if (payload) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

View File

@ -0,0 +1,53 @@
import axios from 'axios';
export const apiClient = axios.create({
baseURL: '/api/backend',
timeout: 60000, // 60 seconds because reload might take a while
});
let isReloading = false;
let reloadPromise: Promise<unknown> | null = null;
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Redirect to login if 401 Unauthorized
if (error.response && error.response.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(error);
}
// Check if error is 500 or 503, hasn't been retried, and is not a reload-model request itself
if (
error.response &&
(error.response.status === 500 || error.response.status === 503) &&
!originalRequest._retry &&
originalRequest.url !== '/reload-model'
) {
originalRequest._retry = true;
if (!isReloading) {
isReloading = true;
// Trigger reload model
reloadPromise = apiClient.post('/reload-model').finally(() => {
isReloading = false;
reloadPromise = null;
});
}
try {
await reloadPromise;
// Retry the original request after reload is complete
return apiClient(originalRequest);
} catch (e) {
return Promise.reject(e);
}
}
return Promise.reject(error);
}
);

View File

@ -0,0 +1,66 @@
import axios from 'axios';
const API_BASE_URL = '/api/backend';
export interface PredictResponse {
sentiment: number;
}
export interface InfoResponse {
status: string;
data: {
label_size: Record<string, number>;
train_size: number;
test_size: number;
};
}
export const sentimentApi = {
/**
* Mengambil informasi model termasuk label_size untuk pemetaan sentimen dinamis
*/
/**
* Mengambil informasi model termasuk label_size untuk pemetaan sentimen dinamis
*/
async getInfo(): Promise<InfoResponse> {
try {
const response = await axios.get<InfoResponse>(`${API_BASE_URL}/info`);
return response.data;
} catch (error: any) {
if (error.response && (error.response.status === 500 || error.response.status === 503)) {
console.warn('Get info merespons 500/503. Mencoba reload model...');
await this.reloadModel();
const retryResponse = await axios.get<InfoResponse>(`${API_BASE_URL}/info`);
return retryResponse.data;
}
throw error;
}
},
/**
* Memanggil ulang model jika terjadi kesalahan 500/503 pada predict
*/
async reloadModel(): Promise<void> {
await axios.post(`${API_BASE_URL}/reload-model`);
},
/**
* Melakukan prediksi batch. Jika mendapat 500/503, otomatis reload model dan coba lagi.
*/
async predict(texts: string[]): Promise<PredictResponse[]> {
try {
const response = await axios.post<PredictResponse[]>(`${API_BASE_URL}/predict`, { texts });
return response.data;
} catch (error: any) {
if (error.response && (error.response.status === 500 || error.response.status === 503)) {
console.warn('Backend merespons dengan 500/503. Mencoba reload model...');
// Coba reload model
await this.reloadModel();
// Coba kembali predict
const retryResponse = await axios.post<PredictResponse[]>(`${API_BASE_URL}/predict`, { texts });
return retryResponse.data;
}
throw error;
}
}
};

View File

@ -0,0 +1,46 @@
import axios from 'axios';
// We don't use the apiClient with the backend proxy for vercel blob,
// because this goes to our own Next.js API route.
export const blobApiClient = axios.create({
baseURL: '/api',
timeout: 60000, // 60 seconds because file upload might take a while
});
export interface BlobModelData {
url: string;
downloadUrl: string;
pathname: string;
size: number;
uploadedAt: string;
}
export interface GetBlobModelResponse {
status: string;
data: BlobModelData | null;
message?: string;
}
export interface UploadBlobModelResponse {
status: string;
data: BlobModelData;
message?: string;
}
export const getActiveModel = async (): Promise<BlobModelData | null> => {
const response = await blobApiClient.get<GetBlobModelResponse>('/blob-model');
return response.data.data;
};
export const uploadModel = async (file: File): Promise<BlobModelData> => {
const formData = new FormData();
formData.append('file', file);
const response = await blobApiClient.post<UploadBlobModelResponse>('/blob-model', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data.data;
};

View File

@ -0,0 +1,17 @@
import { apiClient } from './api-client';
import { MetricsResponse, InfoResponse, ReloadResponse } from '../types/model';
export const fetchMetrics = async (): Promise<MetricsResponse> => {
const response = await apiClient.get<MetricsResponse>('/metrics');
return response.data;
};
export const fetchInfo = async (): Promise<InfoResponse> => {
const response = await apiClient.get<InfoResponse>('/info');
return response.data;
};
export const reloadModel = async (): Promise<ReloadResponse> => {
const response = await apiClient.post<ReloadResponse>('/reload-model');
return response.data;
};

View File

@ -0,0 +1,15 @@
import { create } from 'zustand';
interface DashboardState {
lastReloadedAt: Date | null;
isReloading: boolean;
setLastReloadedAt: (date: Date) => void;
setReloading: (status: boolean) => void;
}
export const useDashboardStore = create<DashboardState>((set) => ({
lastReloadedAt: null,
isReloading: false,
setLastReloadedAt: (date) => set({ lastReloadedAt: date }),
setReloading: (status) => set({ isReloading: status }),
}));

46
fe-nlp/src/types/model.ts Normal file
View File

@ -0,0 +1,46 @@
export interface ModelMetrics {
accuracy: number;
precision: number;
recall: number;
f1_score: number;
}
export interface MetricsResponse {
status: string;
metrics: ModelMetrics;
}
export interface DatasetInfo {
label_size: Record<string, number>;
train_size: number;
test_size: number;
}
export interface InfoResponse {
status: string;
data: DatasetInfo;
}
export interface ReloadResponse {
status: string;
message: string;
}
export interface DynamicLabelInsight {
label: string;
count: number;
percentage: number;
sentimentCategory: 'Positif' | 'Negatif' | 'Netral';
}
export interface SummaryInsight {
dominantSentiment: 'Positif' | 'Negatif' | 'Seimbang';
dominantLabel: string;
dominantPercentage: number;
conclusionText: string;
totalData: number;
trainRatioPercentage: number;
testRatioPercentage: number;
errorRatePercentage: number;
insightsList: string[];
}

View File

@ -0,0 +1,90 @@
import * as XLSX from 'xlsx';
/**
* Membaca file Excel atau TXT dan mengembalikan array of string dari baris/kolom pertama
*/
export async function readTextsFromFile(file: File): Promise<string[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
// Cek jika ini adalah file txt
if (file.name.endsWith('.txt')) {
const text = data as string;
// Pisahkan berdasarkan baris baru dan bersihkan baris kosong
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
resolve(lines);
return;
}
// Proses sebagai file excel
const workbook = XLSX.read(data, { type: 'binary' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Mengubah sheet menjadi array 2D
const json: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
// Ambil data dari kolom pertama (index 0) dan bersihkan nilai kosong/undefined
const texts: string[] = [];
for (let i = 0; i < json.length; i++) {
const row = json[i];
if (row && row[0] !== undefined && row[0] !== null && String(row[0]).trim() !== '') {
const textValue = String(row[0]).trim();
// Abaikan jika baris pertama adalah header (misal: "Ulasan")
if (i === 0 && (textValue.toLowerCase() === 'ulasan' || textValue.toLowerCase() === 'teks' || textValue.toLowerCase() === 'text')) {
continue;
}
texts.push(textValue);
}
}
resolve(texts);
} catch (err) {
reject(new Error('Gagal membaca file'));
}
};
reader.onerror = () => {
reject(new Error('Terjadi kesalahan saat membaca file'));
};
if (file.name.endsWith('.txt')) {
reader.readAsText(file);
} else {
reader.readAsBinaryString(file);
}
});
}
/**
* Mengubah array of object menjadi file Excel dan mengunduhnya
*/
export function downloadResultsAsExcel(results: any[], filename: string = 'Hasil_Analisis_Sentimen.xlsx') {
const worksheet = XLSX.utils.json_to_sheet(results);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Hasil Prediksi');
// Memicu unduhan
XLSX.writeFile(workbook, filename);
}
/**
* Membuat file contoh (template) dan mengunduhnya
*/
export function downloadTemplateExcel() {
const templateData = [
{ Ulasan: "Aplikasi skolla ini sangat membantu saya belajar UTBK!" },
{ Ulasan: "Terkadang loadingnya lama saat buka video pembelajaran." },
{ Ulasan: "Aplikasi nya sering eror tiba tiba." }
];
const worksheet = XLSX.utils.json_to_sheet(templateData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Template Ulasan');
XLSX.writeFile(workbook, 'Template_Input_Skolla.xlsx');
}

View File

@ -0,0 +1,62 @@
import { DatasetInfo, DynamicLabelInsight, SummaryInsight } from '../types/model';
export function parseLabelInsights(data: DatasetInfo): SummaryInsight {
const { label_size, train_size, test_size } = data;
// Sort keys to determine which is lower (Negative) and higher (Positive)
const labels = Object.keys(label_size).sort((a, b) => Number(a) - Number(b));
const totalData = train_size + test_size;
// Create an insight for each label
const insightsList: string[] = [];
const parsedLabels: DynamicLabelInsight[] = labels.map((label, index) => {
const count = label_size[label];
const percentage = (count / totalData) * 100;
// Determine category based on sorting (lowest = Negatif, highest = Positif, middle = Netral)
let category: 'Positif' | 'Negatif' | 'Netral' = 'Netral';
if (index === 0) {
category = 'Negatif';
} else if (index === labels.length - 1) {
category = 'Positif';
}
insightsList.push(`Label ${label} memiliki ${count} sampel (${percentage.toFixed(2)}%) yang merepresentasikan sentimen ${category}.`);
return {
label,
count,
percentage,
sentimentCategory: category
};
});
// Find dominant label
let dominantInsight = parsedLabels[0];
for (const item of parsedLabels) {
if (item.count > dominantInsight.count) {
dominantInsight = item;
}
}
const dominantSentiment = dominantInsight.sentimentCategory === 'Netral' ? 'Seimbang' : dominantInsight.sentimentCategory;
const conclusionText = `Berdasarkan analisis distribusi data, dataset ini memiliki kecenderungan condong ke arah sentimen ${dominantSentiment.toUpperCase()} dengan porsi ${dominantInsight.percentage.toFixed(1)}%.`;
// Provide other summary metrics
const trainRatioPercentage = (train_size / totalData) * 100;
const testRatioPercentage = (test_size / totalData) * 100;
return {
dominantSentiment,
dominantLabel: dominantInsight.label,
dominantPercentage: dominantInsight.percentage,
conclusionText,
totalData,
trainRatioPercentage,
testRatioPercentage,
errorRatePercentage: 0, // This will be calculated in metrics-calculator
insightsList
};
}

View File

@ -0,0 +1,13 @@
import { ModelMetrics } from '../types/model';
export function calculateMetricsDerivatives(metrics: ModelMetrics) {
const errorRatePercentage = (1 - metrics.accuracy) * 100;
return {
accuracyPercentage: metrics.accuracy * 100,
precisionPercentage: metrics.precision * 100,
recallPercentage: metrics.recall * 100,
f1ScorePercentage: metrics.f1_score * 100,
errorRatePercentage,
};
}

34
fe-nlp/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}