anggars commited on
Commit
050ab1a
·
verified ·
1 Parent(s): 96a4460

Sync from GitHub Actions: 75654b613491a09d0e326d3108e71fc8c46837a1

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Sentimind Api
3
  emoji: 🧠
4
  colorFrom: blue
5
  colorTo: indigo
 
1
  ---
2
+ title: Sentimind API
3
  emoji: 🧠
4
  colorFrom: blue
5
  colorTo: indigo
api/core/nlp_handler.py CHANGED
@@ -28,27 +28,61 @@ EMOTION_TRANSLATIONS = {
28
  'neutral': 'Netral'
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  class NLPHandler:
 
 
 
 
32
  @staticmethod
33
  def load_models():
34
  global _model_mbti, _model_emotion
35
  print(f"📂 Loading models from: {BASE_DIR}")
36
- print(f"📁 MBTI path: {MBTI_PATH} (exists: {os.path.exists(MBTI_PATH)})")
37
- print(f"📁 Emotion path: {EMOTION_PATH} (exists: {os.path.exists(EMOTION_PATH)})")
38
 
39
  if _model_mbti is None and os.path.exists(MBTI_PATH):
40
  try:
41
  _model_mbti = joblib.load(MBTI_PATH)
42
- print(" MBTI model loaded successfully")
43
- except Exception as e:
44
- print(f"❌ MBTI model load error: {e}")
45
 
46
  if _model_emotion is None and os.path.exists(EMOTION_PATH):
47
  try:
48
  _model_emotion = joblib.load(EMOTION_PATH)
49
- print(" Emotion model loaded successfully")
50
- except Exception as e:
51
- print(f"❌ Emotion model load error: {e}")
52
 
53
  @staticmethod
54
  def translate_to_english(text):
@@ -79,25 +113,40 @@ class NLPHandler:
79
  NLPHandler.load_models()
80
  processed_text = NLPHandler.translate_to_english(raw_text)
81
 
 
82
  mbti_result = "UNKNOWN"
83
  if _model_mbti:
84
  try: mbti_result = _model_mbti.predict([processed_text])[0]
85
  except: pass
86
 
 
87
  emotion_data = {"id": "Kompleks", "en": "Complex", "raw": "unknown"}
 
 
88
  if _model_emotion:
89
  try:
90
  pred_label = "neutral"
91
  if hasattr(_model_emotion, "predict_proba"):
92
  probs = _model_emotion.predict_proba([processed_text])[0]
93
  classes = _model_emotion.classes_
 
 
94
  neutral_indices = [i for i, c in enumerate(classes) if c.lower() == 'neutral']
95
  if neutral_indices:
96
  idx = neutral_indices[0]
97
  if probs[idx] < 0.65: probs[idx] = 0.0
 
 
 
 
 
 
 
 
98
  if np.sum(probs) > 0:
99
  best_idx = np.argmax(probs)
100
  pred_label = classes[best_idx]
 
101
  else:
102
  pred_label = _model_emotion.predict([processed_text])[0]
103
  else:
@@ -106,11 +155,40 @@ class NLPHandler:
106
  indo_label = EMOTION_TRANSLATIONS.get(pred_label, pred_label.capitalize())
107
  emotion_data = {"id": indo_label, "en": pred_label.capitalize(), "raw": pred_label}
108
  except: pass
109
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  return {
111
  "mbti": mbti_result,
112
  "emotion": emotion_data,
113
- "keywords": NLPHandler.extract_keywords(processed_text)
 
 
 
 
 
114
  }
115
 
116
  # --- JALUR RESMI: YOUTUBE DATA API ---
 
28
  'neutral': 'Netral'
29
  }
30
 
31
+
32
+ MBTI_EXPLANATIONS = {
33
+ 'ISTJ': {'en': "The Logistician. Practical and fact-minded individuals, whose reliability cannot be doubted.",
34
+ 'id': "Si Organisator. Lo orangnya logis, praktis, dan bisa diandelin banget. Anti ribet-ribet club."},
35
+ 'ISFJ': {'en': "The Defender. Very dedicated and warm protectors, always ready to defend their loved ones.",
36
+ 'id': "Si Pelindung. Hati lo lembut, setia, dan care banget sama orang terdekat. Temen curhat terbaik."},
37
+ 'INFJ': {'en': "The Advocate. Quiet and mystical, yet very inspiring and tireless idealists.",
38
+ 'id': "Si Visioner Misterius. Lo peka, idealis, dan suka mikirin makna hidup mendalam. Langka nih!"},
39
+ 'INTJ': {'en': "The Architect. Imaginative and strategic thinkers, with a plan for everything.",
40
+ 'id': "Si Strategis. Otak lo jalan terus, visioner, dan selalu punya rencana cadangan buat segala hal."},
41
+ 'ISTP': {'en': "The Virtuoso. Bold and practical experimenters, masters of all kinds of tools.",
42
+ 'id': "Si Pengrajin. Lo cool, santuy, tapi jago banget mecahin masalah teknis secara praktis."},
43
+ 'ISFP': {'en': "The Adventurer. Flexible and charming artists, always ready to explore and experience something new.",
44
+ 'id': "Si Seniman Bebas. Lo estetik, santai, dan suka banget nge-explore hal baru tanpa banyak drama."},
45
+ 'INFP': {'en': "The Mediator. Poetic, kind and altruistic people, always eager to help a good cause.",
46
+ 'id': "Si Paling Perasa. Hati lo kayak kapas, puitis, idealis banget, dan selalu mau bikin dunia lebih baik."},
47
+ 'INTP': {'en': "The Logician. Innovative inventors with an unquenchable thirst for knowledge.",
48
+ 'id': "Si Pemikir Kritis. Lo kepoan parah, logis abis, dan suka banget debat teori sampe pagi."},
49
+ 'ESTP': {'en': "The Entrepreneur. Smart, energetic and very perceptive people, who truly enjoy living on the edge.",
50
+ 'id': "Si Pemberani. Lo enerjik, spontan, dan jago banget ngambil peluang dalam situasi mepet."},
51
+ 'ESFP': {'en': "The Entertainer. Spontaneous, energetic and enthusiastic people - life is never boring around them.",
52
+ 'id': "Si Penghibur. Lo asik parah, spontan, dan selalu jadi pusat perhatian di tongkrongan."},
53
+ 'ENFP': {'en': "The Campaigner. Enthusiastic, creative and sociable free spirits, who can always find a reason to smile.",
54
+ 'id': "Si Semangat 45. Lo kreatif, ramah, dan punya energi positif yang nular ke semua orang."},
55
+ 'ENTP': {'en': "The Debater. Smart and curious thinkers who cannot resist an intellectual challenge.",
56
+ 'id': "Si Pendebat Ulung. Lo pinter, kritis, dan iseng banget suka mancing debat cuma buat seru-seruan."},
57
+ 'ESTJ': {'en': "The Executive. Excellent administrators, unsurpassed at managing things - or people.",
58
+ 'id': "Si Bos Tegas. Lo jago ngatur, disiplin, dan gak suka liat ada yang lelet atau berantakan."},
59
+ 'ESFJ': {'en': "The Consul. Extraordinarily caring, social and popular people, always eager to help.",
60
+ 'id': "Si Paling Gaul. Lo ramah, suka nolong, dan care banget sama harmoni di sirkel pertemanan."},
61
+ 'ENFJ': {'en': "The Protagonist. Charismatic and inspiring leaders, able to mesmerize their listeners.",
62
+ 'id': "Si Pemimpin Karismatik. Lo jago banget ngomong, inspiratif, dan bisa bikin orang lain nurut sama lo."},
63
+ 'ENTJ': {'en': "The Commander. Bold, imaginative and strong-willed leaders, always finding a way - or making one.",
64
+ 'id': "Si Jenderal. Lo ambisius, tegas, dan punya bakat alami buat mimpin dan naklukin tantangan."}
65
+ }
66
+
67
  class NLPHandler:
68
+ # ... code before ...
69
+ # (The existing static methods load_models, translate_to_english, extract_keywords are unchanged)
70
+ # Re-writing predict_all to include explanation logic
71
+
72
  @staticmethod
73
  def load_models():
74
  global _model_mbti, _model_emotion
75
  print(f"📂 Loading models from: {BASE_DIR}")
 
 
76
 
77
  if _model_mbti is None and os.path.exists(MBTI_PATH):
78
  try:
79
  _model_mbti = joblib.load(MBTI_PATH)
80
+ except Exception as e: print(f" MBTI Load Error: {e}")
 
 
81
 
