Sync from GitHub Actions: 75654b613491a09d0e326d3108e71fc8c46837a1
Browse files- README.md +1 -1
- api/core/nlp_handler.py +88 -10
- api/data/questions.json +40 -56
- api/index.py +21 -0
- api/predict.py +2 -1
- src/app/analyzer/page.tsx +274 -153
- src/app/page.tsx +91 -47
- src/app/quiz/page.tsx +224 -121
- src/components/Navbar.tsx +45 -23
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: Sentimind
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
},
|
| 9 |
-
{
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
},
|
| 16 |
-
{
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
},
|
| 23 |
-
{
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
},
|
| 37 |
-
{
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 50 |
-
|
| 51 |
-
|
| 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 |
-
{
|
| 81 |
-
|
| 82 |
-
|
| 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 =
|
|
|
|
| 92 |
const match = url.match(regExp);
|
| 93 |
-
return
|
| 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}`, {
|
|
|
|
|
|
|
| 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 = (
|
|
|
|
|
|
|
| 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 ===
|
| 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={() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
className={`flex items-center justify-center gap-2 py-2.5 rounded-full font-bold text-sm transition-all border border-transparent
|
| 220 |
-
${
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
| 223 |
>
|
| 224 |
<FileText size={16} /> {content.modeText}
|
| 225 |
</button>
|
| 226 |
<button
|
| 227 |
-
onClick={() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
className={`flex items-center justify-center gap-2 py-2.5 rounded-full font-bold text-sm transition-all border border-transparent
|
| 229 |
-
${
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
| 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) => {
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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) => {
|
|
|
|
|
|
|
|
|
|
| 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" />
|
|
|
|
| 268 |
</p>
|
| 269 |
</div>
|
| 270 |
)}
|
|
@@ -272,17 +330,19 @@ export default function AnalysisPage() {
|
|
| 272 |
|
| 273 |
{/* ERROR MSG */}
|
| 274 |
<AnimatePresence>
|
| 275 |
-
|
| 276 |
-
<motion.div
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
>
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
| 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 |
-
${
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
| 296 |
>
|
| 297 |
-
{loading
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 307 |
-
<motion.div
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
>
|
| 314 |
-
|
| 315 |
{/* MBTI */}
|
| 316 |
-
<div className="liquid-glass p-
|
| 317 |
-
|
| 318 |
<BrainCircuit size={12} /> {content.resMBTI}
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
<div className="text-
|
|
|
|
| 322 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
</div>
|
|
|
|
| 324 |
{/* EMOTION */}
|
| 325 |
-
<div className="liquid-glass p-
|
| 326 |
-
|
| 327 |
<Smile size={12} /> {content.resSentiment}
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
<div className="text-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
| 333 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
</div>
|
|
|
|
| 335 |
{/* KEYWORDS */}
|
| 336 |
-
<div className="liquid-glass p-
|
| 337 |
-
|
| 338 |
<Tag size={12} /> {content.resKeywords}
|
| 339 |
-
|
|
|
|
| 340 |
<div className="flex flex-wrap gap-2 justify-center items-center w-full">
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
| 348 |
-
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
| 352 |
<div className="space-y-4">
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
{result.video && (
|
| 356 |
<div className="liquid-glass overflow-hidden rounded-2xl border border-gray-200 dark:border-white/10">
|
| 357 |
-
|
| 358 |
-
|
| 359 |
<div className="relative aspect-video bg-black">
|
| 360 |
-
|
| 361 |
src={result.video.thumbnail}
|
| 362 |
alt={result.video.title}
|
| 363 |
className="w-full h-full object-cover"
|
| 364 |
-
|
| 365 |
-
|
| 366 |
YouTube
|
| 367 |
-
|
| 368 |
</div>
|
| 369 |
-
|
| 370 |
|
| 371 |
-
|
| 372 |
-
|
| 373 |
<h4 className="text-lg font-bold text-gray-900 dark:text-white leading-tight mb-2">
|
| 374 |
-
|
| 375 |
</h4>
|
| 376 |
|
| 377 |
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
</div>
|
| 386 |
|
| 387 |
{/* Description */}
|
| 388 |
<div className="bg-gray-100 dark:bg-white/5 p-4 rounded-xl">
|
| 389 |
-
|
| 390 |
-
{showFullDesc
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
{result.video.description?.length > 250 &&
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
<button
|
| 395 |
-
|
| 396 |
-
|
| 397 |
>
|
| 398 |
-
|
| 399 |
</button>
|
| 400 |
-
|
| 401 |
-
</div>
|
| 402 |
</div>
|
|
|
|
| 403 |
</div>
|
| 404 |
-
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 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 |
-
|
| 410 |
<MessageSquare size={16} />
|
| 411 |
{result.comments.length} Comments
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
{(showAllComments
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
| 417 |
{/* Avatar */}
|
| 418 |
{comment.authorImage ? (
|
| 419 |
-
|
| 420 |
src={comment.authorImage}
|
| 421 |
alt={comment.author}
|
| 422 |
className="w-10 h-10 rounded-full shrink-0 object-cover"
|
| 423 |
-
|
| 424 |
) : (
|
| 425 |
-
|
| 426 |
-
{comment.author?.charAt(0).toUpperCase() ||
|
| 427 |
-
|
| 428 |
)}
|
| 429 |
|
| 430 |
{/* Comment Content */}
|
| 431 |
<div className="flex-1 min-w-0">
|
| 432 |
-
|
| 433 |
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
| 434 |
-
|
| 435 |
</span>
|
| 436 |
<span className="text-xs text-gray-400">
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
| 438 |
</span>
|
| 439 |
-
|
| 440 |
-
|
| 441 |
{comment.text}
|
| 442 |
-
|
| 443 |
-
|
| 444 |
<span className="flex items-center gap-1 hover:text-gray-700 cursor-pointer">
|
| 445 |
-
|
|
|
|
| 446 |
</span>
|
| 447 |
<span className="flex items-center gap-1 hover:text-gray-700 cursor-pointer">
|
| 448 |
-
|
| 449 |
</span>
|
| 450 |
{comment.replyCount > 0 && (
|
| 451 |
-
|
| 452 |
{comment.replyCount} replies
|
| 453 |
-
|
| 454 |
)}
|
| 455 |
-
|
| 456 |
-
</div>
|
| 457 |
</div>
|
|
|
|
| 458 |
))}
|
| 459 |
-
|
| 460 |
|
| 461 |
-
|
| 462 |
<button
|
| 463 |
-
|
| 464 |
-
|
| 465 |
>
|
| 466 |
-
|
|
|
|
|
|
|
| 467 |
</button>
|
| 468 |
-
|
| 469 |
</div>
|
| 470 |
-
|
| 471 |
|
| 472 |
-
|
| 473 |
-
|
| 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 |
-
|
| 476 |
<FileText size={16} /> Transcript
|
| 477 |
-
|
| 478 |
-
|
| 479 |
{result.fetched_text}
|
| 480 |
-
|
| 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
|
| 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 |
-
{
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
{
|
| 35 |
-
|
| 36 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 76 |
<Sparkles className="w-3 h-3" />
|
| 77 |
<span>{content.badge}</span>
|
| 78 |
</motion.div>
|
| 79 |
|
| 80 |
{/* Title */}
|
| 81 |
-
<motion.h1
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</motion.h1>
|
| 84 |
|
| 85 |
-
<motion.p
|
|
|
|
|
|
|
|
|
|
| 86 |
{content.desc}
|
| 87 |
</motion.p>
|
| 88 |
|
| 89 |
{/* Action Buttons */}
|
| 90 |
-
<motion.div
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
</Link>
|
| 104 |
</Button>
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</motion.div>
|
| 107 |
|
| 108 |
{/* Features Grid */}
|
| 109 |
-
<motion.div
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
| 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
|
| 38 |
-
{ icon: CheckCircle2, text: "Answer instinctively
|
| 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: "
|
| 55 |
-
{ icon: CheckCircle2, text: "Jawab spontan aja
|
| 56 |
-
{ icon: ShieldCheck, text: "Gak ada jawaban
|
| 57 |
-
]
|
| 58 |
-
}
|
| 59 |
};
|
| 60 |
|
| 61 |
const content = t[lang];
|
|
@@ -70,25 +87,44 @@ export default function QuizPage() {
|
|
| 70 |
});
|
| 71 |
}, []);
|
| 72 |
|
| 73 |
-
const
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
} else {
|
| 80 |
-
submitAnswers(
|
| 81 |
}
|
| 82 |
};
|
| 83 |
|
| 84 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
| 107 |
|
| 108 |
if (result) {
|
| 109 |
const data = mbtiDatabase[result];
|
| 110 |
-
const contentData = lang ===
|
| 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
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ===
|
| 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-
|
| 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 |
-
{/*
|
| 186 |
-
<
|
| 187 |
-
<
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 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 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
</div>
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
</div>
|
| 255 |
-
</div>
|
| 256 |
-
|
| 257 |
-
</
|
| 258 |
|
| 259 |
-
{/*
|
| 260 |
-
|
| 261 |
-
<
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 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 |
-
|
| 268 |
-
|
| 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) =>
|
|
|
|
| 28 |
|
| 29 |
const navLinks = [
|
| 30 |
-
{ href: "/", label: lang ===
|
| 31 |
-
{ href: "/analyzer", label: lang ===
|
| 32 |
-
{ href: "/quiz", label: lang ===
|
| 33 |
-
{ href: "/types", label: lang ===
|
| 34 |
-
{ href: "/chat", label: lang ===
|
| 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 |
-
${
|
| 46 |
-
|
|
|
|
| 47 |
- top-4: Turun dikit
|
| 48 |
- w-[92%]: Lebar di layar kecil
|
| 49 |
-
- md:w-[
|
| 50 |
-
|
|
|
|
| 51 |
*/
|
| 52 |
-
|
| 53 |
-
|
| 54 |
- top-0: Nempel atas
|
| 55 |
- w-full: Lebar penuh
|
| 56 |
-
- rounded-none: Kotak
|
| 57 |
*/
|
| 58 |
-
|
| 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 ${
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|