rogasper commited on
Commit
4c7b844
·
1 Parent(s): bede2fc

feat: enhance user experience with new components and improved error handling. Introduce ErrorFallback component for better error display, add pagination for admin content management, and implement dialogs for test session management. Update routing to include new components and improve accessibility with ARIA attributes. Refactor question formats and integrate debounced search functionality for admin features.

Browse files
apps/web/index.html CHANGED
@@ -3,6 +3,14 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
 
 
 
 
 
 
 
6
  <title>labas</title>
7
  </head>
8
 
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="Labas — AI-powered multi-language test practice platform. Practice JLPT, TOPIK, TOAFL, and more with AI-generated questions." />
7
+ <meta name="theme-color" content="#0c0c0c" />
8
+ <meta property="og:title" content="Labas — AI Exam Practice" />
9
+ <meta property="og:description" content="AI-powered multi-language test practice platform" />
10
+ <meta property="og:type" content="website" />
11
+ <meta name="twitter:card" content="summary_large_image" />
12
+ <meta name="twitter:title" content="Labas — AI Exam Practice" />
13
+ <meta name="twitter:description" content="AI-powered multi-language test practice platform" />
14
  <title>labas</title>
15
  </head>
16
 
apps/web/src/components/ErrorFallback.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+
3
+ export function ErrorFallback({ error, reset }: { error: Error; reset?: () => void }) {
4
+ return (
5
+ <div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)] px-4">
6
+ <div className="max-w-md w-full bg-[var(--pure-white)] rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-8 text-center">
7
+ <div className="w-16 h-16 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center mx-auto mb-4">
8
+ <span className="material-symbols-outlined text-3xl text-[var(--pomegranate-400)]">error</span>
9
+ </div>
10
+ <h1 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-2">
11
+ Terjadi Kesalahan
12
+ </h1>
13
+ <p className="text-sm text-[var(--warm-charcoal)] mb-6">
14
+ Maaf, ada masalah saat memuat halaman. Coba refresh atau kembali ke dashboard.
15
+ </p>
16
+ {error.message && (
17
+ <pre className="text-xs text-left bg-[var(--oat-light)] rounded-[var(--radius-md)] p-3 mb-6 overflow-auto text-[var(--clay-black)]">
18
+ {error.message}
19
+ </pre>
20
+ )}
21
+ <div className="flex gap-3 justify-center">
22
+ {reset && (
23
+ <Button onClick={reset} variant="outline" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]">
24
+ Coba Lagi
25
+ </Button>
26
+ )}
27
+ <Button
28
+ onClick={() => window.location.href = "/"}
29
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
30
+ >
31
+ Ke Dashboard
32
+ </Button>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ );
37
+ }
apps/web/src/components/admin/Pagination.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+
3
+ interface PaginationProps {
4
+ page: number;
5
+ totalPages: number;
6
+ onChange: (p: number) => void;
7
+ }
8
+
9
+ export function Pagination({ page, totalPages, onChange }: PaginationProps) {
10
+ if (totalPages <= 1) return null;
11
+
12
+ const pages: (number | "...")[] = [];
13
+ for (let i = 1; i <= totalPages; i++) {
14
+ if (i === 1 || i === totalPages || (i >= page - 2 && i <= page + 2)) {
15
+ pages.push(i);
16
+ } else if (pages[pages.length - 1] !== "...") {
17
+ pages.push("...");
18
+ }
19
+ }
20
+
21
+ return (
22
+ <div className="flex items-center justify-center gap-1 mt-6">
23
+ <Button
24
+ variant="outline"
25
+ size="sm"
26
+ onClick={() => onChange(page - 1)}
27
+ disabled={page <= 1}
28
+ >
29
+ Previous
30
+ </Button>
31
+ {pages.map((p, i) =>
32
+ p === "..." ? (
33
+ <span key={`e-${i}`} className="px-2 text-[var(--warm-charcoal)]">
34
+ ...
35
+ </span>
36
+ ) : (
37
+ <Button
38
+ key={p}
39
+ variant={p === page ? "default" : "outline"}
40
+ size="sm"
41
+ onClick={() => onChange(p as number)}
42
+ >
43
+ {p}
44
+ </Button>
45
+ ),
46
+ )}
47
+ <Button
48
+ variant="outline"
49
+ size="sm"
50
+ onClick={() => onChange(page + 1)}
51
+ disabled={page >= totalPages}
52
+ >
53
+ Next
54
+ </Button>
55
+ </div>
56
+ );
57
+ }
apps/web/src/components/attempt/OptionDisplay.tsx CHANGED
@@ -1,11 +1,6 @@
1
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
2
 
3
- const MCQ_FORMATS = [
4
- "multiple_choice", "synonym", "grammar_in_context", "sentence_completion",
5
- "reference", "kanji_reading", "particle_choice", "article_case",
6
- "matching_headings", "matching_information", "summary_completion", "cloze",
7
- "error_recognition", "text_insertion",
8
- ];
9
 