82
  if _model_emotion is None and os.path.exists(EMOTION_PATH):
83
  try:
84
  _model_emotion = joblib.load(EMOTION_PATH)
85
+ except Exception as e: print(f" Emotion Load Error: {e}")
 
 
86
 
87
  @staticmethod
88
  def translate_to_english(text):
 
113
  NLPHandler.load_models()
114
  processed_text = NLPHandler.translate_to_english(raw_text)
115
 
116
+ # --- MBTI PREDICTION ---
117
  mbti_result = "UNKNOWN"
118
  if _model_mbti:
119
  try: mbti_result = _model_mbti.predict([processed_text])[0]
120
  except: pass
121
 
122
+ # --- EMOTION PREDICTION & CONFIDENCE ---
123
  emotion_data = {"id": "Kompleks", "en": "Complex", "raw": "unknown"}
124
+ confidence_score = 0.0
125
+
126
  if _model_emotion:
127
  try:
128
  pred_label = "neutral"
129
  if hasattr(_model_emotion, "predict_proba"):
130
  probs = _model_emotion.predict_proba([processed_text])[0]
131
  classes = _model_emotion.classes_
132
+
133
+ # Neutral dampening logic
134
  neutral_indices = [i for i, c in enumerate(classes) if c.lower() == 'neutral']
135
  if neutral_indices:
136
  idx = neutral_indices[0]
137
  if probs[idx] < 0.65: probs[idx] = 0.0
138
+
139
+ # RE-NORMALIZE PROBABILITIES
140
+ # Agar sisa probabilitas naik proporsional.
141
+ # Misal: [0.1, 0.1, 0.1, 0.0] -> [0.33, 0.33, 0.33, 0.0]
142
+ total_prob = np.sum(probs)
143
+ if total_prob > 0:
144
+ probs = probs / total_prob
145
+
146
  if np.sum(probs) > 0:
147
  best_idx = np.argmax(probs)
148
  pred_label = classes[best_idx]
149
+ confidence_score = float(probs[best_idx])
150
  else:
151
  pred_label = _model_emotion.predict([processed_text])[0]
152
  else:
 
155
  indo_label = EMOTION_TRANSLATIONS.get(pred_label, pred_label.capitalize())
156
  emotion_data = {"id": indo_label, "en": pred_label.capitalize(), "raw": pred_label}
157
  except: pass
158
+
159
+ # --- REASONING GENERATION ---
160
+ mbti_desc = MBTI_EXPLANATIONS.get(mbti_result, {
161
+ 'en': "Complex personality type.",
162
+ 'id': "Kepribadian yang cukup kompleks."
163
+ })
164
+
165
+ # Emotion Reasoning
166
+ conf_percent = int(confidence_score * 100)
167
+ emotion_reasoning = {
168
+ 'en': f"Based on the text pattern, the AI is {conf_percent}% confident this matches '{emotion_data['en']}'.",
169
+ 'id': f"Dari gaya tulisan lo, AI {conf_percent}% yakin mood lo lagi '{emotion_data['id']}'."
170
+ }
171
+ if conf_percent < 50 and confidence_score > 0:
172
+ emotion_reasoning = {
173
+ 'en': f"The sentiment is mixed, but slightly leans towards '{emotion_data['en']}' ({conf_percent}%).",
174
+ 'id': f"Mood lo campur aduk, tapi agak condong ke '{emotion_data['id']}' dikit ({conf_percent}%)."
175
+ }
176
+
177
+ # Keywords Reasoning
178
+ keywords_reasoning = {
179
+ 'en': "These words appeared most frequently and define the main topic.",
180
+ 'id': "Kata-kata ini paling sering muncul dan jadi inti topik lo."
181
+ }
182
+
183
  return {
184
  "mbti": mbti_result,
185
  "emotion": emotion_data,
186
+ "keywords": NLPHandler.extract_keywords(processed_text),
187
+ "reasoning": {
188
+ "mbti": mbti_desc,
189
+ "emotion": emotion_reasoning,
190
+ "keywords": keywords_reasoning
191
+ }
192
  }
193
 
194
  # --- JALUR RESMI: YOUTUBE DATA API ---
api/data/questions.json CHANGED
@@ -1,58 +1,42 @@
1
  [
2
- {
3
- "id": 1,
4
- "text_id": "Saya merasa lebih berenergi setelah bergaul dengan banyak orang.",
5
- "text_en": "I feel more energized after socializing with a large group of people.",
6
- "dimension": "EI",
7
- "direction": 1
8
- },
9
- {
10
- "id": 2,
11
- "text_id": "Saya lebih suka fokus pada fakta nyata daripada ide abstrak.",
12
- "text_en": "I prefer to focus on real facts rather than abstract ideas.",
13
- "dimension": "SN",
14
- "direction": 1
15
- },
16
- {
17
- "id": 3,
18
- "text_id": "Saya mengambil keputusan berdasarkan logika, bukan perasaan.",
19
- "text_en": "I make decisions based on logic rather than feelings.",
20
- "dimension": "TF",
21
- "direction": 1
22
- },
23
- {
24
- "id": 4,
25
- "text_id": "Saya suka membuat rencana detail sebelum melakukan sesuatu.",
26
- "text_en": "I like to have a detailed plan before doing anything.",
27
- "dimension": "JP",
28
- "direction": 1
29
- },
30
- {
31
- "id": 5,
32
- "text_id": "Saya sering merasa lelah jika harus bersosialisasi terlalu lama.",
33
- "text_en": "I often feel drained if I have to socialize for too long.",
34
- "dimension": "EI",
35
- "direction": -1
36
- },
37
- {
38
- "id": 6,
39
- "text_id": "Saya sering membayangkan masa depan dan kemungkinan-kemungkinannya.",
40
- "text_en": "I often imagine the future and its possibilities.",
41
- "dimension": "SN",
42
- "direction": -1
43
- },
44
- {
45
- "id": 7,
46
- "text_id": "Saya mudah tersentuh secara emosional oleh cerita orang lain.",
47
- "text_en": "I am easily emotionally moved by other people's stories.",
48
- "dimension": "TF",
49
- "direction": -1
50
- },
51
- {
52
- "id": 8,
53
- "text_id": "Saya lebih suka bertindak spontan daripada mengikuti jadwal kaku.",
54
- "text_en": "I prefer to be spontaneous rather than following a rigid schedule.",
55
- "dimension": "JP",
56
- "direction": -1
57
- }
58
  ]
 
