first commit
This commit is contained in:
commit
8336243ad5
|
|
@ -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)}")
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"builds": [
|
||||
{
|
||||
"src": "api/index.py",
|
||||
"use": "@vercel/python"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "api/index.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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"),
|
||||
},
|
||||
});
|
||||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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");
|
||||
|
|
@ -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";
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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">
|
||||
← Kembali ke halaman utama
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 pengalaman—ada 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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());
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -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).*)'],
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue