Sync from GitHub Actions: e01f9df8b054a5718601dd513fd3516293f9eac8
Browse files- .gitattributes +0 -34
- .gitignore +49 -1
- .vercelignore +16 -0
- README.md +30 -21
- README_BACKUP.md +36 -0
- README_HF.md +27 -0
- components.json +22 -0
- dev.bat +27 -0
- eslint.config.mjs +18 -0
- next.config.ts +17 -0
- package-lock.json +0 -0
- package.json +37 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/analyzer/layout.tsx +10 -0
- src/app/analyzer/page.tsx +525 -0
- src/app/chat/layout.tsx +10 -0
- src/app/chat/page.tsx +478 -0
- src/app/globals.css +159 -0
- src/app/icon.tsx +68 -0
- src/app/layout.tsx +37 -0
- src/app/page.tsx +131 -0
- src/app/providers.tsx +39 -0
- src/app/quiz/layout.tsx +10 -0
- src/app/quiz/page.tsx +279 -0
- src/app/types/[codes]/page.tsx +147 -0
- src/app/types/layout.tsx +10 -0
- src/app/types/page.tsx +122 -0
- src/components/Footer.tsx +28 -0
- src/components/Navbar.tsx +138 -0
- src/components/ui/button.tsx +62 -0
- src/components/ui/card.tsx +92 -0
- src/components/ui/input.tsx +21 -0
- src/data/mbti.ts +401 -0
- src/lib/utils.ts +6 -0
- test_models.py +19 -0
- train_emotion.py +92 -0
- train_mbti.py +90 -0
- tsconfig.json +34 -0
- vercel.json +3 -0
.gitattributes
CHANGED
|
@@ -1,35 +1 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1 +1,49 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# Python
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
|
| 7 |
+
# dependencies
|
| 8 |
+
/node_modules
|
| 9 |
+
/.pnp
|
| 10 |
+
.pnp.*
|
| 11 |
+
.yarn/*
|
| 12 |
+
!.yarn/patches
|
| 13 |
+
!.yarn/plugins
|
| 14 |
+
!.yarn/releases
|
| 15 |
+
!.yarn/versions
|
| 16 |
+
|
| 17 |
+
# testing
|
| 18 |
+
/coverage
|
| 19 |
+
|
| 20 |
+
# next.js
|
| 21 |
+
/.next/
|
| 22 |
+
/out/
|
| 23 |
+
|
| 24 |
+
# production
|
| 25 |
+
/build
|
| 26 |
+
|
| 27 |
+
# misc
|
| 28 |
+
.DS_Store
|
| 29 |
+
*.pem
|
| 30 |
+
|
| 31 |
+
# debug
|
| 32 |
+
npm-debug.log*
|
| 33 |
+
yarn-debug.log*
|
| 34 |
+
yarn-error.log*
|
| 35 |
+
.pnpm-debug.log*
|
| 36 |
+
|
| 37 |
+
# env files (can opt-in for committing if needed)
|
| 38 |
+
.env*
|
| 39 |
+
|
| 40 |
+
# vercel
|
| 41 |
+
.vercel
|
| 42 |
+
|
| 43 |
+
# typescript
|
| 44 |
+
*.tsbuildinfo
|
| 45 |
+
next-env.d.ts
|
| 46 |
+
|
| 47 |
+
# data files
|
| 48 |
+
*.csv
|
| 49 |
+
api/data/*.pkl
|
.vercelignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore backend folder - deploy ke HF Spaces
|
| 2 |
+
api/
|
| 3 |
+
|
| 4 |
+
# Ignore Python files
|
| 5 |
+
*.py
|
| 6 |
+
*.pkl
|
| 7 |
+
*.pyc
|
| 8 |
+
__pycache__/
|
| 9 |
+
|
| 10 |
+
# Ignore Docker files
|
| 11 |
+
Dockerfile
|
| 12 |
+
.dockerignore
|
| 13 |
+
|
| 14 |
+
# Ignore training files
|
| 15 |
+
train_*.py
|
| 16 |
+
test_*.py
|
README.md
CHANGED
|
@@ -1,27 +1,36 @@
|
|
| 1 |
-
|
| 2 |
-
title: Sentimind API
|
| 3 |
-
emoji: 🧠
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: red
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
-
pinned: false
|
| 9 |
-
---
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
- `POST /api/chat` - Chat dengan AI assistant
|
| 19 |
-
- `GET /api/quiz` - Get quiz questions
|
| 20 |
-
- `POST /api/quiz` - Submit quiz answers
|
| 21 |
-
- `GET /api/youtube/{video_id}` - Analyze YouTube video
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
## Getting Started
|
| 4 |
|
| 5 |
+
First, run the development server:
|
| 6 |
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
|
| 21 |
+
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.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
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.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
README_BACKUP.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
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.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
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.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
README_HF.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Sentimind API
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Sentimind API Backend
|
| 12 |
+
|
| 13 |
+
Backend API untuk Sentimind - AI Personality Profiler.
|
| 14 |
+
|
| 15 |
+
## Endpoints
|
| 16 |
+
|
| 17 |
+
- `POST /api/predict` - Prediksi MBTI dari teks
|
| 18 |
+
- `POST /api/chat` - Chat dengan AI assistant
|
| 19 |
+
- `GET /api/quiz` - Get quiz questions
|
| 20 |
+
- `POST /api/quiz` - Submit quiz answers
|
| 21 |
+
- `GET /api/youtube/{video_id}` - Analyze YouTube video
|
| 22 |
+
|
| 23 |
+
## Environment Variables
|
| 24 |
+
|
| 25 |
+
Set these in HF Spaces Settings > Repository Secrets:
|
| 26 |
+
- `GOOGLE_API_KEY` - Gemini API key
|
| 27 |
+
- `YOUTUBE_API_KEY` - YouTube Data API key
|
components.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "stone",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"aliases": {
|
| 15 |
+
"components": "@/components",
|
| 16 |
+
"utils": "@/lib/utils",
|
| 17 |
+
"ui": "@/components/ui",
|
| 18 |
+
"lib": "@/lib",
|
| 19 |
+
"hooks": "@/hooks"
|
| 20 |
+
},
|
| 21 |
+
"registries": {}
|
| 22 |
+
}
|
dev.bat
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
title Sentimind Launcher
|
| 3 |
+
echo ==================================================
|
| 4 |
+
echo MEMULAI SENTIMIND DEV ENVIRONMENT
|
| 5 |
+
echo ==================================================
|
| 6 |
+
|
| 7 |
+
:: 1. Cek apakah Conda tersedia & Activate Environment
|
| 8 |
+
echo Mengaktifkan Conda: sentimind...
|
| 9 |
+
call conda activate sentimind
|
| 10 |
+
|
| 11 |
+
:: Cek jika activate gagal (opsional, tapi bagus buat debugging)
|
| 12 |
+
if %errorlevel% neq 0 (
|
| 13 |
+
echo Gagal activate conda 'sentimind'. Pastikan env sudah dibuat!
|
| 14 |
+
pause
|
| 15 |
+
exit /b
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
:: 2. Jalankan FastAPI di Window Baru (Port 8000)
|
| 19 |
+
:: Menggunakan 'start' agar window baru terbuka dan script ini lanjut ke bawah
|
| 20 |
+
echo Menyalakan Backend (FastAPI) di window baru...
|
| 21 |
+
start "Sentimind Backend API" cmd /k "conda activate sentimind && uvicorn api.index:app --reload --port 8000"
|
| 22 |
+
|
| 23 |
+
:: 3. Jalankan Frontend di Window Ini (Port 3000)
|
| 24 |
+
echo Menyalakan Frontend (Next.js)...
|
| 25 |
+
echo Tekan Ctrl+C di sini untuk stop Frontend.
|
| 26 |
+
echo.
|
| 27 |
+
npm run dev
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
rewrites: async () => {
|
| 5 |
+
return [
|
| 6 |
+
{
|
| 7 |
+
source: "/api/:path*",
|
| 8 |
+
destination:
|
| 9 |
+
process.env.NODE_ENV === "development"
|
| 10 |
+
? "http://127.0.0.1:8000/api/:path*"
|
| 11 |
+
: "/api/:path*",
|
| 12 |
+
},
|
| 13 |
+
];
|
| 14 |
+
},
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sentimind",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 13 |
+
"class-variance-authority": "^0.7.1",
|
| 14 |
+
"clsx": "^2.1.1",
|
| 15 |
+
"framer-motion": "^12.25.0",
|
| 16 |
+
"lucide-react": "^0.562.0",
|
| 17 |
+
"next": "16.1.0",
|
| 18 |
+
"next-themes": "^0.4.6",
|
| 19 |
+
"react": "19.2.3",
|
| 20 |
+
"react-dom": "19.2.3",
|
| 21 |
+
"react-markdown": "^10.1.0",
|
| 22 |
+
"remark-gfm": "^4.0.1",
|
| 23 |
+
"tailwind-merge": "^3.4.0",
|
| 24 |
+
"tailwindcss-animate": "^1.0.7"
|
| 25 |
+
},
|
| 26 |
+
"devDependencies": {
|
| 27 |
+
"@tailwindcss/postcss": "^4",
|
| 28 |
+
"@types/node": "^20",
|
| 29 |
+
"@types/react": "^19",
|
| 30 |
+
"@types/react-dom": "^19",
|
| 31 |
+
"eslint": "^9",
|
| 32 |
+
"eslint-config-next": "16.1.0",
|
| 33 |
+
"tailwindcss": "^4",
|
| 34 |
+
"tw-animate-css": "^1.4.0",
|
| 35 |
+
"typescript": "^5"
|
| 36 |
+
}
|
| 37 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/analyzer/layout.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Metadata } from "next";
|
| 2 |
+
|
| 3 |
+
export const metadata: Metadata = {
|
| 4 |
+
title: "Analyzer | Sentimind",
|
| 5 |
+
description: "Analyze your text to reveal hidden personality patterns.",
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
| 9 |
+
return <>{children}</>;
|
| 10 |
+
}
|
src/app/analyzer/page.tsx
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { useLanguage } from "@/app/providers";
|
| 4 |
+
import { Search, Tag, Smile, BrainCircuit, Lightbulb, BookOpen, MessageSquare, FileText, Youtube, AlertCircle, ThumbsUp, ThumbsDown } from "lucide-react";
|
| 5 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 6 |
+
|
| 7 |
+
export default function AnalysisPage() {
|
| 8 |
+
const { lang } = useLanguage();
|
| 9 |
+
const [mode, setMode] = useState<"text" | "youtube">("text");
|
| 10 |
+
|
| 11 |
+
const [inputText, setInputText] = useState("");
|
| 12 |
+
const [youtubeUrl, setYoutubeUrl] = useState("");
|
| 13 |
+
|
| 14 |
+
const [result, setResult] = useState<any>(null);
|
| 15 |
+
const [loading, setLoading] = useState(false);
|
| 16 |
+
|
| 17 |
+
const [errorType, setErrorType] = useState<string | null>(null);
|
| 18 |
+
const [backendErrorMsg, setBackendErrorMsg] = useState("");
|
| 19 |
+
const [showFullDesc, setShowFullDesc] = useState(false);
|
| 20 |
+
const [showAllComments, setShowAllComments] = useState(false);
|
| 21 |
+
|
| 22 |
+
const t = {
|
| 23 |
+
en: {
|
| 24 |
+
title: "Text Analyzer",
|
| 25 |
+
desc: "Paste your text or YouTube link. Let AI decode the personality.",
|
| 26 |
+
placeholder: "Type your story here...",
|
| 27 |
+
btnAnalyze: "Analyze Now",
|
| 28 |
+
btnLoading: "Processing...",
|
| 29 |
+
resMBTI: "MBTI Type",
|
| 30 |
+
resSentiment: "Dominant Emotion",
|
| 31 |
+
resKeywords: "Top Keywords",
|
| 32 |
+
// GANTI LABEL BIAR JUJUR
|
| 33 |
+
resContent: "Analyzed Content",
|
| 34 |
+
|
| 35 |
+
errEmptyText: "Please enter some text first!",
|
| 36 |
+
errEmptyYoutube: "Please paste a YouTube URL!",
|
| 37 |
+
errInvalidYoutube: "Invalid YouTube URL format.",
|
| 38 |
+
errConnection: "Failed to connect to AI Server.",
|
| 39 |
+
errNoTranscript: "This video has no subtitles/transcript to analyze.",
|
| 40 |
+
|
| 41 |
+
modeText: "Text Input",
|
| 42 |
+
modeYoutube: "YouTube Video",
|
| 43 |
+
ytPlaceholder: "Paste YouTube Link (e.g., https://youtu.be/...)",
|
| 44 |
+
ytTip: "Tip: Works best on videos with spoken words (podcasts, vlogs).",
|
| 45 |
+
btnYoutube: "Analyze Video",
|
| 46 |
+
|
| 47 |
+
guideTitle: "How to get accurate results?",
|
| 48 |
+
guides: [
|
| 49 |
+
{ icon: MessageSquare, title: "Be Expressive", text: "Write naturally about your feelings, opinions, or daily life experiences." },
|
| 50 |
+
{ icon: BookOpen, title: "Length Matters", text: "Try to write at least 2-3 sentences. Short texts like 'Hello' won't reveal much." },
|
| 51 |
+
{ icon: Lightbulb, title: "Honesty is Key", text: "Don't overthink it. The AI analyzes your subconscious writing style." }
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
id: {
|
| 55 |
+
title: "Analisis Teks",
|
| 56 |
+
desc: "Tempel curhatan atau link YouTube. Biar AI yang bedah kepribadiannya.",
|
| 57 |
+
placeholder: "Tulis cerita atau unek-unek lo di sini...",
|
| 58 |
+
btnAnalyze: "Analisis Sekarang",
|
| 59 |
+
btnLoading: "Lagi Mikir...",
|
| 60 |
+
resMBTI: "Tipe MBTI",
|
| 61 |
+
resSentiment: "Mood Dominan",
|
| 62 |
+
resKeywords: "Kata Kunci",
|
| 63 |
+
// GANTI LABEL BIAR JUJUR
|
| 64 |
+
resContent: "Data Video & Komentar",
|
| 65 |
+
|
| 66 |
+
errEmptyText: "Eits, isi dulu dong teksnya!",
|
| 67 |
+
errEmptyYoutube: "Link YouTube-nya mana?",
|
| 68 |
+
errInvalidYoutube: "Link YouTube-nya gak valid nih.",
|
| 69 |
+
errConnection: "Yah, gagal connect ke server nih.",
|
| 70 |
+
errNoTranscript: "Video ini gak ada subtitle-nya, cari yang lain gih.",
|
| 71 |
+
|
| 72 |
+
modeText: "Tulis Manual",
|
| 73 |
+
modeYoutube: "Link YouTube",
|
| 74 |
+
ytPlaceholder: "Tempel Link YouTube (misal: https://youtu.be/...)",
|
| 75 |
+
ytTip: "Tips: Paling mantep buat video podcast, vlog, atau opini.",
|
| 76 |
+
btnYoutube: "Bedah Video",
|
| 77 |
+
|
| 78 |
+
guideTitle: "Biar Hasilnya Akurat",
|
| 79 |
+
guides: [
|
| 80 |
+
{ icon: MessageSquare, title: "Yang Ekspresif Dong", text: "Tulis aja secara natural soal perasaan atau opini lo. Gak usah jaim." },
|
| 81 |
+
{ icon: BookOpen, title: "Jangan Pendek-pendek", text: "Minimal 2-3 kalimat lah. Kalau cuma 'Halo' doang, AI-nya bakal bingung." },
|
| 82 |
+
{ icon: Lightbulb, title: "Jujur Itu Kunci", text: "Gak usah overthink. AI bakal baca pola penulisan bawah sadar lo." }
|
| 83 |
+
]
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const content = t[lang];
|
| 88 |
+
|
| 89 |
+
// --- HELPER BUAT AMBIL ID YOUTUBE DARI LINK ---
|
| 90 |
+
const extractVideoId = (url: string) => {
|
| 91 |
+
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
| 92 |
+
const match = url.match(regExp);
|
| 93 |
+
return (match && match[2].length === 11) ? match[2] : null;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const getErrorMessage = () => {
|
| 97 |
+
if (!errorType) return "";
|
| 98 |
+
if (errorType === "EMPTY_TEXT") return content.errEmptyText;
|
| 99 |
+
if (errorType === "EMPTY_YOUTUBE") return content.errEmptyYoutube;
|
| 100 |
+
if (errorType === "INVALID_YOUTUBE") return content.errInvalidYoutube;
|
| 101 |
+
if (errorType === "CONNECTION") return content.errConnection;
|
| 102 |
+
|
| 103 |
+
if (errorType === "BACKEND_ERROR") {
|
| 104 |
+
if (backendErrorMsg === "NO_TRANSCRIPT") return content.errNoTranscript;
|
| 105 |
+
return backendErrorMsg || content.errConnection;
|
| 106 |
+
}
|
| 107 |
+
return "";
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const currentErrorMsg = getErrorMessage();
|
| 111 |
+
|
| 112 |
+
const handleAnalyze = async () => {
|
| 113 |
+
setErrorType(null);
|
| 114 |
+
setBackendErrorMsg("");
|
| 115 |
+
setResult(null);
|
| 116 |
+
|
| 117 |
+
// --- VALIDASI YOUTUBE ---
|
| 118 |
+
if (mode === "youtube") {
|
| 119 |
+
if (!youtubeUrl.trim()) {
|
| 120 |
+
setErrorType("EMPTY_YOUTUBE");
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
const videoId = extractVideoId(youtubeUrl);
|
| 124 |
+
if (!videoId) {
|
| 125 |
+
setErrorType("INVALID_YOUTUBE");
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Panggil API dengan Video ID
|
| 130 |
+
setLoading(true);
|
| 131 |
+
try {
|
| 132 |
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
| 133 |
+
const res = await fetch(`${apiUrl}/api/youtube/${videoId}`, { method: "GET" });
|
| 134 |
+
const data = await res.json();
|
| 135 |
+
if (data.success) {
|
| 136 |
+
setResult(data);
|
| 137 |
+
} else {
|
| 138 |
+
setBackendErrorMsg(data.error);
|
| 139 |
+
setErrorType("BACKEND_ERROR");
|
| 140 |
+
}
|
| 141 |
+
} catch (err) {
|
| 142 |
+
setErrorType("CONNECTION");
|
| 143 |
+
} finally {
|
| 144 |
+
setLoading(false);
|
| 145 |
+
}
|
| 146 |
+
return;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// --- VALIDASI TEXT ---
|
| 150 |
+
if (mode === "text") {
|
| 151 |
+
if (!inputText.trim()) {
|
| 152 |
+
setErrorType("EMPTY_TEXT");
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
setLoading(true);
|
| 156 |
+
try {
|
| 157 |
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
| 158 |
+
const res = await fetch(`${apiUrl}/api/predict`, {
|
| 159 |
+
method: "POST",
|
| 160 |
+
headers: { "Content-Type": "application/json" },
|
| 161 |
+
body: JSON.stringify({ text: inputText }),
|
| 162 |
+
});
|
| 163 |
+
const data = await res.json();
|
| 164 |
+
if (data.success) {
|
| 165 |
+
setResult(data);
|
| 166 |
+
} else {
|
| 167 |
+
setBackendErrorMsg(data.error);
|
| 168 |
+
setErrorType("BACKEND_ERROR");
|
| 169 |
+
}
|
| 170 |
+
} catch (err) {
|
| 171 |
+
setErrorType("CONNECTION");
|
| 172 |
+
} finally {
|
| 173 |
+
setLoading(false);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
| 179 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 180 |
+
const isMobile = window.innerWidth < 768;
|
| 181 |
+
if (mode === "text" && isMobile) {
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
e.preventDefault();
|
| 185 |
+
handleAnalyze();
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const getKeywords = () => {
|
| 190 |
+
if (!result?.keywords) return [];
|
| 191 |
+
if (Array.isArray(result.keywords)) return result.keywords;
|
| 192 |
+
return lang === 'id' ? result.keywords.id : result.keywords.en;
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
const currentKeywords = getKeywords();
|
| 196 |
+
|
| 197 |
+
return (
|
| 198 |
+
<div className="w-full pt-28 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center font-sans">
|
| 199 |
+
|
| 200 |
+
<motion.div
|
| 201 |
+
initial={{ opacity: 0, y: 20 }}
|
| 202 |
+
animate={{ opacity: 1, y: 0 }}
|
| 203 |
+
className="w-full max-w-4xl mx-auto text-center space-y-4 z-10"
|
| 204 |
+
>
|
| 205 |
+
|
| 206 |
+
<div>
|
| 207 |
+
<h1 className="text-4xl md:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 pb-2">
|
| 208 |
+
{content.title}
|
| 209 |
+
</h1>
|
| 210 |
+
<p className="text-gray-600 dark:text-gray-400 text-sm md:text-lg max-w-2xl mx-auto mt-2">
|
| 211 |
+
{content.desc}
|
| 212 |
+
</p>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* --- TOMBOL SWITCH MODE (TEXT vs YOUTUBE) --- */}
|
| 216 |
+
<div className="grid grid-cols-2 gap-3 mt-8 w-full max-w-[340px] mx-auto">
|
| 217 |
+
<button
|
| 218 |
+
onClick={() => { setMode("text"); setResult(null); setErrorType(null); }}
|
| 219 |
+
className={`flex items-center justify-center gap-2 py-2.5 rounded-full font-bold text-sm transition-all border border-transparent
|
| 220 |
+
${mode === "text"
|
| 221 |
+
? "bg-orange-600 text-white shadow-lg shadow-orange-500/20"
|
| 222 |
+
: "bg-gray-100 dark:bg-white/5 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10"}`}
|
| 223 |
+
>
|
| 224 |
+
<FileText size={16} /> {content.modeText}
|
| 225 |
+
</button>
|
| 226 |
+
<button
|
| 227 |
+
onClick={() => { setMode("youtube"); setResult(null); setErrorType(null); }}
|
| 228 |
+
className={`flex items-center justify-center gap-2 py-2.5 rounded-full font-bold text-sm transition-all border border-transparent
|
| 229 |
+
${mode === "youtube"
|
| 230 |
+
? "bg-[#FF0000] text-white shadow-lg shadow-[#FF0000]/20"
|
| 231 |
+
: "bg-gray-100 dark:bg-white/5 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10"}`}
|
| 232 |
+
>
|
| 233 |
+
<Youtube size={16} /> {content.modeYoutube}
|
| 234 |
+
</button>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="liquid-glass p-1.5 shadow-2xl mt-6 max-w-3xl mx-auto w-full">
|
| 238 |
+
<div className="bg-white/50 dark:bg-black/20 rounded-xl p-4 backdrop-blur-sm">
|
| 239 |
+
|
| 240 |
+
{/* AREA INPUT */}
|
| 241 |
+
<div className="min-h-[140px] flex flex-col justify-center">
|
| 242 |
+
{mode === "text" ? (
|
| 243 |
+
<textarea
|
| 244 |
+
value={inputText}
|
| 245 |
+
onChange={(e) => { setInputText(e.target.value); setErrorType(null); }}
|
| 246 |
+
onKeyDown={handleKeyDown}
|
| 247 |
+
placeholder={content.placeholder}
|
| 248 |
+
className="w-full bg-transparent outline-none text-base md:text-lg h-full resize-none placeholder:text-gray-400 dark:text-white text-gray-900"
|
| 249 |
+
style={{ minHeight: '140px' }}
|
| 250 |
+
/>
|
| 251 |
+
) : (
|
| 252 |
+
<div className="py-2 px-2 w-full">
|
| 253 |
+
<div className="relative flex items-center">
|
| 254 |
+
<div className="absolute left-4 text-gray-400 pointer-events-none">
|
| 255 |
+
<Youtube size={20} />
|
| 256 |
+
</div>
|
| 257 |
+
<input
|
| 258 |
+
type="text"
|
| 259 |
+
value={youtubeUrl}
|
| 260 |
+
onChange={(e) => { setYoutubeUrl(e.target.value); setErrorType(null); }}
|
| 261 |
+
onKeyDown={handleKeyDown}
|
| 262 |
+
placeholder={content.ytPlaceholder}
|
| 263 |
+
className="w-full bg-white/50 dark:bg-white/5 border border-gray-300 dark:border-white/10 rounded-xl py-4 pl-12 pr-4 text-lg font-medium focus:border-[#FF0000] focus:ring-2 focus:ring-[#FF0000]/20 focus:outline-none transition-all text-gray-800 dark:text-white placeholder:text-gray-400"
|
| 264 |
+
/>
|
| 265 |
+
</div>
|
| 266 |
+
<p className="text-xs text-left mt-3 ml-1 text-gray-500 dark:text-gray-400 flex items-center gap-1 pl-1">
|
| 267 |
+
<Lightbulb size={12} className="text-yellow-500" /> {content.ytTip}
|
| 268 |
+
</p>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{/* ERROR MSG */}
|
| 274 |
+
<AnimatePresence>
|
| 275 |
+
{currentErrorMsg && (
|
| 276 |
+
<motion.div
|
| 277 |
+
initial={{ height: 0, opacity: 0 }}
|
| 278 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 279 |
+
exit={{ height: 0, opacity: 0 }}
|
| 280 |
+
className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-3 text-red-600 dark:text-red-400 overflow-hidden"
|
| 281 |
+
>
|
| 282 |
+
<AlertCircle size={20} className="shrink-0" />
|
| 283 |
+
<span className="text-sm font-bold text-left">{currentErrorMsg}</span>
|
| 284 |
+
</motion.div>
|
| 285 |
+
)}
|
| 286 |
+
</AnimatePresence>
|
| 287 |
+
|
| 288 |
+
<div className="flex justify-end mt-4 pt-2 border-t border-gray-500/10">
|
| 289 |
+
<button
|
| 290 |
+
onClick={handleAnalyze}
|
| 291 |
+
disabled={loading}
|
| 292 |
+
className={`flex items-center gap-2 text-white px-6 py-2 rounded-lg font-bold transition-all disabled:opacity-50 shadow-lg text-sm md:text-base
|
| 293 |
+
${mode === "youtube"
|
| 294 |
+
? "bg-[#FF0000] hover:bg-red-700 hover:shadow-[#FF0000]/30"
|
| 295 |
+
: "bg-orange-600 hover:bg-orange-700 hover:shadow-orange-500/30"}`}
|
| 296 |
+
>
|
| 297 |
+
{loading ? content.btnLoading : (mode === "youtube" ? content.btnYoutube : content.btnAnalyze)}
|
| 298 |
+
{!loading && <Search className="w-4 h-4" />}
|
| 299 |
+
</button>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
{/* HASIL */}
|
| 305 |
+
<AnimatePresence>
|
| 306 |
+
{result && (
|
| 307 |
+
<motion.div
|
| 308 |
+
initial={{ opacity: 0, y: 100 }}
|
| 309 |
+
animate={{ opacity: 1, y: 0 }}
|
| 310 |
+
exit={{ opacity: 0, y: 100 }}
|
| 311 |
+
transition={{ type: "spring", damping: 20 }}
|
| 312 |
+
className="w-full max-w-3xl mx-auto mt-6 space-y-4 text-left"
|
| 313 |
+
>
|
| 314 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 315 |
+
{/* MBTI */}
|
| 316 |
+
<div className="liquid-glass p-4 border-t-4 border-orange-500 bg-white/60 dark:bg-black/40 backdrop-blur-md rounded-xl flex flex-col h-full">
|
| 317 |
+
<h3 className="text-[10px] font-bold uppercase tracking-widest opacity-60 flex justify-center gap-2 items-center text-gray-800 dark:text-gray-200 mb-2">
|
| 318 |
+
<BrainCircuit size={12} /> {content.resMBTI}
|
| 319 |
+
</h3>
|
| 320 |
+
<div className="flex-1 flex items-center justify-center">
|
| 321 |
+
<div className="text-3xl font-black text-orange-600">{result.mbti_type}</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
{/* EMOTION */}
|
| 325 |
+
<div className="liquid-glass p-4 border-t-4 border-green-500 bg-white/60 dark:bg-black/40 backdrop-blur-md rounded-xl flex flex-col h-full">
|
| 326 |
+
<h3 className="text-[10px] font-bold uppercase tracking-widest opacity-60 flex justify-center gap-2 items-center text-gray-800 dark:text-gray-200 mb-2">
|
| 327 |
+
<Smile size={12} /> {content.resSentiment}
|
| 328 |
+
</h3>
|
| 329 |
+
<div className="flex-1 flex items-center justify-center">
|
| 330 |
+
<div className="text-xl font-bold capitalize text-green-600 dark:text-green-400 truncate px-2 text-center">
|
| 331 |
+
{result.emotion ? (result.emotion[lang] || result.emotion.id || result.emotion) : result.sentiment}
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
{/* KEYWORDS */}
|
| 336 |
+
<div className="liquid-glass p-4 border-t-4 border-blue-500 bg-white/60 dark:bg-black/40 backdrop-blur-md rounded-xl h-full flex flex-col">
|
| 337 |
+
<h3 className="text-[10px] font-bold uppercase tracking-widest opacity-60 flex justify-center gap-2 items-center text-gray-800 dark:text-gray-200 mb-3">
|
| 338 |
+
<Tag size={12} /> {content.resKeywords}
|
| 339 |
+
</h3>
|
| 340 |
+
<div className="flex flex-wrap gap-2 justify-center items-center w-full">
|
| 341 |
+
{currentKeywords.slice(0, 3).map((k: string, i: number) => (
|
| 342 |
+
<span key={i} className="bg-orange-100 dark:bg-orange-900/30 px-3 py-1 rounded-full text-xs font-bold text-orange-700 dark:text-orange-200 border border-orange-200 dark:border-orange-800/50 capitalize">
|
| 343 |
+
{k}
|
| 344 |
+
</span>
|
| 345 |
+
))}
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
{/* PREVIEW CONTENT - YouTube Style */}
|
| 351 |
+
{(result.video || result.fetched_text) && (
|
| 352 |
+
<div className="space-y-4">
|
| 353 |
+
|
| 354 |
+
{/* YouTube Video Card with Thumbnail */}
|
| 355 |
+
{result.video && (
|
| 356 |
+
<div className="liquid-glass overflow-hidden rounded-2xl border border-gray-200 dark:border-white/10">
|
| 357 |
+
{/* Thumbnail */}
|
| 358 |
+
{result.video.thumbnail && (
|
| 359 |
+
<div className="relative aspect-video bg-black">
|
| 360 |
+
<img
|
| 361 |
+
src={result.video.thumbnail}
|
| 362 |
+
alt={result.video.title}
|
| 363 |
+
className="w-full h-full object-cover"
|
| 364 |
+
/>
|
| 365 |
+
<div className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-2 py-1 rounded font-medium">
|
| 366 |
+
YouTube
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
+
{/* Video Info */}
|
| 372 |
+
<div className="p-5 bg-white/60 dark:bg-black/40">
|
| 373 |
+
<h4 className="text-lg font-bold text-gray-900 dark:text-white leading-tight mb-2">
|
| 374 |
+
{result.video.title}
|
| 375 |
+
</h4>
|
| 376 |
+
|
| 377 |
+
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
| 378 |
+
<span className="font-medium text-gray-700 dark:text-gray-300">{result.video.channel}</span>
|
| 379 |
+
<span>•</span>
|
| 380 |
+
<span>{Number(result.video.viewCount).toLocaleString()} views</span>
|
| 381 |
+
<span>•</span>
|
| 382 |
+
<span className="flex items-center gap-1">
|
| 383 |
+
<ThumbsUp size={14} /> {Number(result.video.likeCount).toLocaleString()}
|
| 384 |
+
</span>
|
| 385 |
+
</div>
|
| 386 |
+
|
| 387 |
+
{/* Description */}
|
| 388 |
+
<div className="bg-gray-100 dark:bg-white/5 p-4 rounded-xl">
|
| 389 |
+
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
| 390 |
+
{showFullDesc ? result.video.description : result.video.description?.slice(0, 250)}
|
| 391 |
+
{result.video.description?.length > 250 && !showFullDesc && '...'}
|
| 392 |
+
</div>
|
| 393 |
+
{result.video.description?.length > 250 && (
|
| 394 |
+
<button
|
| 395 |
+
onClick={() => setShowFullDesc(!showFullDesc)}
|
| 396 |
+
className="mt-2 text-sm font-bold text-blue-600 hover:text-blue-700"
|
| 397 |
+
>
|
| 398 |
+
{showFullDesc ? 'Show less' : 'Show more'}
|
| 399 |
+
</button>
|
| 400 |
+
)}
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
)}
|
| 405 |
+
|
| 406 |
+
{/* Comments Section - YouTube Style */}
|
| 407 |
+
{result.comments && result.comments.length > 0 && (
|
| 408 |
+
<div className="liquid-glass p-5 bg-white/60 dark:bg-black/40 rounded-2xl border border-gray-200 dark:border-white/10">
|
| 409 |
+
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
| 410 |
+
<MessageSquare size={16} />
|
| 411 |
+
{result.comments.length} Comments
|
| 412 |
+
</h3>
|
| 413 |
+
|
| 414 |
+
<div className="space-y-4">
|
| 415 |
+
{(showAllComments ? result.comments : result.comments.slice(0, 5)).map((comment: any, idx: number) => (
|
| 416 |
+
<div key={idx} className="flex gap-3">
|
| 417 |
+
{/* Avatar */}
|
| 418 |
+
{comment.authorImage ? (
|
| 419 |
+
<img
|
| 420 |
+
src={comment.authorImage}
|
| 421 |
+
alt={comment.author}
|
| 422 |
+
className="w-10 h-10 rounded-full shrink-0 object-cover"
|
| 423 |
+
/>
|
| 424 |
+
) : (
|
| 425 |
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
| 426 |
+
{comment.author?.charAt(0).toUpperCase() || 'A'}
|
| 427 |
+
</div>
|
| 428 |
+
)}
|
| 429 |
+
|
| 430 |
+
{/* Comment Content */}
|
| 431 |
+
<div className="flex-1 min-w-0">
|
| 432 |
+
<div className="flex items-center gap-2 mb-1">
|
| 433 |
+
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
| 434 |
+
{comment.author}
|
| 435 |
+
</span>
|
| 436 |
+
<span className="text-xs text-gray-400">
|
| 437 |
+
{comment.publishedAt && new Date(comment.publishedAt).toLocaleDateString()}
|
| 438 |
+
</span>
|
| 439 |
+
</div>
|
| 440 |
+
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
| 441 |
+
{comment.text}
|
| 442 |
+
</p>
|
| 443 |
+
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
| 444 |
+
<span className="flex items-center gap-1 hover:text-gray-700 cursor-pointer">
|
| 445 |
+
<ThumbsUp size={14} /> {comment.likeCount || 0}
|
| 446 |
+
</span>
|
| 447 |
+
<span className="flex items-center gap-1 hover:text-gray-700 cursor-pointer">
|
| 448 |
+
<ThumbsDown size={14} />
|
| 449 |
+
</span>
|
| 450 |
+
{comment.replyCount > 0 && (
|
| 451 |
+
<span className="text-blue-600 font-medium">
|
| 452 |
+
{comment.replyCount} replies
|
| 453 |
+
</span>
|
| 454 |
+
)}
|
| 455 |
+
</div>
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
))}
|
| 459 |
+
</div>
|
| 460 |
+
|
| 461 |
+
{result.comments.length > 5 && (
|
| 462 |
+
<button
|
| 463 |
+
onClick={() => setShowAllComments(!showAllComments)}
|
| 464 |
+
className="mt-4 w-full py-3 text-sm font-bold text-blue-600 hover:text-blue-700 bg-blue-50 dark:bg-blue-500/10 hover:bg-blue-100 dark:hover:bg-blue-500/20 rounded-xl transition-colors"
|
| 465 |
+
>
|
| 466 |
+
{showAllComments ? '▲ Show Less' : `▼ View all ${result.comments.length} comments`}
|
| 467 |
+
</button>
|
| 468 |
+
)}
|
| 469 |
+
</div>
|
| 470 |
+
)}
|
| 471 |
+
|
| 472 |
+
{/* Fallback for transcript-only data */}
|
| 473 |
+
{!result.video && result.fetched_text && (
|
| 474 |
+
<div className="liquid-glass p-5 bg-white/60 dark:bg-black/40 rounded-2xl border border-gray-200 dark:border-white/10">
|
| 475 |
+
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
| 476 |
+
<FileText size={16} /> Transcript
|
| 477 |
+
</h3>
|
| 478 |
+
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
| 479 |
+
{result.fetched_text}
|
| 480 |
+
</p>
|
| 481 |
+
</div>
|
| 482 |
+
)}
|
| 483 |
+
</div>
|
| 484 |
+
)}
|
| 485 |
+
</motion.div>
|
| 486 |
+
)}
|
| 487 |
+
</AnimatePresence>
|
| 488 |
+
|
| 489 |
+
{/* GUIDES */}
|
| 490 |
+
{!result && (
|
| 491 |
+
<motion.div
|
| 492 |
+
initial={{ opacity: 0 }}
|
| 493 |
+
animate={{ opacity: 1 }}
|
| 494 |
+
transition={{ delay: 0.3 }}
|
| 495 |
+
className="mt-16 w-full max-w-3xl mx-auto"
|
| 496 |
+
>
|
| 497 |
+
<h3 className="text-lg font-bold text-center mb-6 text-gray-500 uppercase tracking-widest text-xs">
|
| 498 |
+
{content.guideTitle}
|
| 499 |
+
</h3>
|
| 500 |
+
|
| 501 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-left">
|
| 502 |
+
{content.guides.map((item, idx) => (
|
| 503 |
+
<div key={idx} className="group p-6 border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 rounded-xl hover:shadow-lg transition-all duration-300">
|
| 504 |
+
|
| 505 |
+
<div className="p-2.5 bg-orange-100 dark:bg-orange-500/20 w-fit rounded-lg mb-4 text-orange-600 dark:text-orange-400 group-hover:scale-110 transition-transform duration-300">
|
| 506 |
+
<item.icon className="w-5 h-5" />
|
| 507 |
+
</div>
|
| 508 |
+
|
| 509 |
+
<h4 className="text-sm font-bold mb-2 text-gray-900 dark:text-white tracking-tight">
|
| 510 |
+
{item.title}
|
| 511 |
+
</h4>
|
| 512 |
+
|
| 513 |
+
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
| 514 |
+
{item.text}
|
| 515 |
+
</p>
|
| 516 |
+
</div>
|
| 517 |
+
))}
|
| 518 |
+
</div>
|
| 519 |
+
</motion.div>
|
| 520 |
+
)}
|
| 521 |
+
|
| 522 |
+
</motion.div>
|
| 523 |
+
</div>
|
| 524 |
+
);
|
| 525 |
+
}
|
src/app/chat/layout.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Metadata } from "next";
|
| 2 |
+
|
| 3 |
+
export const metadata: Metadata = {
|
| 4 |
+
title: "Chat | Sentimind",
|
| 5 |
+
description: "Chat with Sentimind AI.",
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
| 9 |
+
return <>{children}</>;
|
| 10 |
+
}
|
src/app/chat/page.tsx
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react";
|
| 4 |
+
import { Send, Bot, User, Loader2, Sparkles, AlertCircle, Mic, MicOff } from "lucide-react";
|
| 5 |
+
import ReactMarkdown from 'react-markdown';
|
| 6 |
+
import remarkGfm from 'remark-gfm';
|
| 7 |
+
import { cn } from "@/lib/utils";
|
| 8 |
+
import { useLanguage } from "@/app/providers";
|
| 9 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 10 |
+
|
| 11 |
+
// Fallback utility
|
| 12 |
+
function classNames(...classes: (string | undefined | null | false)[]) {
|
| 13 |
+
return classes.filter(Boolean).join(" ");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface Message {
|
| 17 |
+
id: string;
|
| 18 |
+
role: "user" | "bot";
|
| 19 |
+
content: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// --- REUSABLE MARKDOWN COMPONENTS ---
|
| 23 |
+
const markdownComponents = {
|
| 24 |
+
// Style untuk bold
|
| 25 |
+
strong: ({ children }: any) => <strong className="font-bold text-orange-500">{children}</strong>,
|
| 26 |
+
// Style untuk table
|
| 27 |
+
table: ({ children }: any) => <div className="overflow-x-auto my-4"><table className="border-collapse w-full text-sm">{children}</table></div>,
|
| 28 |
+
thead: ({ children }: any) => <thead className="bg-orange-500/10 dark:bg-orange-500/20">{children}</thead>,
|
| 29 |
+
th: ({ children }: any) => <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-bold">{children}</th>,
|
| 30 |
+
td: ({ children }: any) => <td className="border border-gray-300 dark:border-gray-600 px-3 py-2">{children}</td>,
|
| 31 |
+
// Style untuk list
|
| 32 |
+
ul: ({ children }: any) => <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>,
|
| 33 |
+
ol: ({ children }: any) => <ol className="list-decimal list-outside pl-5 my-2 space-y-1">{children}</ol>,
|
| 34 |
+
// Code block styling
|
| 35 |
+
code: ({ node, inline, className, children, ...props }: any) => {
|
| 36 |
+
const match = /language-(\w+)/.exec(className || "");
|
| 37 |
+
return !inline ? (
|
| 38 |
+
<div className="rounded-md overflow-hidden my-2 border border-gray-200 dark:border-gray-700">
|
| 39 |
+
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs text-gray-500 font-mono border-b border-gray-200 dark:border-gray-700">
|
| 40 |
+
{match ? match[1] : 'code'}
|
| 41 |
+
</div>
|
| 42 |
+
<div className="bg-gray-50 dark:bg-[#1e1e1e] p-3 overflow-x-auto">
|
| 43 |
+
<code className={className} {...props}>
|
| 44 |
+
{children}
|
| 45 |
+
</code>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
) : (
|
| 49 |
+
<code className="bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm font-mono text-orange-600 dark:text-orange-400" {...props}>
|
| 50 |
+
{children}
|
| 51 |
+
</code>
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
// --- TYPEWRITER COMPONENT dengan Markdown Real-time ---
|
| 57 |
+
const Typewriter = ({ text, speed = 10, onTyping, onComplete }: { text: string; speed?: number; onTyping?: () => void; onComplete?: () => void }) => {
|
| 58 |
+
const [displayedText, setDisplayedText] = useState("");
|
| 59 |
+
const [isTyping, setIsTyping] = useState(true);
|
| 60 |
+
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
setDisplayedText("");
|
| 63 |
+
setIsTyping(true);
|
| 64 |
+
let i = 0;
|
| 65 |
+
const interval = setInterval(() => {
|
| 66 |
+
if (i < text.length) {
|
| 67 |
+
setDisplayedText((prev) => prev + text.charAt(i));
|
| 68 |
+
i++;
|
| 69 |
+
// Scroll ke bawah setiap karakter baru
|
| 70 |
+
onTyping?.();
|
| 71 |
+
} else {
|
| 72 |
+
clearInterval(interval);
|
| 73 |
+
setIsTyping(false);
|
| 74 |
+
onComplete?.();
|
| 75 |
+
}
|
| 76 |
+
}, speed);
|
| 77 |
+
|
| 78 |
+
return () => clearInterval(interval);
|
| 79 |
+
}, [text, speed]);
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<div className="markdown-content">
|
| 83 |
+
<ReactMarkdown
|
| 84 |
+
remarkPlugins={[remarkGfm]}
|
| 85 |
+
components={markdownComponents}
|
| 86 |
+
>
|
| 87 |
+
{displayedText}
|
| 88 |
+
</ReactMarkdown>
|
| 89 |
+
{isTyping && (
|
| 90 |
+
<span className="inline-block w-1.5 h-4 ml-1 align-middle bg-orange-500 animate-pulse" />
|
| 91 |
+
)}
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
export default function ChatPage() {
|
| 97 |
+
const { lang } = useLanguage();
|
| 98 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 99 |
+
const [inputValue, setInputValue] = useState("");
|
| 100 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 101 |
+
const [error, setError] = useState<string | null>(null);
|
| 102 |
+
const [isListening, setIsListening] = useState(false);
|
| 103 |
+
const [typedMessages, setTypedMessages] = useState<Set<string>>(new Set());
|
| 104 |
+
|
| 105 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 106 |
+
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
| 107 |
+
const recognitionRef = useRef<any>(null);
|
| 108 |
+
|
| 109 |
+
// Only auto-scroll if user is already near the bottom
|
| 110 |
+
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
| 111 |
+
const container = messagesContainerRef.current;
|
| 112 |
+
if (!container) return;
|
| 113 |
+
|
| 114 |
+
// Threshold lebih kecil (50px) biar user gampang scroll ke atas tanpa ditarik balik
|
| 115 |
+
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
| 116 |
+
if (isNearBottom) {
|
| 117 |
+
messagesEndRef.current?.scrollIntoView({ behavior, block: "end" });
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
useEffect(() => {
|
| 122 |
+
// Force scroll on new messages
|
| 123 |
+
scrollToBottom("smooth");
|
| 124 |
+
}, [messages, isLoading]);
|
| 125 |
+
|
| 126 |
+
// Initialize Speech Recognition
|
| 127 |
+
const accumulatedTranscriptRef = useRef<string>('');
|
| 128 |
+
|
| 129 |
+
useEffect(() => {
|
| 130 |
+
if (typeof window !== 'undefined') {
|
| 131 |
+
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
| 132 |
+
if (SpeechRecognition) {
|
| 133 |
+
const recognition = new SpeechRecognition();
|
| 134 |
+
recognition.continuous = true;
|
| 135 |
+
recognition.interimResults = true;
|
| 136 |
+
recognition.lang = lang === 'id' ? 'id-ID' : 'en-US';
|
| 137 |
+
|
| 138 |
+
recognition.onresult = (event: any) => {
|
| 139 |
+
let finalTranscript = '';
|
| 140 |
+
let interimTranscript = '';
|
| 141 |
+
|
| 142 |
+
for (let i = 0; i < event.results.length; ++i) {
|
| 143 |
+
if (event.results[i].isFinal) {
|
| 144 |
+
finalTranscript += event.results[i][0].transcript + ' ';
|
| 145 |
+
} else {
|
| 146 |
+
interimTranscript += event.results[i][0].transcript;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// Gabungkan final + interim untuk display
|
| 151 |
+
setInputValue(finalTranscript + interimTranscript);
|
| 152 |
+
accumulatedTranscriptRef.current = finalTranscript;
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
recognition.onerror = (event: any) => {
|
| 156 |
+
console.error("Voice Error:", event.error);
|
| 157 |
+
if (event.error !== 'no-speech') {
|
| 158 |
+
setIsListening(false);
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
recognitionRef.current = recognition;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
}, [lang]);
|
| 166 |
+
|
| 167 |
+
const toggleListening = () => {
|
| 168 |
+
if (!recognitionRef.current) {
|
| 169 |
+
alert("Browser kamu gak support voice input bro. Coba Chrome.");
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (isListening) {
|
| 174 |
+
recognitionRef.current.stop();
|
| 175 |
+
setIsListening(false);
|
| 176 |
+
} else {
|
| 177 |
+
// Reset input pas mulai ngomong (opsional, tergantung preferensi)
|
| 178 |
+
// setInputValue("");
|
| 179 |
+
recognitionRef.current.start();
|
| 180 |
+
setIsListening(true);
|
| 181 |
+
}
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const handleSendMessage = async (text: string) => {
|
| 185 |
+
const messageText = text || inputValue;
|
| 186 |
+
if (!messageText.trim()) return;
|
| 187 |
+
|
| 188 |
+
// Stop listening if sending
|
| 189 |
+
if (isListening && recognitionRef.current) {
|
| 190 |
+
recognitionRef.current.stop();
|
| 191 |
+
setIsListening(false);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
setError(null);
|
| 195 |
+
const userMessage: Message = {
|
| 196 |
+
id: Date.now().toString(),
|
| 197 |
+
role: "user",
|
| 198 |
+
content: messageText,
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
setMessages((prev) => [...prev, userMessage]);
|
| 202 |
+
setInputValue("");
|
| 203 |
+
setIsLoading(true);
|
| 204 |
+
|
| 205 |
+
try {
|
| 206 |
+
const apiUrl = process.env.NEXT_PUBLIC_CHATBOT_URL || "http://localhost:8000/api/chat";
|
| 207 |
+
|
| 208 |
+
const response = await fetch(apiUrl, {
|
| 209 |
+
method: "POST",
|
| 210 |
+
headers: { "Content-Type": "application/json" },
|
| 211 |
+
body: JSON.stringify({
|
| 212 |
+
message: messageText,
|
| 213 |
+
lang: lang
|
| 214 |
+
}),
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
if (!response.ok) {
|
| 218 |
+
throw new Error(`Server returned ${response.status}`);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const data = await response.json();
|
| 222 |
+
|
| 223 |
+
const botMessage: Message = {
|
| 224 |
+
id: (Date.now() + 1).toString(),
|
| 225 |
+
role: "bot",
|
| 226 |
+
content: data.response || "Maaf, saya tidak mengerti.",
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
setMessages((prev) => [...prev, botMessage]);
|
| 230 |
+
} catch (err: any) {
|
| 231 |
+
console.error("Chat Error:", err);
|
| 232 |
+
setError("Gagal terhubung ke backend. Pastikan server API (port 8000) sudah jalan.");
|
| 233 |
+
} finally {
|
| 234 |
+
setIsLoading(false);
|
| 235 |
+
}
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
const t = {
|
| 239 |
+
en: {
|
| 240 |
+
title: "Sentimind Chat",
|
| 241 |
+
desc: "Consult about MBTI, psychology, and mental health.",
|
| 242 |
+
placeholder: "Type or use voice...",
|
| 243 |
+
thinking: "Thinking...",
|
| 244 |
+
powerBy: "Powered by Gemini. AI may make mistakes.",
|
| 245 |
+
suggestions: [
|
| 246 |
+
"What is INTJ personality?",
|
| 247 |
+
"How to overcome social anxiety?",
|
| 248 |
+
"Explain Fe vs Fi cognitive functions",
|
| 249 |
+
"Why do INFJs feel lonely?"
|
| 250 |
+
],
|
| 251 |
+
emptyState: "Start a conversation..."
|
| 252 |
+
},
|
| 253 |
+
id: {
|
| 254 |
+
title: "Sentimind Chat",
|
| 255 |
+
desc: "Ngobrol santai soal MBTI, psikologi, dan kesehatan mental.",
|
| 256 |
+
placeholder: "Ketik atau ngomong langsung...",
|
| 257 |
+
thinking: "Bentar bre, mikir dulu...",
|
| 258 |
+
powerBy: "Ditenagai Gemini. AI bisa aja salah, namanya juga bot.",
|
| 259 |
+
suggestions: [
|
| 260 |
+
"Apa itu tipe kepribadian INTJ?",
|
| 261 |
+
"Gimana cara ngilangin cemas?",
|
| 262 |
+
"Bedanya Fe sama Fi apa sih?",
|
| 263 |
+
"Kenapa INFJ sering merasa kesepian?"
|
| 264 |
+
],
|
| 265 |
+
emptyState: "Tanya apa gitu..."
|
| 266 |
+
}
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
const content = t[lang] || t.en;
|
| 270 |
+
|
| 271 |
+
return (
|
| 272 |
+
<div className="w-full flex flex-col pt-28 md:pt-32 font-sans min-h-screen justify-start">
|
| 273 |
+
|
| 274 |
+
{/* Main Chat Content */}
|
| 275 |
+
<div className="flex-1 w-full max-w-3xl mx-auto px-4 md:px-0 flex flex-col">
|
| 276 |
+
|
| 277 |
+
{/* Header (Only show if no messages) */}
|
| 278 |
+
<AnimatePresence>
|
| 279 |
+
{messages.length === 0 && (
|
| 280 |
+
<motion.div
|
| 281 |
+
initial={{ opacity: 0, y: 20 }}
|
| 282 |
+
animate={{ opacity: 1, y: 0 }}
|
| 283 |
+
exit={{ opacity: 0, y: -20 }}
|
| 284 |
+
transition={{ duration: 0.5 }}
|
| 285 |
+
className="flex flex-col items-center text-center space-y-6 py-10"
|
| 286 |
+
>
|
| 287 |
+
<motion.div
|
| 288 |
+
initial={{ scale: 0 }}
|
| 289 |
+
animate={{ scale: 1 }}
|
| 290 |
+
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
|
| 291 |
+
className="p-2 bg-orange-100 dark:bg-orange-500/10 rounded-full mb-2"
|
| 292 |
+
>
|
| 293 |
+
<Sparkles className="text-orange-600 dark:text-orange-400 w-6 h-6" />
|
| 294 |
+
</motion.div>
|
| 295 |
+
<div>
|
| 296 |
+
<h1 className="text-5xl md:text-7xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 leading-[1.1] pb-2">
|
| 297 |
+
{content.title}
|
| 298 |
+
</h1>
|
| 299 |
+
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl leading-relaxed">
|
| 300 |
+
{content.desc}
|
| 301 |
+
</p>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<motion.div
|
| 305 |
+
className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-2xl mt-12"
|
| 306 |
+
initial="hidden"
|
| 307 |
+
animate="visible"
|
| 308 |
+
variants={{
|
| 309 |
+
hidden: { opacity: 0 },
|
| 310 |
+
visible: {
|
| 311 |
+
opacity: 1,
|
| 312 |
+
transition: {
|
| 313 |
+
staggerChildren: 0.1
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
}}
|
| 317 |
+
>
|
| 318 |
+
{content.suggestions.map((s, i) => (
|
| 319 |
+
<motion.button
|
| 320 |
+
key={i}
|
| 321 |
+
variants={{
|
| 322 |
+
hidden: { opacity: 0, y: 20 },
|
| 323 |
+
visible: { opacity: 1, y: 0 }
|
| 324 |
+
}}
|
| 325 |
+
whileHover={{ scale: 1.02 }}
|
| 326 |
+
whileTap={{ scale: 0.98 }}
|
| 327 |
+
onClick={() => handleSendMessage(s)}
|
| 328 |
+
className="p-4 text-left text-sm bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:bg-orange-50 dark:hover:bg-neutral-800 hover:border-orange-300 dark:hover:border-orange-700/50 rounded-2xl transition-colors text-gray-600 dark:text-gray-300 shadow-sm"
|
| 329 |
+
>
|
| 330 |
+
"{s}"
|
| 331 |
+
</motion.button>
|
| 332 |
+
))}
|
| 333 |
+
</motion.div>
|
| 334 |
+
</motion.div>
|
| 335 |
+
)}
|
| 336 |
+
</AnimatePresence>
|
| 337 |
+
|
| 338 |
+
{/* Chat Messages */}
|
| 339 |
+
<div ref={messagesContainerRef} className="space-y-6 flex-1 mb-8">
|
| 340 |
+
<AnimatePresence mode="popLayout">
|
| 341 |
+
{messages.map((msg) => (
|
| 342 |
+
<motion.div
|
| 343 |
+
key={msg.id}
|
| 344 |
+
layout
|
| 345 |
+
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
| 346 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 347 |
+
exit={{ opacity: 0, scale: 0.9 }}
|
| 348 |
+
transition={{ duration: 0.3 }}
|
| 349 |
+
className={classNames(
|
| 350 |
+
"flex gap-4 md:gap-6",
|
| 351 |
+
msg.role === "user" ? "flex-row-reverse" : "flex-row"
|
| 352 |
+
)}
|
| 353 |
+
>
|
| 354 |
+
{/* Avatar */}
|
| 355 |
+
<div className={classNames(
|
| 356 |
+
"w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm mt-1",
|
| 357 |
+
msg.role === "user"
|
| 358 |
+
? "bg-gray-200 dark:bg-neutral-700 text-gray-600 dark:text-gray-200"
|
| 359 |
+
: "bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400"
|
| 360 |
+
)}>
|
| 361 |
+
{msg.role === "user" ? <User size={18} /> : <Bot size={20} />}
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
{/* Content */}
|
| 365 |
+
<div className={classNames(
|
| 366 |
+
"max-w-[85%] md:max-w-[80%] text-[15px] md:text-base leading-7",
|
| 367 |
+
msg.role === "user"
|
| 368 |
+
? "bg-orange-600 text-white px-5 py-3 rounded-2xl rounded-tr-sm shadow-md"
|
| 369 |
+
: "text-gray-800 dark:text-gray-200 px-2 py-1 prose dark:prose-invert max-w-none"
|
| 370 |
+
)}>
|
| 371 |
+
{msg.role === "bot" ? (
|
| 372 |
+
typedMessages.has(msg.id) ? (
|
| 373 |
+
<div className="markdown-content">
|
| 374 |
+
<ReactMarkdown
|
| 375 |
+
remarkPlugins={[remarkGfm]}
|
| 376 |
+
components={markdownComponents}
|
| 377 |
+
>
|
| 378 |
+
{msg.content}
|
| 379 |
+
</ReactMarkdown>
|
| 380 |
+
</div>
|
| 381 |
+
) : (
|
| 382 |
+
<Typewriter
|
| 383 |
+
text={msg.content}
|
| 384 |
+
speed={15}
|
| 385 |
+
onTyping={() => scrollToBottom("auto")}
|
| 386 |
+
onComplete={() => setTypedMessages(prev => new Set([...prev, msg.id]))}
|
| 387 |
+
/>
|
| 388 |
+
)
|
| 389 |
+
) : (
|
| 390 |
+
msg.content
|
| 391 |
+
)}
|
| 392 |
+
</div>
|
| 393 |
+
</motion.div>
|
| 394 |
+
))}
|
| 395 |
+
</AnimatePresence>
|
| 396 |
+
|
| 397 |
+
{/* Loading State */}
|
| 398 |
+
{isLoading && (
|
| 399 |
+
<motion.div
|
| 400 |
+
initial={{ opacity: 0, y: 10 }}
|
| 401 |
+
animate={{ opacity: 1, y: 0 }}
|
| 402 |
+
className="flex gap-4 md:gap-6"
|
| 403 |
+
>
|
| 404 |
+
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400 flex items-center justify-center shrink-0 mt-1">
|
| 405 |
+
<Bot size={20} />
|
| 406 |
+
</div>
|
| 407 |
+
<div className="flex flex-col gap-2 mt-2">
|
| 408 |
+
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
|
| 409 |
+
<Loader2 size={16} className="animate-spin" />
|
| 410 |
+
{content.thinking}
|
| 411 |
+
</div>
|
| 412 |
+
</div>
|
| 413 |
+
</motion.div>
|
| 414 |
+
)}
|
| 415 |
+
|
| 416 |
+
{error && (
|
| 417 |
+
<motion.div
|
| 418 |
+
initial={{ opacity: 0 }}
|
| 419 |
+
animate={{ opacity: 1 }}
|
| 420 |
+
className="p-4 bg-red-50 text-red-600 border border-red-200 rounded-xl text-center"
|
| 421 |
+
>
|
| 422 |
+
<p>{error}</p>
|
| 423 |
+
</motion.div>
|
| 424 |
+
)}
|
| 425 |
+
<div ref={messagesEndRef} />
|
| 426 |
+
</div>
|
| 427 |
+
</div>
|
| 428 |
+
|
| 429 |
+
{/* STICKY Input Area */}
|
| 430 |
+
<div className="sticky bottom-0 left-0 w-full bg-background pb-6 pt-4 px-4 md:px-0 z-30">
|
| 431 |
+
<div className="max-w-3xl mx-auto relative">
|
| 432 |
+
{/* Shadow gradient top for nice effect */}
|
| 433 |
+
<div className="absolute -top-10 left-0 w-full h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
| 434 |
+
|
| 435 |
+
<motion.div
|
| 436 |
+
initial={{ y: 50, opacity: 0 }}
|
| 437 |
+
animate={{ y: 0, opacity: 1 }}
|
| 438 |
+
transition={{ delay: 0.5, type: "spring" }}
|
| 439 |
+
className="relative flex items-center bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 shadow-sm rounded-3xl p-2 transition-all focus-within:border-orange-500"
|
| 440 |
+
>
|
| 441 |
+
{/* Voice Button */}
|
| 442 |
+
<button
|
| 443 |
+
onClick={toggleListening}
|
| 444 |
+
className={classNames(
|
| 445 |
+
"p-3 rounded-full transition-all flex items-center justify-center mr-1",
|
| 446 |
+
isListening
|
| 447 |
+
? "bg-red-500 text-white animate-pulse"
|
| 448 |
+
: "bg-transparent text-gray-400 hover:bg-gray-200 dark:hover:bg-neutral-800 hover:text-gray-600"
|
| 449 |
+
)}
|
| 450 |
+
>
|
| 451 |
+
{isListening ? <MicOff size={20} /> : <Mic size={20} />}
|
| 452 |
+
</button>
|
| 453 |
+
|
| 454 |
+
<input
|
| 455 |
+
type="text"
|
| 456 |
+
value={inputValue}
|
| 457 |
+
onChange={(e) => setInputValue(e.target.value)}
|
| 458 |
+
onKeyDown={(e) => e.key === "Enter" && handleSendMessage(inputValue)}
|
| 459 |
+
placeholder={content.placeholder}
|
| 460 |
+
disabled={isLoading}
|
| 461 |
+
className="w-full bg-transparent border-none outline-none focus:ring-0 focus:outline-none rounded-full px-2 py-3 text-base text-gray-800 dark:text-gray-200 placeholder:text-gray-400"
|
| 462 |
+
/>
|
| 463 |
+
<button
|
| 464 |
+
onClick={() => handleSendMessage(inputValue)}
|
| 465 |
+
disabled={!inputValue.trim() || isLoading}
|
| 466 |
+
className="p-3 bg-orange-600 hover:bg-orange-700 disabled:opacity-50 disabled:hover:bg-orange-600 text-white rounded-full transition-all shadow-sm transform hover:scale-105 active:scale-95 ml-2"
|
| 467 |
+
>
|
| 468 |
+
<Send size={18} />
|
| 469 |
+
</button>
|
| 470 |
+
</motion.div>
|
| 471 |
+
<p className="text-center text-[10px] md:text-xs text-gray-400 mt-3 -mb-3 opacity-70">
|
| 472 |
+
{content.powerBy}
|
| 473 |
+
</p>
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
</div>
|
| 477 |
+
);
|
| 478 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
|
| 4 |
+
@custom-variant dark (&:is(.dark *));
|
| 5 |
+
|
| 6 |
+
@variant dark (&:where(.dark, .dark *));
|
| 7 |
+
|
| 8 |
+
@theme {
|
| 9 |
+
--color-background: var(--background);
|
| 10 |
+
--color-foreground: var(--foreground);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
:root {
|
| 14 |
+
/* LIGHT MODE: Cream Polos */
|
| 15 |
+
|
| 16 |
+
--glass-bg: rgba(255, 255, 255, 0.9);
|
| 17 |
+
--glass-border: rgba(249, 115, 22, 0.2);
|
| 18 |
+
--radius: 0.625rem;
|
| 19 |
+
--background: oklch(1 0 0);
|
| 20 |
+
--foreground: oklch(0.147 0.004 49.25);
|
| 21 |
+
--card: oklch(1 0 0);
|
| 22 |
+
--card-foreground: oklch(0.147 0.004 49.25);
|
| 23 |
+
--popover: oklch(1 0 0);
|
| 24 |
+
--popover-foreground: oklch(0.147 0.004 49.25);
|
| 25 |
+
--primary: oklch(0.216 0.006 56.043);
|
| 26 |
+
--primary-foreground: oklch(0.985 0.001 106.423);
|
| 27 |
+
--secondary: oklch(0.97 0.001 106.424);
|
| 28 |
+
--secondary-foreground: oklch(0.216 0.006 56.043);
|
| 29 |
+
--muted: oklch(0.97 0.001 106.424);
|
| 30 |
+
--muted-foreground: oklch(0.553 0.013 58.071);
|
| 31 |
+
--accent: oklch(0.97 0.001 106.424);
|
| 32 |
+
--accent-foreground: oklch(0.216 0.006 56.043);
|
| 33 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 34 |
+
--border: oklch(0.923 0.003 48.717);
|
| 35 |
+
--input: oklch(0.923 0.003 48.717);
|
| 36 |
+
--ring: oklch(0.709 0.01 56.259);
|
| 37 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
| 38 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
| 39 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
| 40 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
| 41 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
| 42 |
+
--sidebar: oklch(0.985 0.001 106.423);
|
| 43 |
+
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
| 44 |
+
--sidebar-primary: oklch(0.216 0.006 56.043);
|
| 45 |
+
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
| 46 |
+
--sidebar-accent: oklch(0.97 0.001 106.424);
|
| 47 |
+
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
| 48 |
+
--sidebar-border: oklch(0.923 0.003 48.717);
|
| 49 |
+
--sidebar-ring: oklch(0.709 0.01 56.259);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.dark {
|
| 53 |
+
/* DARK MODE: Hitam Pekat Polos */
|
| 54 |
+
--background: oklch(0.147 0.004 49.25);
|
| 55 |
+
--foreground: oklch(0.985 0.001 106.423);
|
| 56 |
+
|
| 57 |
+
--glass-bg: rgba(12, 10, 9, 0.8);
|
| 58 |
+
--glass-border: rgba(249, 115, 22, 0.2);
|
| 59 |
+
--card: oklch(0.216 0.006 56.043);
|
| 60 |
+
--card-foreground: oklch(0.985 0.001 106.423);
|
| 61 |
+
--popover: oklch(0.216 0.006 56.043);
|
| 62 |
+
--popover-foreground: oklch(0.985 0.001 106.423);
|
| 63 |
+
--primary: oklch(0.923 0.003 48.717);
|
| 64 |
+
--primary-foreground: oklch(0.216 0.006 56.043);
|
| 65 |
+
--secondary: oklch(0.268 0.007 34.298);
|
| 66 |
+
--secondary-foreground: oklch(0.985 0.001 106.423);
|
| 67 |
+
--muted: oklch(0.268 0.007 34.298);
|
| 68 |
+
--muted-foreground: oklch(0.709 0.01 56.259);
|
| 69 |
+
--accent: oklch(0.268 0.007 34.298);
|
| 70 |
+
--accent-foreground: oklch(0.985 0.001 106.423);
|
| 71 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 72 |
+
--border: oklch(1 0 0 / 10%);
|
| 73 |
+
--input: oklch(1 0 0 / 15%);
|
| 74 |
+
--ring: oklch(0.553 0.013 58.071);
|
| 75 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
| 76 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
| 77 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
| 78 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
| 79 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
| 80 |
+
--sidebar: oklch(0.216 0.006 56.043);
|
| 81 |
+
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
| 82 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
| 83 |
+
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
| 84 |
+
--sidebar-accent: oklch(0.268 0.007 34.298);
|
| 85 |
+
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
| 86 |
+
--sidebar-border: oklch(1 0 0 / 10%);
|
| 87 |
+
--sidebar-ring: oklch(0.553 0.013 58.071);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
body {
|
| 91 |
+
/* HAPUS background-image/radial-gradient DARI SINI */
|
| 92 |
+
background-color: var(--background);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* Utility buat kaca tetep ada, tapi background body udah polos */
|
| 96 |
+
@utility liquid-glass {
|
| 97 |
+
@apply backdrop-blur-xl border shadow-lg rounded-2xl;
|
| 98 |
+
background: var(--glass-bg);
|
| 99 |
+
border-color: var(--glass-border);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
@utility navbar-transition {
|
| 103 |
+
transition-property: all;
|
| 104 |
+
transition-duration: 700ms;
|
| 105 |
+
will-change: width, top, border-radius;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
@theme inline {
|
| 109 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 110 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 111 |
+
--radius-lg: var(--radius);
|
| 112 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 113 |
+
--radius-2xl: calc(var(--radius) + 8px);
|
| 114 |
+
--radius-3xl: calc(var(--radius) + 12px);
|
| 115 |
+
--radius-4xl: calc(var(--radius) + 16px);
|
| 116 |
+
--color-background: var(--background);
|
| 117 |
+
--color-foreground: var(--foreground);
|
| 118 |
+
--color-card: var(--card);
|
| 119 |
+
--color-card-foreground: var(--card-foreground);
|
| 120 |
+
--color-popover: var(--popover);
|
| 121 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 122 |
+
--color-primary: var(--primary);
|
| 123 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 124 |
+
--color-secondary: var(--secondary);
|
| 125 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 126 |
+
--color-muted: var(--muted);
|
| 127 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 128 |
+
--color-accent: var(--accent);
|
| 129 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 130 |
+
--color-destructive: var(--destructive);
|
| 131 |
+
--color-border: var(--border);
|
| 132 |
+
--color-input: var(--input);
|
| 133 |
+
--color-ring: var(--ring);
|
| 134 |
+
--color-chart-1: var(--chart-1);
|
| 135 |
+
--color-chart-2: var(--chart-2);
|
| 136 |
+
--color-chart-3: var(--chart-3);
|
| 137 |
+
--color-chart-4: var(--chart-4);
|
| 138 |
+
--color-chart-5: var(--chart-5);
|
| 139 |
+
--color-sidebar: var(--sidebar);
|
| 140 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 141 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 142 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 143 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 144 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 145 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 146 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
@layer base {
|
| 150 |
+
* {
|
| 151 |
+
@apply border-border outline-ring/50;
|
| 152 |
+
}
|
| 153 |
+
body {
|
| 154 |
+
@apply bg-background text-foreground;
|
| 155 |
+
}
|
| 156 |
+
body {
|
| 157 |
+
@apply antialiased;
|
| 158 |
+
}
|
| 159 |
+
}
|
src/app/icon.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// src/app/icon.tsx
|
| 2 |
+
import { ImageResponse } from 'next/og';
|
| 3 |
+
|
| 4 |
+
// Route segment config
|
| 5 |
+
export const runtime = 'edge';
|
| 6 |
+
|
| 7 |
+
// Image metadata
|
| 8 |
+
export const size = {
|
| 9 |
+
width: 32,
|
| 10 |
+
height: 32,
|
| 11 |
+
};
|
| 12 |
+
export const contentType = 'image/png';
|
| 13 |
+
|
| 14 |
+
// Image generation
|
| 15 |
+
export default function Icon() {
|
| 16 |
+
return new ImageResponse(
|
| 17 |
+
(
|
| 18 |
+
// Container Background
|
| 19 |
+
<div
|
| 20 |
+
style={{
|
| 21 |
+
fontSize: 24,
|
| 22 |
+
background: '#ea580c', // Warna orange-600 (Sesuai Navbar)
|
| 23 |
+
width: '100%',
|
| 24 |
+
height: '100%',
|
| 25 |
+
display: 'flex',
|
| 26 |
+
alignItems: 'center',
|
| 27 |
+
justifyContent: 'center',
|
| 28 |
+
color: 'white',
|
| 29 |
+
borderRadius: '8px', // Rounded biar tidak kaku
|
| 30 |
+
}}
|
| 31 |
+
>
|
| 32 |
+
{/* SVG BrainCircuit (Official Lucide Paths) */}
|
| 33 |
+
<svg
|
| 34 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 35 |
+
width="20"
|
| 36 |
+
height="20"
|
| 37 |
+
viewBox="0 0 24 24"
|
| 38 |
+
fill="none"
|
| 39 |
+
stroke="currentColor"
|
| 40 |
+
strokeWidth="2"
|
| 41 |
+
strokeLinecap="round"
|
| 42 |
+
strokeLinejoin="round"
|
| 43 |
+
>
|
| 44 |
+
{/* Path 1: Bagian Kiri Otak */}
|
| 45 |
+
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
|
| 46 |
+
|
| 47 |
+
{/* Path 2: Bagian Kanan Otak */}
|
| 48 |
+
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" />
|
| 49 |
+
|
| 50 |
+
{/* Path 3: Koneksi Tengah */}
|
| 51 |
+
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
|
| 52 |
+
|
| 53 |
+
{/* Path 4-9: Sirkuit / Nodes Kecil */}
|
| 54 |
+
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
|
| 55 |
+
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
|
| 56 |
+
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
|
| 57 |
+
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
|
| 58 |
+
<path d="M6 18a4 4 0 0 1-1.97-1.364" />
|
| 59 |
+
<path d="M17.97 16.636A4 4 0 0 1 16 18" />
|
| 60 |
+
</svg>
|
| 61 |
+
</div>
|
| 62 |
+
),
|
| 63 |
+
// Options
|
| 64 |
+
{
|
| 65 |
+
...size,
|
| 66 |
+
}
|
| 67 |
+
);
|
| 68 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { Providers } from "./providers";
|
| 5 |
+
import Navbar from "@/components/Navbar";
|
| 6 |
+
import Footer from "@/components/Footer";
|
| 7 |
+
|
| 8 |
+
const inter = Inter({ subsets: ["latin"] });
|
| 9 |
+
|
| 10 |
+
export const metadata: Metadata = {
|
| 11 |
+
title: "Sentimind - AI Personality Profiler",
|
| 12 |
+
description: "Analyze your personality using AI",
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export default function RootLayout({
|
| 16 |
+
children,
|
| 17 |
+
}: {
|
| 18 |
+
children: React.ReactNode;
|
| 19 |
+
}) {
|
| 20 |
+
return (
|
| 21 |
+
<html lang="en" suppressHydrationWarning>
|
| 22 |
+
<body className={`${inter.className} min-h-screen bg-background text-foreground flex flex-col`}>
|
| 23 |
+
<Providers>
|
| 24 |
+
<Navbar />
|
| 25 |
+
|
| 26 |
+
{/* Main content fills available space */}
|
| 27 |
+
<main className="container mx-auto px-4 md:px-8 flex-grow">
|
| 28 |
+
{children}
|
| 29 |
+
</main>
|
| 30 |
+
|
| 31 |
+
<Footer />
|
| 32 |
+
|
| 33 |
+
</Providers>
|
| 34 |
+
</body>
|
| 35 |
+
</html>
|
| 36 |
+
);
|
| 37 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { Sparkles, BrainCircuit, Search, BookOpen } from "lucide-react";
|
| 5 |
+
import { useLanguage } from "@/app/providers";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { motion, Variants } from "framer-motion";
|
| 8 |
+
|
| 9 |
+
export default function Home() {
|
| 10 |
+
const { lang } = useLanguage();
|
| 11 |
+
|
| 12 |
+
const t = {
|
| 13 |
+
en: {
|
| 14 |
+
badge: "AI Personality Profiler",
|
| 15 |
+
titleLine1: "Understand",
|
| 16 |
+
titleLine2: "Your Personality.",
|
| 17 |
+
desc: "Sentimind analyzes your writing style to reveal your MBTI type, emotional tone, and communication patterns from simple text.",
|
| 18 |
+
btnStart: "Start Analysis",
|
| 19 |
+
btnLibrary: "Explore Types",
|
| 20 |
+
features: [
|
| 21 |
+
{ title: "MBTI Prediction", desc: "Predicts one of 16 personality types based on your writing style." },
|
| 22 |
+
{ title: "Sentiment Analysis", desc: "Detects the dominant emotional tone and mood in your text." },
|
| 23 |
+
{ title: "Keyword Extraction", desc: "Highlights key topics and patterns from your daily conversations." }
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
id: {
|
| 27 |
+
badge: "Profil Kepribadian AI",
|
| 28 |
+
titleLine1: "Pahami",
|
| 29 |
+
titleLine2: "Kepribadian Lo.",
|
| 30 |
+
desc: "Gak perlu tes berjam-jam. Sentimind baca gaya nulis lo buat nebak MBTI, mood, dan pola pikir yang mungkin lo sendiri gak sadar.",
|
| 31 |
+
btnStart: "Mulai Analisis",
|
| 32 |
+
btnLibrary: "Kamus MBTI",
|
| 33 |
+
features: [
|
| 34 |
+
{ title: "Prediksi MBTI", desc: "Tebak satu dari 16 tipe kepribadian based on gaya tulisan lo." },
|
| 35 |
+
{ title: "Analisis Sentimen", desc: "Cek vibes tulisan lo, apakah lagi positif banget atau malah gloomy." },
|
| 36 |
+
{ title: "Ekstraksi Kata Kunci", desc: "Highlight topik-topik yang sering lo bahas tanpa sadar." }
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const content = t[lang];
|
| 42 |
+
const icons = [BrainCircuit, Sparkles, Search];
|
| 43 |
+
|
| 44 |
+
const containerVariants: Variants = {
|
| 45 |
+
hidden: { opacity: 0 },
|
| 46 |
+
visible: {
|
| 47 |
+
opacity: 1,
|
| 48 |
+
transition: {
|
| 49 |
+
staggerChildren: 0.15,
|
| 50 |
+
delayChildren: 0.1
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const itemVariants: Variants = {
|
| 56 |
+
hidden: { y: 20, opacity: 0 },
|
| 57 |
+
visible: {
|
| 58 |
+
y: 0,
|
| 59 |
+
opacity: 1,
|
| 60 |
+
transition: { type: "spring", stiffness: 100 }
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<motion.div
|
| 66 |
+
initial="hidden"
|
| 67 |
+
animate="visible"
|
| 68 |
+
variants={containerVariants}
|
| 69 |
+
className="flex flex-col items-center justify-start pt-28 md:pt-32 font-sans gap-8 w-full min-h-screen"
|
| 70 |
+
>
|
| 71 |
+
|
| 72 |
+
<div className="flex flex-col items-center justify-center text-center gap-4 relative w-full px-4 max-w-4xl mx-auto">
|
| 73 |
+
|
| 74 |
+
{/* Badge */}
|
| 75 |
+
<motion.div variants={itemVariants} className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-orange-50/50 dark:bg-orange-950/30 text-orange-600 dark:text-orange-400 text-xs font-medium border border-orange-200 dark:border-orange-800">
|
| 76 |
+
<Sparkles className="w-3 h-3" />
|
| 77 |
+
<span>{content.badge}</span>
|
| 78 |
+
</motion.div>
|
| 79 |
+
|
| 80 |
+
{/* Title */}
|
| 81 |
+
<motion.h1 variants={itemVariants} className="text-5xl md:text-7xl font-black tracking-tighter text-gray-900 dark:text-white leading-[1.1] pb-2">
|
| 82 |
+
{content.titleLine1} <span className="text-transparent bg-clip-text bg-gradient-to-br from-orange-500 to-amber-600">{content.titleLine2}</span>
|
| 83 |
+
</motion.h1>
|
| 84 |
+
|
| 85 |
+
<motion.p variants={itemVariants} className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl leading-relaxed">
|
| 86 |
+
{content.desc}
|
| 87 |
+
</motion.p>
|
| 88 |
+
|
| 89 |
+
{/* Action Buttons */}
|
| 90 |
+
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8 w-full">
|
| 91 |
+
|
| 92 |
+
<Button asChild size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-semibold rounded-lg shadow-sm cursor-pointer bg-orange-600 hover:bg-orange-700 text-white border-transparent">
|
| 93 |
+
<Link href="/analyzer">
|
| 94 |
+
<Search className="w-4 h-4 mr-2" />
|
| 95 |
+
{content.btnStart}
|
| 96 |
+
</Link>
|
| 97 |
+
</Button>
|
| 98 |
+
|
| 99 |
+
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-semibold rounded-lg border-gray-200 dark:border-white/10 hover:bg-gray-100 dark:hover:bg-white/5 bg-transparent cursor-pointer">
|
| 100 |
+
<Link href="/types">
|
| 101 |
+
<BookOpen className="w-4 h-4 mr-2" />
|
| 102 |
+
{content.btnLibrary}
|
| 103 |
+
</Link>
|
| 104 |
+
</Button>
|
| 105 |
+
|
| 106 |
+
</motion.div>
|
| 107 |
+
|
| 108 |
+
{/* Features Grid */}
|
| 109 |
+
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-20 pb-20 w-full text-left">
|
| 110 |
+
{content.features.map((item, i) => {
|
| 111 |
+
const Icon = icons[i];
|
| 112 |
+
return (
|
| 113 |
+
<motion.div
|
| 114 |
+
key={i}
|
| 115 |
+
whileHover={{ y: -5 }}
|
| 116 |
+
className="group p-6 border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 rounded-xl hover:shadow-lg transition-all duration-300"
|
| 117 |
+
>
|
| 118 |
+
<div className="p-2.5 bg-orange-100 dark:bg-orange-500/20 w-fit rounded-lg mb-4 text-orange-600 dark:text-orange-400 group-hover:scale-110 transition-transform duration-300">
|
| 119 |
+
<Icon className="w-5 h-5" />
|
| 120 |
+
</div>
|
| 121 |
+
<h3 className="text-sm font-bold mb-2 text-gray-900 dark:text-white tracking-tight">{item.title}</h3>
|
| 122 |
+
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">{item.desc}</p>
|
| 123 |
+
</motion.div>
|
| 124 |
+
);
|
| 125 |
+
})}
|
| 126 |
+
</motion.div>
|
| 127 |
+
|
| 128 |
+
</div>
|
| 129 |
+
</motion.div>
|
| 130 |
+
);
|
| 131 |
+
}
|
src/app/providers.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { ThemeProvider } from "next-themes";
|
| 4 |
+
import { useState, useEffect, createContext, useContext } from "react";
|
| 5 |
+
|
| 6 |
+
type LangContextType = {
|
| 7 |
+
lang: "en" | "id";
|
| 8 |
+
toggleLang: () => void;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const LanguageContext = createContext<LangContextType | undefined>(undefined);
|
| 12 |
+
|
| 13 |
+
export function Providers({ children }: { children: React.ReactNode }) {
|
| 14 |
+
const [mounted, setMounted] = useState(false);
|
| 15 |
+
const [lang, setLang] = useState<"en" | "id">("en");
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
setMounted(true);
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const toggleLang = () => {
|
| 22 |
+
setLang((prev) => (prev === "en" ? "id" : "en"));
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<LanguageContext.Provider value={{ lang, toggleLang }}>
|
| 28 |
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
| 29 |
+
{children}
|
| 30 |
+
</ThemeProvider>
|
| 31 |
+
</LanguageContext.Provider>
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export const useLanguage = () => {
|
| 36 |
+
const context = useContext(LanguageContext);
|
| 37 |
+
if (!context) throw new Error("useLanguage must be used within Providers");
|
| 38 |
+
return context;
|
| 39 |
+
};
|
src/app/quiz/layout.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Metadata } from "next";
|
| 2 |
+
|
| 3 |
+
export const metadata: Metadata = {
|
| 4 |
+
title: "Mini Test | Sentimind",
|
| 5 |
+
description: "Take a quick personality test to know your MBTI.",
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
| 9 |
+
return <>{children}</>;
|
| 10 |
+
}
|
src/app/quiz/page.tsx
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { useLanguage } from "@/app/providers";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import { mbtiDatabase } from "@/data/mbti";
|
| 7 |
+
import { CheckCircle2, Clock, ShieldCheck } from "lucide-react";
|
| 8 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 9 |
+
|
| 10 |
+
type Question = {
|
| 11 |
+
id: number;
|
| 12 |
+
text_id: string;
|
| 13 |
+
text_en: string;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default function QuizPage() {
|
| 17 |
+
const { lang } = useLanguage();
|
| 18 |
+
const [questions, setQuestions] = useState<Question[]>([]);
|
| 19 |
+
const [step, setStep] = useState(0);
|
| 20 |
+
const [answers, setAnswers] = useState<Record<string, number>>({});
|
| 21 |
+
const [result, setResult] = useState<string | null>(null);
|
| 22 |
+
const [loading, setLoading] = useState(true);
|
| 23 |
+
|
| 24 |
+
const t = {
|
| 25 |
+
en: {
|
| 26 |
+
loading: "Loading questions...",
|
| 27 |
+
title: "Personality Quiz",
|
| 28 |
+
subtitle: "Answer honestly to reveal your true type.",
|
| 29 |
+
questionLabel: "Question",
|
| 30 |
+
agree: "Agree",
|
| 31 |
+
disagree: "Disagree",
|
| 32 |
+
result: "Your Result:",
|
| 33 |
+
retry: "Retake Quiz",
|
| 34 |
+
submitError: "Failed to calculate result.",
|
| 35 |
+
infoTitle: "Things to know",
|
| 36 |
+
infos: [
|
| 37 |
+
{ icon: Clock, text: "Takes less than 2 minutes to complete." },
|
| 38 |
+
{ icon: CheckCircle2, text: "Answer instinctively, don't overthink." },
|
| 39 |
+
{ icon: ShieldCheck, text: "No right or wrong answers." }
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
id: {
|
| 43 |
+
loading: "Lagi nyiapin soal...",
|
| 44 |
+
title: "Kuis Kepribadian",
|
| 45 |
+
subtitle: "Jawab jujur ya, biar tipe aslinya ketahuan.",
|
| 46 |
+
questionLabel: "Pertanyaan",
|
| 47 |
+
agree: "Setuju",
|
| 48 |
+
disagree: "Gak Setuju",
|
| 49 |
+
result: "Hasil Kamu:",
|
| 50 |
+
retry: "Ulangi Tes",
|
| 51 |
+
submitError: "Gagal ngitung hasil nih.",
|
| 52 |
+
infoTitle: "Info Penting",
|
| 53 |
+
infos: [
|
| 54 |
+
{ icon: Clock, text: "Gak sampe 2 menit kok, santai." },
|
| 55 |
+
{ icon: CheckCircle2, text: "Jawab spontan aja, gak usah mikir keras." },
|
| 56 |
+
{ icon: ShieldCheck, text: "Gak ada jawaban bener atau salah." }
|
| 57 |
+
]
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const content = t[lang];
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
| 65 |
+
fetch(`${apiUrl}/api/quiz`)
|
| 66 |
+
.then((res) => res.json())
|
| 67 |
+
.then((data) => {
|
| 68 |
+
setQuestions(data.questions || []);
|
| 69 |
+
setLoading(false);
|
| 70 |
+
});
|
| 71 |
+
}, []);
|
| 72 |
+
|
| 73 |
+
const handleAnswer = (val: number) => {
|
| 74 |
+
const currentQ = questions[step];
|
| 75 |
+
setAnswers((prev) => ({ ...prev, [currentQ.id]: val }));
|
| 76 |
+
|
| 77 |
+
if (step < questions.length - 1) {
|
| 78 |
+
setStep(step + 1);
|
| 79 |
+
} else {
|
| 80 |
+
submitAnswers({ ...answers, [currentQ.id]: val });
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const submitAnswers = async (finalAnswers: Record<string, number>) => {
|
| 85 |
+
setLoading(true);
|
| 86 |
+
try {
|
| 87 |
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
| 88 |
+
const res = await fetch(`${apiUrl}/api/quiz`, {
|
| 89 |
+
method: "POST",
|
| 90 |
+
headers: { "Content-Type": "application/json" },
|
| 91 |
+
body: JSON.stringify({ answers: finalAnswers }),
|
| 92 |
+
});
|
| 93 |
+
const data = await res.json();
|
| 94 |
+
setResult(data.mbti);
|
| 95 |
+
} catch (e) {
|
| 96 |
+
alert(content.submitError);
|
| 97 |
+
} finally {
|
| 98 |
+
setLoading(false);
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
if (loading) return (
|
| 103 |
+
<div className="pt-40 flex items-center justify-center font-bold text-orange-600 animate-pulse">
|
| 104 |
+
{content.loading}
|
| 105 |
+
</div>
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
if (result) {
|
| 109 |
+
const data = mbtiDatabase[result];
|
| 110 |
+
const contentData = lang === 'en' ? data?.en : data?.id;
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="w-full pt-28 pb-12 flex flex-col justify-center items-center font-sans relative px-4">
|
| 114 |
+
<motion.div
|
| 115 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 116 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 117 |
+
transition={{ type: "spring", duration: 0.6 }}
|
| 118 |
+
className="liquid-glass p-8 md:p-12 text-center bg-white/40 dark:bg-black/20 border border-white/20 max-w-2xl w-full rounded-3xl shadow-2xl"
|
| 119 |
+
>
|
| 120 |
+
|
| 121 |
+
<h2 className="text-sm font-bold opacity-60 uppercase tracking-widest text-gray-800 dark:text-gray-200 mb-4">
|
| 122 |
+
{content.result}
|
| 123 |
+
</h2>
|
| 124 |
+
|
| 125 |
+
<div className={`p-6 rounded-2xl border-2 bg-white/50 dark:bg-black/40 backdrop-blur-md mb-8 ${data?.color || 'border-gray-500'}`}>
|
| 126 |
+
<motion.div
|
| 127 |
+
initial={{ scale: 0 }}
|
| 128 |
+
animate={{ scale: 1 }}
|
| 129 |
+
transition={{ delay: 0.3, type: "spring" }}
|
| 130 |
+
className="text-6xl md:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-red-600 mb-2"
|
| 131 |
+
>
|
| 132 |
+
{result}
|
| 133 |
+
</motion.div>
|
| 134 |
+
<h3 className={`text-2xl font-bold mb-2 ${data?.textColor}`}>
|
| 135 |
+
{contentData?.name}
|
| 136 |
+
</h3>
|
| 137 |
+
<p className="text-gray-600 dark:text-gray-300 text-sm md:text-base line-clamp-3">
|
| 138 |
+
{contentData?.desc}
|
| 139 |
+
</p>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
| 143 |
+
<Link
|
| 144 |
+
href={`/types/${result}`}
|
| 145 |
+
className="px-8 py-3 bg-orange-600 text-white rounded-xl font-bold hover:bg-orange-700 transition-all shadow-lg hover:shadow-orange-500/30"
|
| 146 |
+
>
|
| 147 |
+
{lang === 'en' ? "Read Full Profile" : "Baca Profil Lengkap"}
|
| 148 |
+
</Link>
|
| 149 |
+
|
| 150 |
+
<button
|
| 151 |
+
onClick={() => window.location.reload()}
|
| 152 |
+
className="px-8 py-3 bg-white dark:bg-white/10 border border-gray-200 dark:border-white/20 rounded-xl font-bold hover:bg-gray-100 dark:hover:bg-white/20 transition-all text-gray-900 dark:text-white"
|
| 153 |
+
>
|
| 154 |
+
{content.retry}
|
| 155 |
+
</button>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
</motion.div>
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (questions.length === 0) return null;
|
| 164 |
+
|
| 165 |
+
const currentQuestionText = lang === 'en' ? questions[step].text_en : questions[step].text_id;
|
| 166 |
+
|
| 167 |
+
return (
|
| 168 |
+
<div className="w-full pt-28 pb-12 px-4 sm:px-6 lg:px-8 font-sans flex flex-col items-center">
|
| 169 |
+
<div className="max-w-3xl w-full z-10">
|
| 170 |
+
|
| 171 |
+
{/* HEADER */}
|
| 172 |
+
<motion.div
|
| 173 |
+
initial={{ y: -20, opacity: 0 }}
|
| 174 |
+
animate={{ y: 0, opacity: 1 }}
|
| 175 |
+
className="text-center mb-12"
|
| 176 |
+
>
|
| 177 |
+
<h1 className="text-4xl md:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 pb-2 mb-2">
|
| 178 |
+
{content.title}
|
| 179 |
+
</h1>
|
| 180 |
+
<p className="text-gray-600 dark:text-gray-400 text-sm md:text-lg max-w-2xl mx-auto">
|
| 181 |
+
{content.subtitle}
|
| 182 |
+
</p>
|
| 183 |
+
</motion.div>
|
| 184 |
+
|
| 185 |
+
{/* CARD SOAL */}
|
| 186 |
+
<AnimatePresence mode="wait">
|
| 187 |
+
<motion.div
|
| 188 |
+
key={step}
|
| 189 |
+
initial={{ x: 50, opacity: 0 }}
|
| 190 |
+
animate={{ x: 0, opacity: 1 }}
|
| 191 |
+
exit={{ x: -50, opacity: 0 }}
|
| 192 |
+
transition={{ duration: 0.3 }}
|
| 193 |
+
className="liquid-glass p-6 md:p-10 bg-white/50 dark:bg-black/30 backdrop-blur-md shadow-2xl border border-white/20 rounded-3xl"
|
| 194 |
+
>
|
| 195 |
+
<div className="flex justify-between items-end mb-6 border-b border-gray-500/10 pb-4">
|
| 196 |
+
<span className="text-xs font-bold uppercase tracking-widest opacity-50 text-gray-700 dark:text-gray-300">
|
| 197 |
+
{content.questionLabel}
|
| 198 |
+
</span>
|
| 199 |
+
<span className="text-2xl font-black text-orange-600">
|
| 200 |
+
{step + 1} <span className="text-sm font-medium text-gray-400">/ {questions.length}</span>
|
| 201 |
+
</span>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<h2 className="text-xl md:text-3xl font-bold mb-12 text-center leading-snug min-h-[100px] flex items-center justify-center text-gray-900 dark:text-white">
|
| 205 |
+
{currentQuestionText}
|
| 206 |
+
</h2>
|
| 207 |
+
|
| 208 |
+
<div className="relative">
|
| 209 |
+
<div className="hidden md:flex justify-between absolute -top-8 w-full text-xs font-bold opacity-60 px-2">
|
| 210 |
+
<span className="text-red-500">{content.disagree}</span>
|
| 211 |
+
<span className="text-green-500">{content.agree}</span>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div className="flex justify-between items-center gap-2 md:gap-4">
|
| 215 |
+
{[-3, -2, -1, 0, 1, 2, 3].map((val) => (
|
| 216 |
+
<motion.button
|
| 217 |
+
key={val}
|
| 218 |
+
whileHover={{ scale: 1.2 }}
|
| 219 |
+
whileTap={{ scale: 0.9 }}
|
| 220 |
+
onClick={() => handleAnswer(val)}
|
| 221 |
+
className={`
|
| 222 |
+
group relative rounded-full border-2 transition-all duration-300 flex items-center justify-center shadow-sm
|
| 223 |
+
${val === 0
|
| 224 |
+
? 'w-10 h-10 md:w-12 md:h-12 border-gray-300 text-gray-400 hover:bg-gray-200 dark:hover:bg-white/10'
|
| 225 |
+
: 'w-12 h-12 md:w-16 md:h-16'
|
| 226 |
+
}
|
| 227 |
+
${val < 0
|
| 228 |
+
? 'border-red-400/50 text-red-500 hover:bg-red-500 hover:border-red-500 hover:text-white'
|
| 229 |
+
: val > 0
|
| 230 |
+
? 'border-green-400/50 text-green-500 hover:bg-green-500 hover:border-green-500 hover:text-white'
|
| 231 |
+
: ''
|
| 232 |
+
}
|
| 233 |
+
`}
|
| 234 |
+
title={`${val}`}
|
| 235 |
+
>
|
| 236 |
+
<span className={`
|
| 237 |
+
absolute rounded-full transition-all duration-300
|
| 238 |
+
${Math.abs(val) === 3 ? 'w-3 h-3 md:w-4 md:h-4' : ''}
|
| 239 |
+
${Math.abs(val) === 2 ? 'w-2.5 h-2.5 md:w-3 md:h-3' : ''}
|
| 240 |
+
${Math.abs(val) === 1 ? 'w-2 h-2 md:w-2 md:h-2' : ''}
|
| 241 |
+
${val === 0 ? 'w-1.5 h-1.5 bg-gray-400' : 'bg-current'}
|
| 242 |
+
group-hover:bg-white
|
| 243 |
+
`}></span>
|
| 244 |
+
<span className="md:hidden absolute -bottom-6 text-[10px] font-bold opacity-0 group-hover:opacity-100 transition-opacity text-gray-500">
|
| 245 |
+
{val === -3 ? 'Sgt Tdk' : val === 3 ? 'Sgt Iya' : val}
|
| 246 |
+
</span>
|
| 247 |
+
</motion.button>
|
| 248 |
+
))}
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<div className="flex justify-between mt-6 text-[10px] font-bold opacity-60 uppercase md:hidden tracking-wider">
|
| 252 |
+
<span className="text-red-500">{content.disagree}</span>
|
| 253 |
+
<span className="text-green-500">{content.agree}</span>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</motion.div>
|
| 257 |
+
</AnimatePresence>
|
| 258 |
+
|
| 259 |
+
{/* INFO SECTION */}
|
| 260 |
+
{!result && (
|
| 261 |
+
<motion.div
|
| 262 |
+
initial={{ opacity: 0 }}
|
| 263 |
+
animate={{ opacity: 0.6 }}
|
| 264 |
+
transition={{ delay: 0.5 }}
|
| 265 |
+
className="mt-12 flex flex-col md:flex-row justify-center gap-6 md:gap-12 text-sm text-gray-600 dark:text-gray-400"
|
| 266 |
+
>
|
| 267 |
+
{content.infos.map((info, idx) => (
|
| 268 |
+
<div key={idx} className="flex items-center gap-2 justify-center">
|
| 269 |
+
<info.icon size={16} />
|
| 270 |
+
<span>{info.text}</span>
|
| 271 |
+
</div>
|
| 272 |
+
))}
|
| 273 |
+
</motion.div>
|
| 274 |
+
)}
|
| 275 |
+
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
);
|
| 279 |
+
}
|
src/app/types/[codes]/page.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import React, { useEffect, useState } from "react";
|
| 3 |
+
import { useParams, useRouter } from "next/navigation";
|
| 4 |
+
import { mbtiDatabase } from "@/data/mbti";
|
| 5 |
+
import { useLanguage } from "@/app/providers";
|
| 6 |
+
import { ArrowLeft, Quote } from "lucide-react";
|
| 7 |
+
|
| 8 |
+
export default function DetailPage() {
|
| 9 |
+
const params = useParams();
|
| 10 |
+
const router = useRouter();
|
| 11 |
+
const { lang } = useLanguage();
|
| 12 |
+
|
| 13 |
+
const rawCode = params?.codes;
|
| 14 |
+
|
| 15 |
+
const [code, setCode] = useState<string>("");
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (rawCode) {
|
| 19 |
+
const c = (Array.isArray(rawCode) ? rawCode[0] : rawCode).toUpperCase();
|
| 20 |
+
setCode(c);
|
| 21 |
+
document.title = `${c} - Detail | Sentimind`;
|
| 22 |
+
}
|
| 23 |
+
}, [rawCode]);
|
| 24 |
+
|
| 25 |
+
if (!code) return <div className="min-h-screen bg-white dark:bg-black" />;
|
| 26 |
+
|
| 27 |
+
const data = mbtiDatabase[code];
|
| 28 |
+
|
| 29 |
+
if (!data) {
|
| 30 |
+
return (
|
| 31 |
+
<div className="min-h-screen flex flex-col items-center justify-center bg-white dark:bg-black text-gray-900 dark:text-white p-4 text-center">
|
| 32 |
+
<h2 className="text-3xl font-black mb-4">Type Not Found 😕</h2>
|
| 33 |
+
<p className="text-gray-500 mb-8">
|
| 34 |
+
Tipe kepribadian <span className="font-mono bg-gray-100 px-2 py-1 rounded">{code}</span> gak ketemu nih.
|
| 35 |
+
</p>
|
| 36 |
+
<button
|
| 37 |
+
onClick={() => router.push("/types")}
|
| 38 |
+
className="px-6 py-3 bg-orange-600 text-white rounded-full font-bold hover:bg-orange-700 transition-all"
|
| 39 |
+
>
|
| 40 |
+
Kembali ke Daftar
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const content = lang === 'en' ? data.en : data.id;
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="min-h-screen pt-28 pb-12 px-4">
|
| 50 |
+
<div className="max-w-4xl mx-auto">
|
| 51 |
+
|
| 52 |
+
{/* Tombol Back */}
|
| 53 |
+
<button
|
| 54 |
+
onClick={() => router.push("/types")}
|
| 55 |
+
className="flex items-center gap-2 text-gray-500 hover:text-orange-500 mb-8 transition-colors group"
|
| 56 |
+
>
|
| 57 |
+
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
| 58 |
+
{lang === 'en' ? "Back to Types" : "Balik ke Daftar"}
|
| 59 |
+
</button>
|
| 60 |
+
|
| 61 |
+
{/* Header */}
|
| 62 |
+
<div className={`rounded-3xl p-8 md:p-12 mb-12 border ${data.color} bg-gray-50 dark:bg-gray-900`}>
|
| 63 |
+
<div className="flex flex-col md:flex-row gap-8 items-start md:items-center">
|
| 64 |
+
<div>
|
| 65 |
+
<span className={`inline-block px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 bg-white dark:bg-black ${data.textColor}`}>
|
| 66 |
+
{data.group}
|
| 67 |
+
</span>
|
| 68 |
+
<h1 className="text-5xl md:text-7xl font-black text-gray-900 dark:text-white mb-2">
|
| 69 |
+
{code}
|
| 70 |
+
</h1>
|
| 71 |
+
<h2 className={`text-2xl md:text-3xl font-bold ${data.textColor}`}>
|
| 72 |
+
{content.name}
|
| 73 |
+
</h2>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<p className="mt-8 text-lg md:text-xl text-gray-700 dark:text-gray-300 leading-relaxed max-w-2xl">
|
| 78 |
+
{content.desc}
|
| 79 |
+
</p>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* Content Body */}
|
| 83 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
| 84 |
+
|
| 85 |
+
{/* Quote Section */}
|
| 86 |
+
<div className="col-span-1 md:col-span-2 bg-gradient-to-br from-gray-900 to-black text-white p-8 rounded-2xl relative overflow-hidden shadow-2xl">
|
| 87 |
+
<Quote className="absolute top-4 right-4 text-white/10" size={100} />
|
| 88 |
+
<blockquote className="relative z-10 text-xl md:text-2xl font-serif italic text-center leading-relaxed">
|
| 89 |
+
"{content.quote}"
|
| 90 |
+
</blockquote>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{/* Relationships */}
|
| 94 |
+
<div className="p-6 rounded-2xl bg-pink-50 dark:bg-pink-900/10 border border-pink-100 dark:border-pink-900/30">
|
| 95 |
+
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-pink-600 dark:text-pink-400">
|
| 96 |
+
{lang === 'en' ? "Relationships" : "Soal Hubungan"}
|
| 97 |
+
</h3>
|
| 98 |
+
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
|
| 99 |
+
{content.relationships}
|
| 100 |
+
</p>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
{/* Career */}
|
| 104 |
+
<div className="p-6 rounded-2xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/30">
|
| 105 |
+
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
| 106 |
+
{lang === 'en' ? "Career Paths" : "Karir yang Cocok"}
|
| 107 |
+
</h3>
|
| 108 |
+
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
|
| 109 |
+
{content.career}
|
| 110 |
+
</p>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Strengths */}
|
| 114 |
+
<div>
|
| 115 |
+
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-green-600">
|
| 116 |
+
{lang === 'en' ? "Strengths" : "Kelebihan"}
|
| 117 |
+
</h3>
|
| 118 |
+
<ul className="space-y-3">
|
| 119 |
+
{content.strengths.map((s, idx) => (
|
| 120 |
+
<li key={idx} className="flex items-start gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-900/30">
|
| 121 |
+
<div className="w-1.5 h-1.5 rounded-full bg-green-500 mt-2 shrink-0"></div>
|
| 122 |
+
<span className="text-gray-700 dark:text-gray-200 font-medium">{s}</span>
|
| 123 |
+
</li>
|
| 124 |
+
))}
|
| 125 |
+
</ul>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Weaknesses */}
|
| 129 |
+
<div>
|
| 130 |
+
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-red-500">
|
| 131 |
+
{lang === 'en' ? "Weaknesses" : "Kekurangan"}
|
| 132 |
+
</h3>
|
| 133 |
+
<ul className="space-y-3">
|
| 134 |
+
{content.weaknesses.map((w, idx) => (
|
| 135 |
+
<li key={idx} className="flex items-start gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/30">
|
| 136 |
+
<div className="w-1.5 h-1.5 rounded-full bg-red-500 mt-2 shrink-0"></div>
|
| 137 |
+
<span className="text-gray-700 dark:text-gray-200 font-medium">{w}</span>
|
| 138 |
+
</li>
|
| 139 |
+
))}
|
| 140 |
+
</ul>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
}
|
src/app/types/layout.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Metadata } from "next";
|
| 2 |
+
|
| 3 |
+
export const metadata: Metadata = {
|
| 4 |
+
title: "MBTI Types | Sentimind",
|
| 5 |
+
description: "Explore all 16 personality types.",
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
| 9 |
+
return <>{children}</>;
|
| 10 |
+
}
|
src/app/types/page.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { mbtiDatabase } from "@/data/mbti";
|
| 6 |
+
import { useLanguage } from "@/app/providers";
|
| 7 |
+
import { motion, Variants } from "framer-motion";
|
| 8 |
+
|
| 9 |
+
export default function TypesPage() {
|
| 10 |
+
const { lang } = useLanguage();
|
| 11 |
+
const codes = Object.keys(mbtiDatabase);
|
| 12 |
+
|
| 13 |
+
const grouped = codes.reduce((acc, code) => {
|
| 14 |
+
const data = mbtiDatabase[code];
|
| 15 |
+
if (!acc[data.group]) acc[data.group] = [];
|
| 16 |
+
acc[data.group].push({ code, ...data });
|
| 17 |
+
return acc;
|
| 18 |
+
}, {} as Record<string, any[]>);
|
| 19 |
+
|
| 20 |
+
const groups = ["Analysts", "Diplomats", "Sentinels", "Explorers"];
|
| 21 |
+
|
| 22 |
+
const containerVariants: Variants = {
|
| 23 |
+
hidden: { opacity: 0 },
|
| 24 |
+
visible: {
|
| 25 |
+
opacity: 1,
|
| 26 |
+
transition: {
|
| 27 |
+
staggerChildren: 0.1,
|
| 28 |
+
delayChildren: 0.1
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const itemVariants: Variants = {
|
| 34 |
+
hidden: { y: 20, opacity: 0 },
|
| 35 |
+
visible: {
|
| 36 |
+
y: 0,
|
| 37 |
+
opacity: 1,
|
| 38 |
+
transition: { type: "spring", stiffness: 100 }
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="min-h-screen pt-28 pb-12 px-4 sm:px-6 lg:px-8 font-sans">
|
| 44 |
+
<div className="max-w-7xl mx-auto">
|
| 45 |
+
<motion.div
|
| 46 |
+
initial={{ opacity: 0, y: -20 }}
|
| 47 |
+
animate={{ opacity: 1, y: 0 }}
|
| 48 |
+
transition={{ duration: 0.5 }}
|
| 49 |
+
className="text-center mb-16"
|
| 50 |
+
>
|
| 51 |
+
|
| 52 |
+
<h1 className="text-4xl md:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 pb-2 mb-2">
|
| 53 |
+
{lang === 'en' ? "Personality Types" : "Tipe Kepribadian"}
|
| 54 |
+
</h1>
|
| 55 |
+
|
| 56 |
+
<p className="text-gray-600 dark:text-gray-400 text-sm md:text-lg max-w-2xl mx-auto">
|
| 57 |
+
{lang === 'en'
|
| 58 |
+
? "Explore the 16 personality types. Click specifically on any card to learn more."
|
| 59 |
+
: "Jelajahi 16 tipe kepribadian. Klik secara spesifik pada kartu untuk mempelajari lebih lanjut."}
|
| 60 |
+
</p>
|
| 61 |
+
</motion.div>
|
| 62 |
+
|
| 63 |
+
<motion.div
|
| 64 |
+
className="space-y-20"
|
| 65 |
+
initial="hidden"
|
| 66 |
+
whileInView="visible"
|
| 67 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 68 |
+
variants={containerVariants}
|
| 69 |
+
>
|
| 70 |
+
{groups.map((groupName) => (
|
| 71 |
+
<motion.div key={groupName} variants={itemVariants}>
|
| 72 |
+
<div className="flex items-center gap-4 mb-8">
|
| 73 |
+
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100">
|
| 74 |
+
{groupName}
|
| 75 |
+
</h2>
|
| 76 |
+
<div className="h-1 flex-1 bg-gray-200 dark:bg-gray-800 rounded-full"></div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 80 |
+
{grouped[groupName]?.map((item) => {
|
| 81 |
+
const content = lang === 'en' ? item.en : item.id;
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<Link href={`/types/${item.code}`} key={item.code} className="block h-full">
|
| 85 |
+
<motion.div
|
| 86 |
+
whileHover={{ y: -5 }}
|
| 87 |
+
className={`
|
| 88 |
+
h-full relative group rounded-3xl p-6 border-2 transition-all duration-300
|
| 89 |
+
hover:shadow-2xl
|
| 90 |
+
bg-white/80 dark:bg-gray-900/50 backdrop-blur-sm cursor-pointer
|
| 91 |
+
${item.color}
|
| 92 |
+
`}
|
| 93 |
+
>
|
| 94 |
+
<div className="flex items-center justify-between mb-4">
|
| 95 |
+
<div className={`text-4xl font-black ${item.textColor} opacity-80 group-hover:opacity-100 transition-opacity`}>
|
| 96 |
+
{item.code}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
| 101 |
+
{content.name}
|
| 102 |
+
</h3>
|
| 103 |
+
|
| 104 |
+
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed line-clamp-3">
|
| 105 |
+
{content.desc}
|
| 106 |
+
</p>
|
| 107 |
+
|
| 108 |
+
<div className={`mt-4 text-xs font-bold uppercase tracking-wider ${item.textColor} flex items-center gap-1`}>
|
| 109 |
+
{lang === 'en' ? "Read More" : "Baca Selengkapnya"} →
|
| 110 |
+
</div>
|
| 111 |
+
</motion.div>
|
| 112 |
+
</Link>
|
| 113 |
+
);
|
| 114 |
+
})}
|
| 115 |
+
</div>
|
| 116 |
+
</motion.div>
|
| 117 |
+
))}
|
| 118 |
+
</motion.div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
}
|
src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { useLanguage } from "@/app/providers";
|
| 4 |
+
|
| 5 |
+
export default function Footer() {
|
| 6 |
+
const { lang } = useLanguage();
|
| 7 |
+
const year = new Date().getFullYear();
|
| 8 |
+
|
| 9 |
+
return (
|
| 10 |
+
<footer className="w-full py-8 mt-auto border-t border-gray-200 dark:border-white/10 bg-white/50 dark:bg-black/50 backdrop-blur-sm text-center">
|
| 11 |
+
<div className="container mx-auto px-4">
|
| 12 |
+
<div className="mb-4">
|
| 13 |
+
<span className="text-2xl font-black tracking-tighter text-orange-600">Sentimind.</span>
|
| 14 |
+
</div>
|
| 15 |
+
<div className="flex justify-center gap-6 mb-6 text-sm font-medium text-gray-600 dark:text-gray-300">
|
| 16 |
+
<Link href="/" className="hover:text-orange-600 transition-colors">Home</Link>
|
| 17 |
+
<Link href="/analyzer" className="hover:text-orange-600 transition-colors">Analyzer</Link>
|
| 18 |
+
<Link href="/quiz" className="hover:text-orange-600 transition-colors">Mini Test</Link>
|
| 19 |
+
<Link href="/types" className="hover:text-orange-600 transition-colors">{lang === 'en' ? "Types" : "Tipe"}</Link>
|
| 20 |
+
<Link href="/chat" className="hover:text-orange-600 transition-colors">Chat</Link>
|
| 21 |
+
</div>
|
| 22 |
+
<p className="text-gray-500 dark:text-gray-400 text-xs">
|
| 23 |
+
© {year} Sentimind Project. {"All rights reserved."}
|
| 24 |
+
</p>
|
| 25 |
+
</div>
|
| 26 |
+
</footer>
|
| 27 |
+
);
|
| 28 |
+
}
|
src/components/Navbar.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { usePathname } from "next/navigation";
|
| 5 |
+
import { useTheme } from "next-themes";
|
| 6 |
+
import { useState, useEffect } from "react";
|
| 7 |
+
import { Sun, Moon, BrainCircuit, Menu, X } from "lucide-react";
|
| 8 |
+
import { useLanguage } from "@/app/providers";
|
| 9 |
+
import { Button } from "@/components/ui/button";
|
| 10 |
+
|
| 11 |
+
export default function Navbar() {
|
| 12 |
+
const { theme, setTheme } = useTheme();
|
| 13 |
+
const { lang, toggleLang } = useLanguage();
|
| 14 |
+
const pathname = usePathname();
|
| 15 |
+
|
| 16 |
+
const [isScrolled, setIsScrolled] = useState(false);
|
| 17 |
+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
| 18 |
+
const [mounted, setMounted] = useState(false);
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
setMounted(true);
|
| 22 |
+
const handleScroll = () => setIsScrolled(window.scrollY > 20);
|
| 23 |
+
window.addEventListener("scroll", handleScroll);
|
| 24 |
+
return () => window.removeEventListener("scroll", handleScroll);
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
const getLinkVariant = (path: string) => pathname === path ? "secondary" : "ghost";
|
| 28 |
+
|
| 29 |
+
const navLinks = [
|
| 30 |
+
{ href: "/", label: lang === 'en' ? "Home" : "Beranda" },
|
| 31 |
+
{ href: "/analyzer", label: lang === 'en' ? "Analyzer" : "Analisis" },
|
| 32 |
+
{ href: "/quiz", label: lang === 'en' ? "Mini Test" : "Tes Mini" },
|
| 33 |
+
{ href: "/types", label: lang === 'en' ? "Types" : "Tipe" },
|
| 34 |
+
{ href: "/chat", label: lang === 'en' ? "Chat" : "Chat" },
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<>
|
| 39 |
+
<nav
|
| 40 |
+
className={`
|
| 41 |
+
fixed left-1/2 -translate-x-1/2 z-50
|
| 42 |
+
flex justify-between items-center px-4 py-3
|
| 43 |
+
/* GANTI BAGIAN TRANSISI DI SINI: */
|
| 44 |
+
transition-all duration-700 ease-[cubic-bezier(0.25,0.1,0.25,1.0)] will-change-[width,top,background]
|
| 45 |
+
${isScrolled
|
| 46 |
+
/* SCROLLED STATE:
|
| 47 |
+
- top-4: Turun dikit
|
| 48 |
+
- w-[92%]: Lebar di layar kecil
|
| 49 |
+
- md:w-[64rem]: KUNCI ANIMASI! Kita set lebar fix (setara max-w-5xl) biar width-nya yang animasi, bukan max-width.
|
| 50 |
+
- rounded-[12px]: Jadi kotak tumpul (sebelumnya rounded-full)
|
| 51 |
+
*/
|
| 52 |
+
? "top-4 w-[92%] md:w-[64rem] rounded-[12px] bg-white/80 dark:bg-black/80 backdrop-blur-md border border-gray-200 dark:border-white/10 shadow-sm"
|
| 53 |
+
/* DEFAULT STATE:
|
| 54 |
+
- top-0: Nempel atas
|
| 55 |
+
- w-full: Lebar penuh
|
| 56 |
+
- rounded-none: Kotak
|
| 57 |
+
*/
|
| 58 |
+
: "top-0 w-full bg-transparent border-b border-transparent"
|
| 59 |
+
}
|
| 60 |
+
`}
|
| 61 |
+
>
|
| 62 |
+
{/* LOGO */}
|
| 63 |
+
<Link href="/" className="flex items-center gap-2 pl-2">
|
| 64 |
+
<div className="bg-orange-600 p-1.5 rounded-[8px]">
|
| 65 |
+
<BrainCircuit className="text-white w-5 h-5" />
|
| 66 |
+
</div>
|
| 67 |
+
<span className="font-bold text-lg tracking-tight text-gray-900 dark:text-white">
|
| 68 |
+
Sentimind<span className="text-orange-600">.</span>
|
| 69 |
+
</span>
|
| 70 |
+
</Link>
|
| 71 |
+
|
| 72 |
+
{/* DESKTOP MENU */}
|
| 73 |
+
<div className="hidden md:flex items-center gap-1">
|
| 74 |
+
{navLinks.map((link) => (
|
| 75 |
+
<Button
|
| 76 |
+
key={link.href}
|
| 77 |
+
asChild
|
| 78 |
+
variant={getLinkVariant(link.href)}
|
| 79 |
+
size="sm"
|
| 80 |
+
className={`cursor-pointer text-sm font-medium ${pathname === link.href ? "text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950/30" : "text-gray-600 dark:text-gray-400"}`}
|
| 81 |
+
>
|
| 82 |
+
<Link href={link.href}>
|
| 83 |
+
{link.label}
|
| 84 |
+
</Link>
|
| 85 |
+
</Button>
|
| 86 |
+
))}
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
{/* KANAN: Lang + Theme */}
|
| 90 |
+
<div className="flex items-center gap-2 pr-2">
|
| 91 |
+
<Button
|
| 92 |
+
onClick={toggleLang}
|
| 93 |
+
variant="ghost"
|
| 94 |
+
size="sm"
|
| 95 |
+
className="w-9 h-9 p-0 text-xs font-bold text-gray-500"
|
| 96 |
+
>
|
| 97 |
+
{lang.toUpperCase()}
|
| 98 |
+
</Button>
|
| 99 |
+
|
| 100 |
+
<Button
|
| 101 |
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
| 102 |
+
variant="ghost"
|
| 103 |
+
size="icon"
|
| 104 |
+
className="w-9 h-9 rounded-full text-gray-500"
|
| 105 |
+
>
|
| 106 |
+
{!mounted ? <div className="w-4 h-4" /> : theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
| 107 |
+
</Button>
|
| 108 |
+
|
| 109 |
+
{/* Mobile Menu Button */}
|
| 110 |
+
<div className="md:hidden">
|
| 111 |
+
<Button
|
| 112 |
+
variant="ghost"
|
| 113 |
+
size="icon"
|
| 114 |
+
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
| 115 |
+
>
|
| 116 |
+
{isMobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
| 117 |
+
</Button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</nav>
|
| 121 |
+
|
| 122 |
+
{/* MOBILE MENU */}
|
| 123 |
+
{isMobileMenuOpen && (
|
| 124 |
+
<div className="fixed inset-0 z-40 bg-white dark:bg-black pt-24 px-6 animate-in slide-in-from-top-10 fade-in duration-200">
|
| 125 |
+
<div className="flex flex-col gap-2">
|
| 126 |
+
{navLinks.map((link) => (
|
| 127 |
+
<Link key={link.href} href={link.href} onClick={() => setIsMobileMenuOpen(false)}>
|
| 128 |
+
<Button variant="ghost" size="lg" className="w-full justify-start text-lg font-medium">
|
| 129 |
+
{link.label}
|
| 130 |
+
</Button>
|
| 131 |
+
</Link>
|
| 132 |
+
))}
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</>
|
| 137 |
+
);
|
| 138 |
+
}
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 13 |
+
destructive:
|
| 14 |
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 15 |
+
outline:
|
| 16 |
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
| 17 |
+
secondary:
|
| 18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 19 |
+
ghost:
|
| 20 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
size: {
|
| 24 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
| 25 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
| 26 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
| 27 |
+
icon: "size-9",
|
| 28 |
+
"icon-sm": "size-8",
|
| 29 |
+
"icon-lg": "size-10",
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
defaultVariants: {
|
| 33 |
+
variant: "default",
|
| 34 |
+
size: "default",
|
| 35 |
+
},
|
| 36 |
+
}
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
function Button({
|
| 40 |
+
className,
|
| 41 |
+
variant = "default",
|
| 42 |
+
size = "default",
|
| 43 |
+
asChild = false,
|
| 44 |
+
...props
|
| 45 |
+
}: React.ComponentProps<"button"> &
|
| 46 |
+
VariantProps<typeof buttonVariants> & {
|
| 47 |
+
asChild?: boolean
|
| 48 |
+
}) {
|
| 49 |
+
const Comp = asChild ? Slot : "button"
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<Comp
|
| 53 |
+
data-slot="button"
|
| 54 |
+
data-variant={variant}
|
| 55 |
+
data-size={size}
|
| 56 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 57 |
+
{...props}
|
| 58 |
+
/>
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export { Button, buttonVariants }
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
| 6 |
+
return (
|
| 7 |
+
<div
|
| 8 |
+
data-slot="card"
|
| 9 |
+
className={cn(
|
| 10 |
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
data-slot="card-header"
|
| 22 |
+
className={cn(
|
| 23 |
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
| 24 |
+
className
|
| 25 |
+
)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 32 |
+
return (
|
| 33 |
+
<div
|
| 34 |
+
data-slot="card-title"
|
| 35 |
+
className={cn("leading-none font-semibold", className)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 42 |
+
return (
|
| 43 |
+
<div
|
| 44 |
+
data-slot="card-description"
|
| 45 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 46 |
+
{...props}
|
| 47 |
+
/>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 52 |
+
return (
|
| 53 |
+
<div
|
| 54 |
+
data-slot="card-action"
|
| 55 |
+
className={cn(
|
| 56 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 57 |
+
className
|
| 58 |
+
)}
|
| 59 |
+
{...props}
|
| 60 |
+
/>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 65 |
+
return (
|
| 66 |
+
<div
|
| 67 |
+
data-slot="card-content"
|
| 68 |
+
className={cn("px-6", className)}
|
| 69 |
+
{...props}
|
| 70 |
+
/>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 75 |
+
return (
|
| 76 |
+
<div
|
| 77 |
+
data-slot="card-footer"
|
| 78 |
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export {
|
| 85 |
+
Card,
|
| 86 |
+
CardHeader,
|
| 87 |
+
CardFooter,
|
| 88 |
+
CardTitle,
|
| 89 |
+
CardAction,
|
| 90 |
+
CardDescription,
|
| 91 |
+
CardContent,
|
| 92 |
+
}
|
src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
| 6 |
+
return (
|
| 7 |
+
<input
|
| 8 |
+
type={type}
|
| 9 |
+
data-slot="input"
|
| 10 |
+
className={cn(
|
| 11 |
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 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 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
| 12 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
| 13 |
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 14 |
+
className
|
| 15 |
+
)}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export { Input }
|
src/data/mbti.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type MBTIData = {
|
| 2 |
+
group: "Analysts" | "Diplomats" | "Sentinels" | "Explorers";
|
| 3 |
+
color: string;
|
| 4 |
+
textColor: string;
|
| 5 |
+
en: {
|
| 6 |
+
name: string;
|
| 7 |
+
desc: string;
|
| 8 |
+
quote: string;
|
| 9 |
+
strengths: string[];
|
| 10 |
+
weaknesses: string[];
|
| 11 |
+
relationships: string;
|
| 12 |
+
career: string;
|
| 13 |
+
};
|
| 14 |
+
id: {
|
| 15 |
+
name: string;
|
| 16 |
+
desc: string;
|
| 17 |
+
quote: string;
|
| 18 |
+
strengths: string[];
|
| 19 |
+
weaknesses: string[];
|
| 20 |
+
relationships: string;
|
| 21 |
+
career: string;
|
| 22 |
+
};
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export const mbtiDatabase: Record<string, MBTIData> = {
|
| 26 |
+
// --- ANALYSTS ---
|
| 27 |
+
INTJ: {
|
| 28 |
+
group: "Analysts",
|
| 29 |
+
color: "border-purple-500 shadow-purple-500/20",
|
| 30 |
+
textColor: "text-purple-600 dark:text-purple-400",
|
| 31 |
+
en: {
|
| 32 |
+
name: "Architect",
|
| 33 |
+
desc: "Imaginative and strategic thinkers, with a plan for everything. They are one of the rarest and most capable personality types.",
|
| 34 |
+
quote: "Thought constitutes the greatness of man. Man is a reed, the feeblest thing in nature, but he is a thinking reed.",
|
| 35 |
+
strengths: ["Rational", "Informed", "Independent", "Determined"],
|
| 36 |
+
weaknesses: ["Arrogant", "Dismissive of Emotions", "Overly Critical"],
|
| 37 |
+
relationships: "In relationships, Architects are looking for an intellectual equal. They prize honesty and open communication but may struggle with emotional expression.",
|
| 38 |
+
career: "They thrive in careers that require complex problem-solving and strategic planning, such as Systems Engineering, Strategy, or Science."
|
| 39 |
+
},
|
| 40 |
+
id: {
|
| 41 |
+
name: "Arsitek",
|
| 42 |
+
desc: "Pemikir yang super imajinatif dan strategis. Selalu punya plan A sampai Z. Tipe yang langka banget tapi capable parah.",
|
| 43 |
+
quote: "Pikiran itu bikin manusia hebat. Manusia emang lemah, tapi dia adalah 'buluh yang berpikir'.",
|
| 44 |
+
strengths: ["Rasional Abis", "Berwawasan Luas", "Independen", "Tekun"],
|
| 45 |
+
weaknesses: ["Agak Arogan", "Cuek sama Perasaan", "Terlalu Kritis"],
|
| 46 |
+
relationships: "Nyari pasangan yang selevel otaknya. Mereka suka kejujuran dan komunikasi yang to the point, tapi kadang kikuk kalau soal perasaan.",
|
| 47 |
+
career: "Cocok banget di kerjaan yang butuh mikir keras dan strategi, kayak System Engineer, Strategist, atau Scientist."
|
| 48 |
+
},
|
| 49 |
+
},
|
| 50 |
+
INTP: {
|
| 51 |
+
group: "Analysts",
|
| 52 |
+
color: "border-purple-500 shadow-purple-500/20",
|
| 53 |
+
textColor: "text-purple-600 dark:text-purple-400",
|
| 54 |
+
en: {
|
| 55 |
+
name: "Logician",
|
| 56 |
+
desc: "Innovative inventors with an unquenchable thirst for knowledge. They love patterns and spotting discrepancies in statements.",
|
| 57 |
+
quote: "Learn from yesterday, live for today, hope for tomorrow. The important thing is not to stop questioning.",
|
| 58 |
+
strengths: ["Analytical", "Original", "Open-minded", "Curious"],
|
| 59 |
+
weaknesses: ["Disconnected", "Insensitive", "Dissatisfied"],
|
| 60 |
+
relationships: "Logicians are often laid-back partners but can be oblivious to their partner's emotional needs, preferring to fix problems logically.",
|
| 61 |
+
career: "Ideal careers involve abstract theory and analysis, such as Programming, Mathematics, or Academic Research."
|
| 62 |
+
},
|
| 63 |
+
id: {
|
| 64 |
+
name: "Ahli Logika",
|
| 65 |
+
desc: "Penemu yang inovatif dan haus ilmu. Hobi banget nyari pola dan bakal nge-notice kalau ada yang gak logis dari omongan lo.",
|
| 66 |
+
quote: "Belajar dari kemarin, hidup buat hari ini, berharap buat besok. Intinya jangan berhenti nanya 'kenapa'.",
|
| 67 |
+
strengths: ["Analitis", "Orisinal", "Open Minded", "Kepo Banget"],
|
| 68 |
+
weaknesses: ["Suka Bengong Sendiri", "Gak Peka", "Gampang Bosen"],
|
| 69 |
+
relationships: "Pasangan yang santuy sebenernya, tapi sering gak peka sama kode-kodean. Lebih suka nyelesain masalah pake logika daripada perasaan.",
|
| 70 |
+
career: "Kerjaan yang butuh teori abstrak kayak Coding, Matematika, atau Riset Akademis itu makanan sehari-hari mereka."
|
| 71 |
+
},
|
| 72 |
+
},
|
| 73 |
+
ENTJ: {
|
| 74 |
+
group: "Analysts",
|
| 75 |
+
color: "border-purple-500 shadow-purple-500/20",
|
| 76 |
+
textColor: "text-purple-600 dark:text-purple-400",
|
| 77 |
+
en: {
|
| 78 |
+
name: "Commander",
|
| 79 |
+
desc: "Bold, imaginative and strong-willed leaders, always finding a way - or making one.",
|
| 80 |
+
quote: "Time is limited, so don't waste it living someone else's life.",
|
| 81 |
+
strengths: ["Efficient", "Energetic", "Self-Confident", "Strong-willed"],
|
| 82 |
+
weaknesses: ["Stubborn", "Intolerant", "Impatient", "Arrogant"],
|
| 83 |
+
relationships: "Commanders approach dating like a project. They look for growth-oriented partners and can be very dominant.",
|
| 84 |
+
career: "Natural leaders who excel in Executive roles, Entrepreneurship, and Management Consulting."
|
| 85 |
+
},
|
| 86 |
+
id: {
|
| 87 |
+
name: "Komandan",
|
| 88 |
+
desc: "Pemimpin yang bold dan imajinatif. Kalau gak nemu jalan, ya mereka bikin jalan sendiri. Boss energy banget.",
|
| 89 |
+
quote: "Waktu itu terbatas, bestie. Jangan buang waktu jalanin hidup orang lain.",
|
| 90 |
+
strengths: ["Efisien", "Energik Parah", "PD Abis", "Kemauan Keras"],
|
| 91 |
+
weaknesses: ["Keras Kepala", "Gak Sabaran", "Kurang Toleran", "Suka Ngeboss"],
|
| 92 |
+
relationships: "Ngedate itu kayak project bisnis buat mereka. Nyari pasangan yang visioner, tapi hati-hati, mereka dominan banget.",
|
| 93 |
+
career: "Terlahir jadi pemimpin. Cocok jadi CEO, Entrepreneur, atau Konsultan Manajemen."
|
| 94 |
+
},
|
| 95 |
+
},
|
| 96 |
+
ENTP: {
|
| 97 |
+
group: "Analysts",
|
| 98 |
+
color: "border-purple-500 shadow-purple-500/20",
|
| 99 |
+
textColor: "text-purple-600 dark:text-purple-400",
|
| 100 |
+
en: {
|
| 101 |
+
name: "Debater",
|
| 102 |
+
desc: "Smart and curious thinkers who cannot resist an intellectual challenge. They tend to play the devil's advocate.",
|
| 103 |
+
quote: "Follow the path of the unsafe, independent thinker. Expose your ideas to the dangers of controversy.",
|
| 104 |
+
strengths: ["Knowledgeable", "Quick-thinking", "Original", "Charismatic"],
|
| 105 |
+
weaknesses: ["Very Argumentative", "Insensitive", "Intolerant", "Can find it hard to focus"],
|
| 106 |
+
relationships: "Spontaneous and exciting partners who love to explore new ideas together, though they may struggle with stability.",
|
| 107 |
+
career: "They need freedom and creativity, excelling in Entrepreneurship, Marketing, or Law."
|
| 108 |
+
},
|
| 109 |
+
id: {
|
| 110 |
+
name: "Pendebat",
|
| 111 |
+
desc: "Pinter, iseng, dan gak bisa nolak debat. Suka banget jadi 'devil's advocate' cuma buat ngetes argumen orang.",
|
| 112 |
+
quote: "Jadilah pemikir independen yang berani ambil risiko. Biarin ide lo diuji sama kontroversi.",
|
| 113 |
+
strengths: ["Wawasan Luas", "Gercep Mikirnya", "Orisinal", "Karismatik"],
|
| 114 |
+
weaknesses: ["Hobi Debat", "Gak Peka", "Gak Sabaran", "Susah Fokus"],
|
| 115 |
+
relationships: "Pasangan yang seru dan spontan. Suka diajak diskusi ide gila, tapi mungkin agak susah kalau diajak serius soal kestabilan.",
|
| 116 |
+
career: "Butuh kebebasan berkreasi. Jago banget kalau jadi Entrepreneur, Marketer, atau Pengacara."
|
| 117 |
+
},
|
| 118 |
+
},
|
| 119 |
+
|
| 120 |
+
// --- DIPLOMATS ---
|
| 121 |
+
INFJ: {
|
| 122 |
+
group: "Diplomats",
|
| 123 |
+
color: "border-green-500 shadow-green-500/20",
|
| 124 |
+
textColor: "text-green-600 dark:text-green-400",
|
| 125 |
+
en: {
|
| 126 |
+
name: "Advocate",
|
| 127 |
+
desc: "Quiet and mystical, yet very inspiring and tireless idealists. They approach life with deep thoughtfulness and imagination.",
|
| 128 |
+
quote: "Treat people as if they were what they ought to be and you help them to become what they are capable of being.",
|
| 129 |
+
strengths: ["Creative", "Insightful", "Principled", "Passionate"],
|
| 130 |
+
weaknesses: ["Sensitive to Criticism", "Reluctant to Open Up", "Perfectionistic"],
|
| 131 |
+
relationships: "They seek deep, meaningful connections and honesty, often taking time to find the 'perfect' partner.",
|
| 132 |
+
career: "Drawn to meaningful work like Counseling, Psychology, Writing, or Non-profit work."
|
| 133 |
+
},
|
| 134 |
+
id: {
|
| 135 |
+
name: "Advokat",
|
| 136 |
+
desc: "Pendiam dan misterius, tapi idealis banget. Hidupnya penuh pemikiran mendalam dan imajinasi. Inspiratif parah.",
|
| 137 |
+
quote: "Perlakukan orang sebagaimana mestinya, dan lo bantu mereka jadi versi terbaik diri mereka.",
|
| 138 |
+
strengths: ["Kreatif", "Punya Insight Dalem", "Punya Prinsip", "Passionate"],
|
| 139 |
+
weaknesses: ["Baperan kalau Dikritik", "Susah Terbuka", "Perfeksionis"],
|
| 140 |
+
relationships: "Nyari hubungan yang deep dan meaningful. Sering kelamaan jomblo karena nunggu 'the perfect one'.",
|
| 141 |
+
career: "Suka kerjaan yang punya makna kayak Konseling, Psikologi, Penulis, atau di NGO."
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
INFP: {
|
| 145 |
+
group: "Diplomats",
|
| 146 |
+
color: "border-green-500 shadow-green-500/20",
|
| 147 |
+
textColor: "text-green-600 dark:text-green-400",
|
| 148 |
+
en: {
|
| 149 |
+
name: "Mediator",
|
| 150 |
+
desc: "Poetic, kind and altruistic people, always eager to help a good cause. They are true idealists.",
|
| 151 |
+
quote: "Not all those who wander are lost.",
|
| 152 |
+
strengths: ["Empathetic", "Generous", "Open-minded", "Creative"],
|
| 153 |
+
weaknesses: ["Unrealistic", "Self-Isolating", "Unfocused"],
|
| 154 |
+
relationships: "Hopeless romantics who dream of a perfect soulmate connection and are deeply supportive partners.",
|
| 155 |
+
career: "They prefer work that aligns with their values, such as Writing, Arts, or Social Work."
|
| 156 |
+
},
|
| 157 |
+
id: {
|
| 158 |
+
name: "Mediator",
|
| 159 |
+
desc: "Puitis, baik hati, dan tulus banget. Selalu mau bantu orang lain. Bener-bener idealis sejati.",
|
| 160 |
+
quote: "Gak semua orang yang mengembara itu tersesat kok.",
|
| 161 |
+
strengths: ["Empati Tinggi", "Dermawan", "Open Minded", "Kreatif"],
|
| 162 |
+
weaknesses: ["Terlalu Khayal", "Suka Mengurung Diri", "Gak Fokus"],
|
| 163 |
+
relationships: "Hopeless romantic yang ngehayal punya soulmate sempurna. Pasangan yang super supportive.",
|
| 164 |
+
career: "Kerja harus sesuai hati nurani, kayak Penulis, Seniman, atau Pekerja Sosial."
|
| 165 |
+
},
|
| 166 |
+
},
|
| 167 |
+
ENFJ: {
|
| 168 |
+
group: "Diplomats",
|
| 169 |
+
color: "border-green-500 shadow-green-500/20",
|
| 170 |
+
textColor: "text-green-600 dark:text-green-400",
|
| 171 |
+
en: {
|
| 172 |
+
name: "Protagonist",
|
| 173 |
+
desc: "Charismatic and inspiring leaders, able to mesmerize their listeners. They love helping others grow.",
|
| 174 |
+
quote: "Everything you do right now ripples outward and affects everyone. Your posture can shine your heart or transmit anxiety.",
|
| 175 |
+
strengths: ["Receptive", "Reliable", "Passionate", "Altruistic"],
|
| 176 |
+
weaknesses: ["Unrealistic", "Overly Idealistic", "Condescending"],
|
| 177 |
+
relationships: "Dedicated partners who put a lot of effort into the relationship and their partner's happiness.",
|
| 178 |
+
career: "They excel in people-oriented roles like Teaching, Public Relations, or Human Resources."
|
| 179 |
+
},
|
| 180 |
+
id: {
|
| 181 |
+
name: "Protagonis",
|
| 182 |
+
desc: "Pemimpin karismatik yang jago banget ngomong. Hobi banget bantuin orang lain buat berkembang. Main character energy.",
|
| 183 |
+
quote: "Apapun yang lo lakuin sekarang bakal ngaruh ke orang lain. Lo bisa nyebarin semangat atau kecemasan.",
|
| 184 |
+
strengths: ["Enak Diajak Ngobrol", "Bisa Diandalkan", "Semangat", "Suka Nolong"],
|
| 185 |
+
weaknesses: ["Kurang Realistis", "Terlalu Idealis", "Kadang Merendahkan"],
|
| 186 |
+
relationships: "Pasangan yang totalitas banget. Rela lakuin apa aja demi kebahagiaan ayang.",
|
| 187 |
+
career: "Jago di bidang yang ngurusin orang kayak Guru, PR, atau HRD."
|
| 188 |
+
},
|
| 189 |
+
},
|
| 190 |
+
ENFP: {
|
| 191 |
+
group: "Diplomats",
|
| 192 |
+
color: "border-green-500 shadow-green-500/20",
|
| 193 |
+
textColor: "text-green-600 dark:text-green-400",
|
| 194 |
+
en: {
|
| 195 |
+
name: "Campaigner",
|
| 196 |
+
desc: "Enthusiastic, creative and sociable free spirits, who can always find a reason to smile.",
|
| 197 |
+
quote: "It doesn't interest me what you do for a living. I want to know what you ache for.",
|
| 198 |
+
strengths: ["Curious", "Observant", "Energetic", "Excellent Communicator"],
|
| 199 |
+
weaknesses: ["Poor Practical Skills", "Find it Difficult to Focus", "Overthink Things"],
|
| 200 |
+
relationships: "Warm and adventurous lovers who are always looking for new ways to connect emotionally.",
|
| 201 |
+
career: "They need variety and creativity, fitting well in Journalism, Entertainment, or Event Planning."
|
| 202 |
+
},
|
| 203 |
+
id: {
|
| 204 |
+
name: "Juru Kampanye",
|
| 205 |
+
desc: "Antusias, kreatif, dan jiwa bebas banget. Selalu nemu alasan buat senyum di situasi apapun.",
|
| 206 |
+
quote: "Gue gak peduli kerjaan lo apa. Gue mau tau apa yang bikin hati lo bergetar.",
|
| 207 |
+
strengths: ["Kepo Positif", "Jago Mengamati", "Energik", "Jago Ngomong"],
|
| 208 |
+
weaknesses: ["Kurang Praktis", "Susah Fokus", "Overthinking"],
|
| 209 |
+
relationships: "Pasangan yang hangat dan petualang. Selalu cari cara baru buat bonding emosional.",
|
| 210 |
+
career: "Butuh variasi dan kreativitas. Cocok di Jurnalisme, Entertainment, atau Event Organizer."
|
| 211 |
+
},
|
| 212 |
+
},
|
| 213 |
+
|
| 214 |
+
// --- SENTINELS ---
|
| 215 |
+
ISTJ: {
|
| 216 |
+
group: "Sentinels",
|
| 217 |
+
color: "border-blue-500 shadow-blue-500/20",
|
| 218 |
+
textColor: "text-blue-600 dark:text-blue-400",
|
| 219 |
+
en: {
|
| 220 |
+
name: "Logistician",
|
| 221 |
+
desc: "Practical and fact-minded individuals, whose reliability cannot be doubted. They value tradition and order.",
|
| 222 |
+
quote: "My observation is that whenever one person is found adequate to the discharge of a duty... it is worse executed by two persons.",
|
| 223 |
+
strengths: ["Honest", "Direct", "Strong-willed", "Responsible"],
|
| 224 |
+
weaknesses: ["Stubborn", "Insensitive", "Always by the Book", "Judgmental"],
|
| 225 |
+
relationships: "Dependable and loyal partners who show love through actions and stability rather than grand gestures.",
|
| 226 |
+
career: "They prefer structured environments like Accounting, Military, Law, or Data Analysis."
|
| 227 |
+
},
|
| 228 |
+
id: {
|
| 229 |
+
name: "Ahli Logistik",
|
| 230 |
+
desc: "Praktis dan fakta banget. Keandalannya gak usah diragukan lagi. Menghargai tradisi dan ketertiban.",
|
| 231 |
+
quote: "Satu orang yang kompeten itu lebih baik daripada dua orang yang ngerjain hal yang sama tapi berantakan.",
|
| 232 |
+
strengths: ["Jujur", "To The Point", "Teguh Pendirian", "Tanggung Jawab"],
|
| 233 |
+
weaknesses: ["Keras Kepala", "Gak Peka", "Kaku Banget", "Suka Menghakimi"],
|
| 234 |
+
relationships: "Pasangan setia yang bisa diandelin. Cara mereka nunjukin cinta itu lewat kestabilan, bukan gombalan.",
|
| 235 |
+
career: "Suka lingkungan terstruktur kayak Akuntansi, Militer, Hukum, atau Analisis Data."
|
| 236 |
+
},
|
| 237 |
+
},
|
| 238 |
+
ISFJ: {
|
| 239 |
+
group: "Sentinels",
|
| 240 |
+
color: "border-blue-500 shadow-blue-500/20",
|
| 241 |
+
textColor: "text-blue-600 dark:text-blue-400",
|
| 242 |
+
en: {
|
| 243 |
+
name: "Defender",
|
| 244 |
+
desc: "Very dedicated and warm protectors, always ready to defend their loved ones.",
|
| 245 |
+
quote: "Love only grows by sharing. You can only have more for yourself by giving it away to others.",
|
| 246 |
+
strengths: ["Supportive", "Reliable", "Patient", "Imaginative"],
|
| 247 |
+
weaknesses: ["Humble", "Take Things Personally", "Repress Their Feelings"],
|
| 248 |
+
relationships: "Committed and caring partners who prioritize their family and home harmony above all else.",
|
| 249 |
+
career: "They thrive in service roles like Nursing, Teaching, Customer Service, or Administration."
|
| 250 |
+
},
|
| 251 |
+
id: {
|
| 252 |
+
name: "Pembela",
|
| 253 |
+
desc: "Pelindung yang hangat dan dedikasi tinggi. Selalu siap pasang badan buat orang tersayang.",
|
| 254 |
+
quote: "Cinta itu tumbuh karena berbagi. Semakin banyak lo ngasih, semakin banyak yang lo dapet.",
|
| 255 |
+
strengths: ["Supportive", "Bisa Diandalkan", "Sabar", "Imajinatif"],
|
| 256 |
+
weaknesses: ["Terlalu Merendah", "Baperan", "Suka Mendam Perasaan"],
|
| 257 |
+
relationships: "Pasangan yang peduli banget. Keluarga dan keharmonisan rumah itu prioritas nomor satu.",
|
| 258 |
+
career: "Cocok di bidang pelayanan kayak Perawat, Guru, CS, atau Admin."
|
| 259 |
+
},
|
| 260 |
+
},
|
| 261 |
+
ESTJ: {
|
| 262 |
+
group: "Sentinels",
|
| 263 |
+
color: "border-blue-500 shadow-blue-500/20",
|
| 264 |
+
textColor: "text-blue-600 dark:text-blue-400",
|
| 265 |
+
en: {
|
| 266 |
+
name: "Executive",
|
| 267 |
+
desc: "Excellent administrators, unsurpassed at managing things - or people.",
|
| 268 |
+
quote: "Good order is the foundation of all things.",
|
| 269 |
+
strengths: ["Dedicated", "Strong-willed", "Direct", "Honest"],
|
| 270 |
+
weaknesses: ["Inflexible", "Uncomfortable with Unconventional Situations", "Judgmental"],
|
| 271 |
+
relationships: "Stable and responsible partners who take their commitments very seriously, though they may struggle with emotions.",
|
| 272 |
+
career: "Natural managers who excel in Business Administration, Law Enforcement, or Finance."
|
| 273 |
+
},
|
| 274 |
+
id: {
|
| 275 |
+
name: "Eksekutif",
|
| 276 |
+
desc: "Administrator handal. Jago banget ngatur barang atau orang. Gak ada yang bisa ngalahin skill manajemennya.",
|
| 277 |
+
quote: "Ketertiban itu pondasi dari segalanya, bro.",
|
| 278 |
+
strengths: ["Dedikasi Tinggi", "Kemauan Kuat", "Langsung", "Jujur"],
|
| 279 |
+
weaknesses: ["Gak Fleksibel", "Gak Suka Hal Aneh", "Suka Ngejudge"],
|
| 280 |
+
relationships: "Pasangan stabil dan bertanggung jawab. Komitmen itu harga mati, tapi mungkin agak kaku soal emosi.",
|
| 281 |
+
career: "Manajer alami. Jago di Admin Bisnis, Kepolisian, atau Keuangan."
|
| 282 |
+
},
|
| 283 |
+
},
|
| 284 |
+
ESFJ: {
|
| 285 |
+
group: "Sentinels",
|
| 286 |
+
color: "border-blue-500 shadow-blue-500/20",
|
| 287 |
+
textColor: "text-blue-600 dark:text-blue-400",
|
| 288 |
+
en: {
|
| 289 |
+
name: "Consul",
|
| 290 |
+
desc: "Extraordinarily caring, social and popular people, always eager to help.",
|
| 291 |
+
quote: "Encourage, lift and strengthen one another. For the positive energy spread to one will be felt by us all.",
|
| 292 |
+
strengths: ["Strong Practical Skills", "Strong Sense of Duty", "Very Loyal", "Sensitive"],
|
| 293 |
+
weaknesses: ["Worried about their Social Status", "Inflexible", "Vulnerable to Criticism"],
|
| 294 |
+
relationships: "Very supportive and traditional partners who want to feel appreciated and build a strong family unit.",
|
| 295 |
+
career: "Great at connecting with others in roles like Sales, Healthcare, or Social Work."
|
| 296 |
+
},
|
| 297 |
+
id: {
|
| 298 |
+
name: "Konsul",
|
| 299 |
+
desc: "Orang yang super peduli, sosial, dan populer. Selalu gercep kalau ada yang butuh bantuan.",
|
| 300 |
+
quote: "Saling dukung dan kuatin satu sama lain. Energi positif lo bakal kerasa buat kita semua.",
|
| 301 |
+
strengths: ["Skill Praktis Oke", "Tanggung Jawab", "Setia Banget", "Peka"],
|
| 302 |
+
weaknesses: ["Gila Hormat", "Kaku", "Gak Tahan Kritik"],
|
| 303 |
+
relationships: "Pasangan yang suportif dan tradisional. Pengen banget dihargai dan bangun keluarga harmonis.",
|
| 304 |
+
career: "Jago konek sama orang, kayak di Sales, Kesehatan, atau Pekerjaan Sosial."
|
| 305 |
+
},
|
| 306 |
+
},
|
| 307 |
+
|
| 308 |
+
// --- EXPLORERS ---
|
| 309 |
+
ISTP: {
|
| 310 |
+
group: "Explorers",
|
| 311 |
+
color: "border-yellow-500 shadow-yellow-500/20",
|
| 312 |
+
textColor: "text-yellow-600 dark:text-yellow-400",
|
| 313 |
+
en: {
|
| 314 |
+
name: "Virtuoso",
|
| 315 |
+
desc: "Bold and practical experimenters, masters of all kinds of tools.",
|
| 316 |
+
quote: "I wanted to live deep and suck out all the marrow of life.",
|
| 317 |
+
strengths: ["Optimistic", "Creative", "Spontaneous", "Rational"],
|
| 318 |
+
weaknesses: ["Stubborn", "Insensitive", "Private and Reserved", "Easily Bored"],
|
| 319 |
+
relationships: "Independent partners who need their own space but enjoy shared activities and adventures.",
|
| 320 |
+
career: "Hands-on work suits them best, like Engineering, Mechanics, Forensics, or Construction."
|
| 321 |
+
},
|
| 322 |
+
id: {
|
| 323 |
+
name: "Pengrajin",
|
| 324 |
+
desc: "Eksperimentator yang berani dan praktis. Jago banget pake segala macem alat.",
|
| 325 |
+
quote: "Gue pengen hidup seutuhnya dan nikmatin setiap detiknya.",
|
| 326 |
+
strengths: ["Optimis", "Kreatif", "Spontan", "Rasional"],
|
| 327 |
+
weaknesses: ["Keras Kepala", "Gak Peka", "Tertutup", "Gampang Bosan"],
|
| 328 |
+
relationships: "Pasangan mandiri yang butuh 'me time', tapi seneng kalau diajak petualangan bareng.",
|
| 329 |
+
career: "Kerja lapangan paling cocok, kayak Teknik, Mekanik, Forensik, atau Konstruksi."
|
| 330 |
+
},
|
| 331 |
+
},
|
| 332 |
+
ISFP: {
|
| 333 |
+
group: "Explorers",
|
| 334 |
+
color: "border-yellow-500 shadow-yellow-500/20",
|
| 335 |
+
textColor: "text-yellow-600 dark:text-yellow-400",
|
| 336 |
+
en: {
|
| 337 |
+
name: "Adventurer",
|
| 338 |
+
desc: "Flexible and charming artists, always ready to explore and experience something new.",
|
| 339 |
+
quote: "I change during the course of a day. I wake and I'm one person, and when I go to sleep I know for certain I'm somebody else.",
|
| 340 |
+
strengths: ["Charming", "Sensitive to Others", "Imaginative", "Passionate"],
|
| 341 |
+
weaknesses: ["Fiercely Independent", "Unpredictable", "Easily Stressed"],
|
| 342 |
+
relationships: "Gentle and caring partners who express love through actions and shared experiences rather than words.",
|
| 343 |
+
career: "They need creative freedom, often choosing Fashion, Photography, or Interior Design."
|
| 344 |
+
},
|
| 345 |
+
id: {
|
| 346 |
+
name: "Petualang",
|
| 347 |
+
desc: "Seniman yang fleksibel dan menawan. Selalu siap buat eksplor hal-hal baru.",
|
| 348 |
+
quote: "Gue berubah tiap saat. Pagi gue siapa, malem gue bisa jadi orang yang beda lagi.",
|
| 349 |
+
strengths: ["Mempesona", "Peka sama Orang", "Imajinatif", "Passionate"],
|
| 350 |
+
weaknesses: ["Terlalu Mandiri", "Susah Ditebak", "Gampang Stres"],
|
| 351 |
+
relationships: "Pasangan lembut yang nunjukin cinta lewat tindakan, bukan cuma omong doang.",
|
| 352 |
+
career: "Butuh kebebasan kreatif, sering milih Fashion, Fotografi, atau Desain Interior."
|
| 353 |
+
},
|
| 354 |
+
},
|
| 355 |
+
ESTP: {
|
| 356 |
+
group: "Explorers",
|
| 357 |
+
color: "border-yellow-500 shadow-yellow-500/20",
|
| 358 |
+
textColor: "text-yellow-600 dark:text-yellow-400",
|
| 359 |
+
en: {
|
| 360 |
+
name: "Entrepreneur",
|
| 361 |
+
desc: "Smart, energetic and very perceptive people, who truly enjoy living on the edge.",
|
| 362 |
+
quote: "Life is either a daring adventure or nothing at all.",
|
| 363 |
+
strengths: ["Bold", "Rational", "Practical", "Perceptive"],
|
| 364 |
+
weaknesses: ["Insensitive", "Impatient", "Risk-prone", "Unstructured"],
|
| 365 |
+
relationships: "Fun-loving and spontaneous partners who keep things exciting but may struggle with long-term planning.",
|
| 366 |
+
career: "Action-oriented careers like Sales, Business, Emergency Services, or Sports."
|
| 367 |
+
},
|
| 368 |
+
id: {
|
| 369 |
+
name: "Pengusaha",
|
| 370 |
+
desc: "Pinter, energik, dan peka banget. Suka hidup yang menantang dan berisiko.",
|
| 371 |
+
quote: "Hidup itu antara petualangan yang berani atau gak sama sekali.",
|
| 372 |
+
strengths: ["Berani", "Rasional", "Praktis", "Peka Situasi"],
|
| 373 |
+
weaknesses: ["Gak Peka Perasaan", "Gak Sabaran", "Hobi Ambil Risiko", "Berantakan"],
|
| 374 |
+
relationships: "Pasangan seru yang spontan. Hubungan gak bakal bosenin, tapi mungkin susah diajak mikir jangka panjang.",
|
| 375 |
+
career: "Karir penuh aksi kayak Sales, Bisnis, Tim SAR, atau Atlet."
|
| 376 |
+
},
|
| 377 |
+
},
|
| 378 |
+
ESFP: {
|
| 379 |
+
group: "Explorers",
|
| 380 |
+
color: "border-yellow-500 shadow-yellow-500/20",
|
| 381 |
+
textColor: "text-yellow-600 dark:text-yellow-400",
|
| 382 |
+
en: {
|
| 383 |
+
name: "Entertainer",
|
| 384 |
+
desc: "Spontaneous, energetic and enthusiastic people - life is never boring around them.",
|
| 385 |
+
quote: "I'm selfish, impatient and a little insecure. I make mistakes, I am out of control and at times hard to handle.",
|
| 386 |
+
strengths: ["Bold", "Original", "Aesthetics", "Observant"],
|
| 387 |
+
weaknesses: ["Sensitive", "Conflict-Averse", "Easily Bored", "Poor Long-term Planners"],
|
| 388 |
+
relationships: "Warm and affectionate partners who love to lavish attention on their loved ones and have fun.",
|
| 389 |
+
career: "They love social interaction and attention, excelling in Event Planning, Sales, or Performing Arts."
|
| 390 |
+
},
|
| 391 |
+
id: {
|
| 392 |
+
name: "Penghibur",
|
| 393 |
+
desc: "Spontan, energik, dan antusias. Hidup gak bakal ngebosenin kalau ada mereka.",
|
| 394 |
+
quote: "Gue egois, gak sabaran, dan agak insecure. Gue bikin salah, dan kadang susah diatur.",
|
| 395 |
+
strengths: ["Berani Tampil", "Orisinal", "Estetik", "Jago Mengamati"],
|
| 396 |
+
weaknesses: ["Sensitif", "Anti Konflik", "Gampang Bosan", "Gak Bisa Planning"],
|
| 397 |
+
relationships: "Pasangan yang hangat dan manja. Suka banget ngasih perhatian ke ayang dan have fun bareng.",
|
| 398 |
+
career: "Suka jadi pusat perhatian, cocok di Event Organizer, Sales, atau Seni Pertunjukan."
|
| 399 |
+
},
|
| 400 |
+
},
|
| 401 |
+
};
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
test_models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import google.generativeai as genai
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
api_key = os.getenv("GEMINI_API_KEY")
|
| 9 |
+
if not api_key:
|
| 10 |
+
print("No API Key found")
|
| 11 |
+
else:
|
| 12 |
+
genai.configure(api_key=api_key)
|
| 13 |
+
print("Listing available models...")
|
| 14 |
+
try:
|
| 15 |
+
for m in genai.list_models():
|
| 16 |
+
if 'generateContent' in m.supported_generation_methods:
|
| 17 |
+
print(m.name)
|
| 18 |
+
except Exception as e:
|
| 19 |
+
print(f"Error: {e}")
|
train_emotion.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# train_emotion.py
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import re
|
| 4 |
+
import joblib
|
| 5 |
+
import os
|
| 6 |
+
from datasets import load_dataset
|
| 7 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 8 |
+
from sklearn.linear_model import LogisticRegression
|
| 9 |
+
from sklearn.pipeline import Pipeline
|
| 10 |
+
from sklearn.model_selection import train_test_split
|
| 11 |
+
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
|
| 12 |
+
|
| 13 |
+
# ==========================================
|
| 14 |
+
# 🔧 KONFIGURASI
|
| 15 |
+
# ==========================================
|
| 16 |
+
MODEL_OUTPUT = 'api/data/model_emotion.pkl'
|
| 17 |
+
# ==========================================
|
| 18 |
+
|
| 19 |
+
print("🔍 Mengunduh dataset GoEmotions...")
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
dataset = load_dataset("google-research-datasets/go_emotions", "simplified", split="train")
|
| 23 |
+
df = pd.DataFrame(dataset)
|
| 24 |
+
labels_list = dataset.features['labels'].feature.names
|
| 25 |
+
|
| 26 |
+
def get_first_label(label_ids):
|
| 27 |
+
if len(label_ids) > 0:
|
| 28 |
+
return labels_list[label_ids[0]]
|
| 29 |
+
return "neutral"
|
| 30 |
+
|
| 31 |
+
df['emotion_label'] = df['labels'].apply(get_first_label)
|
| 32 |
+
X = df['text']
|
| 33 |
+
y = df['emotion_label']
|
| 34 |
+
print(f"✅ Data siap: {len(df)} baris.")
|
| 35 |
+
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"❌ Error: {e}")
|
| 38 |
+
exit()
|
| 39 |
+
|
| 40 |
+
# --- CLEANING DATA ---
|
| 41 |
+
def clean_text(text):
|
| 42 |
+
text = str(text).lower()
|
| 43 |
+
text = re.sub(r'http\S+', '', text)
|
| 44 |
+
text = re.sub(r'[^a-zA-Z\s]', '', text)
|
| 45 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 46 |
+
return text
|
| 47 |
+
|
| 48 |
+
print("🧹 Membersihkan data emosi...")
|
| 49 |
+
X = X.apply(clean_text)
|
| 50 |
+
|
| 51 |
+
# --- TRAINING ---
|
| 52 |
+
print("🚀 Melatih Model Emosi (Logistic Regression Fixed)...")
|
| 53 |
+
|
| 54 |
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 55 |
+
|
| 56 |
+
pipeline = Pipeline([
|
| 57 |
+
('tfidf', TfidfVectorizer(
|
| 58 |
+
max_features=12000, # Fitur banyak biar detail
|
| 59 |
+
stop_words='english',
|
| 60 |
+
ngram_range=(1, 2), # Baca kata per kata & frasa
|
| 61 |
+
sublinear_tf=True # [TRICK] Scaling logaritmik (Penting!)
|
| 62 |
+
)),
|
| 63 |
+
('clf', LogisticRegression(
|
| 64 |
+
max_iter=1000,
|
| 65 |
+
solver='lbfgs', # Ganti ke lbfgs biar aman dari error multiclass
|
| 66 |
+
C=1.2 # Agak agresif dikit (di atas 1.0) biar akurasi naik
|
| 67 |
+
))
|
| 68 |
+
])
|
| 69 |
+
|
| 70 |
+
pipeline.fit(X_train, y_train)
|
| 71 |
+
|
| 72 |
+
# --- EVALUASI ---
|
| 73 |
+
print("📊 Menghitung Metrik Evaluasi...")
|
| 74 |
+
predictions = pipeline.predict(X_test)
|
| 75 |
+
|
| 76 |
+
accuracy = accuracy_score(y_test, predictions)
|
| 77 |
+
precision, recall, f1, _ = precision_recall_fscore_support(y_test, predictions, average='weighted', zero_division=0)
|
| 78 |
+
|
| 79 |
+
print("\n" + "="*40)
|
| 80 |
+
print(" HASIL EVALUASI MODEL EMOSI (FINAL)")
|
| 81 |
+
print("="*40)
|
| 82 |
+
print(f"{'Metrik':<15} | {'Skor':<10}")
|
| 83 |
+
print("-" * 30)
|
| 84 |
+
print(f"{'Akurasi':<15} | {accuracy:.3f} ({accuracy*100:.1f}%)")
|
| 85 |
+
print(f"{'Precision':<15} | {precision:.3f}")
|
| 86 |
+
print(f"{'Recall':<15} | {recall:.3f}")
|
| 87 |
+
print(f"{'F1-Score':<15} | {f1:.3f}")
|
| 88 |
+
print("="*40 + "\n")
|
| 89 |
+
|
| 90 |
+
os.makedirs(os.path.dirname(MODEL_OUTPUT), exist_ok=True)
|
| 91 |
+
joblib.dump(pipeline, MODEL_OUTPUT)
|
| 92 |
+
print(f"💾 SUKSES! Model Emosi disimpan di: {MODEL_OUTPUT}")
|
train_mbti.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# train_mbti.py
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import re
|
| 4 |
+
import joblib
|
| 5 |
+
import os
|
| 6 |
+
from datasets import load_dataset
|
| 7 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 8 |
+
from sklearn.svm import LinearSVC
|
| 9 |
+
from sklearn.pipeline import Pipeline
|
| 10 |
+
from sklearn.model_selection import train_test_split
|
| 11 |
+
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
|
| 12 |
+
|
| 13 |
+
# ==========================================
|
| 14 |
+
# 🔧 KONFIGURASI
|
| 15 |
+
# ==========================================
|
| 16 |
+
MODEL_OUTPUT = 'api/data/model_mbti.pkl'
|
| 17 |
+
# ==========================================
|
| 18 |
+
|
| 19 |
+
print("🔍 Mengunduh dataset MBTI (7000 Data)...")
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
# Kita pake dataset yang pasti jalan aja
|
| 23 |
+
dataset = load_dataset("gmnsong/MBTI.csv", split="train")
|
| 24 |
+
df = pd.DataFrame(dataset)
|
| 25 |
+
|
| 26 |
+
# Pastikan nama kolom benar
|
| 27 |
+
if 'type' not in df.columns:
|
| 28 |
+
df.rename(columns={'label': 'type', 'text': 'posts'}, inplace=True)
|
| 29 |
+
|
| 30 |
+
X = df['posts']
|
| 31 |
+
y = df['type']
|
| 32 |
+
print(f"✅ Data siap: {len(df)} baris.")
|
| 33 |
+
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"❌ Error: {e}")
|
| 36 |
+
exit()
|
| 37 |
+
|
| 38 |
+
# --- CLEANING DATA ---
|
| 39 |
+
def clean_text(text):
|
| 40 |
+
text = str(text).lower()
|
| 41 |
+
text = re.sub(r'http\S+', '', text)
|
| 42 |
+
text = re.sub(r'[^a-zA-Z\s]', '', text)
|
| 43 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 44 |
+
return text
|
| 45 |
+
|
| 46 |
+
print("🧹 Membersihkan data...")
|
| 47 |
+
X = X.apply(clean_text)
|
| 48 |
+
|
| 49 |
+
# --- TRAINING ---
|
| 50 |
+
print("🚀 Melatih Model MBTI (SVM Optimized)...")
|
| 51 |
+
|
| 52 |
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 53 |
+
|
| 54 |
+
pipeline = Pipeline([
|
| 55 |
+
('tfidf', TfidfVectorizer(
|
| 56 |
+
max_features=15000, # Fitur diperbanyak dikit
|
| 57 |
+
stop_words='english',
|
| 58 |
+
ngram_range=(1, 2), # Unigram + Bigram
|
| 59 |
+
sublinear_tf=True # [TRICK] Scaling logaritmik biar kata umum gak dominan
|
| 60 |
+
)),
|
| 61 |
+
('clf', LinearSVC(
|
| 62 |
+
dual=False, # Wajib False buat dataset teks > 1000
|
| 63 |
+
C=0.6, # Sedikit melonggarkan regularisasi
|
| 64 |
+
class_weight='balanced' # Tetap balanced biar F1-Score bagus
|
| 65 |
+
))
|
| 66 |
+
])
|
| 67 |
+
|
| 68 |
+
pipeline.fit(X_train, y_train)
|
| 69 |
+
|
| 70 |
+
# --- EVALUASI ---
|
| 71 |
+
print("📊 Menghitung Metrik Evaluasi...")
|
| 72 |
+
predictions = pipeline.predict(X_test)
|
| 73 |
+
|
| 74 |
+
accuracy = accuracy_score(y_test, predictions)
|
| 75 |
+
precision, recall, f1, _ = precision_recall_fscore_support(y_test, predictions, average='weighted', zero_division=0)
|
| 76 |
+
|
| 77 |
+
print("\n" + "="*40)
|
| 78 |
+
print(" HASIL EVALUASI MODEL MBTI (FINAL)")
|
| 79 |
+
print("="*40)
|
| 80 |
+
print(f"{'Metrik':<15} | {'Skor':<10}")
|
| 81 |
+
print("-" * 30)
|
| 82 |
+
print(f"{'Akurasi':<15} | {accuracy:.3f} ({accuracy*100:.1f}%)")
|
| 83 |
+
print(f"{'Precision':<15} | {precision:.3f}")
|
| 84 |
+
print(f"{'Recall':<15} | {recall:.3f}")
|
| 85 |
+
print(f"{'F1-Score':<15} | {f1:.3f}")
|
| 86 |
+
print("="*40 + "\n")
|
| 87 |
+
|
| 88 |
+
os.makedirs(os.path.dirname(MODEL_OUTPUT), exist_ok=True)
|
| 89 |
+
joblib.dump(pipeline, MODEL_OUTPUT)
|
| 90 |
+
print(f"💾 SUKSES! Model MBTI disimpan di: {MODEL_OUTPUT}")
|
tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|
vercel.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "nextjs"
|
| 3 |
+
}
|