1
  [
2
+ { "id": 1, "dimension": "EI", "direction": 1, "text_id": "Saya merasa lebih berenergi setelah berinteraksi dengan banyak orang di pesta.", "text_en": "I feel more energized after interacting with many people at a party." },
3
+ { "id": 2, "dimension": "SN", "direction": 1, "text_id": "Saya lebih suka fokus pada apa yang nyata dan sedang terjadi saat ini.", "text_en": "I prefer to focus on what is real and happening right now." },
4
+ { "id": 3, "dimension": "TF", "direction": 1, "text_id": "Saya cenderung mengambil keputusan berdasarkan logika dan nalar.", "text_en": "I tend to make decisions based on logic and reason." },
5
+ { "id": 4, "dimension": "JP", "direction": 1, "text_id": "Saya suka memiliki jadwal yang terencana dan menaatinya.", "text_en": "I like to have a planned schedule and stick to it." },
6
+ { "id": 5, "dimension": "EI", "direction": -1, "text_id": "Saya sering merasa perlu menyendiri untuk mengisi ulang energi setelah bersosialisasi.", "text_en": "I often feel the need to be alone to recharge after socializing." },
7
+ { "id": 6, "dimension": "SN", "direction": -1, "text_id": "Saya sering berimajinasi tentang masa depan dan makna di balik segala sesuatu.", "text_en": "I often imagine the future and the meaning behind things." },
8
+ { "id": 7, "dimension": "TF", "direction": -1, "text_id": "Saya sering membiarkan hati dan perasaan membimbing keputusan saya.", "text_en": "I often let my heart and feelings guide my decisions." },
9
+ { "id": 8, "dimension": "JP", "direction": -1, "text_id": "Saya lebih suka fleksibel dan membiarkan hal-hal terjadi secara spontan.", "text_en": "I prefer to be flexible and let things happen spontaneously." },
10
+ { "id": 9, "dimension": "EI", "direction": 1, "text_id": "Saya mudah memulai percakapan dengan orang asing.", "text_en": "I find it easy to start conversations with strangers." },
11
+ { "id": 10, "dimension": "SN", "direction": 1, "text_id": "Saya lebih percaya pada pengalaman nyata daripada teori abstrak.", "text_en": "I trust actual experience more than abstract theories." },
12
+ { "id": 11, "dimension": "TF", "direction": 1, "text_id": "Kejujuran dan kebenaran lebih penting daripada menjaga perasaan orang lain.", "text_en": "Honesty and truth are more important than sparing someone's feelings." },
13
+ { "id": 12, "dimension": "JP", "direction": 1, "text_id": "Saya merasa gelisah jika tugas belum selesai atau rencana belum dibuat.", "text_en": "I feel uneasy if tasks are not finished or plans are not made." },
14
+ { "id": 13, "dimension": "EI", "direction": -1, "text_id": "Saya lebih suka mendengarkan daripada menjadi pusat perhatian.", "text_en": "I prefer listening over being the center of attention." },
15
+ { "id": 14, "dimension": "SN", "direction": -1, "text_id": "Saya sering tersesat dalam pikiran dan ide-ide saya sendiri.", "text_en": "I often get lost in my own thoughts and ideas." },
16
+ { "id": 15, "dimension": "TF", "direction": -1, "text_id": "Saya sangat peduli dengan keharmonisan dan menghindari konflik.", "text_en": "I care deeply about harmony and avoid conflict." },
17
+ { "id": 16, "dimension": "JP", "direction": -1, "text_id": "Saya sering mengerjakan sesuatu pada menit-menit terakhir.", "text_en": "I often do things at the last minute." },
18
+ { "id": 17, "dimension": "EI", "direction": 1, "text_id": "Saya punya lingkaran teman yang sangat luas.", "text_en": "I have a very wide circle of friends." },
19
+ { "id": 18, "dimension": "SN", "direction": 1, "text_id": "Saya sangat teliti dan memperhatikan detail kecil.", "text_en": "I am very meticulous and pay attention to small details." },
20
+ { "id": 19, "dimension": "TF", "direction": 1, "text_id": "Saya bisa tetap tenang dan objektif dalam situasi emosional.", "text_en": "I can remain calm and objective in emotional situations." },
21
+ { "id": 20, "dimension": "JP", "direction": 1, "text_id": "Saya suka membuat daftar tugas (to-do list).", "text_en": "I like making to-do lists." },
22
+ { "id": 21, "dimension": "EI", "direction": -1, "text_id": "Saya merasa canggung di keramaian.", "text_en": "I feel awkward in crowds." },
23
+ { "id": 22, "dimension": "SN", "direction": -1, "text_id": "Saya sering berpikir 'bagaimana jika' daripada 'apa adanya'.", "text_en": "I often think 'what if' rather than 'what is'." },
24
+ { "id": 23, "dimension": "TF", "direction": -1, "text_id": "Saya mudah empati dan merasakan apa yang dirasakan orang lain.", "text_en": "I easily empathize and feel what others are feeling." },
25
+ { "id": 24, "dimension": "JP", "direction": -1, "text_id": "Saya tidak suka dikekang oleh aturan yang ketat.", "text_en": "I dislike being constrained by strict rules." },
26
+ { "id": 25, "dimension": "EI", "direction": 1, "text_id": "Saya aktif berpartisipasi dalam diskusi kelompok.", "text_en": "I actively participate in group discussions." },
27
+ { "id": 26, "dimension": "SN", "direction": 1, "text_id": "Saya lebih suka instruksi yang jelas dan langkah demi langkah.", "text_en": "I prefer clear, step-by-step instructions." },
28
+ { "id": 27, "dimension": "TF", "direction": 1, "text_id": "Saya lebih sering menganalisis masalah daripada menghibur orang.", "text_en": "I analyze problems more often than I comfort people." },
29
+ { "id": 28, "dimension": "JP", "direction": 1, "text_id": "Saya merasa puas ketika menyelesaikan pekerjaan jauh sebelum tenggat waktu.", "text_en": "I feel satisfied when finishing work well before the deadline." },
30
+ { "id": 29, "dimension": "EI", "direction": -1, "text_id": "Saya butuh waktu lama untuk terbuka pada orang baru.", "text_en": "It takes me a long time to open up to new people." },
31
+ { "id": 30, "dimension": "SN", "direction": -1, "text_id": "Saya sering melihat pola dan hubungan yang tidak dilihat orang lain.", "text_en": "I often see patterns and connections that others miss." },
32
+ { "id": 31, "dimension": "TF", "direction": -1, "text_id": "Saya sering mengambil keputusan berdasarkan kata hati.", "text_en": "I often make decisions based on my gut feeling." },
33
+ { "id": 32, "dimension": "JP", "direction": -1, "text_id": "Saya menikmati kejutan dan perubahan rencana.", "text_en": "I enjoy surprises and changes in plans." },
34
+ { "id": 33, "dimension": "EI", "direction": 1, "text_id": "Saya sering bertindak dulu, berpikir kemudian.", "text_en": "I often act first, think later." },
35
+ { "id": 34, "dimension": "SN", "direction": 1, "text_id": "Saya fokus pada fakta dan data, bukan opini.", "text_en": "I focus on facts and data, not opinions." },
36
+ { "id": 35, "dimension": "TF", "direction": 1, "text_id": "Debat intelektual lebih menarik daripada obrolan ringan.", "text_en": "Intellectual debates are more interesting than small talk." },
37
+ { "id": 36, "dimension": "JP", "direction": 1, "text_id": "Saya suka lingkungan kerja yang teratur dan terstruktur.", "text_en": "I like an organized and structured work environment." },
38
+ { "id": 37, "dimension": "EI", "direction": -1, "text_id": "Saya lebih produktif saat bekerja sendirian.", "text_en": "I am more productive when working alone." },
39
+ { "id": 38, "dimension": "SN", "direction": -1, "text_id": "Saya suka berfilsafat tentang makna kehidupan.", "text_en": "I like philosophizing about the meaning of life." },
40
+ { "id": 39, "dimension": "TF", "direction": -1, "text_id": "Saya sulit bersikap tegas jika itu menyakiti orang lain.", "text_en": "I find it hard to be firm if it hurts others." },
41
+ { "id": 40, "dimension": "JP", "direction": -1, "text_id": "Saya sering memulai proyek baru tanpa menyelesaikan yang lama.", "text_en": "I often start new projects without finishing old ones." }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  ]
api/index.py CHANGED
@@ -1,4 +1,5 @@
1
  from fastapi import FastAPI
 
2
  from dotenv import load_dotenv
3
  from .core.nlp_handler import NLPHandler
4
  import os
@@ -65,6 +66,24 @@ def health_check():
65
  "api_key_detected": has_key
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  # --- ROUTE YOUTUBE BARU ---
69
  @app.get("/api/youtube/{video_id}")
70
  def analyze_youtube_video(video_id: str):
@@ -87,6 +106,7 @@ def analyze_youtube_video(video_id: str):
87
  "mbti_type": result["mbti"],
88
  "emotion": result["emotion"],
89
  "keywords": result["keywords"],
 
90
  "video": data.get("video"),
91
  "comments": data.get("comments", []),
92
  "fetched_text": text_for_analysis
@@ -99,5 +119,6 @@ def analyze_youtube_video(video_id: str):
99
  "mbti_type": result["mbti"],
100
  "emotion": result["emotion"],
101
  "keywords": result["keywords"],
 
102
  "fetched_text": data
103
  }
 
1
  from fastapi import FastAPI
2
+ from fastapi.responses import HTMLResponse
3
  from dotenv import load_dotenv
4
  from .core.nlp_handler import NLPHandler
5
  import os
 
66
  "api_key_detected": has_key
67
  }
68
 
69
+ @app.get("/", response_class=HTMLResponse)
70
+ def read_root():
71
+ return """
72
+ <!DOCTYPE html>
73
+ <html>
74
+ <head>
75
+ <title>Sentimind API</title>
76
+ <style>
77
+ body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; }
78
+ iframe { width: 100%; height: 100%; border: none; }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <iframe src="https://sentimind.vercel.app"></iframe>
83
+ </body>
84
+ </html>
85
+ """
86
+
87
  # --- ROUTE YOUTUBE BARU ---
88
  @app.get("/api/youtube/{video_id}")
89
  def analyze_youtube_video(video_id: str):
 
106
  "mbti_type": result["mbti"],
107
  "emotion": result["emotion"],
108
  "keywords": result["keywords"],
109
+ "reasoning": result["reasoning"],
110
  "video": data.get("video"),
111
  "comments": data.get("comments", []),
112
  "fetched_text": text_for_analysis
 
119
  "mbti_type": result["mbti"],
120
  "emotion": result["emotion"],
121
  "keywords": result["keywords"],
