anggars commited on
Commit
0630ed4
·
verified ·
1 Parent(s): 54ed3b2

Sync from GitHub Actions: e01f9df8b054a5718601dd513fd3516293f9eac8

Browse files
.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
- *.pyc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"} &rarr;
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
+ }