10
  interface OptionDisplayProps {
11
  format: string;
@@ -29,7 +24,7 @@ export function OptionDisplay({ format, options, correctAnswer, userAnswer }: Op
29
  return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
30
  }
31
 
32
- if (MCQ_FORMATS.includes(format)) {
33
  return <McqOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
34
  }
35
 
 
1
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
2
 
3
+ import { MCQ_FORMATS, McqFormat } from "@/lib/question-formats";
 
 
 
 
 
4
 
5
  interface OptionDisplayProps {
6
  format: string;
 
24
  return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
25
  }
26
 
27
+ if (MCQ_FORMATS.includes(format as McqFormat)) {
28
  return <McqOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
29
  }
30
 
apps/web/src/components/sidebar.tsx CHANGED
@@ -71,6 +71,7 @@ function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boole
71
  <Link
72
  to={item.to}
73
  {...tourAttr}
 
74
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover cursor-pointer ${
75
  isActive
76
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
@@ -184,6 +185,7 @@ export function Sidebar() {
184
  {/* Help Tour Button */}
185
  <button
186
  onClick={triggerGlobalTour}
 
187
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover cursor-pointer text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
188
  collapsed ? "justify-center py-3 px-2" : "py-3 px-3 text-left"
189
  }`}
@@ -196,6 +198,7 @@ export function Sidebar() {
196
  {isLoggedIn ? (
197
  <button
198
  onClick={handleSignOut}
 
199
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover cursor-pointer text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
200
  collapsed ? "justify-center py-3 px-2" : "py-3 px-3 text-left"
201
  }`}
@@ -222,6 +225,8 @@ export function Sidebar() {
222
  {/* Floating toggle button (right edge of sidebar) */}
223
  <button
224
  onClick={toggle}
 
 
225
  title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
226
  style={{ left: collapsed ? "48px" : "240px" }}
227
  className="hidden md:flex fixed top-6 z-40 items-center justify-center w-10 h-10 rounded-full bg-[var(--pure-white)] border-2 border-[var(--oat-border)] shadow-md hover:bg-[var(--oat-light)] transition-all duration-300 text-[var(--warm-charcoal)] clay-hover cursor-pointer"
@@ -258,6 +263,7 @@ export function Sidebar() {
258
  <Link
259
  key={item.to}
260
  to={item.to}
 
261
  className={`flex flex-col items-center justify-center px-3 py-1.5 transition-all rounded-[var(--radius-lg)] cursor-pointer ${
262
  isActive
263
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
 
71
  <Link
72
  to={item.to}
73
  {...tourAttr}
74
+ aria-current={isActive ? "page" : undefined}
75
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover cursor-pointer ${
76
  isActive
77
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
 
185
  {/* Help Tour Button */}
186
  <button
187
  onClick={triggerGlobalTour}
188
+ aria-label="Panduan"
189
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover cursor-pointer text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
190
  collapsed ? "justify-center py-3 px-2" : "py-3 px-3 text-left"
191
  }`}
 
198
  {isLoggedIn ? (
199
  <button
200
  onClick={handleSignOut}
201
+ aria-label="Keluar"
202
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover cursor-pointer text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
203
  collapsed ? "justify-center py-3 px-2" : "py-3 px-3 text-left"
204
  }`}
 
225
  {/* Floating toggle button (right edge of sidebar) */}
226
  <button
227
  onClick={toggle}
228
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
229
+ aria-expanded={!collapsed}
230
  title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
231
  style={{ left: collapsed ? "48px" : "240px" }}
232
  className="hidden md:flex fixed top-6 z-40 items-center justify-center w-10 h-10 rounded-full bg-[var(--pure-white)] border-2 border-[var(--oat-border)] shadow-md hover:bg-[var(--oat-light)] transition-all duration-300 text-[var(--warm-charcoal)] clay-hover cursor-pointer"
 
263
  <Link
264
  key={item.to}
265
  to={item.to}
266
+ aria-current={isActive ? "page" : undefined}
267
  className={`flex flex-col items-center justify-center px-3 py-1.5 transition-all rounded-[var(--radius-lg)] cursor-pointer ${
268
  isActive
269
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
apps/web/src/components/test/AttemptTestView.tsx CHANGED
@@ -1,5 +1,4 @@
1
  import { useState, useEffect, useRef, useCallback, memo } from "react";
2
- import { Link } from "@tanstack/react-router";
3
  import { useQuery } from "@tanstack/react-query";
4
  import { Button } from "@labas/ui/components/button";
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
@@ -8,6 +7,10 @@ import { trpc } from "@/utils/trpc";
8
  import { QuestionInput } from "./QuestionInput";
9
  import { AccentKeyboard } from "./AccentKeyboard";
10
  import { parseFurigana } from "@/lib/furigana";
 
 
 
 
11
 
12
  interface AttemptTestViewProps {
13
  attemptId: string;
@@ -85,6 +88,7 @@ const QuestionCard = memo(function QuestionCard({
85
  </div>
86
  <button
87
  onClick={() => toggleMarkQuestion(q.id)}
 
88
  className={`shrink-0 w-10 h-10 flex items-center justify-center rounded-full transition-colors ${
89
  isMarked
90
  ? "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]"
@@ -143,7 +147,6 @@ export function AttemptTestView({
143
  submittingQId,
144
  markedQuestions,
145
  toggleMarkQuestion,
146
- startQuestionTimer,
147
  }: AttemptTestViewProps) {
148
  const [showFinishDialog, setShowFinishDialog] = useState(false);
149
  const [showAbandonDialog, setShowAbandonDialog] = useState(false);
@@ -152,8 +155,6 @@ export function AttemptTestView({
152
  const navSliderRef = useRef<HTMLDivElement>(null);
153
  const hasInitRef = useRef(false);
154
 
155
- console.log("[AttemptTestView] render, attemptId:", attemptId, "sections:", pkg.sections?.length);
156
-
157
  const attemptQuery = useQuery(
158
  trpc.attempt.getById.queryOptions(
159
  { id: attemptId },
@@ -198,9 +199,6 @@ export function AttemptTestView({
198
  });
199
  });
200
 
201
- const isAnswered = (qId: string) => !!answers[qId];
202
- const isMarked = (qId: string) => markedQuestions.has(qId);
203
-
204
  // Initialize active question once on mount
205
  useEffect(() => {
206
  if (!hasInitRef.current && allQuestions.length > 0) {
@@ -278,51 +276,18 @@ export function AttemptTestView({
278
  .hide-scrollbar::-webkit-scrollbar { display: none; }
279
  .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
280
  `}</style>
281
-
282
- <div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
283
- {/* TopAppBar Shell */}
284
- <header className="bg-[var(--pure-white)] border-b border-[var(--oat-border)] flex justify-between items-center w-full px-6 py-3 shrink-0 z-50">
285
- <div className="flex items-center gap-4">
286
- <button onClick={() => setShowAbandonDialog(true)} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center">
287
- <MaterialIcon name="close" />
288
- </button>
289
- <span className="text-xl font-bold tracking-tight text-[var(--clay-black)] truncate max-w-[200px] md:max-w-sm">{pkg.title}</span>
290
- <span className="bg-[var(--oat-light)] px-3 py-1 rounded-full text-xs font-semibold text-[var(--warm-charcoal)] uppercase tracking-widest hidden md:inline-block">
291
- {currentSection.title}
292
- </span>
293
- </div>
294
-
295
- {/* Timer Box */}
296
- <div className="bg-[var(--pure-white)]/70 backdrop-blur-md rounded-xl hidden sm:flex items-center gap-3 px-6 py-2 shadow-sm border border-[var(--oat-border)]">
297
- <MaterialIcon name="timer" className="text-[var(--matcha-600)]" />
298
- <div className="flex flex-col">
299
- <span className="text-[10px] leading-none uppercase font-bold text-[var(--warm-silver)] tracking-tighter">Waktu Berlalu</span>
300
- <span className="text-xl font-bold font-headline tabular-nums text-[var(--clay-black)]">{formatTime(timeElapsed)}</span>
301
- </div>
302
- </div>
303
 
304
- <div className="flex items-center gap-6">
305
- <div className="hidden md:flex items-center gap-2">
306
- <div className="w-24 h-2 bg-[var(--oat-light)] rounded-full overflow-hidden">
307
- <div
308
- className="h-full bg-[var(--matcha-600)] transition-all rounded-full"
309
- style={{ width: `${totalQuestions > 0 ? (answeredCount / totalQuestions) * 100 : 0}%` }}
310
- />
311
- </div>
312
- <span className="text-xs font-bold text-[var(--clay-black)]">{answeredCount}/{totalQuestions} Dijawab</span>
313
- </div>
314
- <button className="bg-[var(--oat-light)] p-2 rounded-xl text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors sm:hidden">
315
- <MaterialIcon name="timer" />
316
- </button>
317
- <Button
318
- onClick={() => setShowFinishDialog(true)}
319
- disabled={isFinished}
320
- className="bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] px-4 py-2 rounded-xl text-sm font-bold transition-opacity"
321
- >
322
- Selesai Test
323
- </Button>
324
- </div>
325
- </header>
326
 
327
  {/* Main Exam Workspace */}
328
  <main className="flex-1 flex overflow-hidden flex-col lg:flex-row">
@@ -352,7 +317,7 @@ export function AttemptTestView({
352
  <span className="text-sm font-bold text-[var(--warm-charcoal)] bg-[var(--oat-light)] px-3 py-1.5 rounded-lg">
353
  Soal {activeGlobalIdx} / {totalQuestions}
354
  </span>
355
- {isMarked(activeQuestionId ?? "") && (
356
  <span className="flex items-center gap-1 text-xs font-semibold text-[var(--pomegranate-400)] bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full">
357
  <MaterialIcon name="bookmark" className="text-xs" />
358
  Ditandai
@@ -377,7 +342,7 @@ export function AttemptTestView({
377
  globalIdx={activeGlobalIdx}
378
  answerValue={answers[activeQuestionWithMeta.id] ?? ""}
379
  sectionResultId={sectionResultId}
380
- isMarked={isMarked(activeQuestionWithMeta.id)}
381
  isFinished={isFinished}
382
  isSubmitting={submittingQId === activeQuestionWithMeta.id}
383
  onAnswerChange={onAnswerChange}
@@ -390,7 +355,7 @@ export function AttemptTestView({
390
  )}
391
 
392
  {/* Prev / Next Navigation */}
393
- <nav className="flex justify-between items-center pt-8">
394
  <Button
395
  variant="ghost"
396
  onClick={goToPrevQuestion}
@@ -414,176 +379,36 @@ export function AttemptTestView({
414
  </section>
415
  </main>
416
 
417
- {/* Floating Question Navigation Slider */}
418
- <div className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]">
419
- {/* Scroll Left */}
420
- <button
421
- onClick={() => {
422
- navSliderRef.current?.scrollBy({ left: -200, behavior: "smooth" });
423
- }}
424
- className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
425
- >
426
- <MaterialIcon name="chevron_left" className="text-sm" />
427
- </button>
428
-
429
- {/* Scrollable Strip */}
430
- <div
431
- ref={navSliderRef}
432
- className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
433
- >
434
- {allQuestions.map((q, gIdx) => {
435
- const answered = isAnswered(q.id);
436
- const marked = isMarked(q.id);
437
- const isActive = q.id === activeQuestionId;
438
-
439
- return (
440
- <button
441
- key={q.id}
442
- data-qid={q.id}
443
- onClick={() => goToQuestion(q.id)}
444
- className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
445
- answered
446
- ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
447
- : isActive
448
- ? "bg-white/30 text-white"
449
- : "bg-white/10 text-white/60 hover:bg-white/20"
450
- }`}
451
- title={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}`}
452
- >
453
- {gIdx + 1}
454
- {marked && (
455
- <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--pomegranate-400)] rounded-full border border-[var(--clay-black)]" />
456
- )}
457
- </button>
458
- );
459
- })}
460
- </div>
461
-
462
- {/* Scroll Right */}
463
- <button
464
- onClick={() => {
465
- navSliderRef.current?.scrollBy({ left: 200, behavior: "smooth" });
466
- }}
467
- className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
468
- >
469
- <MaterialIcon name="chevron_right" className="text-sm" />
470
- </button>
471
-
472
- <div className="w-px h-6 bg-white/10 shrink-0"></div>
473
-
474
- {/* Top Button */}
475
- <button
476
- onClick={() => {
477
- questionPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" });
478
- }}
479
- className="flex items-center gap-1 px-3 py-2 shrink-0 rounded-full text-white/80 font-semibold text-xs hover:text-white transition-all"
480
- >
481
- <MaterialIcon name="arrow_upward" className="text-sm" />
482
- <span className="hidden sm:inline">Atas</span>
483
- </button>
484
- </div>
485
  </div>
486
 
487
- {/* Finish Confirmation Dialog */}
488
- {showFinishDialog && (
489
- <div
490
- className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
491
- onClick={(e) => {
492
- if (e.target === e.currentTarget) setShowFinishDialog(false);
493
- }}
494
- >
495
- <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
496
- <div className="flex items-center gap-3 mb-4">
497
- <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
498
- <MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
499
- </div>
500
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
501
- Selesaikan Latihan?
502
- </h2>
503
- </div>
504
-
505
- <div className="space-y-3 mb-6">
506
- <p className="text-sm text-[var(--warm-charcoal)]">
507
- Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
508
- </p>
509
- {answeredCount < totalQuestions && (
510
- <div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
511
- <MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
512
- <span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
513
- </div>
514
- )}
515
- <p className="text-sm text-[var(--warm-charcoal)]">
516
- Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
517
- </p>
518
- </div>
519
-
520
- <div className="flex gap-3">
521
- <Button
522
- variant="outline"
523
- onClick={() => setShowFinishDialog(false)}
524
- className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
525
- >
526
- Lanjutkan
527
- </Button>
528
- <Button
529
- onClick={() => {
530
- setShowFinishDialog(false);
531
- onFinish();
532
- }}
533
- className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
534
- >
535
- Selesaikan
536
- </Button>
537
- </div>
538
- </div>
539
- </div>
540
- )}
541
-
542
- {/* Abandon Confirmation Dialog */}
543
- {showAbandonDialog && (
544
- <div
545
- className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
546
- onClick={(e) => {
547
- if (e.target === e.currentTarget) setShowAbandonDialog(false);
548
- }}
549
- >
550
- <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
551
- <div className="flex items-center gap-3 mb-4">
552
- <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
553
- <MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
554
- </div>
555
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
556
- Keluar dari Latihan?
557
- </h2>
558
- </div>
559
-
560
- <div className="space-y-3 mb-6">
561
- <p className="text-sm text-[var(--warm-charcoal)]">
562
- Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
563
- </p>
564
- </div>
565
-
566
- <div className="flex gap-3">
567
- <Button
568
- variant="outline"
569
- onClick={() => setShowAbandonDialog(false)}
570
- className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
571
- >
572
- Batal
573
- </Button>
574
- <Button
575
- onClick={() => {
576
- setShowAbandonDialog(false);
577
- onAbandon();
578
- }}
579
- className="flex-1 bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] rounded-[var(--radius-lg)]"
580
- >
581
- Keluar
582
- </Button>
583
- </div>
584
- </div>
585
- </div>
586
- )}
587
  </>
588
  );
589
  }
 
1
  import { useState, useEffect, useRef, useCallback, memo } from "react";
 
2
  import { useQuery } from "@tanstack/react-query";
3
  import { Button } from "@labas/ui/components/button";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
7
  import { QuestionInput } from "./QuestionInput";
8
  import { AccentKeyboard } from "./AccentKeyboard";
9
  import { parseFurigana } from "@/lib/furigana";
10
+ import { AttemptHeader } from "./attempt/AttemptHeader";
11
+ import { FloatingNav } from "./attempt/FloatingNav";
12
+ import { FinishDialog } from "./attempt/FinishDialog";
13
+ import { AbandonDialog } from "./attempt/AbandonDialog";
14
 
15
  interface AttemptTestViewProps {
16
  attemptId: string;
 
88
  </div>
89
  <button
90
  onClick={() => toggleMarkQuestion(q.id)}
91
+ aria-label={isMarked ? "Hapus tanda" : "Tandai untuk review"}
92
  className={`shrink-0 w-10 h-10 flex items-center justify-center rounded-full transition-colors ${
93
  isMarked
94
  ? "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]"
 
147
  submittingQId,
148
  markedQuestions,
149
  toggleMarkQuestion,
 
150
  }: AttemptTestViewProps) {
151
  const [showFinishDialog, setShowFinishDialog] = useState(false);
152
  const [showAbandonDialog, setShowAbandonDialog] = useState(false);
 
155
  const navSliderRef = useRef<HTMLDivElement>(null);
156
  const hasInitRef = useRef(false);
157
 
 
 
158
  const attemptQuery = useQuery(
159
  trpc.attempt.getById.queryOptions(
160
  { id: attemptId },
 
199
  });
200
  });
201
 
 
 
 
202
  // Initialize active question once on mount
203
  useEffect(() => {
204
  if (!hasInitRef.current && allQuestions.length > 0) {
 
276
  .hide-scrollbar::-webkit-scrollbar { display: none; }
277
  .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
278
  `}</style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
+ <div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
281
+ <AttemptHeader
282
+ pkgTitle={pkg.title}
283
+ currentSectionTitle={currentSection.title}
284
+ timeElapsed={timeElapsed}
285
+ answeredCount={answeredCount}
286
+ totalQuestions={totalQuestions}
287
+ isFinished={isFinished}
288
+ onAbandon={() => setShowAbandonDialog(true)}
289
+ onFinish={() => setShowFinishDialog(true)}
290
+ />
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  {/* Main Exam Workspace */}
293
  <main className="flex-1 flex overflow-hidden flex-col lg:flex-row">
 
317
  <span className="text-sm font-bold text-[var(--warm-charcoal)] bg-[var(--oat-light)] px-3 py-1.5 rounded-lg">
318
  Soal {activeGlobalIdx} / {totalQuestions}
319
  </span>
320
+ {markedQuestions.has(activeQuestionId ?? "") && (
321
  <span className="flex items-center gap-1 text-xs font-semibold text-[var(--pomegranate-400)] bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full">
322
  <MaterialIcon name="bookmark" className="text-xs" />
323
  Ditandai
 
342
  globalIdx={activeGlobalIdx}
343
  answerValue={answers[activeQuestionWithMeta.id] ?? ""}
344
  sectionResultId={sectionResultId}
345
+ isMarked={markedQuestions.has(activeQuestionWithMeta.id)}
346
  isFinished={isFinished}
347
  isSubmitting={submittingQId === activeQuestionWithMeta.id}
348
  onAnswerChange={onAnswerChange}
 
355
  )}
356
 
357
  {/* Prev / Next Navigation */}
358
+ <nav className="flex justify-between items-center pt-8" aria-label="Navigasi soal sebelumnya dan berikutnya">
359
  <Button
360
  variant="ghost"
361
  onClick={goToPrevQuestion}
 
379
  </section>
380
  </main>
381
 
382
+ <FloatingNav
383
+ questions={allQuestions}
384
+ activeQuestionId={activeQuestionId}
385
+ answers={answers}
386
+ markedQuestions={markedQuestions}
387
+ onGoToQuestion={goToQuestion}
388
+ onScrollToTop={() => questionPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
389
+ navSliderRef={navSliderRef}
390
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  </div>
392
 
393
+ <FinishDialog
394
+ open={showFinishDialog}
395
+ onClose={() => setShowFinishDialog(false)}
396
+ onConfirm={() => {
397
+ setShowFinishDialog(false);
398
+ onFinish();
399
+ }}
400
+ answeredCount={answeredCount}
401
+ totalQuestions={totalQuestions}
402
+ />
403
+
404
+ <AbandonDialog
405
+ open={showAbandonDialog}
406
+ onClose={() => setShowAbandonDialog(false)}
407
+ onConfirm={() => {
408
+ setShowAbandonDialog(false);
409
+ onAbandon();
410
+ }}
411
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  </>
413
  );
414
  }
apps/web/src/components/test/QuestionInput.tsx CHANGED
@@ -1,23 +1,7 @@
1
  import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
 
4
- const MCQ_FORMATS = [
5
- "multiple_choice",
6
- "synonym",
7
- "grammar_in_context",
8
- "sentence_completion",
9
- "reference",
10
- "kanji_reading",
11
- "particle_choice",
12
- "article_case",
13
- "matching_headings",
14
- "matching_information",
15
- "summary_completion",
16
- "cloze",
17
- "error_recognition",
18
- "text_insertion",
19
- "matching_pairs",
20
- ];
21
 
22
  const TRUE_FALSE_CHOICES = [
23
  { key: "TRUE", label: "True" },
 
1
  import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
 
4
+ import { MCQ_FORMATS } from "@/lib/question-formats";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  const TRUE_FALSE_CHOICES = [
7
  { key: "TRUE", label: "True" },
apps/web/src/components/test/attempt/AbandonDialog.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ interface AbandonDialogProps {
5
+ open: boolean;
6
+ onClose: () => void;
7
+ onConfirm: () => void;
8
+ }
9
+
10
+ export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps) {
11
+ if (!open) return null;
12
+
13
+ return (
14
+ <div
15
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
16
+ onClick={(e) => {
17
+ if (e.target === e.currentTarget) onClose();
18
+ }}
19
+ role="dialog"
20
+ aria-modal="true"
21
+ aria-labelledby="abandon-dialog-title"
22
+ >
23
+ <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
24
+ <div className="flex items-center gap-3 mb-4">
25
+ <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
26
+ <MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
27
+ </div>
28
+ <h2 id="abandon-dialog-title" className="text-xl font-headline font-bold text-[var(--clay-black)]">
29
+ Keluar dari Latihan?
30
+ </h2>
31
+ </div>
32
+
33
+ <div className="space-y-3 mb-6">
34
+ <p className="text-sm text-[var(--warm-charcoal)]">
35
+ Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
36
+ </p>
37
+ </div>
38
+
39
+ <div className="flex gap-3">
40
+ <Button
41
+ variant="outline"
42
+ onClick={onClose}
43
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
44
+ >
45
+ Batal
46
+ </Button>
47
+ <Button
48
+ onClick={onConfirm}
49
+ className="flex-1 bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] rounded-[var(--radius-lg)]"
50
+ >
51
+ Keluar
52
+ </Button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
apps/web/src/components/test/attempt/AttemptHeader.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+ import { formatTime } from "@/lib/time";
4
+
5
+ interface AttemptHeaderProps {
6
+ pkgTitle: string;
7
+ currentSectionTitle: string;
8
+ timeElapsed: number;
9
+ answeredCount: number;
10
+ totalQuestions: number;
11
+ isFinished: boolean;
12
+ onAbandon: () => void;
13
+ onFinish: () => void;
14
+ }
15
+
16
+ export function AttemptHeader({
17
+ pkgTitle,
18
+ currentSectionTitle,
19
+ timeElapsed,
20
+ answeredCount,
21
+ totalQuestions,
22
+ isFinished,
23
+ onAbandon,
24
+ onFinish,
25
+ }: AttemptHeaderProps) {
26
+ return (
27
+ <header className="bg-[var(--pure-white)] border-b border-[var(--oat-border)] flex justify-between items-center w-full px-6 py-3 shrink-0 z-50">
28
+ <div className="flex items-center gap-4">
29
+ <button
30
+ onClick={onAbandon}
31
+ aria-label="Keluar dari latihan"
32
+ className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center"
33
+ >
34
+ <MaterialIcon name="close" />
35
+ </button>
36
+ <span className="text-xl font-bold tracking-tight text-[var(--clay-black)] truncate max-w-[200px] md:max-w-sm">
37
+ {pkgTitle}
38
+ </span>
39
+ <span className="bg-[var(--oat-light)] px-3 py-1 rounded-full text-xs font-semibold text-[var(--warm-charcoal)] uppercase tracking-widest hidden md:inline-block">
40
+ {currentSectionTitle}
41
+ </span>
42
+ </div>
43
+
44
+ <div className="bg-[var(--pure-white)]/70 backdrop-blur-md rounded-xl hidden sm:flex items-center gap-3 px-6 py-2 shadow-sm border border-[var(--oat-border)]">
45
+ <MaterialIcon name="timer" className="text-[var(--matcha-600)]" />
46
+ <div className="flex flex-col">
47
+ <span className="text-[10px] leading-none uppercase font-bold text-[var(--warm-silver)] tracking-tighter">
48
+ Waktu Berlalu
49
+ </span>
50
+ <span className="text-xl font-bold font-headline tabular-nums text-[var(--clay-black)]">
51
+ {formatTime(timeElapsed)}
52
+ </span>
53
+ </div>
54
+ </div>
55
+
56
+ <div className="flex items-center gap-6">
57
+ <div className="hidden md:flex items-center gap-2">
58
+ <div className="w-24 h-2 bg-[var(--oat-light)] rounded-full overflow-hidden">
59
+ <div
60
+ className="h-full bg-[var(--matcha-600)] transition-all rounded-full"
61
+ style={{ width: `${totalQuestions > 0 ? (answeredCount / totalQuestions) * 100 : 0}%` }}
62
+ />
63
+ </div>
64
+ <span className="text-xs font-bold text-[var(--clay-black)]">
65
+ {answeredCount}/{totalQuestions} Dijawab
66
+ </span>
67
+ </div>
68
+ <button
69
+ className="bg-[var(--oat-light)] p-2 rounded-xl text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors sm:hidden"
70
+ aria-label="Waktu berlalu"
71
+ >
72
+ <MaterialIcon name="timer" />
73
+ </button>
74
+ <Button
75
+ onClick={onFinish}
76
+ disabled={isFinished}
77
+ className="bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] px-4 py-2 rounded-xl text-sm font-bold transition-opacity"
78
+ >
79
+ Selesai Test
80
+ </Button>
81
+ </div>
82
+ </header>
83
+ );
84
+ }
apps/web/src/components/test/attempt/FinishDialog.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ interface FinishDialogProps {
5
+ open: boolean;
6
+ onClose: () => void;
7
+ onConfirm: () => void;
8
+ answeredCount: number;
9
+ totalQuestions: number;
10
+ }
11
+
12
+ export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQuestions }: FinishDialogProps) {
13
+ if (!open) return null;
14
+
15
+ return (
16
+ <div
17
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
18
+ onClick={(e) => {
19
+ if (e.target === e.currentTarget) onClose();
20
+ }}
21
+ role="dialog"
22
+ aria-modal="true"
23
+ aria-labelledby="finish-dialog-title"
24
+ >
25
+ <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
26
+ <div className="flex items-center gap-3 mb-4">
27
+ <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
28
+ <MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
29
+ </div>
30
+ <h2 id="finish-dialog-title" className="text-xl font-headline font-bold text-[var(--clay-black)]">
31
+ Selesaikan Latihan?
32
+ </h2>
33
+ </div>
34
+
35
+ <div className="space-y-3 mb-6">
36
+ <p className="text-sm text-[var(--warm-charcoal)]">
37
+ Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
38
+ </p>
39
+ {answeredCount < totalQuestions && (
40
+ <div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
41
+ <MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
42
+ <span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
43
+ </div>
44
+ )}
45
+ <p className="text-sm text-[var(--warm-charcoal)]">
46
+ Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
47
+ </p>
48
+ </div>
49
+
50
+ <div className="flex gap-3">
51
+ <Button
52
+ variant="outline"
53
+ onClick={onClose}
54
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
55
+ >
56
+ Lanjutkan
57
+ </Button>
58
+ <Button
59
+ onClick={onConfirm}
60
+ className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
61
+ >
62
+ Selesaikan
63
+ </Button>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
apps/web/src/components/test/attempt/FloatingNav.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RefObject } from "react";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ interface FloatingNavProps {
5
+ questions: Array<{ id: string }>;
6
+ activeQuestionId: string | null;
7
+ answers: Record<string, string>;
8
+ markedQuestions: Set<string>;
9
+ onGoToQuestion: (qId: string) => void;
10
+ onScrollToTop: () => void;
11
+ navSliderRef?: RefObject<HTMLDivElement | null>;
12
+ }
13
+
14
+ export function FloatingNav({
15
+ questions,
16
+ activeQuestionId,
17
+ answers,
18
+ markedQuestions,
19
+ onGoToQuestion,
20
+ onScrollToTop,
21
+ navSliderRef,
22
+ }: FloatingNavProps) {
23
+ const isAnswered = (qId: string) => !!answers[qId];
24
+ const isMarked = (qId: string) => markedQuestions.has(qId);
25
+
26
+ return (
27
+ <nav
28
+ className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]"
29
+ aria-label="Navigasi soal"
30
+ >
31
+ <button
32
+ onClick={() => {
33
+ navSliderRef?.current?.scrollBy({ left: -200, behavior: "smooth" });
34
+ }}
35
+ className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
36
+ aria-label="Geser navigasi ke kiri"
37
+ >
38
+ <MaterialIcon name="chevron_left" className="text-sm" />
39
+ </button>
40
+
41
+ <div
42
+ ref={navSliderRef}
43
+ className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
44
+ role="tablist"
45
+ aria-label="Daftar soal"
46
+ >
47
+ {questions.map((q, gIdx) => {
48
+ const answered = isAnswered(q.id);
49
+ const marked = isMarked(q.id);
50
+ const isActive = q.id === activeQuestionId;
51
+
52
+ return (
53
+ <button
54
+ key={q.id}
55
+ data-qid={q.id}
56
+ onClick={() => onGoToQuestion(q.id)}
57
+ role="tab"
58
+ aria-selected={isActive}
59
+ aria-label={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}${answered ? " (sudah dijawab)" : ""}`}
60
+ className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
61
+ answered
62
+ ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
63
+ : isActive
64
+ ? "bg-white/30 text-white"
65
+ : "bg-white/10 text-white/60 hover:bg-white/20"
66
+ }`}
67
+ >
68
+ {gIdx + 1}
69
+ {marked && (
70
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--pomegranate-400)] rounded-full border border-[var(--clay-black)]" />
71
+ )}
72
+ </button>
73
+ );
74
+ })}
75
+ </div>
76
+
77
+ <button
78
+ onClick={() => {
79
+ navSliderRef?.current?.scrollBy({ left: 200, behavior: "smooth" });
80
+ }}
81
+ className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
82
+ aria-label="Geser navigasi ke kanan"
83
+ >
84
+ <MaterialIcon name="chevron_right" className="text-sm" />
85
+ </button>
86
+
87
+ <div className="w-px h-6 bg-white/10 shrink-0" />
88
+
89
+ <button
90
+ onClick={onScrollToTop}
91
+ className="flex items-center gap-1 px-3 py-2 shrink-0 rounded-full text-white/80 font-semibold text-xs hover:text-white transition-all"
92
+ aria-label="Kembali ke atas"
93
+ >
94
+ <MaterialIcon name="arrow_upward" className="text-sm" />
95
+ <span className="hidden sm:inline">Atas</span>
96
+ </button>
97
+ </nav>
98
+ );
99
+ }
apps/web/src/hooks/use-debounced-value.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+
3
+ /** Debounce a value by `delay` ms. Returns the debounced value and a setter.
4
+ * Resets internal timer whenever the setter is called. */
5
+ export function useDebouncedValue<T>(initial: T, delay = 300) {
6
+ const [value, setValue] = useState<T>(initial);
7
+ const [debounced, setDebounced] = useState<T>(initial);
8
+
9
+ useEffect(() => {
10
+ const timer = setTimeout(() => setDebounced(value), delay);
11
+ return () => clearTimeout(timer);
12
+ }, [value, delay]);
13
+
14
+ return [value, debounced, setValue] as const;
15
+ }
apps/web/src/lib/error-utils.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Extract a human-readable message from an unknown error value.
2
+ * Safe to use in tRPC `onError` callbacks where the error type is `unknown`. */
3
+ export function getErrorMessage(err: unknown): string {
4
+ if (err instanceof Error) return err.message;
5
+ if (typeof err === "string") return err;
6
+ if (
7
+ err &&
8
+ typeof err === "object" &&
9
+ "message" in err &&
10
+ typeof (err as Record<string, unknown>).message === "string"
11
+ ) {
12
+ return (err as Record<string, unknown>).message as string;
13
+ }
14
+ return "An unexpected error occurred";
15
+ }
apps/web/src/lib/exam-constants.ts CHANGED
@@ -1,46 +1,20 @@
1
- export const EXAM_TYPES = [
2
- { id: "IELTS", name: "IELTS" },
3
- { id: "TOEFL", name: "TOEFL" },
4
- { id: "JLPT", name: "JLPT" },
5
- { id: "HSK", name: "HSK" },
6
- { id: "GOETHE", name: "German" },
7
- { id: "TOPIK", name: "Korean" },
8
- { id: "TOAFL", name: "Arabic" },
9
- { id: "DELE", name: "Spanish" },
10
- ];
11
 
12
- export const SECTIONS = [
13
- { id: "READING", name: "Reading" },
14
- { id: "WRITING", name: "Writing" },
15
- ];
16
 
17
- export const FORMATS = [
18
- "multiple_choice",
19
- "true_false_not_given",
20
- "fill_blank",
21
- "synonym",
22
- "grammar_in_context",
23
- "sentence_completion",
24
- "cloze",
25
- "reference",
26
- "author_view",
27
- "matching_headings",
28
- "matching_information",
29
- "summary_completion",
30
- "matching_pairs",
31
- "error_recognition",
32
- "text_insertion",
33
- "kanji_reading",
34
- "particle_choice",
35
- "article_case",
36
- "character_reading",
37
- "sentence_arrangement",
38
- ];
39
 
40
- export const DIFFICULTIES = [
41
- { value: 1, label: "Beginner" },
42
- { value: 2, label: "Elementary" },
43
- { value: 3, label: "Intermediate" },
44
- { value: 4, label: "Advanced" },
45
- { value: 5, label: "Expert" },
46
- ];
 
 
1
+ import {
2
+ EXAM_TYPES as RICH_EXAM_TYPES,
3
+ SECTIONS as RICH_SECTIONS,
4
+ FORMATS as RICH_FORMATS,
5
+ DIFFICULTIES as RICH_DIFFICULTIES,
6
+ } from "./generate-constants";
 
 
 
 
7
 
8
+ /** Derived simple arrays for filter UIs that don't need rich metadata.
9
+ * Source of truth lives in generate-constants.ts — edit there. */
 
 
10
 
11
+ export const EXAM_TYPES = RICH_EXAM_TYPES.map(({ id, name }) => ({ id, name }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ export const SECTIONS = RICH_SECTIONS.map(({ id, name }) => ({ id, name }));
14
+
15
+ export const FORMATS = RICH_FORMATS.map(({ id }) => id);
16
+
17
+ export const DIFFICULTIES = RICH_DIFFICULTIES.map((label, index) => ({
18
+ value: index + 1,
19
+ label,
20
+ }));
apps/web/src/lib/question-formats.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Question formats that render as multiple-choice options (MCQ). */
2
+ export const MCQ_FORMATS = [
3
+ "multiple_choice",
4
+ "synonym",
5
+ "grammar_in_context",
6
+ "sentence_completion",
7
+ "reference",
8
+ "kanji_reading",
9
+ "particle_choice",
10
+ "article_case",
11
+ "matching_headings",
12
+ "matching_information",
13
+ "summary_completion",
14
+ "cloze",
15
+ "error_recognition",
16
+ "text_insertion",
17
+ "matching_pairs",
18
+ ] as const;
19
+
20
+ export type McqFormat = (typeof MCQ_FORMATS)[number];
apps/web/src/routes/__root.tsx CHANGED
@@ -8,6 +8,7 @@ import { Sidebar } from "@/components/sidebar";
8
  import { ThemeProvider } from "@/components/theme-provider";
9
  import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
 
11
  import type { trpc } from "@/utils/trpc";
12
 
13
  import "../index.css";
@@ -19,13 +20,16 @@ export interface RouterAppContext {
19
 
20
  export const Route = createRootRouteWithContext<RouterAppContext>()({
21
  component: RootComponent,
 
22
  head: () => ({
23
  meta: [
24
  { title: "Labas — AI Exam Practice" },
25
  { name: "description", content: "AI-powered multi-language test practice platform" },
26
  ],
27
- links: [
28
  { rel: "icon", href: "/favicon.ico" },
 
 
29
  { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
30
  ],
31
  }),
@@ -77,8 +81,12 @@ function RootComponent() {
77
  <GlobalGenerationProgress />
78
  <Toaster richColors />
79
  </ThemeProvider>
80
- <TanStackRouterDevtools position="bottom-left" />
81
- <ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
 
 
 
 
82
  </>
83
  );
84
  }
 
8
  import { ThemeProvider } from "@/components/theme-provider";
9
  import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
11
+ import { ErrorFallback } from "@/components/ErrorFallback";
12
  import type { trpc } from "@/utils/trpc";
13
 
14
  import "../index.css";
 
20
 
21
  export const Route = createRootRouteWithContext<RouterAppContext>()({
22
  component: RootComponent,
23
+ errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
24
  head: () => ({
25
  meta: [
26
  { title: "Labas — AI Exam Practice" },
27
  { name: "description", content: "AI-powered multi-language test practice platform" },
28
  ],
29
+ links: [
30
  { rel: "icon", href: "/favicon.ico" },
31
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
32
+ { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
33
  { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
34
  ],
35
  }),
 
81
  <GlobalGenerationProgress />
82
  <Toaster richColors />
83
  </ThemeProvider>
84
+ {import.meta.env.DEV && (
85
+ <>
86
+ <TanStackRouterDevtools position="bottom-left" />
87
+ <ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
88
+ </>
89
+ )}
90
  </>
91
  );
92
  }
apps/web/src/routes/admin.credits.tsx CHANGED
@@ -5,14 +5,15 @@ import { trpc } from "@/utils/trpc";
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
 
 
8
 
9
  export const Route = createFileRoute("/admin/credits")({
10
  component: AdminCredits,
11
  });
12
 
13
  function AdminCredits() {
14
- const [search, setSearch] = useState("");
15
- const [debouncedSearch, setDebouncedSearch] = useState("");
16
  const [selectedUserId, setSelectedUserId] = useState("");
17
  const [selectedUserName, setSelectedUserName] = useState("");
18
  const [amount, setAmount] = useState("");
@@ -49,24 +50,14 @@ function AdminCredits() {
49
  setAmount("");
50
  setDescription("");
51
  },
52
- onError: (e: any) => toast.error(e.message),
53
  }),
54
  );
55
 
56
- function handleSearch(val: string) {
57
- setSearch(val);
58
- const t = (window as any).__ct;
59
- if (t) clearTimeout(t);
60
- (window as any).__ct = setTimeout(() => {
61
- setDebouncedSearch(val);
62
- }, 300);
63
- }
64
-
65
  function selectUser(id: string, name: string) {
66
  setSelectedUserId(id);
67
  setSelectedUserName(name);
68
  setSearch("");
69
- setDebouncedSearch("");
70
  }
71
 
72
  function handleAdjust() {
@@ -166,7 +157,7 @@ function AdminCredits() {
166
  </tr>
167
  </thead>
168
  <tbody>
169
- {historyQuery.data.transactions.map((txn: any) => (
170
  <tr key={txn.id} className="border-t border-[var(--oat-border)]">
171
  <td className="px-4 py-3">
172
  <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
 
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
8
+ import { getErrorMessage } from "@/lib/error-utils";
9
+ import { useDebouncedValue } from "@/hooks/use-debounced-value";
10
 
11
  export const Route = createFileRoute("/admin/credits")({
12
  component: AdminCredits,
13
  });
14
 
15
  function AdminCredits() {
16
+ const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
 
17
  const [selectedUserId, setSelectedUserId] = useState("");
18
  const [selectedUserName, setSelectedUserName] = useState("");
19
  const [amount, setAmount] = useState("");
 
50
  setAmount("");
51
  setDescription("");
52
  },
53
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
54
  }),
55
  );
56
 
 
 
 
 
 
 
 
 
 
57
  function selectUser(id: string, name: string) {
58
  setSelectedUserId(id);
59
  setSelectedUserName(name);
60
  setSearch("");
 
61
  }
62
 
63
  function handleAdjust() {
 
157
  </tr>
158
  </thead>
159
  <tbody>
160
+ {historyQuery.data.transactions.map((txn) => (
161
  <tr key={txn.id} className="border-t border-[var(--oat-border)]">
162
  <td className="px-4 py-3">
163
  <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
apps/web/src/routes/admin.featured.tsx CHANGED
@@ -5,6 +5,8 @@ import { trpc } from "@/utils/trpc";
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
 
 
8
 
9
  export const Route = createFileRoute("/admin/featured")({
10
  component: AdminFeatured,
@@ -14,8 +16,7 @@ type Tab = "featured" | "packages" | "questions";
14
 
15
  function AdminFeatured() {
16
  const [tab, setTab] = useState<Tab>("featured");
17
- const [search, setSearch] = useState("");
18
- const [debouncedSearch, setDebouncedSearch] = useState("");
19
  const [page, setPage] = useState(1);
20
  const limit = 15;
21
  const queryClient = useQueryClient();
@@ -38,7 +39,7 @@ function AdminFeatured() {
38
  queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
39
  toast.success("Package updated");
40
  },
41
- onError: (e: any) => toast.error(e.message),
42
  }),
43
  );
44
 
@@ -49,18 +50,13 @@ function AdminFeatured() {
49
  queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
50
  toast.success("Question updated");
51
  },
52
- onError: (e: any) => toast.error(e.message),
53
  }),
54
  );
55
 
56
  function handleSearch(val: string) {
57
  setSearch(val);
58
- const t = (window as any).__ft;
59
- if (t) clearTimeout(t);
60
- (window as any).__ft = setTimeout(() => {
61
- setDebouncedSearch(val);
62
- setPage(1);
63
- }, 300);
64
  }
65
 
66
  const tabs: { key: Tab; label: string }[] = [
@@ -78,7 +74,7 @@ function AdminFeatured() {
78
  {tabs.map((t) => (
79
  <button
80
  key={t.key}
81
- onClick={() => { setTab(t.key); setSearch(""); setDebouncedSearch(""); setPage(1); }}
82
  className={`px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] transition-colors ${
83
  tab === t.key
84
  ? "bg-[var(--pure-white)] text-[var(--clay-black)] border border-[var(--oat-border)] border-b-[var(--pure-white)] -mb-[1px]"
@@ -101,7 +97,7 @@ function AdminFeatured() {
101
  <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
102
  ) : (
103
  <div className="space-y-2">
104
- {fPackages.map((pkg: any) => (
105
  <div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
106
  <div>
107
  <p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
@@ -123,7 +119,7 @@ function AdminFeatured() {
123
  <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
124
  ) : (
125
  <div className="space-y-2">
126
- {fQuestions.map((q: any) => (
127
  <div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
128
  <div className="flex-1 min-w-0">
129
  <p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
@@ -155,14 +151,14 @@ function AdminFeatured() {
155
  {debouncedSearch ? `${searchQuery.data?.total ?? 0} results for "${debouncedSearch}"` : `Showing ${searchQuery.data?.total ?? 0} ${tab}`}
156
  </p>
157
  <div className="space-y-2">
158
- {(searchQuery.data?.items ?? []).map((item: any) => (
159
  <div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
160
  <div className="flex-1 min-w-0">
161
  <p className="font-medium text-[var(--clay-black)] truncate">
162
- {tab === "packages" ? item.title : item.questionText}
163
  </p>
164
  <p className="text-xs text-[var(--warm-charcoal)]">
165
- {tab === "packages" ? item.examTypeId : `${item.format} · ${item.examTypeId}`}
166
  {item.isFeatured && (
167
  <span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
168
  )}
 
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
8
+ import { getErrorMessage } from "@/lib/error-utils";
9
+ import { useDebouncedValue } from "@/hooks/use-debounced-value";
10
 
11
  export const Route = createFileRoute("/admin/featured")({
12
  component: AdminFeatured,
 
16
 
17
  function AdminFeatured() {
18
  const [tab, setTab] = useState<Tab>("featured");
19
+ const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
 
20
  const [page, setPage] = useState(1);
21
  const limit = 15;
22
  const queryClient = useQueryClient();
 
39
  queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
40
  toast.success("Package updated");
41
  },
42
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
43
  }),
44
  );
45
 
 
50
  queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
51
  toast.success("Question updated");
52
  },
53
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
54
  }),
55
  );
56
 
57
  function handleSearch(val: string) {
58
  setSearch(val);
59
+ setPage(1);
 
 
 
 
 
60
  }
61
 
62
  const tabs: { key: Tab; label: string }[] = [
 
74
  {tabs.map((t) => (
75
  <button
76
  key={t.key}
77
+ onClick={() => { setTab(t.key); setSearch(""); setPage(1); }}
78
  className={`px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] transition-colors ${
79
  tab === t.key
80
  ? "bg-[var(--pure-white)] text-[var(--clay-black)] border border-[var(--oat-border)] border-b-[var(--pure-white)] -mb-[1px]"
 
97
  <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
98
  ) : (
99
  <div className="space-y-2">
100
+ {fPackages.map((pkg) => (
101
  <div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
102
  <div>
103
  <p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
 
119
  <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
120
  ) : (
121
  <div className="space-y-2">
122
+ {fQuestions.map((q) => (
123
  <div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
124
  <div className="flex-1 min-w-0">
125
  <p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
 
151
  {debouncedSearch ? `${searchQuery.data?.total ?? 0} results for "${debouncedSearch}"` : `Showing ${searchQuery.data?.total ?? 0} ${tab}`}
152
  </p>
153
  <div className="space-y-2">
154
+ {(searchQuery.data?.items ?? []).map((item) => (
155
  <div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
156
  <div className="flex-1 min-w-0">
157
  <p className="font-medium text-[var(--clay-black)] truncate">
158
+ {"title" in item ? item.title : item.questionText}
159
  </p>
160
  <p className="text-xs text-[var(--warm-charcoal)]">
161
+ {"format" in item ? `${item.format} · ${item.examTypeId}` : item.examTypeId}
162
  {item.isFeatured && (
163
  <span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
164
  )}
apps/web/src/routes/admin.index.tsx CHANGED
@@ -5,6 +5,7 @@ import { trpc } from "@/utils/trpc";
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
 
8
 
9
  export const Route = createFileRoute("/admin/")({
10
  component: AdminDashboard,
@@ -33,7 +34,7 @@ function AdminDashboard() {
33
  queryClient.invalidateQueries({ queryKey: trpc.admin.dashboardStats.queryKey() });
34
  toast.success("Config updated");
35
  },
36
- onError: (e: any) => toast.error(e.message),
37
  }),
38
  );
39
 
 
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
8
+ import { getErrorMessage } from "@/lib/error-utils";
9
 
10
  export const Route = createFileRoute("/admin/")({
11
  component: AdminDashboard,
 
34
  queryClient.invalidateQueries({ queryKey: trpc.admin.dashboardStats.queryKey() });
35
  toast.success("Config updated");
36
  },
37
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
38
  }),
39
  );
40
 
apps/web/src/routes/admin.jobs.tsx CHANGED
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { toast } from "sonner";
 
7
 
8
  const STATUSES = ["all", "pending", "running", "completed", "failed", "cancelled"] as const;
9
 
@@ -34,7 +35,7 @@ function AdminJobs() {
34
  queryClient.invalidateQueries({ queryKey: trpc.admin.listAllJobs.queryKey() });
35
  toast.success("Job cancelled");
36
  },
37
- onError: (e: any) => toast.error(e.message),
38
  }),
39
  );
40
 
 
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { toast } from "sonner";
7
+ import { getErrorMessage } from "@/lib/error-utils";
8
 
9
  const STATUSES = ["all", "pending", "running", "completed", "failed", "cancelled"] as const;
10
 
 
35
  queryClient.invalidateQueries({ queryKey: trpc.admin.listAllJobs.queryKey() });
36
  toast.success("Job cancelled");
37
  },
38
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
39
  }),
40
  );
41
 
apps/web/src/routes/admin.moderation.tsx CHANGED
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { toast } from "sonner";
 
7
 
8
  export const Route = createFileRoute("/admin/moderation")({
9
  component: AdminModeration,
@@ -29,7 +30,7 @@ function AdminModeration() {
29
  queryClient.invalidateQueries({ queryKey: trpc.admin.listLatestQuestions.queryKey() });
30
  toast.success(data.isPublic ? "Made public" : "Made private");
31
  },
32
- onError: (e: any) => toast.error(e.message),
33
  }),
34
  );
35
 
 
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { toast } from "sonner";
7
+ import { getErrorMessage } from "@/lib/error-utils";
8
 
9
  export const Route = createFileRoute("/admin/moderation")({
10
  component: AdminModeration,
 
30
  queryClient.invalidateQueries({ queryKey: trpc.admin.listLatestQuestions.queryKey() });
31
  toast.success(data.isPublic ? "Made public" : "Made private");
32
  },
33
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
34
  }),
35
  );
36
 
apps/web/src/routes/admin.users.tsx CHANGED
@@ -5,38 +5,18 @@ import { trpc } from "@/utils/trpc";
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
 
 
 
8
 
9
  const PAGE_SIZE = 20;
10
 
11
- function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
12
- if (totalPages <= 1) return null;
13
- const pages: (number | "...")[] = [];
14
- for (let i = 1; i <= totalPages; i++) {
15
- if (i === 1 || i === totalPages || (i >= page - 2 && i <= page + 2)) pages.push(i);
16
- else if (pages[pages.length - 1] !== "...") pages.push("...");
17
- }
18
- return (
19
- <div className="flex items-center justify-center gap-1 mt-6">
20
- <Button variant="outline" size="sm" onClick={() => onChange(page - 1)} disabled={page <= 1}>Previous</Button>
21
- {pages.map((p, i) =>
22
- p === "..." ? (
23
- <span key={`e-${i}`} className="px-2 text-[var(--warm-charcoal)]">...</span>
24
- ) : (
25
- <Button key={p} variant={p === page ? "default" : "outline"} size="sm" onClick={() => onChange(p as number)}>{p}</Button>
26
- ),
27
- )}
28
- <Button variant="outline" size="sm" onClick={() => onChange(page + 1)} disabled={page >= totalPages}>Next</Button>
29
- </div>
30
- );
31
- }
32
-
33
  export const Route = createFileRoute("/admin/users")({
34
  component: AdminUsers,
35
  });
36
 
37
  function AdminUsers() {
38
- const [search, setSearch] = useState("");
39
- const [debouncedSearch, setDebouncedSearch] = useState("");
40
  const [page, setPage] = useState(1);
41
 
42
  const usersQuery = useQuery(
@@ -49,8 +29,7 @@ function AdminUsers() {
49
 
50
  function handleSearch(val: string) {
51
  setSearch(val);
52
- clearTimeout((window as any).__ut);
53
- (window as any).__ut = setTimeout(() => { setDebouncedSearch(val); setPage(1); }, 300);
54
  }
55
 
56
  return (
@@ -107,7 +86,7 @@ function UserRow({ user }: { user: { id: string; name: string; email: string; ro
107
  queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
108
  toast.success(data.suspended ? "User suspended" : "User unsuspended");
109
  },
110
- onError: (e: any) => toast.error(e.message),
111
  }),
112
  );
113
 
@@ -117,7 +96,7 @@ function UserRow({ user }: { user: { id: string; name: string; email: string; ro
117
  queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
118
  toast.success(`Role: ${data.role}`);
119
  },
120
- onError: (e: any) => toast.error(e.message),
121
  }),
122
  );
123
 
 
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
  import { toast } from "sonner";
8
+ import { getErrorMessage } from "@/lib/error-utils";
9
+ import { useDebouncedValue } from "@/hooks/use-debounced-value";
10
+ import { Pagination } from "@/components/admin/Pagination";
11
 
12
  const PAGE_SIZE = 20;
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  export const Route = createFileRoute("/admin/users")({
15
  component: AdminUsers,
16
  });
17
 
18
  function AdminUsers() {
19
+ const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
 
20
  const [page, setPage] = useState(1);
21
 
22
  const usersQuery = useQuery(
 
29
 
30
  function handleSearch(val: string) {
31
  setSearch(val);
32
+ setPage(1);
 
33
  }
34
 
35
  return (
 
86
  queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
87
  toast.success(data.suspended ? "User suspended" : "User unsuspended");
88
  },
89
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
90
  }),
91
  );
92
 
 
96
  queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
97
  toast.success(`Role: ${data.role}`);
98
  },
99
+ onError: (e: unknown) => toast.error(getErrorMessage(e)),
100
  }),
101
  );
102
 
apps/web/src/routes/packages.tsx CHANGED
@@ -21,6 +21,8 @@ import { CalloutCard } from "@/components/bank/CalloutCard";
21
  import { PageTour, TourHelpButton } from "@/components/TourGuide";
22
  import type { Step } from "react-joyride";
23
  import { toast } from "sonner";
 
 
24
 
25
  export const Route = createFileRoute("/packages")({
26
  component: PackagesComponent,
@@ -40,14 +42,6 @@ export const Route = createFileRoute("/packages")({
40
  },
41
  });
42
 
43
- const EXAM_TYPES = [
44
- { id: "IELTS", name: "IELTS" },
45
- { id: "TOEFL", name: "TOEFL" },
46
- { id: "JLPT", name: "JLPT" },
47
- { id: "HSK", name: "HSK" },
48
- { id: "GOETHE", name: "German" },
49
- ];
50
-
51
  type Tab = "all" | "mine";
52
 
53
  function PackagesComponent() {
@@ -99,36 +93,38 @@ function PackagesComponent() {
99
  const total = query.data?.total ?? 0;
100
  const totalPages = Math.ceil(total / limit);
101
 
102
- const updateMutation = useMutation({
103
- ...trpc.package.update.mutationOptions(),
104
- onSuccess: () => {
105
- query.refetch();
106
- },
107
- });
 
108
 
109
  const togglePublic = (pkgId: string, current: boolean) => {
110
  updateMutation.mutate({ id: pkgId, isPublic: !current });
111
  };
112
 
113
- const bulkPublish = useMutation({
114
- ...trpc.package.bulkPublish.mutationOptions(),
115
- onSuccess: (data) => {
116
- query.refetch();
117
- setBulkMode(false);
118
- setSelectedIds(new Set());
119
- if (data.skipped > 0) {
120
- toast.success(
121
- `${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
122
- { description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
123
- );
124
- } else {
125
- toast.success(`${data.updated} paket berhasil dipublikasikan`);
126
- }
127
- },
128
- onError: (err: any) => {
129
- toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: err.message });
130
- },
131
- });
 
132
 
133
  // ── Bulk select ──
134
  const [bulkMode, setBulkMode] = useState(false);
@@ -144,7 +140,7 @@ function PackagesComponent() {
144
  };
145
 
146
  const clearSelection = () => setSelectedIds(new Set());
147
- const selectAll = () => setSelectedIds(new Set(packages.map((p: any) => p.id)));
148
 
149
  useEffect(() => {
150
  setBulkMode(false);
@@ -160,7 +156,7 @@ function PackagesComponent() {
160
  typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
161
  );
162
  const privatePackages = packages.filter(
163
- (p: any) => !p.isPublic && p.creatorUserId === userId,
164
  );
165
 
166
  const handleDismissCallout = () => {
@@ -169,7 +165,7 @@ function PackagesComponent() {
169
  };
170
 
171
  const handlePublishAllPrivate = () => {
172
- const ids = privatePackages.map((p: any) => p.id);
173
  if (ids.length > 0) bulkPublish.mutate({ ids });
174
  };
175
 
 
21
  import { PageTour, TourHelpButton } from "@/components/TourGuide";
22
  import type { Step } from "react-joyride";
23
  import { toast } from "sonner";
24
+ import { getErrorMessage } from "@/lib/error-utils";
25
+ import { EXAM_TYPES } from "@/lib/exam-constants";
26
 
27
  export const Route = createFileRoute("/packages")({
28
  component: PackagesComponent,
 
42
  },
43
  });
44
 
 
 
 
 
 
 
 
 
45
  type Tab = "all" | "mine";
46
 
47
  function PackagesComponent() {
 
93
  const total = query.data?.total ?? 0;
94
  const totalPages = Math.ceil(total / limit);
95
 
96
+ const updateMutation = useMutation(
97
+ trpc.package.update.mutationOptions({
98
+ onSuccess: () => {
99
+ query.refetch();
100
+ },
101
+ }),
102
+ );
103
 
104
  const togglePublic = (pkgId: string, current: boolean) => {
105
  updateMutation.mutate({ id: pkgId, isPublic: !current });
106
  };
107
 
108
+ const bulkPublish = useMutation(
109
+ trpc.package.bulkPublish.mutationOptions({
110
+ onSuccess: (data) => {
111
+ query.refetch();
112
+ setBulkMode(false);
113
+ setSelectedIds(new Set());
114
+ if (data.skipped > 0) {
115
+ toast.success(
116
+ `${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
117
+ { description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
118
+ );
119
+ } else {
120
+ toast.success(`${data.updated} paket berhasil dipublikasikan`);
121
+ }
122
+ },
123
+ onError: (err: unknown) => {
124
+ toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: getErrorMessage(err) });
125
+ },
126
+ }),
127
+ );
128
 
129
  // ── Bulk select ──
130
  const [bulkMode, setBulkMode] = useState(false);
 
140
  };
141
 
142
  const clearSelection = () => setSelectedIds(new Set());
143
+ const selectAll = () => setSelectedIds(new Set(packages.map((p) => p.id)));
144
 
145
  useEffect(() => {
146
  setBulkMode(false);
 
156
  typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
157
  );
158
  const privatePackages = packages.filter(
159
+ (p) => !p.isPublic && p.creatorUserId === userId,
160
  );
161
 
162
  const handleDismissCallout = () => {
 
165
  };
166
 
167
  const handlePublishAllPrivate = () => {
168
+ const ids = privatePackages.map((p) => p.id);
169
  if (ids.length > 0) bulkPublish.mutate({ ids });
170
  };
171
 
apps/web/vite.config.ts CHANGED
@@ -11,6 +11,18 @@ export default defineConfig({
11
  resolve: {
12
  tsconfigPaths: true,
13
  },
 
 
 
 
 
 
 
 
 
 
 
 
14
  plugins: [
15
  tailwindcss(),
16
  tanstackRouter({
@@ -23,11 +35,11 @@ export default defineConfig({
23
  manifest: {
24
  name: "labas",
25
  short_name: "labas",
26
- description: "labas - PWA Application",
27
  theme_color: "#0c0c0c",
28
  },
29
  pwaAssets: { disabled: false, config: true },
30
- devOptions: { enabled: true },
31
  }),
32
  ],
33
  });
 
11
  resolve: {
12
  tsconfigPaths: true,
13
  },
14
+ build: {
15
+ rollupOptions: {
16
+ output: {
17
+ manualChunks(id: string) {
18
+ if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) return "vendor";
19
+ if (id.includes("node_modules/@tanstack/react-router") || id.includes("node_modules/@tanstack/react-query")) return "router";
20
+ if (id.includes("node_modules/recharts")) return "charts";
21
+ if (id.includes("node_modules/react-joyride")) return "tour";
22
+ },
23
+ },
24
+ },
25
+ },
26
  plugins: [
27
  tailwindcss(),
28
  tanstackRouter({
 
35
  manifest: {
36
  name: "labas",
37
  short_name: "labas",
38
+ description: "AI-powered multi-language test practice platform",
39
  theme_color: "#0c0c0c",
40
  },
41
  pwaAssets: { disabled: false, config: true },
42
+ devOptions: { enabled: false },
43
  }),
44
  ],
45
  });
skills-lock.json CHANGED
@@ -11,11 +11,23 @@
11
  "sourceType": "github",
12
  "computedHash": "300fb8578258161e1752a2a4142a7e9ff178c960bcb83b84422e2987421f33bf"
13
  },
 
 
 
 
 
 
14
  "hono": {
15
  "source": "yusukebe/hono-skill",
16
  "sourceType": "github",
17
  "computedHash": "220e5e1b12bbaeec49ec362b6e2262d77632d0536e9758bba9acbbc323fef990"
18
  },
 
 
 
 
 
 
19
  "react-best-practices": {
20
  "source": "mastra-ai/mastra",
21
  "sourceType": "github",
@@ -48,6 +60,12 @@
48
  "skillPath": "skills/test-driven-development/SKILL.md",
49
  "computedHash": "126f1ebf6ccd414f42544f6e83d8cc5adb089e1108eaffb7c400701e37eecd9f"
50
  },
 
 
 
 
 
 
51
  "to-prd": {
52
  "source": "mattpocock/skills",
53
  "sourceType": "github",
 
11
  "sourceType": "github",
12
  "computedHash": "300fb8578258161e1752a2a4142a7e9ff178c960bcb83b84422e2987421f33bf"
13
  },
14
+ "grill-with-docs": {
15
+ "source": "mattpocock/skills",
16
+ "sourceType": "github",
17
+ "skillPath": "skills/engineering/grill-with-docs/SKILL.md",
18
+ "computedHash": "1adf321072f53cce3dcaf5357d91b8230d4aa647bb8a51756745337a6ee567b8"
19
+ },
20
  "hono": {
21
  "source": "yusukebe/hono-skill",
22
  "sourceType": "github",
23
  "computedHash": "220e5e1b12bbaeec49ec362b6e2262d77632d0536e9758bba9acbbc323fef990"
24
  },
25
+ "improve-codebase-architecture": {
26
+ "source": "mattpocock/skills",
27
+ "sourceType": "github",
28
+ "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
29
+ "computedHash": "c77b86b4332919499608f9af1880074e1fec65a59b95c70c27a9f39cd137865e"
30
+ },
31
  "react-best-practices": {
32
  "source": "mastra-ai/mastra",
33
  "sourceType": "github",
 
60
  "skillPath": "skills/test-driven-development/SKILL.md",
61
  "computedHash": "126f1ebf6ccd414f42544f6e83d8cc5adb089e1108eaffb7c400701e37eecd9f"
62
  },
63
+ "to-issues": {
64
+ "source": "mattpocock/skills",
65
+ "sourceType": "github",
66
+ "skillPath": "skills/engineering/to-issues/SKILL.md",
67
+ "computedHash": "47f648f3414848ccfc62cb41d2828b7e575fb5e7cbd6c4bdf630c063b5dc5e82"
68
+ },
69
  "to-prd": {
70
  "source": "mattpocock/skills",
71
  "sourceType": "github",