122
+ "reasoning": result["reasoning"],
123
  "fetched_text": data
124
  }
api/predict.py CHANGED
@@ -21,5 +21,6 @@ def predict_endpoint(input_data: UserInput):
21
  "success": True,
22
  "mbti_type": result["mbti"],
23
  "emotion": result["emotion"],
24
- "keywords": result["keywords"]
 
25
  }
 
21
  "success": True,
22
  "mbti_type": result["mbti"],
23
  "emotion": result["emotion"],
24
+ "keywords": result["keywords"],
25
+ "reasoning": result["reasoning"]
26
  }
src/app/analyzer/page.tsx CHANGED
@@ -1,7 +1,20 @@
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() {
@@ -46,10 +59,22 @@ export default function AnalysisPage() {
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",
@@ -77,20 +102,33 @@ export default function AnalysisPage() {
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 = () => {
@@ -130,7 +168,9 @@ export default function AnalysisPage() {
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);
@@ -175,7 +215,9 @@ export default function AnalysisPage() {
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) {
@@ -189,20 +231,18 @@ export default function AnalysisPage() {
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}
@@ -215,20 +255,32 @@ export default function AnalysisPage() {
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>
@@ -236,17 +288,19 @@ export default function AnalysisPage() {
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">
@@ -257,14 +311,18 @@ export default function AnalysisPage() {
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
  )}
@@ -272,17 +330,19 @@ export default function AnalysisPage() {
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">
@@ -290,11 +350,17 @@ export default function AnalysisPage() {
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>
@@ -303,192 +369,246 @@ export default function AnalysisPage() {
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 }}
@@ -500,8 +620,10 @@ export default function AnalysisPage() {
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>
@@ -518,8 +640,7 @@ export default function AnalysisPage() {
518
  </div>
519
  </motion.div>
520
  )}
521
-
522
  </motion.div>
523
  </div>
524
  );
525
- }
 
1
  "use client";
2
  import { useState } from "react";
3
  import { useLanguage } from "@/app/providers";
4
+ import {
5
+ Search,
6
+ Tag,
7
+ Smile,
8
+ BrainCircuit,
9
+ Lightbulb,
10
+ BookOpen,
11
+ MessageSquare,
12
+ FileText,
13
+ Youtube,
14
+ AlertCircle,
15
+ ThumbsUp,
16
+ ThumbsDown,
17
+ } from "lucide-react";
18
  import { motion, AnimatePresence } from "framer-motion";
19
 
20
  export default function AnalysisPage() {
 
59
 
60
  guideTitle: "How to get accurate results?",
61
  guides: [
62
+ {
63
+ icon: MessageSquare,
64
+ title: "Be Expressive",
65
+ text: "Write naturally about your feelings, opinions, or daily life experiences.",
66
+ },
67
+ {
68
+ icon: BookOpen,
69
+ title: "Length Matters",
70
+ text: "Try to write at least 2-3 sentences. Short texts like 'Hello' won't reveal much.",
71
+ },
72
+ {
73
+ icon: Lightbulb,
74
+ title: "Honesty is Key",
75
+ text: "Don't overthink it. The AI analyzes your subconscious writing style.",
76
+ },
77
+ ],
78
  },
79
  id: {
80
  title: "Analisis Teks",
 
102
 
103
  guideTitle: "Biar Hasilnya Akurat",
104
  guides: [
105
+ {
106
+ icon: MessageSquare,
107
+ title: "Yang Ekspresif Dong",
108
+ text: "Tulis aja secara natural soal perasaan atau opini lo. Gak usah jaim.",
109
+ },
110
+ {
111
+ icon: BookOpen,
112
+ title: "Jangan Pendek-pendek",
113
+ text: "Minimal 2-3 kalimat lah. Kalau cuma 'Halo' doang, AI-nya bakal bingung.",
114
+ },
115
+ {
116
+ icon: Lightbulb,
117
+ title: "Jujur Itu Kunci",
118
+ text: "Gak usah overthink. AI bakal baca pola penulisan bawah sadar lo.",
119
+ },
120
+ ],
121
+ },
122
  };
123
 
124
  const content = t[lang];
125
 
126
  // --- HELPER BUAT AMBIL ID YOUTUBE DARI LINK ---
127
  const extractVideoId = (url: string) => {
128
+ const regExp =
129
+ /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
130
  const match = url.match(regExp);
131
+ return match && match[2].length === 11 ? match[2] : null;
132
  };
133
 
134
  const getErrorMessage = () => {
 
168
  setLoading(true);
169
  try {
170
  const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
171
+ const res = await fetch(`${apiUrl}/api/youtube/${videoId}`, {
172
+ method: "GET",
173
+ });
174
  const data = await res.json();
175
  if (data.success) {
176
  setResult(data);
 
215
  }
216
  };
217
 
218
+ const handleKeyDown = (
219
+ e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>
220
+ ) => {
221
  if (e.key === "Enter" && !e.shiftKey) {
222
  const isMobile = window.innerWidth < 768;
223
  if (mode === "text" && isMobile) {
 
231
  const getKeywords = () => {
232
  if (!result?.keywords) return [];
233
  if (Array.isArray(result.keywords)) return result.keywords;
234
+ return lang === "id" ? result.keywords.id : result.keywords.en;
235
  };
236
 
237
  const currentKeywords = getKeywords();
238
 
239
  return (
240
  <div className="w-full pt-28 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center font-sans">
241
+ <motion.div
 
242
  initial={{ opacity: 0, y: 20 }}
243
  animate={{ opacity: 1, y: 0 }}
244
  className="w-full max-w-4xl mx-auto text-center space-y-4 z-10"
245
  >
 
246
  <div>
247
  <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">
248
  {content.title}
 
255
  {/* --- TOMBOL SWITCH MODE (TEXT vs YOUTUBE) --- */}
256
  <div className="grid grid-cols-2 gap-3 mt-8 w-full max-w-[340px] mx-auto">
257
  <button
258
+ onClick={() => {
259
+ setMode("text");
260
+ setResult(null);
261
+ setErrorType(null);
262
+ }}
263
  className={`flex items-center justify-center gap-2 py-2.5 rounded-full font-bold text-sm transition-all border border-transparent
264
+ ${
265
+ mode === "text"
266
+ ? "bg-orange-600 text-white shadow-lg shadow-orange-500/20"
267
+ : "bg-gray-100 dark:bg-white/5 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10"
268
+ }`}
269
  >
270
  <FileText size={16} /> {content.modeText}
271
  </button>
272
  <button
273
+ onClick={() => {
274
+ setMode("youtube");
275
+ setResult(null);
276
+ setErrorType(null);
277
+ }}
278
  className={`flex items-center justify-center gap-2 py-2.5 rounded-full font-bold text-sm transition-all border border-transparent
279
+ ${
280
+ mode === "youtube"
281
+ ? "bg-[#FF0000] text-white shadow-lg shadow-[#FF0000]/20"
282
+ : "bg-gray-100 dark:bg-white/5 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10"
283
+ }`}
284
  >
285
  <Youtube size={16} /> {content.modeYoutube}
286
  </button>
 
288
 
289
  <div className="liquid-glass p-1.5 shadow-2xl mt-6 max-w-3xl mx-auto w-full">
290
  <div className="bg-white/50 dark:bg-black/20 rounded-xl p-4 backdrop-blur-sm">
 
291
  {/* AREA INPUT */}
292
  <div className="min-h-[140px] flex flex-col justify-center">
293
  {mode === "text" ? (
294
  <textarea
295
  value={inputText}
296
+ onChange={(e) => {
297
+ setInputText(e.target.value);
298
+ setErrorType(null);
299
+ }}
300
  onKeyDown={handleKeyDown}
301
  placeholder={content.placeholder}
302
  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"
303
+ style={{ minHeight: "140px" }}
304
  />
305
  ) : (
306
  <div className="py-2 px-2 w-full">
 
311
  <input
312
  type="text"
313
  value={youtubeUrl}
314
+ onChange={(e) => {
315
+ setYoutubeUrl(e.target.value);
316
+ setErrorType(null);
317
+ }}
318
  onKeyDown={handleKeyDown}
319
  placeholder={content.ytPlaceholder}
320
  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"
321
  />
322
  </div>
323
  <p className="text-xs text-left mt-3 ml-1 text-gray-500 dark:text-gray-400 flex items-center gap-1 pl-1">
324
+ <Lightbulb size={12} className="text-yellow-500" />{" "}
325
+ {content.ytTip}
326
  </p>
327
  </div>
328
  )}
 
330
 
331
  {/* ERROR MSG */}
332
  <AnimatePresence>
333
+ {currentErrorMsg && (
334
+ <motion.div
335
+ initial={{ height: 0, opacity: 0 }}
336
+ animate={{ height: "auto", opacity: 1 }}
337
+ exit={{ height: 0, opacity: 0 }}
338
+ 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"
339
  >
340
+ <AlertCircle size={20} className="shrink-0" />
341
+ <span className="text-sm font-bold text-left">
342
+ {currentErrorMsg}
343
+ </span>
344
  </motion.div>
345
+ )}
346
  </AnimatePresence>
347
 
348
  <div className="flex justify-end mt-4 pt-2 border-t border-gray-500/10">
 
350
  onClick={handleAnalyze}
351
  disabled={loading}
352
  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
353
+ ${
354
+ mode === "youtube"
355
+ ? "bg-[#FF0000] hover:bg-red-700 hover:shadow-[#FF0000]/30"
356
+ : "bg-orange-600 hover:bg-orange-700 hover:shadow-orange-500/30"
357
+ }`}
358
  >
359
+ {loading
360
+ ? content.btnLoading
361
+ : mode === "youtube"
362
+ ? content.btnYoutube
363
+ : content.btnAnalyze}
364
  {!loading && <Search className="w-4 h-4" />}
365
  </button>
366
  </div>
 
369
 
370
  {/* HASIL */}
371
  <AnimatePresence>
372
+ {result && (
373
+ <motion.div
374
+ initial={{ opacity: 0, y: 100 }}
375
+ animate={{ opacity: 1, y: 0 }}
376
+ exit={{ opacity: 0, y: 100 }}
377
+ transition={{ type: "spring", damping: 20 }}
378
+ className="w-full max-w-3xl mx-auto mt-6 space-y-4 text-left"
379
  >
380
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
381
  {/* MBTI */}
382
+ <div className="liquid-glass p-5 border-t-4 border-orange-500 bg-white/60 dark:bg-black/40 backdrop-blur-md rounded-xl flex flex-col h-full group hover:bg-white/80 dark:hover:bg-white/5 transition-all">
383
+ <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">
384
  <BrainCircuit size={12} /> {content.resMBTI}
385
+ </h3>
386
+ <div className="flex-1 flex flex-col items-center justify-center gap-2">
387
+ <div className="text-4xl font-black text-orange-600 tracking-tight">
388
+ {result.mbti_type}
389
  </div>
390
+ {/* Reasoning MBTI */}
391
+ {result.reasoning && (
392
+ <p className="text-xs text-center text-gray-600 dark:text-gray-300 leading-relaxed px-2 font-medium">
393
+ {result.reasoning.mbti?.[lang] ||
394
+ "Analisis kepribadian mendalam."}
395
+ </p>
396
+ )}
397
+ </div>
398
  </div>
399
+
400
  {/* EMOTION */}
401
+ <div className="liquid-glass p-5 border-t-4 border-green-500 bg-white/60 dark:bg-black/40 backdrop-blur-md rounded-xl flex flex-col h-full group hover:bg-white/80 dark:hover:bg-white/5 transition-all">
402
+ <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">
403
  <Smile size={12} /> {content.resSentiment}
404
+ </h3>
405
+ <div className="flex-1 flex flex-col items-center justify-center gap-2">
406
+ <div className="text-2xl font-bold capitalize text-green-600 dark:text-green-400 truncate px-2 text-center">
407
+ {result.emotion
408
+ ? result.emotion[lang] ||
409
+ result.emotion.id ||
410
+ result.emotion
411
+ : result.sentiment}
412
  </div>
413
+ {/* Reasoning Emotion */}
414
+ {result.reasoning && (
415
+ <p className="text-xs text-center text-gray-600 dark:text-gray-300 leading-relaxed px-2 font-medium">
416
+ {result.reasoning.emotion?.[lang] ||
417
+ "Analisis sentimen teks."}
418
+ </p>
419
+ )}
420
+ </div>
421
  </div>
422
+
423
  {/* KEYWORDS */}
424
+ <div className="liquid-glass p-5 border-t-4 border-blue-500 bg-white/60 dark:bg-black/40 backdrop-blur-md rounded-xl h-full flex flex-col group hover:bg-white/80 dark:hover:bg-white/5 transition-all">
425
+ <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">
426
  <Tag size={12} /> {content.resKeywords}
427
+ </h3>
428
+ <div className="flex-1 flex flex-col items-center justify-center gap-3">
429
  <div className="flex flex-wrap gap-2 justify-center items-center w-full">
430
+ {currentKeywords
431
+ .slice(0, 3)
432
+ .map((k: string, i: number) => (
433
+ <span
434
+ key={i}
435
+ className="bg-orange-100 dark:bg-orange-900/30 px-3 py-1.5 rounded-full text-xs font-bold text-orange-700 dark:text-orange-200 border border-orange-200 dark:border-orange-800/50 capitalize shadow-sm"
436
+ >
437
+ {k}
438
+ </span>
439
+ ))}
440
  </div>
441
+ {/* Reasoning Keywords */}
442
+ {result.reasoning && (
443
+ <p className="text-[10px] text-center text-gray-500 dark:text-gray-400 mt-1 italic">
444
+ {result.reasoning.keywords?.[lang] ||
445
+ "Kata kunci dominan."}
446
+ </p>
447
+ )}
448
+ </div>
449
  </div>
450
+ </div>
451
 
452
+ {/* PREVIEW CONTENT - YouTube Style */}
453
+ {(result.video || result.fetched_text) && (
454
  <div className="space-y-4">
455
+ {/* YouTube Video Card with Thumbnail */}
456
+ {result.video && (
 
457
  <div className="liquid-glass overflow-hidden rounded-2xl border border-gray-200 dark:border-white/10">
458
+ {/* Thumbnail */}
459
+ {result.video.thumbnail && (
460
  <div className="relative aspect-video bg-black">
461
+ <img
462
  src={result.video.thumbnail}
463
  alt={result.video.title}
464
  className="w-full h-full object-cover"
465
+ />
466
+ <div className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-2 py-1 rounded font-medium">
467
  YouTube
468
+ </div>
469
  </div>
470
+ )}
471
 
472
+ {/* Video Info */}
473
+ <div className="p-5 bg-white/60 dark:bg-black/40">
474
  <h4 className="text-lg font-bold text-gray-900 dark:text-white leading-tight mb-2">
475
+ {result.video.title}
476
  </h4>
477
 
478
  <div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 mb-4">
479
+ <span className="font-medium text-gray-700 dark:text-gray-300">
480
+ {result.video.channel}
481
+ </span>
482
+ <span>•</span>
483
+ <span>
484
+ {Number(result.video.viewCount).toLocaleString()}{" "}
485
+ views
486
+ </span>
487
+ <span>•</span>
488
+ <span className="flex items-center gap-1">
489
+ <ThumbsUp size={14} />{" "}
490
+ {Number(result.video.likeCount).toLocaleString()}
491
+ </span>
492
  </div>
493
 
494
  {/* Description */}
495
  <div className="bg-gray-100 dark:bg-white/5 p-4 rounded-xl">
496
+ <div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
497
+ {showFullDesc
498
+ ? result.video.description
499
+ : result.video.description?.slice(0, 250)}
500
+ {result.video.description?.length > 250 &&
501
+ !showFullDesc &&
502
+ "..."}
503
+ </div>
504
+ {result.video.description?.length > 250 && (
505
  <button
506
+ onClick={() => setShowFullDesc(!showFullDesc)}
507
+ className="mt-2 text-sm font-bold text-blue-600 hover:text-blue-700"
508
  >
509
+ {showFullDesc ? "Show less" : "Show more"}
510
  </button>
511
+ )}
 
512
  </div>
513
+ </div>
514
  </div>
515
+ )}
516
 
517
+ {/* Comments Section - YouTube Style */}
518
+ {result.comments && result.comments.length > 0 && (
519
  <div className="liquid-glass p-5 bg-white/60 dark:bg-black/40 rounded-2xl border border-gray-200 dark:border-white/10">
520
+ <h3 className="text-sm font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
521
  <MessageSquare size={16} />
522
  {result.comments.length} Comments
523
+ </h3>
524
+
525
+ <div className="space-y-4">
526
+ {(showAllComments
527
+ ? result.comments
528
+ : result.comments.slice(0, 5)
529
+ ).map((comment: any, idx: number) => (
530
+ <div key={idx} className="flex gap-3">
531
  {/* Avatar */}
532
  {comment.authorImage ? (
533
+ <img
534
  src={comment.authorImage}
535
  alt={comment.author}
536
  className="w-10 h-10 rounded-full shrink-0 object-cover"
537
+ />
538
  ) : (
539
+ <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">
540
+ {comment.author?.charAt(0).toUpperCase() || "A"}
541
+ </div>
542
  )}
543
 
544
  {/* Comment Content */}
545
  <div className="flex-1 min-w-0">
546
+ <div className="flex items-center gap-2 mb-1">
547
  <span className="text-sm font-medium text-gray-900 dark:text-white truncate">
548
+ {comment.author}
549
  </span>
550
  <span className="text-xs text-gray-400">
551
+ {comment.publishedAt &&
552
+ new Date(
553
+ comment.publishedAt
554
+ ).toLocaleDateString()}
555
  </span>
556
+ </div>
557
+ <p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
558
  {comment.text}
559
+ </p>
560
+ <div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
561
  <span className="flex items-center gap-1 hover:text-gray-700 cursor-pointer">
562
+ <ThumbsUp size={14} />{" "}
563
+ {comment.likeCount || 0}
564
  </span>
565
  <span className="flex items-center gap-1 hover:text-gray-700 cursor-pointer">
566
+ <ThumbsDown size={14} />
567
  </span>
568
  {comment.replyCount > 0 && (
569
+ <span className="text-blue-600 font-medium">
570
  {comment.replyCount} replies
571
+ </span>
572
  )}
573
+ </div>
 
574
  </div>
575
+ </div>
576
  ))}
577
+ </div>
578
 
579
+ {result.comments.length > 5 && (
580
  <button
581
+ onClick={() => setShowAllComments(!showAllComments)}
582
+ 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"
583
  >
584
+ {showAllComments
585
+ ? "▲ Show Less"
586
+ : `▼ View all ${result.comments.length} comments`}
587
  </button>
588
+ )}
589
  </div>
590
+ )}
591
 
592
+ {/* Fallback for transcript-only data */}
593
+ {!result.video && result.fetched_text && (
594
  <div className="liquid-glass p-5 bg-white/60 dark:bg-black/40 rounded-2xl border border-gray-200 dark:border-white/10">
595
+ <h3 className="text-sm font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
596
  <FileText size={16} /> Transcript
597
+ </h3>
598
+ <p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
599
  {result.fetched_text}
600
+ </p>
601
  </div>
602
+ )}
603
  </div>
604
+ )}
605
  </motion.div>
606
+ )}
607
  </AnimatePresence>
608
 
609
  {/* GUIDES */}
610
  {!result && (
611
+ <motion.div
612
  initial={{ opacity: 0 }}
613
  animate={{ opacity: 1 }}
614
  transition={{ delay: 0.3 }}
 
620
 
621
  <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-left">
622
  {content.guides.map((item, idx) => (
623
+ <div
624
+ key={idx}
625
+ 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"
626
+ >
627
  <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">
628
  <item.icon className="w-5 h-5" />
629
  </div>
 
640
  </div>
641
  </motion.div>
642
  )}
 
643
  </motion.div>
644
  </div>
645
  );
646
+ }
src/app/page.tsx CHANGED
@@ -1,7 +1,7 @@
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";
@@ -18,10 +18,19 @@ export default function Home() {
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",
@@ -31,11 +40,20 @@ export default function Home() {
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];
@@ -43,89 +61,115 @@ export default function Home() {
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
- }
 
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";
 
18
  btnStart: "Start Analysis",
19
  btnLibrary: "Explore Types",
20
  features: [
21
+ {
22
+ title: "MBTI Prediction",
23
+ desc: "Predicts one of 16 personality types based on your writing style.",
24
+ },
25
+ {
26
+ title: "Sentiment Analysis",
27
+ desc: "Detects the dominant emotional tone and mood in your text.",
28
+ },
29
+ {
30
+ title: "Keyword Extraction",
31
+ desc: "Highlights key topics and patterns from your daily conversations.",
32
+ },
33
+ ],
34
  },
35
  id: {
36
  badge: "Profil Kepribadian AI",
 
40
  btnStart: "Mulai Analisis",
41
  btnLibrary: "Kamus MBTI",
42
  features: [
43
+ {
44
+ title: "Prediksi MBTI",
45
+ desc: "Tebak satu dari 16 tipe kepribadian based on gaya tulisan lo.",
46
+ },
47
+ {
48
+ title: "Analisis Sentimen",
49
+ desc: "Cek vibes tulisan lo, apakah lagi positif banget atau malah gloomy.",
50
+ },
51
+ {
52
+ title: "Ekstraksi Kata Kunci",
53
+ desc: "Highlight topik-topik yang sering lo bahas tanpa sadar.",
54
+ },
55
+ ],
56
+ },
57
  };
58
 
59
  const content = t[lang];
 
61
 
62
  const containerVariants: Variants = {
63
  hidden: { opacity: 0 },
64
+ visible: {
65
  opacity: 1,
66
+ transition: {
67
  staggerChildren: 0.15,
68
+ delayChildren: 0.1,
69
+ },
70
+ },
71
  };
72
 
73
  const itemVariants: Variants = {
74
  hidden: { y: 20, opacity: 0 },
75
+ visible: {
76
+ y: 0,
77
  opacity: 1,
78
+ transition: { type: "spring", stiffness: 100 },
79
+ },
80
  };
81
 
82
  return (
83
+ <motion.div
84
  initial="hidden"
85
  animate="visible"
86
  variants={containerVariants}
87
  className="flex flex-col items-center justify-start pt-28 md:pt-32 font-sans gap-8 w-full min-h-screen"
88
  >
 
89
  <div className="flex flex-col items-center justify-center text-center gap-4 relative w-full px-4 max-w-4xl mx-auto">
 
90
  {/* Badge */}
91
+ <motion.div
92
+ variants={itemVariants}
93
+ 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"
94
+ >
95
  <Sparkles className="w-3 h-3" />
96
  <span>{content.badge}</span>
97
  </motion.div>
98
 
99
  {/* Title */}
100
+ <motion.h1
101
+ variants={itemVariants}
102
+ className="text-5xl md:text-7xl font-black tracking-tighter text-gray-900 dark:text-white leading-[1.1] pb-2"
103
+ >
104
+ {content.titleLine1}{" "}
105
+ <span className="text-transparent bg-clip-text bg-gradient-to-br from-orange-500 to-amber-600">
106
+ {content.titleLine2}
107
+ </span>
108
  </motion.h1>
109
 
110
+ <motion.p
111
+ variants={itemVariants}
112
+ className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl leading-relaxed"
113
+ >
114
  {content.desc}
115
  </motion.p>
116
 
117
  {/* Action Buttons */}
118
+ <motion.div
119
+ variants={itemVariants}
120
+ className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-6 w-full"
121
+ >
122
+ <Button
123
+ asChild
124
+ size="lg"
125
+ 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"
126
+ >
127
+ <Link href="/analyzer">
128
+ <Search className="w-4 h-4 mr-2" />
129
+ {content.btnStart}
130
+ </Link>
 
131
  </Button>
132
 
133
+ <Button
134
+ asChild
135
+ variant="outline"
136
+ size="lg"
137
+ 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"
138
+ >
139
+ <Link href="/types">
140
+ <BookOpen className="w-4 h-4 mr-2" />
141
+ {content.btnLibrary}
142
+ </Link>
143
+ </Button>
144
  </motion.div>
145
 
146
  {/* Features Grid */}
147
+ <motion.div
148
+ variants={itemVariants}
149
+ className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-8 pb-12 w-full text-left"
150
+ >
151
  {content.features.map((item, i) => {
152
  const Icon = icons[i];
153
  return (
154
+ <motion.div
155
+ key={i}
156
  whileHover={{ y: -5 }}
157
  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"
158
  >
159
  <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">
160
  <Icon className="w-5 h-5" />
161
  </div>
162
+ <h3 className="text-sm font-bold mb-2 text-gray-900 dark:text-white tracking-tight">
163
+ {item.title}
164
+ </h3>
165
+ <p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
166
+ {item.desc}
167
+ </p>
168
  </motion.div>
169
  );
170
  })}
171
  </motion.div>
 
172
  </div>
173
  </motion.div>
174
  );
175
+ }
src/app/quiz/page.tsx CHANGED
@@ -4,7 +4,14 @@ 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 = {
@@ -13,10 +20,12 @@ type Question = {
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);
@@ -33,11 +42,15 @@ export default function QuizPage() {
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...",
@@ -50,12 +63,16 @@ export default function QuizPage() {
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];
@@ -70,25 +87,44 @@ export default function QuizPage() {
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);
@@ -99,31 +135,34 @@ export default function QuizPage() {
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" }}
@@ -144,7 +183,7 @@ export default function QuizPage() {
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
@@ -154,7 +193,6 @@ export default function QuizPage() {
154
  {content.retry}
155
  </button>
156
  </div>
157
-
158
  </motion.div>
159
  </div>
160
  );
@@ -162,17 +200,14 @@ export default function QuizPage() {
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}
@@ -182,98 +217,166 @@ export default function QuizPage() {
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
- }
 
4
  import { useLanguage } from "@/app/providers";
5
  import Link from "next/link";
6
  import { mbtiDatabase } from "@/data/mbti";
7
+ import {
8
+ CheckCircle2,
9
+ Clock,
10
+ ShieldCheck,
11
+ ChevronLeft,
12
+ ChevronRight,
13
+ Send,
14
+ } from "lucide-react";
15
  import { motion, AnimatePresence } from "framer-motion";
16
 
17
  type Question = {
 
20
  text_en: string;
21
  };
22
 
23
+ const QUESTIONS_PER_PAGE = 5;
24
+
25
  export default function QuizPage() {
26
  const { lang } = useLanguage();
27
  const [questions, setQuestions] = useState<Question[]>([]);
28
+ const [currentPage, setCurrentPage] = useState(0);
29
  const [answers, setAnswers] = useState<Record<string, number>>({});
30
  const [result, setResult] = useState<string | null>(null);
31
  const [loading, setLoading] = useState(true);
 
42
  retry: "Retake Quiz",
43
  submitError: "Failed to calculate result.",
44
  infoTitle: "Things to know",
45
+ prev: "Previous",
46
+ next: "Next",
47
+ submit: "Submit Quiz",
48
+ progress: "Page",
49
  infos: [
50
+ { icon: Clock, text: "Takes about 5 minutes." },
51
+ { icon: CheckCircle2, text: "Answer instinctively." },
52
+ { icon: ShieldCheck, text: "No right or wrong answers." },
53
+ ],
54
  },
55
  id: {
56
  loading: "Lagi nyiapin soal...",
 
63
  retry: "Ulangi Tes",
64
  submitError: "Gagal ngitung hasil nih.",
65
  infoTitle: "Info Penting",
66
+ prev: "Sebelumnya",
67
+ next: "Lanjut",
68
+ submit: "Lihat Hasil",
69
+ progress: "Halaman",
70
  infos: [
71
+ { icon: Clock, text: "Sekitar 5 menitan." },
72
+ { icon: CheckCircle2, text: "Jawab spontan aja." },
73
+ { icon: ShieldCheck, text: "Gak ada jawaban salah." },
74
+ ],
75
+ },
76
  };
77
 
78
  const content = t[lang];
 
87
  });
88
  }, []);
89
 
90
+ const totalPages = Math.ceil(questions.length / QUESTIONS_PER_PAGE);
91
+ const currentQuestions = questions.slice(
92
+ currentPage * QUESTIONS_PER_PAGE,
93
+ (currentPage + 1) * QUESTIONS_PER_PAGE
94
+ );
95
+
96
+ const handleAnswer = (qId: number, val: number) => {
97
+ setAnswers((prev) => ({ ...prev, [qId]: val }));
98
+ };
99
+
100
+ const isPageComplete = currentQuestions.every(
101
+ (q) => answers[q.id] !== undefined
102
+ );
103
 
104
+ const handleNext = () => {
105
+ if (currentPage < totalPages - 1) {
106
+ setCurrentPage(currentPage + 1);
107
+ window.scrollTo({ top: 0, behavior: "smooth" });
108
  } else {
109
+ submitAnswers();
110
  }
111
  };
112
 
113
+ const handlePrev = () => {
114
+ if (currentPage > 0) {
115
+ setCurrentPage(currentPage - 1);
116
+ window.scrollTo({ top: 0, behavior: "smooth" });
117
+ }
118
+ };
119
+
120
+ const submitAnswers = async () => {
121
  setLoading(true);
122
  try {
123
  const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
124
  const res = await fetch(`${apiUrl}/api/quiz`, {
125
  method: "POST",
126
  headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ answers: answers }),
128
  });
129
  const data = await res.json();
130
  setResult(data.mbti);
 
135
  }
136
  };
137
 
138
+ if (loading && !result)
139
+ return (
140
+ <div className="pt-40 flex items-center justify-center font-bold text-orange-600 animate-pulse">
141
+ {content.loading}
142
+ </div>
143
+ );
144
 
145
  if (result) {
146
  const data = mbtiDatabase[result];
147
+ const contentData = lang === "en" ? data?.en : data?.id;
148
 
149
  return (
150
  <div className="w-full pt-28 pb-12 flex flex-col justify-center items-center font-sans relative px-4">
151
+ <motion.div
152
  initial={{ scale: 0.9, opacity: 0 }}
153
  animate={{ scale: 1, opacity: 1 }}
 
154
  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"
155
  >
 
156
  <h2 className="text-sm font-bold opacity-60 uppercase tracking-widest text-gray-800 dark:text-gray-200 mb-4">
157
  {content.result}
158
  </h2>
159
 
160
+ <div
161
+ className={`p-6 rounded-2xl border-2 bg-white/50 dark:bg-black/40 backdrop-blur-md mb-8 ${
162
+ data?.color || "border-gray-500"
163
+ }`}
164
+ >
165
+ <motion.div
166
  initial={{ scale: 0 }}
167
  animate={{ scale: 1 }}
168
  transition={{ delay: 0.3, type: "spring" }}
 
183
  href={`/types/${result}`}
184
  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"
185
  >
186
+ {lang === "en" ? "Read Full Profile" : "Baca Profil Lengkap"}
187
  </Link>
188
 
189
  <button
 
193
  {content.retry}
194
  </button>
195
  </div>
 
196
  </motion.div>
197
  </div>
198
  );
 
200
 
201
  if (questions.length === 0) return null;
202
 
 
 
203
  return (
204
  <div className="w-full pt-28 pb-12 px-4 sm:px-6 lg:px-8 font-sans flex flex-col items-center">
205
  <div className="max-w-3xl w-full z-10">
 
206
  {/* HEADER */}
207
+ <motion.div
208
  initial={{ y: -20, opacity: 0 }}
209
  animate={{ y: 0, opacity: 1 }}
210
+ className="text-center mb-8"
211
  >
212
  <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">
213
  {content.title}
 
217
  </p>
218
  </motion.div>
219
 
220
+ {/* PROGRESS BAR */}
221
+ <div className="w-full mb-8">
222
+ <div className="flex justify-between text-xs font-bold text-gray-500 mb-2 uppercase tracking-wide">
223
+ <span>
224
+ {content.progress} {currentPage + 1} / {totalPages}
225
+ </span>
226
+ <span>{Math.round((currentPage / totalPages) * 100)}%</span>
227
+ </div>
228
+ <div className="w-full h-2 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
229
+ <motion.div
230
+ initial={{ width: 0 }}
231
+ animate={{ width: `${((currentPage + 1) / totalPages) * 100}%` }}
232
+ className="h-full bg-orange-500 rounded-full"
233
+ />
234
+ </div>
235
+ </div>
 
 
 
 
 
 
236
 
237
+ {/* QUESTIONS LIST (PAGINATED) */}
238
+ <div className="space-y-8">
239
+ {currentQuestions.map((q, idx) => (
240
+ <motion.div
241
+ key={q.id}
242
+ initial={{ opacity: 0, y: 20 }}
243
+ animate={{ opacity: 1, y: 0 }}
244
+ transition={{ delay: idx * 0.1 }}
245
+ className="liquid-glass p-6 md:p-8 bg-white/50 dark:bg-black/30 backdrop-blur-md shadow-lg border border-white/20 rounded-3xl"
246
+ >
247
+ <div className="mb-6">
248
+ <span className="text-xs font-bold uppercase tracking-widest opacity-50 text-gray-700 dark:text-gray-300 block mb-2">
249
+ {content.questionLabel}{" "}
250
+ {currentPage * QUESTIONS_PER_PAGE + idx + 1}
251
+ </span>
252
+ <h3 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white leading-snug">
253
+ {lang === "en" ? q.text_en : q.text_id}
254
+ </h3>
255
  </div>
256
 
257
+ {/* SCALE INPUT */}
258
+ <div className="relative pt-6">
259
+ <div className="hidden md:flex justify-between absolute top-0 w-full text-[10px] font-bold opacity-60 px-2 uppercase tracking-wider">
260
+ <span className="text-red-500">{content.disagree}</span>
261
+ <span className="text-green-500">{content.agree}</span>
262
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
+ <div className="flex justify-between items-center gap-2">
265
+ {[-3, -2, -1, 0, 1, 2, 3].map((val) => (
266
+ <button
267
+ key={val}
268
+ onClick={() => handleAnswer(q.id, val)}
269
+ className={`
270
+ group relative rounded-full transition-all duration-200 flex items-center justify-center
271
+ ${
272
+ val === 0
273
+ ? "w-8 h-8 md:w-10 md:h-10 border-2 border-gray-300 text-gray-400"
274
+ : "w-10 h-10 md:w-14 md:h-14 border-2"
275
+ }
276
+ ${
277
+ answers[q.id] === val
278
+ ? "scale-110 ring-4 ring-orange-200 dark:ring-orange-900"
279
+ : "hover:scale-105"
280
+ }
281
+ ${
282
+ val < 0
283
+ ? `border-red-400 text-red-500 ${
284
+ answers[q.id] === val
285
+ ? "bg-red-500 text-white"
286
+ : "hover:bg-red-50"
287
+ }`
288
+ : val > 0
289
+ ? `border-green-400 text-green-500 ${
290
+ answers[q.id] === val
291
+ ? "bg-green-500 text-white"
292
+ : "hover:bg-green-50"
293
+ }`
294
+ : `${
295
+ answers[q.id] === val
296
+ ? "bg-gray-400 text-white border-gray-400"
297
+ : ""
298
+ }`
299
+ }
300
+ `}
301
+ >
302
+ <span
303
+ className={`
304
+ rounded-full transition-all
305
+ ${
306
+ Math.abs(val) === 3
307
+ ? "w-3 h-3 md:w-4 md:h-4"
308
+ : ""
309
+ }
310
+ ${
311
+ Math.abs(val) === 2
312
+ ? "w-2.5 h-2.5 md:w-3 md:h-3"
313
+ : ""
314
+ }
315
+ ${
316
+ Math.abs(val) === 1
317
+ ? "w-2 h-2 md:w-2 md:h-2"
318
+ : ""
319
+ }
320
+ ${
321
+ val === 0
322
+ ? "w-1.5 h-1.5"
323
+ : "bg-current"
324
+ }
325
+ ${
326
+ answers[q.id] === val
327
+ ? "bg-white"
328
+ : val === 0
329
+ ? "bg-gray-400"
330
+ : "bg-current"
331
+ }
332
+ `}
333
+ ></span>
334
+ </button>
335
+ ))}
336
+ </div>
337
+ <div className="flex justify-between mt-3 text-[10px] font-bold opacity-60 uppercase md:hidden tracking-wider">
338
+ <span className="text-red-500">{content.disagree}</span>
339
+ <span className="text-green-500">{content.agree}</span>
340
+ </div>
341
  </div>
342
+ </motion.div>
343
+ ))}
344
+ </div>
345
 
346
+ {/* NAVIGATION BUTTONS */}
347
+ <div className="flex justify-between items-center mt-12 gap-4 pb-20">
348
+ <button
349
+ onClick={handlePrev}
350
+ disabled={currentPage === 0}
351
+ className="flex items-center gap-2 px-6 py-3 rounded-xl font-bold text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/5 transition-all"
 
352
  >
353
+ <ChevronLeft size={20} /> {content.prev}
354
+ </button>
 
 
 
 
 
 
355
 
356
+ <button
357
+ onClick={handleNext}
358
+ disabled={!isPageComplete}
359
+ className={`
360
+ flex items-center gap-2 px-8 py-3 rounded-xl font-bold text-white transition-all shadow-lg
361
+ ${
362
+ isPageComplete
363
+ ? "bg-orange-600 hover:bg-orange-700 hover:shadow-orange-500/30 transform hover:-translate-y-1"
364
+ : "bg-gray-400 cursor-not-allowed opacity-50"
365
+ }
366
+ `}
367
+ >
368
+ {currentPage === totalPages - 1 ? (
369
+ <>
370
+ {content.submit} <Send size={18} />
371
+ </>
372
+ ) : (
373
+ <>
374
+ {content.next} <ChevronRight size={20} />
375
+ </>
376
+ )}
377
+ </button>
378
+ </div>
379
  </div>
380
  </div>
381
  );
382
+ }
src/components/Navbar.tsx CHANGED
@@ -24,14 +24,15 @@ export default function Navbar() {
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 (
@@ -42,20 +43,21 @@ export default function Navbar() {
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
  >
@@ -77,11 +79,13 @@ export default function Navbar() {
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>
@@ -103,7 +107,13 @@ export default function Navbar() {
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 */}
@@ -113,7 +123,11 @@ export default function Navbar() {
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>
@@ -124,8 +138,16 @@ export default function Navbar() {
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>
@@ -135,4 +157,4 @@ export default function Navbar() {
135
  )}
136
  </>
137
  );
138
- }
 
24
  return () => window.removeEventListener("scroll", handleScroll);
25
  }, []);
26
 
27
+ const getLinkVariant = (path: string) =>
28
+ pathname === path ? "secondary" : "ghost";
29
 
30
  const navLinks = [
31
+ { href: "/", label: lang === "en" ? "Home" : "Beranda" },
32
+ { href: "/analyzer", label: lang === "en" ? "Analyzer" : "Analisis" },
33
+ { href: "/quiz", label: lang === "en" ? "MBTI Quiz" : "Kuis MBTI" },
34
+ { href: "/types", label: lang === "en" ? "Types" : "Tipe" },
35
+ { href: "/chat", label: lang === "en" ? "Chat" : "Chat" },
36
  ];
37
 
38
  return (
 
43
  flex justify-between items-center px-4 py-3
44
  /* GANTI BAGIAN TRANSISI DI SINI: */
45
  transition-all duration-700 ease-[cubic-bezier(0.25,0.1,0.25,1.0)] will-change-[width,top,background]
46
+ ${
47
+ isScrolled
48
+ ? /* SCROLLED STATE:
49
  - top-4: Turun dikit
50
  - w-[92%]: Lebar di layar kecil
51
+ - md:w-[48rem]: KUNCI ANIMASI! Pake w-[48rem] (sama dengan max-w-3xl) biar transisi width-nya mulus.
52
+ Jangan pake max-w-3xl karena bakal crash sama transisi width.
53
+ - rounded-[12px]: Jadi kotak tumpul
54
  */
55
+ "top-4 w-[92%] md:w-[48rem] rounded-[12px] bg-white/40 dark:bg-black/40 backdrop-blur-xl border border-gray-200 dark:border-white/10 shadow-sm"
56
+ : /* DEFAULT STATE:
57
  - top-0: Nempel atas
58
  - w-full: Lebar penuh
 
59
  */
60
+ "top-0 w-full bg-transparent border-b border-transparent"
61
  }
62
  `}
63
  >
 
79
  asChild
80
  variant={getLinkVariant(link.href)}
81
  size="sm"
82
+ className={`cursor-pointer text-sm font-medium ${
83
+ pathname === link.href
84
+ ? "text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950/30"
85
+ : "text-gray-600 dark:text-gray-400"
86
+ }`}
87
  >
88
+ <Link href={link.href}>{link.label}</Link>
 
 
89
  </Button>
90
  ))}
91
  </div>
 
107
  size="icon"
108
  className="w-9 h-9 rounded-full text-gray-500"
109
  >
110
+ {!mounted ? (
111
+ <div className="w-4 h-4" />
112
+ ) : theme === "dark" ? (
113
+ <Sun className="w-4 h-4" />
114
+ ) : (
115
+ <Moon className="w-4 h-4" />
116
+ )}
117
  </Button>
118
 
119
  {/* Mobile Menu Button */}
 
123
  size="icon"
124
  onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
125
  >
126
+ {isMobileMenuOpen ? (
127
+ <X className="w-5 h-5" />
128
+ ) : (
129
+ <Menu className="w-5 h-5" />
130
+ )}
131
  </Button>
132
  </div>
133
  </div>
 
138
  <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">
139
  <div className="flex flex-col gap-2">
140
  {navLinks.map((link) => (
141
+ <Link
142
+ key={link.href}
143
+ href={link.href}
144
+ onClick={() => setIsMobileMenuOpen(false)}
145
+ >
146
+ <Button
147
+ variant="ghost"
148
+ size="lg"
149
+ className="w-full justify-start text-lg font-medium"
150
+ >
151
  {link.label}
152
  </Button>
153
  </Link>
 
157
  )}
158
  </>
159
  );
160
+ }