rogasper commited on
Commit
dbeb485
·
1 Parent(s): f90228b

Add new components for package creation and question detail, implement auto bundle modal, and update routing for builder combo. Also, include new hooks for job management and enhance UI with material icons and star rating components.

Browse files
apps/web/src/components/bank/AutoBundleModal.tsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Input } from "@labas/ui/components/input";
3
+ import { Button } from "@labas/ui/components/button";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+
6
+ export function AutoBundleModal({
7
+ availableCount,
8
+ examTypeName,
9
+ sectionTypeName,
10
+ onClose,
11
+ onCreate,
12
+ isPending,
13
+ }: {
14
+ availableCount: number;
15
+ examTypeName: string;
16
+ sectionTypeName: string;
17
+ onClose: () => void;
18
+ onCreate: (data: {
19
+ title: string;
20
+ description: string;
21
+ isPublic: boolean;
22
+ count: number;
23
+ sortOrder: "random" | "difficulty";
24
+ }) => void;
25
+ isPending: boolean;
26
+ }) {
27
+ const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
28
+ const [count, setCount] = useState(Math.min(availableCount, 10));
29
+ const [title, setTitle] = useState(`${examTypeName} ${sectionTypeName} Bundle — ${dateStr}`);
30
+ const [description, setDescription] = useState("");
31
+ const [isPublic, setIsPublic] = useState(false);
32
+ const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
33
+
34
+ return (
35
+ <div
36
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
37
+ onClick={(e) => {
38
+ if (e.target === e.currentTarget) onClose();
39
+ }}
40
+ >
41
+ <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
42
+ <div className="flex items-center justify-between mb-6">
43
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
44
+ Auto Bundle
45
+ </h2>
46
+ <button
47
+ onClick={onClose}
48
+ className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
49
+ >
50
+ <MaterialIcon name="close" className="text-[var(--clay-black)]" />
51
+ </button>
52
+ </div>
53
+
54
+ <div className="flex items-center gap-2 mb-4">
55
+ <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
56
+ {examTypeName}
57
+ </span>
58
+ {sectionTypeName && (
59
+ <span className="px-3 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
60
+ {sectionTypeName}
61
+ </span>
62
+ )}
63
+ <span className="text-sm text-[var(--warm-charcoal)]">
64
+ {availableCount} soal tersedia
65
+ </span>
66
+ </div>
67
+
68
+ {availableCount < 5 && (
69
+ <div className="mb-4 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">
70
+ <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
71
+ <span>
72
+ Soal yang tersedia sedikit ({availableCount} soal). Paket tetap bisa dibuat, tapi disarankan untuk menambah soal.
73
+ </span>
74
+ </div>
75
+ )}
76
+
77
+ <div className="space-y-4">
78
+ <div>
79
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
80
+ Judul Paket
81
+ </label>
82
+ <Input
83
+ value={title}
84
+ onChange={(e) => setTitle(e.target.value)}
85
+ placeholder="e.g. IELTS Reading Bundle"
86
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
87
+ />
88
+ </div>
89
+
90
+ <div>
91
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
92
+ Deskripsi
93
+ </label>
94
+ <Input
95
+ value={description}
96
+ onChange={(e) => setDescription(e.target.value)}
97
+ placeholder="Deskripsi singkat..."
98
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
99
+ />
100
+ </div>
101
+
102
+ <div>
103
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
104
+ Jumlah Soal: {count}
105
+ </label>
106
+ <input
107
+ type="range"
108
+ min={1}
109
+ max={availableCount}
110
+ value={count}
111
+ onChange={(e) => setCount(Number(e.target.value))}
112
+ className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
113
+ />
114
+ </div>
115
+
116
+ <div>
117
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
118
+ Urutan Soal
119
+ </label>
120
+ <div className="flex gap-2">
121
+ <button
122
+ onClick={() => setSortOrder("random")}
123
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
124
+ sortOrder === "random"
125
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
126
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
127
+ }`}
128
+ >
129
+ <MaterialIcon name="shuffle" className="text-sm mr-1" />
130
+ Acak
131
+ </button>
132
+ <button
133
+ onClick={() => setSortOrder("difficulty")}
134
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
135
+ sortOrder === "difficulty"
136
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
137
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
138
+ }`}
139
+ >
140
+ <MaterialIcon name="trending_up" className="text-sm mr-1" />
141
+ Difficulty
142
+ </button>
143
+ </div>
144
+ </div>
145
+
146
+ <label className="flex items-center gap-2 cursor-pointer">
147
+ <input
148
+ type="checkbox"
149
+ checked={isPublic}
150
+ onChange={(e) => setIsPublic(e.target.checked)}
151
+ className="w-4 h-4 rounded border-[var(--oat-border)]"
152
+ />
153
+ <span className="text-sm text-[var(--warm-charcoal)]">
154
+ Publikasikan ke Bank Soal
155
+ </span>
156
+ </label>
157
+ </div>
158
+
159
+ <div className="mt-6 flex gap-3">
160
+ <Button
161
+ variant="outline"
162
+ onClick={onClose}
163
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
164
+ >
165
+ Batal
166
+ </Button>
167
+ <Button
168
+ onClick={() =>
169
+ onCreate({
170
+ title,
171
+ description,
172
+ isPublic,
173
+ count,
174
+ sortOrder,
175
+ })
176
+ }
177
+ disabled={!title || isPending || count < 1}
178
+ className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
179
+ >
180
+ {isPending ? "Membuat..." : "Buat Paket"}
181
+ </Button>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ );
186
+ }
apps/web/src/components/bank/CreatePackageModal.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Input } from "@labas/ui/components/input";
3
+ import { Button } from "@labas/ui/components/button";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+
6
+ export function CreatePackageModal({
7
+ selectedCount,
8
+ onClose,
9
+ onCreate,
10
+ isPending,
11
+ examTypeName,
12
+ lowCount,
13
+ }: {
14
+ selectedCount: number;
15
+ onClose: () => void;
16
+ onCreate: (data: { title: string; description: string; isPublic: boolean }) => void;
17
+ isPending: boolean;
18
+ examTypeName: string;
19
+ lowCount: boolean;
20
+ }) {
21
+ const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
22
+ const [title, setTitle] = useState(`${examTypeName} Bundle — ${selectedCount} Soal`);
23
+ const [description, setDescription] = useState("");
24
+ const [isPublic, setIsPublic] = useState(false);
25
+
26
+ return (
27
+ <div
28
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
29
+ onClick={(e) => {
30
+ if (e.target === e.currentTarget) onClose();
31
+ }}
32
+ >
33
+ <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
34
+ <div className="flex items-center justify-between mb-6">
35
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
36
+ Buat Paket Soal
37
+ </h2>
38
+ <button
39
+ onClick={onClose}
40
+ className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
41
+ >
42
+ <MaterialIcon name="close" className="text-[var(--clay-black)]" />
43
+ </button>
44
+ </div>
45
+
46
+ <div className="flex items-center gap-2 mb-4">
47
+ <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
48
+ {examTypeName}
49
+ </span>
50
+ <span className="text-sm text-[var(--warm-charcoal)]">
51
+ {selectedCount} soal
52
+ </span>
53
+ </div>
54
+
55
+ {lowCount && (
56
+ <div className="mb-4 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">
57
+ <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
58
+ <span>
59
+ Soal yang tersedia sedikit ({selectedCount} soal). Paket tetap bisa dibuat, tapi disarankan untuk menambah soal.
60
+ </span>
61
+ </div>
62
+ )}
63
+
64
+ <div className="space-y-4">
65
+ <div>
66
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
67
+ Judul Paket
68
+ </label>
69
+ <Input
70
+ value={title}
71
+ onChange={(e) => setTitle(e.target.value)}
72
+ placeholder="e.g. IELTS Reading Bundle"
73
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
74
+ />
75
+ </div>
76
+
77
+ <div>
78
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
79
+ Deskripsi
80
+ </label>
81
+ <Input
82
+ value={description}
83
+ onChange={(e) => setDescription(e.target.value)}
84
+ placeholder="Deskripsi singkat..."
85
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
86
+ />
87
+ </div>
88
+
89
+ <label className="flex items-center gap-2 cursor-pointer">
90
+ <input
91
+ type="checkbox"
92
+ checked={isPublic}
93
+ onChange={(e) => setIsPublic(e.target.checked)}
94
+ className="w-4 h-4 rounded border-[var(--oat-border)]"
95
+ />
96
+ <span className="text-sm text-[var(--warm-charcoal)]">
97
+ Publikasikan ke Bank Soal
98
+ </span>
99
+ </label>
100
+ </div>
101
+
102
+ <div className="mt-6 flex gap-3">
103
+ <Button
104
+ variant="outline"
105
+ onClick={onClose}
106
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
107
+ >
108
+ Batal
109
+ </Button>
110
+ <Button
111
+ onClick={() => onCreate({ title, description, isPublic })}
112
+ disabled={!title || isPending}
113
+ className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
114
+ >
115
+ {isPending ? "Membuat..." : "Buat Paket"}
116
+ </Button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
apps/web/src/components/bank/QuestionDetailModal.tsx ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, useMutation } from "@tanstack/react-query";
2
+ import { Button } from "@labas/ui/components/button";
3
+ import { Card, CardContent } from "@labas/ui/components/card";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { trpc } from "@/utils/trpc";
6
+ import { formatLabel } from "@/lib/format";
7
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
+
9
+ export function QuestionDetailModal({
10
+ question,
11
+ onClose,
12
+ isSelected,
13
+ onToggleSelect,
14
+ isSelectable,
15
+ }: {
16
+ question: any;
17
+ onClose: () => void;
18
+ isSelected: boolean;
19
+ onToggleSelect: () => void;
20
+ isSelectable: boolean;
21
+ }) {
22
+ const { data: session } = authClient.useSession();
23
+ const isOwner = question.creatorUserId === session?.user.id;
24
+
25
+ const ratingQuery = useQuery(
26
+ trpc.rating.getQuestionRating.queryOptions({ questionId: question.id }),
27
+ );
28
+
29
+ const rateMutation = useMutation({
30
+ ...trpc.rating.rateQuestion.mutationOptions(),
31
+ onSuccess: () => ratingQuery.refetch(),
32
+ });
33
+
34
+ return (
35
+ <div
36
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
37
+ onClick={(e) => {
38
+ if (e.target === e.currentTarget) onClose();
39
+ }}
40
+ >
41
+ <div className="bg-[var(--warm-cream)] w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
42
+ {/* Header */}
43
+ <div className="flex items-start justify-between mb-6">
44
+ <div className="flex gap-2 flex-wrap">
45
+ <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
46
+ {question.examTypeName}
47
+ </span>
48
+ <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
49
+ {question.sectionTypeName}
50
+ </span>
51
+ <span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
52
+ {formatLabel(question.format)}
53
+ </span>
54
+ <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
55
+ Level {question.difficulty}
56
+ </span>
57
+ </div>
58
+ <button
59
+ onClick={onClose}
60
+ className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
61
+ >
62
+ <MaterialIcon name="close" className="text-[var(--clay-black)]" />
63
+ </button>
64
+ </div>
65
+
66
+ {/* Passage */}
67
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
68
+ <CardContent className="p-6">
69
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
70
+ <MaterialIcon name="menu_book" />
71
+ Teks Bacaan
72
+ </h2>
73
+ <div className="text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap text-sm">
74
+ {question.passageText}
75
+ </div>
76
+ </CardContent>
77
+ </Card>
78
+
79
+ {/* Question */}
80
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
81
+ <CardContent className="p-6">
82
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
83
+ <MaterialIcon name="help_outline" />
84
+ Pertanyaan
85
+ </h2>
86
+ <p className="text-lg text-[var(--clay-black)] font-medium mb-4">
87
+ {question.questionText}
88
+ </p>
89
+
90
+ {!!question.options &&
91
+ Array.isArray(question.options as unknown[]) &&
92
+ (question.options as unknown[]).length > 0 && (
93
+ <div className="space-y-2 mt-4">
94
+ {(question.options as Array<{ key: string; text: string }>).map(
95
+ (opt) => (
96
+ <div
97
+ key={opt.key}
98
+ className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
99
+ >
100
+ <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
101
+ {opt.key}
102
+ </span>
103
+ <span className="text-[var(--clay-black)]">{opt.text}</span>
104
+ </div>
105
+ ),
106
+ )}
107
+ </div>
108
+ )}
109
+
110
+ {!question.options && (
111
+ <div className="p-4 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)] mt-4 text-sm text-[var(--warm-charcoal)]">
112
+ <span className="font-semibold text-[var(--clay-black)]">
113
+ Jenis soal:{" "}
114
+ </span>
115
+ {formatLabel(question.format)}
116
+ </div>
117
+ )}
118
+ </CardContent>
119
+ </Card>
120
+
121
+ {/* Lock card */}
122
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
123
+ <CardContent className="p-6">
124
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 flex items-center gap-2">
125
+ <MaterialIcon name="lock" />
126
+ Jawaban & Penjelasan
127
+ </h2>
128
+ <p className="text-sm text-[var(--warm-charcoal)]">
129
+ Jawaban dan penjelasan akan tersedia setelah kamu mencoba mengerjakan
130
+ soal ini dalam paket latihan.
131
+ </p>
132
+ </CardContent>
133
+ </Card>
134
+
135
+ {/* Rating */}
136
+ <div className="flex items-center gap-4 mb-6">
137
+ <div className="flex gap-1">
138
+ {[1, 2, 3, 4, 5].map((star) => (
139
+ <button
140
+ key={star}
141
+ onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
142
+ className="transition-transform hover:scale-110"
143
+ >
144
+ <MaterialIcon
145
+ name={(ratingQuery.data?.myRating ?? 0) >= star ? "star" : "star_outline"}
146
+ className={`text-xl ${(ratingQuery.data?.myRating ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
147
+ />
148
+ </button>
149
+ ))}
150
+ </div>
151
+ {ratingQuery.data?.avgRating && (
152
+ <span className="text-sm text-[var(--warm-charcoal)]">
153
+ {ratingQuery.data.avgRating}/5 ({question.usageCount}x digunakan)
154
+ </span>
155
+ )}
156
+ {!ratingQuery.data?.avgRating && (
157
+ <span className="text-sm text-[var(--warm-charcoal)]">
158
+ {question.usageCount}x digunakan
159
+ </span>
160
+ )}
161
+ </div>
162
+
163
+ {/* Meta & Owner Actions */}
164
+ <div className="flex flex-wrap items-center justify-between gap-4 text-sm text-[var(--warm-charcoal)] border-t border-[var(--oat-border)] pt-4">
165
+ <div className="flex gap-4">
166
+ <span>Dibuat oleh {question.creatorName ?? "Anonim"}</span>
167
+ <span>•</span>
168
+ <span className="capitalize">{question.source}</span>
169
+ </div>
170
+ {isOwner && (
171
+ <div className="flex gap-2 items-center">
172
+ <span
173
+ className={`px-3 py-1.5 rounded-full text-xs font-semibold ${
174
+ question.isPublic
175
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
176
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
177
+ }`}
178
+ >
179
+ {question.isPublic ? "Publik" : "Privat"}
180
+ </span>
181
+ </div>
182
+ )}
183
+ </div>
184
+
185
+ {/* Footer Actions */}
186
+ <div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3">
187
+ <Button
188
+ variant="outline"
189
+ onClick={onToggleSelect}
190
+ disabled={!isSelectable}
191
+ className={`rounded-[var(--radius-lg)] border-2 clay-hover ${
192
+ !isSelectable
193
+ ? "opacity-40 cursor-not-allowed border-[var(--oat-border)] text-[var(--warm-silver)]"
194
+ : isSelected
195
+ ? "border-[var(--pomegranate-400)] text-[var(--pomegranate-600)] bg-[var(--pomegranate-50)]"
196
+ : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
197
+ }`}
198
+ >
199
+ <MaterialIcon name={isSelected ? "remove" : "add"} className="mr-2" />
200
+ {!isSelectable
201
+ ? "Jenis ujian berbeda"
202
+ : isSelected
203
+ ? "Hapus dari Pilihan"
204
+ : "Tambah ke Paket"}
205
+ </Button>
206
+ <Button
207
+ onClick={onClose}
208
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
209
+ >
210
+ Tutup
211
+ </Button>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
apps/web/src/components/generate/ResultSection.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "@tanstack/react-router";
2
+ import { Button } from "@labas/ui/components/button";
3
+ import { Card, CardContent, CardHeader } from "@labas/ui/components/card";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+ import type { GenerationResult } from "@labas/ai";
6
+
7
+ interface ResultSectionProps {
8
+ result: GenerationResult;
9
+ generatedPackageId: string | null;
10
+ }
11
+
12
+ export function ResultSection({ result, generatedPackageId }: ResultSectionProps) {
13
+ return (
14
+ <div className="mt-16 space-y-6">
15
+ <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
16
+ <div>
17
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h2>
18
+ <p className="text-sm text-[var(--warm-charcoal)] mt-1">
19
+ Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
20
+ </p>
21
+ </div>
22
+ <div className="flex gap-3">
23
+ {generatedPackageId && (
24
+ <Link to="/package/$id" params={{ id: generatedPackageId }}>
25
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
26
+ <MaterialIcon name="play_arrow" className="mr-2" />
27
+ Lihat Paket
28
+ </Button>
29
+ </Link>
30
+ )}
31
+ <Link to="/bank">
32
+ <Button className="bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]">
33
+ <MaterialIcon name="database" className="mr-2" />
34
+ Bank Soal
35
+ </Button>
36
+ </Link>
37
+ </div>
38
+ </div>
39
+
40
+ <div className="text-sm text-[var(--warm-charcoal)] mb-4 font-mono">
41
+ Model: {result.meta.model} · Tokens: {result.meta.tokensUsed ?? "?"} · Waktu: {result.meta.durationMs}ms · {result.questions.length} soal
42
+ </div>
43
+
44
+ {result.questions.map((q, idx) => (
45
+ <Card key={idx} className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
46
+ <CardHeader>
47
+ <div className="flex items-center gap-3">
48
+ <span className="bg-[var(--matcha-600)] text-[var(--pure-white)] w-8 h-8 flex items-center justify-center rounded-[var(--radius-md)] font-bold text-sm">
49
+ {idx + 1}
50
+ </span>
51
+ <span className="text-sm font-bold text-[var(--matcha-600)] uppercase tracking-tighter">
52
+ {q.format.replace(/_/g, " ")}
53
+ </span>
54
+ </div>
55
+ </CardHeader>
56
+ <CardContent className="space-y-4">
57
+ <div className="p-5 rounded-[var(--radius-lg)] bg-[var(--oat-light)] text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap text-base">
58
+ {q.passageText}
59
+ </div>
60
+ <p className="font-medium text-[var(--clay-black)] text-lg">{q.questionText}</p>
61
+ {"options" in q && q.options && (
62
+ <div className="space-y-2">
63
+ {q.options.map((opt) => (
64
+ <div
65
+ key={opt.key}
66
+ className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
67
+ >
68
+ <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
69
+ {opt.key}
70
+ </span>
71
+ <span className="text-[var(--clay-black)]">{opt.text}</span>
72
+ </div>
73
+ ))}
74
+ </div>
75
+ )}
76
+ <div className="flex gap-2 flex-wrap">
77
+ {q.skillTags.map((tag) => (
78
+ <span
79
+ key={tag}
80
+ className="px-3 py-1 rounded-full bg-[var(--oat-light)] text-xs font-medium text-[var(--warm-charcoal)]"
81
+ >
82
+ {tag}
83
+ </span>
84
+ ))}
85
+ </div>
86
+ </CardContent>
87
+ </Card>
88
+ ))}
89
+ </div>
90
+ );
91
+ }
apps/web/src/components/generate/TestBlueprintCard.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
3
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
4
+ import { EXAM_TYPES } from "@/lib/generate-constants";
5
+
6
+ interface TestBlueprintCardProps {
7
+ examType: string;
8
+ section: string;
9
+ selectedFormats: string[];
10
+ questionCount: number;
11
+ weaknessAlign: number;
12
+ mode: "quick" | "agentic";
13
+ setMode: (mode: "quick" | "agentic") => void;
14
+ isGenerating: boolean;
15
+ generatePending: boolean;
16
+ hasKey: boolean;
17
+ jobProgress: number;
18
+ jobProgressMessage?: string | null;
19
+ error: string | null;
20
+ onGenerate: () => void;
21
+ }
22
+
23
+ export function TestBlueprintCard({
24
+ examType,
25
+ selectedFormats,
26
+ questionCount,
27
+ weaknessAlign,
28
+ mode,
29
+ setMode,
30
+ isGenerating,
31
+ generatePending,
32
+ hasKey,
33
+ jobProgress,
34
+ jobProgressMessage,
35
+ error,
36
+ onGenerate,
37
+ }: TestBlueprintCardProps) {
38
+ return (
39
+ <div className="lg:col-span-4 sticky top-8">
40
+ <Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] clay-shadow rounded-[var(--radius-xl)]">
41
+ <CardHeader>
42
+ <div className="flex items-center gap-3">
43
+ <MaterialIcon name="analytics" className="text-[var(--matcha-600)]" />
44
+ <CardTitle className="font-headline text-xl text-[var(--clay-black)]">Test Blueprint</CardTitle>
45
+ </div>
46
+ </CardHeader>
47
+ <CardContent className="space-y-6">
48
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
49
+ <span className="text-[var(--warm-charcoal)]">Estimasi Durasi</span>
50
+ <span className="font-bold text-[var(--clay-black)]">{questionCount * 2} Menit</span>
51
+ </div>
52
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
53
+ <span className="text-[var(--warm-charcoal)]">Jumlah Soal</span>
54
+ <span className="font-bold text-[var(--clay-black)]">{questionCount} Soal</span>
55
+ </div>
56
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
57
+ <span className="text-[var(--warm-charcoal)]">Format</span>
58
+ <span className="font-bold text-[var(--clay-black)]">{selectedFormats.length} Jenis</span>
59
+ </div>
60
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
61
+ <span className="text-[var(--warm-charcoal)]">Ujian</span>
62
+ <span className="font-bold text-[var(--clay-black)]">
63
+ {EXAM_TYPES.find((t) => t.id === examType)?.name}
64
+ </span>
65
+ </div>
66
+
67
+ {/* AI Confidence Score Orbit */}
68
+ <div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-6 flex flex-col items-center gap-4 relative overflow-hidden">
69
+ <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--matcha-600)] to-transparent" />
70
+ <span className="text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)] font-bold">
71
+ AI Confidence Score
72
+ </span>
73
+ <div className="relative flex items-center justify-center">
74
+ <svg className="w-32 h-32 transform -rotate-90">
75
+ <circle className="text-[var(--warm-silver)]" cx="64" cy="64" fill="transparent" r="56" stroke="currentColor" strokeWidth="8" />
76
+ <circle
77
+ className="text-[var(--matcha-600)]"
78
+ cx="64"
79
+ cy="64"
80
+ fill="transparent"
81
+ r="56"
82
+ stroke="currentColor"
83
+ strokeDasharray="351.85"
84
+ strokeDashoffset={351.85 - (351.85 * (weaknessAlign / 100))}
85
+ strokeWidth="8"
86
+ />
87
+ </svg>
88
+ <span className="absolute text-3xl font-headline font-extrabold text-[var(--clay-black)]">{Math.round(weaknessAlign)}%</span>
89
+ </div>
90
+ <p className="text-[11px] text-center text-[var(--warm-charcoal)] px-2 leading-tight">
91
+ Probabilitas tinggi untuk mengatasi blindspot linguistik utama.
92
+ </p>
93
+ </div>
94
+
95
+ {/* Mode Toggle */}
96
+ <div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
97
+ <button
98
+ onClick={() => setMode("quick")}
99
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
100
+ mode === "quick"
101
+ ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
102
+ : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
103
+ }`}
104
+ >
105
+ <MaterialIcon name="flash_on" className="text-sm mr-1" />
106
+ Quick
107
+ </button>
108
+ <button
109
+ onClick={() => setMode("agentic")}
110
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
111
+ mode === "agentic"
112
+ ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
113
+ : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
114
+ }`}
115
+ >
116
+ <MaterialIcon name="psychology" className="text-sm mr-1" />
117
+ Agentic
118
+ </button>
119
+ </div>
120
+
121
+ {mode === "agentic" && (
122
+ <div className="p-3 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] border-2 border-[var(--badge-blue-bg)] text-xs text-[var(--badge-blue-text)]">
123
+ <div className="flex items-center gap-2 mb-1">
124
+ <MaterialIcon name="info" className="text-sm" />
125
+ <span className="font-semibold">Mode Agentic</span>
126
+ </div>
127
+ <p>Multi-step validation: passage → validate → questions → self-check → quality score. Lebih lambat tapi kualitas lebih terjamin.</p>
128
+ </div>
129
+ )}
130
+
131
+ {/* Primary CTA */}
132
+ <Button
133
+ className="w-full py-5 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg flex items-center justify-center gap-3 clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] transition-all active:scale-95 h-auto"
134
+ onClick={onGenerate}
135
+ disabled={isGenerating || generatePending || selectedFormats.length === 0 || !hasKey}
136
+ >
137
+ <MaterialIcon name="auto_awesome" className="group-hover:rotate-12 transition-transform" />
138
+ {isGenerating
139
+ ? mode === "agentic"
140
+ ? `Agentic... ${jobProgress}%`
141
+ : `Generating... ${jobProgress}%`
142
+ : "Generate & Launch"}
143
+ </Button>
144
+
145
+ {/* Progress */}
146
+ {isGenerating && (
147
+ <div className="space-y-2">
148
+ <div className="flex items-center gap-3 p-3 rounded-[var(--radius-md)] bg-[var(--matcha-300)]/20 border-2 border-[var(--matcha-300)]">
149
+ <MaterialIcon name="psychology" className="text-[var(--matcha-600)] animate-pulse" />
150
+ <div>
151
+ <p className="text-sm font-semibold text-[var(--matcha-800)]">
152
+ {jobProgressMessage ?? "Sedang berjalan..."}
153
+ </p>
154
+ <p className="text-xs text-[var(--warm-charcoal)]">
155
+ {mode === "agentic"
156
+ ? "Passage → Validate → Questions → Self-Check → Score"
157
+ : "Quick generation mode"}
158
+ </p>
159
+ </div>
160
+ </div>
161
+ <div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
162
+ <div
163
+ className="h-full bg-[var(--matcha-600)] transition-all duration-500"
164
+ style={{ width: `${jobProgress}%` }}
165
+ />
166
+ </div>
167
+ <p className="text-xs text-[var(--warm-charcoal)] text-center">
168
+ Bisa ditinggal — hasil akan muncul otomatis
169
+ </p>
170
+ </div>
171
+ )}
172
+
173
+ {error && (
174
+ <div className="p-4 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm border-2 border-[var(--badge-blue-bg)]">
175
+ {error}
176
+ </div>
177
+ )}
178
+ </CardContent>
179
+ </Card>
180
+ </div>
181
+ );
182
+ }
apps/web/src/components/test/AttemptTestView.tsx ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "@tanstack/react-router";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Button } from "@labas/ui/components/button";
4
+ import { Card, CardContent } from "@labas/ui/components/card";
5
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
+ import { formatTime } from "@/lib/time";
7
+ import { trpc } from "@/utils/trpc";
8
+ import { QuestionInput } from "./QuestionInput";
9
+
10
+ interface AttemptTestViewProps {
11
+ attemptId: string;
12
+ pkg: any;
13
+ currentSectionIdx: number;
14
+ setCurrentSectionIdx: (idx: number) => void;
15
+ answers: Record<string, string>;
16
+ onAnswerChange: (questionId: string, sectionResultId: string, value: string) => void;
17
+ timeElapsed: number;
18
+ answeredCount: number;
19
+ totalQuestions: number;
20
+ onFinish: () => void;
21
+ isFinished: boolean;
22
+ submittingQId: string | null;
23
+ }
24
+
25
+ export function AttemptTestView({
26
+ attemptId,
27
+ pkg,
28
+ currentSectionIdx,
29
+ setCurrentSectionIdx,
30
+ answers,
31
+ onAnswerChange,
32
+ timeElapsed,
33
+ answeredCount,
34
+ totalQuestions,
35
+ onFinish,
36
+ isFinished,
37
+ submittingQId,
38
+ }: AttemptTestViewProps) {
39
+ const attemptQuery = useQuery(trpc.attempt.getById.queryOptions({ id: attemptId }));
40
+ const attempt = attemptQuery.data;
41
+ const currentSection = pkg.sections[currentSectionIdx];
42
+ const sectionData = attempt?.sections?.[currentSectionIdx];
43
+ const sectionResultId = sectionData?.sectionResultId;
44
+
45
+ return (
46
+ <div className="min-h-screen bg-[var(--warm-cream)]">
47
+ {/* Top Bar */}
48
+ <div className="sticky top-0 z-50 bg-[var(--pure-white)] border-b-2 border-[var(--oat-border)] px-4 md:px-8 py-3">
49
+ <div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
50
+ <div className="flex items-center gap-3">
51
+ <Link to="/package/$id" params={{ id: pkg.id }} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]">
52
+ <MaterialIcon name="close" />
53
+ </Link>
54
+ <h1 className="font-headline font-bold text-[var(--clay-black)] text-sm md:text-base truncate max-w-[200px] md:max-w-sm">
55
+ {pkg.title}
56
+ </h1>
57
+ </div>
58
+
59
+ <div className="flex items-center gap-4">
60
+ <div className="flex items-center gap-1.5 bg-[var(--oat-light)] px-3 py-1.5 rounded-full text-sm font-mono text-[var(--clay-black)]">
61
+ <MaterialIcon name="timer" className="text-sm" />
62
+ {formatTime(timeElapsed)}
63
+ </div>
64
+ <div className="hidden md:flex items-center gap-1.5 text-sm text-[var(--warm-charcoal)]">
65
+ <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
66
+ {answeredCount}/{totalQuestions}
67
+ </div>
68
+ <Button
69
+ onClick={onFinish}
70
+ disabled={isFinished}
71
+ className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)] text-sm px-4 py-2"
72
+ >
73
+ Selesai
74
+ </Button>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ {/* Section Tabs */}
80
+ <div className="max-w-5xl mx-auto px-4 md:px-8 py-4">
81
+ <div className="flex gap-2 overflow-x-auto pb-2">
82
+ {pkg.sections.map((sec: any, idx: number) => (
83
+ <button
84
+ key={sec.id}
85
+ onClick={() => setCurrentSectionIdx(idx)}
86
+ className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold whitespace-nowrap border-2 transition-colors ${
87
+ idx === currentSectionIdx
88
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] border-[var(--clay-black)]"
89
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-[var(--oat-border)] hover:border-[var(--matcha-400)]"
90
+ }`}
91
+ >
92
+ {sec.title}
93
+ </button>
94
+ ))}
95
+ </div>
96
+ </div>
97
+
98
+ {/* Main Content */}
99
+ <div className="max-w-5xl mx-auto px-4 md:px-8 pb-32">
100
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
101
+ {/* Left: Passage */}
102
+ <div className="lg:col-span-1">
103
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-24">
104
+ <CardContent className="p-5">
105
+ <h2 className="font-headline font-bold text-[var(--clay-black)] mb-3 flex items-center gap-2">
106
+ <MaterialIcon name="menu_book" className="text-[var(--matcha-600)]" />
107
+ Bacaan
108
+ </h2>
109
+ <div className="prose prose-sm max-w-none text-[var(--clay-black)] whitespace-pre-wrap text-sm leading-relaxed max-h-[60vh] overflow-y-auto pr-2">
110
+ {currentSection.questions[0]?.passageText ?? "Tidak ada bacaan untuk section ini."}
111
+ </div>
112
+ </CardContent>
113
+ </Card>
114
+ </div>
115
+
116
+ {/* Right: Questions */}
117
+ <div className="lg:col-span-2 space-y-4">
118
+ {currentSection.questions.map((q: any, idx: number) => {
119
+ const answerValue = answers[q.id] ?? "";
120
+ return (
121
+ <Card
122
+ key={q.id}
123
+ className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]"
124
+ >
125
+ <CardContent className="p-5">
126
+ <div className="flex items-start gap-3 mb-4">
127
+ <span className="w-8 h-8 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0">
128
+ {idx + 1}
129
+ </span>
130
+ <div>
131
+ <p className="text-[var(--clay-black)] font-medium leading-relaxed">
132
+ {q.questionText}
133
+ </p>
134
+ <div className="flex gap-2 mt-1">
135
+ <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
136
+ {q.format.replace(/_/g, " ")}
137
+ </span>
138
+ {q.difficulty && (
139
+ <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
140
+ Lv.{q.difficulty}
141
+ </span>
142
+ )}
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <div className="pl-11">
148
+ {submittingQId === q.id && (
149
+ <div className="text-xs text-[var(--matcha-600)] mb-2 flex items-center gap-1">
150
+ <MaterialIcon name="sync" className="text-xs animate-spin" />
151
+ Menyimpan...
152
+ </div>
153
+ )}
154
+ <QuestionInput
155
+ question={q}
156
+ value={answerValue}
157
+ onChange={(val) => {
158
+ if (sectionResultId) {
159
+ onAnswerChange(q.id, sectionResultId, val);
160
+ }
161
+ }}
162
+ disabled={isFinished || !sectionResultId}
163
+ />
164
+ </div>
165
+ </CardContent>
166
+ </Card>
167
+ );
168
+ })}
169
+
170
+ {/* Section Navigation */}
171
+ <div className="flex justify-between pt-4">
172
+ <Button
173
+ variant="outline"
174
+ onClick={() => setCurrentSectionIdx(Math.max(0, currentSectionIdx - 1))}
175
+ disabled={currentSectionIdx === 0}
176
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
177
+ >
178
+ <MaterialIcon name="arrow_back" />
179
+ <span className="ml-2">Sebelumnya</span>
180
+ </Button>
181
+ {currentSectionIdx < pkg.sections.length - 1 ? (
182
+ <Button
183
+ onClick={() => setCurrentSectionIdx(currentSectionIdx + 1)}
184
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
185
+ >
186
+ <span className="mr-2">Selanjutnya</span>
187
+ <MaterialIcon name="arrow_forward" />
188
+ </Button>
189
+ ) : (
190
+ <Button
191
+ onClick={onFinish}
192
+ disabled={isFinished}
193
+ className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)]"
194
+ >
195
+ <MaterialIcon name="check_circle" />
196
+ <span className="ml-2">Selesaikan</span>
197
+ </Button>
198
+ )}
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ );
205
+ }
apps/web/src/components/test/QuestionInput.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Input } from "@labas/ui/components/input";
2
+
3
+ const MCQ_FORMATS = [
4
+ "multiple_choice",
5
+ "synonym",
6
+ "grammar_in_context",
7
+ "sentence_completion",
8
+ "reference",
9
+ "kanji_reading",
10
+ "particle_choice",
11
+ "article_case",
12
+ "matching_headings",
13
+ "matching_information",
14
+ "summary_completion",
15
+ "cloze",
16
+ ];
17
+
18
+ const TRUE_FALSE_CHOICES = [
19
+ { key: "TRUE", label: "True" },
20
+ { key: "FALSE", label: "False" },
21
+ { key: "NOT_GIVEN", label: "Not Given" },
22
+ ];
23
+
24
+ const AUTHOR_VIEW_CHOICES = [
25
+ { key: "YES", label: "Yes" },
26
+ { key: "NO", label: "No" },
27
+ { key: "NOT_GIVEN", label: "Not Given" },
28
+ ];
29
+
30
+ const radioClass =
31
+ "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-400)] transition-colors";
32
+ const radioSelected = "border-[var(--matcha-600)] bg-[var(--matcha-100)]";
33
+ const radioDisabled = "opacity-60 cursor-not-allowed";
34
+
35
+ export function QuestionInput({
36
+ question,
37
+ value,
38
+ onChange,
39
+ disabled,
40
+ }: {
41
+ question: any;
42
+ value: string;
43
+ onChange: (val: string) => void;
44
+ disabled?: boolean;
45
+ }) {
46
+ const format = question.format;
47
+ const options = question.options as Array<{ key: string; text: string }> | undefined;
48
+
49
+ if (MCQ_FORMATS.includes(format)) {
50
+ if (!options || options.length === 0) {
51
+ return (
52
+ <div className="text-sm text-[var(--warm-silver)] italic">
53
+ Tidak ada opsi tersedia untuk soal ini.
54
+ </div>
55
+ );
56
+ }
57
+ return (
58
+ <div className="space-y-2">
59
+ {options.map((opt) => (
60
+ <label
61
+ key={opt.key}
62
+ className={`${radioClass} ${value === opt.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
63
+ >
64
+ <input
65
+ type="radio"
66
+ name={question.id}
67
+ value={opt.key}
68
+ checked={value === opt.key}
69
+ onChange={() => onChange(opt.key)}
70
+ disabled={disabled}
71
+ className="hidden"
72
+ />
73
+ <span className="w-8 h-8 rounded-full bg-[var(--oat-light)] text-[var(--clay-black)] text-sm font-bold flex items-center justify-center shrink-0">
74
+ {opt.key}
75
+ </span>
76
+ <span className="text-sm text-[var(--clay-black)]">{opt.text}</span>
77
+ </label>
78
+ ))}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ if (format === "true_false_not_given") {
84
+ return (
85
+ <div className="space-y-2">
86
+ {TRUE_FALSE_CHOICES.map((c) => (
87
+ <label
88
+ key={c.key}
89
+ className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
90
+ >
91
+ <input
92
+ type="radio"
93
+ name={question.id}
94
+ value={c.key}
95
+ checked={value === c.key}
96
+ onChange={() => onChange(c.key)}
97
+ disabled={disabled}
98
+ className="hidden"
99
+ />
100
+ <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
101
+ </label>
102
+ ))}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (format === "author_view") {
108
+ return (
109
+ <div className="space-y-2">
110
+ {AUTHOR_VIEW_CHOICES.map((c) => (
111
+ <label
112
+ key={c.key}
113
+ className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
114
+ >
115
+ <input
116
+ type="radio"
117
+ name={question.id}
118
+ value={c.key}
119
+ checked={value === c.key}
120
+ onChange={() => onChange(c.key)}
121
+ disabled={disabled}
122
+ className="hidden"
123
+ />
124
+ <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
125
+ </label>
126
+ ))}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <Input
133
+ value={value}
134
+ onChange={(e) => onChange(e.target.value)}
135
+ disabled={disabled}
136
+ placeholder="Ketik jawaban Anda..."
137
+ className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
138
+ />
139
+ );
140
+ }
apps/web/src/components/ui/MaterialIcon.tsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
2
+ return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
3
+ }
apps/web/src/components/ui/StarRating.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MaterialIcon } from "./MaterialIcon";
2
+
3
+ export function StarRating({ value }: { value: number | null }) {
4
+ return (
5
+ <div className="flex gap-1">
6
+ {[1, 2, 3, 4, 5].map((star) => (
7
+ <MaterialIcon
8
+ key={star}
9
+ name={(value ?? 0) >= star ? "star" : "star_outline"}
10
+ className={`text-xl ${(value ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
11
+ />
12
+ ))}
13
+ </div>
14
+ );
15
+ }
apps/web/src/hooks/use-generation-job.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { trpc } from "@/utils/trpc";
4
+ import type { GenerationResult } from "@labas/ai";
5
+
6
+ const STORAGE_KEY = "labas_active_job";
7
+
8
+ export function useGenerationJob() {
9
+ const [result, setResult] = useState<GenerationResult | null>(null);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const [generatedPackageId, setGeneratedPackageId] = useState<string | null>(null);
12
+ const [jobId, setJobIdState] = useState<string | null>(null);
13
+
14
+ const setJobId = useCallback((id: string | null) => {
15
+ setJobIdState(id);
16
+ if (id) {
17
+ sessionStorage.setItem(STORAGE_KEY, id);
18
+ } else {
19
+ sessionStorage.removeItem(STORAGE_KEY);
20
+ }
21
+ }, []);
22
+
23
+ // Recover from sessionStorage on mount
24
+ useEffect(() => {
25
+ const saved = sessionStorage.getItem(STORAGE_KEY);
26
+ if (saved) setJobIdState(saved);
27
+ }, []);
28
+
29
+ // Fallback: check myJobs for active jobs
30
+ const myJobsQuery = useQuery(trpc.ai.myJobs.queryOptions({ limit: 10, offset: 0 }));
31
+
32
+ useEffect(() => {
33
+ if (!jobId && myJobsQuery.data) {
34
+ const active = myJobsQuery.data.find(
35
+ (j: any) => j.status === "pending" || j.status === "running",
36
+ );
37
+ if (active) setJobId(active.id);
38
+ }
39
+ }, [myJobsQuery.data, jobId, setJobId]);
40
+
41
+ // Poll job status
42
+ const jobQuery = useQuery({
43
+ ...trpc.ai.getJobStatus.queryOptions({ jobId: jobId! }, { enabled: !!jobId }),
44
+ refetchInterval: (query) => {
45
+ const data = query.state.data;
46
+ if (!data) return 1000;
47
+ if (data.status === "completed" || data.status === "failed") return false;
48
+ return 1000;
49
+ },
50
+ });
51
+
52
+ const isGenerating =
53
+ jobId !== null &&
54
+ (!jobQuery.data || (jobQuery.data.status !== "completed" && jobQuery.data.status !== "failed"));
55
+
56
+ // Handle completion / failure
57
+ useEffect(() => {
58
+ if (jobQuery.data?.status === "completed" && jobQuery.data.resultJson) {
59
+ const res = jobQuery.data.resultJson as GenerationResult & { generatedPackageId?: string | null };
60
+ setResult(res);
61
+ setGeneratedPackageId(res.generatedPackageId ?? null);
62
+ setJobId(null);
63
+ }
64
+ if (jobQuery.data?.status === "failed") {
65
+ setError(jobQuery.data.errorMessage ?? "Generation failed");
66
+ setJobId(null);
67
+ }
68
+ }, [jobQuery.data, setJobId]);
69
+
70
+ const reset = useCallback(() => {
71
+ setResult(null);
72
+ setError(null);
73
+ setGeneratedPackageId(null);
74
+ setJobId(null);
75
+ }, [setJobId]);
76
+
77
+ return {
78
+ result,
79
+ error,
80
+ generatedPackageId,
81
+ jobId,
82
+ isGenerating,
83
+ jobQuery,
84
+ setError,
85
+ setJobId,
86
+ reset,
87
+ };
88
+ }
apps/web/src/hooks/use-package-builder.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { useNavigate } from "@tanstack/react-router";
3
+ import { trpc } from "@/utils/trpc";
4
+
5
+ export function usePackageBuilder() {
6
+ const navigate = useNavigate();
7
+
8
+ const createPackage = useMutation(trpc.package.create.mutationOptions());
9
+ const addSection = useMutation(trpc.package.addSection.mutationOptions());
10
+ const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
11
+
12
+ const isPending = createPackage.isPending || addSection.isPending || addQuestion.isPending;
13
+
14
+ const handleCreatePackage = async (params: {
15
+ selectedIds: Set<string>;
16
+ questions: Array<{ id: string; examTypeId: string; sectionTypeId: string; sectionTypeName?: string | null; [key: string]: any }>;
17
+ title: string;
18
+ description: string;
19
+ isPublic: boolean;
20
+ }) => {
21
+ if (params.selectedIds.size === 0) return;
22
+
23
+ const firstId = Array.from(params.selectedIds)[0];
24
+ const firstQ = params.questions.find((q) => q.id === firstId);
25
+ const examTypeId = firstQ?.examTypeId ?? "";
26
+
27
+ try {
28
+ const pkg = await createPackage.mutateAsync({
29
+ title: params.title,
30
+ description: params.description,
31
+ examTypeId,
32
+ isPublic: params.isPublic,
33
+ estimatedDurationMin: params.selectedIds.size * 2,
34
+ });
35
+
36
+ const sec = await addSection.mutateAsync({
37
+ packageId: pkg.id,
38
+ sectionTypeId: firstQ?.sectionTypeId ?? "READING",
39
+ title: `${firstQ?.sectionTypeName ?? "Reading"} Section`,
40
+ orderIndex: 0,
41
+ });
42
+
43
+ const ids = Array.from(params.selectedIds);
44
+ for (let i = 0; i < ids.length; i++) {
45
+ await addQuestion.mutateAsync({
46
+ sectionId: sec.id,
47
+ questionId: ids[i],
48
+ orderIndex: i,
49
+ });
50
+ }
51
+
52
+ navigate({ to: "/package/$id", params: { id: pkg.id } });
53
+ } catch (err: any) {
54
+ alert("Gagal membuat paket: " + err.message);
55
+ }
56
+ };
57
+
58
+ const handleAutoBundle = async (params: {
59
+ title: string;
60
+ description: string;
61
+ isPublic: boolean;
62
+ examTypeId: string;
63
+ sectionTypeId: string;
64
+ count: number;
65
+ sortOrder: "random" | "difficulty";
66
+ allQuestions: Array<{ id: string; examTypeId: string; difficulty: number }>;
67
+ }) => {
68
+ const available = params.allQuestions.filter((q) => q.examTypeId === params.examTypeId);
69
+
70
+ let picked = available;
71
+ if (params.sortOrder === "random") {
72
+ picked = [...available].sort(() => Math.random() - 0.5);
73
+ } else {
74
+ picked = [...available].sort((a, b) => a.difficulty - b.difficulty);
75
+ }
76
+ picked = picked.slice(0, params.count);
77
+
78
+ if (picked.length === 0) {
79
+ alert("Tidak ada soal tersedia untuk ujian ini.");
80
+ return;
81
+ }
82
+
83
+ try {
84
+ const pkg = await createPackage.mutateAsync({
85
+ title: params.title,
86
+ description: params.description,
87
+ examTypeId: params.examTypeId,
88
+ isPublic: params.isPublic,
89
+ estimatedDurationMin: picked.length * 2,
90
+ });
91
+
92
+ const sec = await addSection.mutateAsync({
93
+ packageId: pkg.id,
94
+ sectionTypeId: params.sectionTypeId,
95
+ title: `${params.sectionTypeId} Section`,
96
+ orderIndex: 0,
97
+ });
98
+
99
+ for (let i = 0; i < picked.length; i++) {
100
+ await addQuestion.mutateAsync({
101
+ sectionId: sec.id,
102
+ questionId: picked[i].id,
103
+ orderIndex: i,
104
+ });
105
+ }
106
+
107
+ navigate({ to: "/package/$id", params: { id: pkg.id } });
108
+ } catch (err: any) {
109
+ alert("Gagal membuat paket: " + err.message);
110
+ }
111
+ };
112
+
113
+ return {
114
+ isPending,
115
+ handleCreatePackage,
116
+ handleAutoBundle,
117
+ };
118
+ }
apps/web/src/hooks/use-question-selection.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useMemo } from "react";
2
+
3
+ interface Question {
4
+ id: string;
5
+ examTypeId: string;
6
+ [key: string]: any;
7
+ }
8
+
9
+ export function useQuestionSelection(questions: Question[]) {
10
+ const [isSelectMode, setIsSelectMode] = useState(false);
11
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
12
+
13
+ const lockedExamType = useMemo(() => {
14
+ if (selectedIds.size === 0) return null;
15
+ const firstId = Array.from(selectedIds)[0];
16
+ const firstQ = questions.find((q) => q.id === firstId);
17
+ return firstQ?.examTypeId ?? null;
18
+ }, [selectedIds, questions]);
19
+
20
+ const toggleSelection = (id: string) => {
21
+ const q = questions.find((item) => item.id === id);
22
+ if (!q) return;
23
+ if (lockedExamType && q.examTypeId !== lockedExamType) return;
24
+
25
+ setSelectedIds((prev) => {
26
+ const next = new Set(prev);
27
+ if (next.has(id)) next.delete(id);
28
+ else next.add(id);
29
+ return next;
30
+ });
31
+ };
32
+
33
+ const selectAll = () => {
34
+ if (lockedExamType) {
35
+ setSelectedIds(
36
+ new Set(questions.filter((q) => q.examTypeId === lockedExamType).map((q) => q.id)),
37
+ );
38
+ } else {
39
+ setSelectedIds(new Set(questions.map((q) => q.id)));
40
+ }
41
+ };
42
+
43
+ const clearSelection = () => setSelectedIds(new Set());
44
+
45
+ const exitSelectMode = () => {
46
+ setIsSelectMode(false);
47
+ setSelectedIds(new Set());
48
+ };
49
+
50
+ return {
51
+ isSelectMode,
52
+ setIsSelectMode,
53
+ selectedIds,
54
+ lockedExamType,
55
+ toggleSelection,
56
+ selectAll,
57
+ clearSelection,
58
+ exitSelectMode,
59
+ };
60
+ }
apps/web/src/hooks/use-test-session.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { useNavigate } from "@tanstack/react-router";
4
+ import { trpc } from "@/utils/trpc";
5
+
6
+ export function useTestSession(packageId: string) {
7
+ const navigate = useNavigate();
8
+
9
+ const [attemptId, setAttemptId] = useState<string | null>(null);
10
+ const [currentSectionIdx, setCurrentSectionIdx] = useState(0);
11
+ const [answers, setAnswers] = useState<Record<string, string>>({});
12
+ const [timeElapsed, setTimeElapsed] = useState(0);
13
+ const [isStarted, setIsStarted] = useState(false);
14
+ const [isFinished, setIsFinished] = useState(false);
15
+ const [submittingQId, setSubmittingQId] = useState<string | null>(null);
16
+
17
+ const startMutation = useMutation(trpc.attempt.start.mutationOptions());
18
+ const submitMutation = useMutation(trpc.attempt.submitAnswer.mutationOptions());
19
+ const finishMutation = useMutation(trpc.attempt.finish.mutationOptions());
20
+
21
+ // Timer
22
+ useEffect(() => {
23
+ if (!isStarted || isFinished) return;
24
+ const interval = setInterval(() => {
25
+ setTimeElapsed((t) => t + 1);
26
+ }, 1000);
27
+ return () => clearInterval(interval);
28
+ }, [isStarted, isFinished]);
29
+
30
+ const handleStart = useCallback(async () => {
31
+ const res = await startMutation.mutateAsync({ packageId });
32
+ setAttemptId(res.attemptId);
33
+ setIsStarted(true);
34
+ }, [packageId, startMutation]);
35
+
36
+ const handleAnswerChange = useCallback(
37
+ async (questionId: string, sectionResultId: string, value: string) => {
38
+ if (!attemptId || isFinished) return;
39
+ setAnswers((prev) => ({ ...prev, [questionId]: value }));
40
+ setSubmittingQId(questionId);
41
+ try {
42
+ await submitMutation.mutateAsync({
43
+ attemptId,
44
+ sectionResultId,
45
+ questionId,
46
+ userAnswer: value,
47
+ });
48
+ } finally {
49
+ setSubmittingQId(null);
50
+ }
51
+ },
52
+ [attemptId, isFinished, submitMutation],
53
+ );
54
+
55
+ const handleFinish = useCallback(async () => {
56
+ if (!attemptId) return;
57
+ setIsFinished(true);
58
+ await finishMutation.mutateAsync({ attemptId });
59
+ navigate({ to: "/attempt/$id", params: { id: attemptId } });
60
+ }, [attemptId, finishMutation, navigate]);
61
+
62
+ return {
63
+ attemptId,
64
+ currentSectionIdx,
65
+ setCurrentSectionIdx,
66
+ answers,
67
+ timeElapsed,
68
+ isStarted,
69
+ isFinished,
70
+ submittingQId,
71
+ startPending: startMutation.isPending,
72
+ handleStart,
73
+ handleAnswerChange,
74
+ handleFinish,
75
+ };
76
+ }
apps/web/src/lib/exam-constants.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ];
8
+
9
+ export const SECTIONS = [
10
+ { id: "READING", name: "Reading" },
11
+ { id: "WRITING", name: "Writing" },
12
+ ];
13
+
14
+ export const FORMATS = [
15
+ "multiple_choice",
16
+ "true_false_not_given",
17
+ "fill_blank",
18
+ "synonym",
19
+ "grammar_in_context",
20
+ "sentence_completion",
21
+ "cloze",
22
+ "reference",
23
+ "author_view",
24
+ "matching_headings",
25
+ "kanji_reading",
26
+ "particle_choice",
27
+ "article_case",
28
+ ];
29
+
30
+ export const DIFFICULTIES = [
31
+ { value: 1, label: "Beginner" },
32
+ { value: 2, label: "Elementary" },
33
+ { value: 3, label: "Intermediate" },
34
+ { value: 4, label: "Advanced" },
35
+ { value: 5, label: "Expert" },
36
+ ];
apps/web/src/lib/format.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export function formatLabel(fmt: string) {
2
+ return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
3
+ }
apps/web/src/lib/generate-constants.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const EXAM_TYPES = [
2
+ { id: "IELTS", name: "IELTS Academic", code: "gb" },
3
+ { id: "TOEFL", name: "TOEFL iBT", code: "us" },
4
+ { id: "JLPT", name: "JLPT", code: "jp" },
5
+ { id: "HSK", name: "HSK", code: "cn" },
6
+ { id: "GOETHE", name: "Goethe-Zertifikat", code: "de" },
7
+ ];
8
+
9
+ export const SECTIONS = [
10
+ { id: "READING", name: "Reading", icon: "menu_book" },
11
+ { id: "WRITING", name: "Writing", icon: "edit_note" },
12
+ ];
13
+
14
+ export const FORMATS = [
15
+ { id: "multiple_choice", name: "Multiple Choice" },
16
+ { id: "true_false_not_given", name: "True / False / Not Given" },
17
+ { id: "fill_blank", name: "Fill in Blank" },
18
+ { id: "synonym", name: "Synonym / Vocabulary" },
19
+ { id: "grammar_in_context", name: "Grammar in Context" },
20
+ { id: "sentence_completion", name: "Sentence Completion" },
21
+ { id: "cloze", name: "Cloze Test" },
22
+ { id: "reference", name: "Reference (Pronoun)" },
23
+ { id: "author_view", name: "Author's View" },
24
+ { id: "matching_headings", name: "Matching Headings" },
25
+ { id: "kanji_reading", name: "Kanji Reading (JLPT)" },
26
+ { id: "particle_choice", name: "Particle Choice (JLPT)" },
27
+ { id: "article_case", name: "Article / Case (German)" },
28
+ ];
29
+
30
+ export const TOPICS = ["Science & Tech", "Business", "Sociology", "Arts", "History", "Environment", "Health", "Education"];
31
+ export const DIFFICULTIES = ["Beginner", "Intermediate", "Academic", "Expert"];
apps/web/src/lib/time.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export function formatTime(totalSeconds: number) {
2
+ const m = Math.floor(totalSeconds / 60);
3
+ const s = totalSeconds % 60;
4
+ return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
5
+ }
apps/web/src/routeTree.gen.ts CHANGED
@@ -62,9 +62,9 @@ const PackageIdRoute = PackageIdRouteImport.update({
62
  getParentRoute: () => rootRouteImport,
63
  } as any)
64
  const BuilderComboRoute = BuilderComboRouteImport.update({
65
- id: '/combo',
66
- path: '/combo',
67
- getParentRoute: () => BuilderRoute,
68
  } as any)
69
  const AttemptIdRoute = AttemptIdRouteImport.update({
70
  id: '/attempt/$id',
@@ -168,6 +168,7 @@ export interface RootRouteChildren {
168
  PackagesRoute: typeof PackagesRoute
169
  SettingsRoute: typeof SettingsRoute
170
  AttemptIdRoute: typeof AttemptIdRoute
 
171
  PackageIdRoute: typeof PackageIdRouteWithChildren
172
  }
173
 
@@ -231,10 +232,10 @@ declare module '@tanstack/react-router' {
231
  }
232
  '/builder/combo': {
233
  id: '/builder/combo'
234
- path: '/combo'
235
  fullPath: '/builder/combo'
236
  preLoaderRoute: typeof BuilderComboRouteImport
237
- parentRoute: typeof BuilderRoute
238
  }
239
  '/attempt/$id': {
240
  id: '/attempt/$id'
@@ -274,6 +275,7 @@ const rootRouteChildren: RootRouteChildren = {
274
  PackagesRoute: PackagesRoute,
275
  SettingsRoute: SettingsRoute,
276
  AttemptIdRoute: AttemptIdRoute,
 
277
  PackageIdRoute: PackageIdRouteWithChildren,
278
  }
279
  export const routeTree = rootRouteImport
 
62
  getParentRoute: () => rootRouteImport,
63
  } as any)
64
  const BuilderComboRoute = BuilderComboRouteImport.update({
65
+ id: '/builder/combo',
66
+ path: '/builder/combo',
67
+ getParentRoute: () => rootRouteImport,
68
  } as any)
69
  const AttemptIdRoute = AttemptIdRouteImport.update({
70
  id: '/attempt/$id',
 
168
  PackagesRoute: typeof PackagesRoute
169
  SettingsRoute: typeof SettingsRoute
170
  AttemptIdRoute: typeof AttemptIdRoute
171
+ BuilderComboRoute: typeof BuilderComboRoute
172
  PackageIdRoute: typeof PackageIdRouteWithChildren
173
  }
174
 
 
232
  }
233
  '/builder/combo': {
234
  id: '/builder/combo'
235
+ path: '/builder/combo'
236
  fullPath: '/builder/combo'
237
  preLoaderRoute: typeof BuilderComboRouteImport
238
+ parentRoute: typeof rootRouteImport
239
  }
240
  '/attempt/$id': {
241
  id: '/attempt/$id'
 
275
  PackagesRoute: PackagesRoute,
276
  SettingsRoute: SettingsRoute,
277
  AttemptIdRoute: AttemptIdRoute,
278
+ BuilderComboRoute: BuilderComboRoute,
279
  PackageIdRoute: PackageIdRouteWithChildren,
280
  }
281
  export const routeTree = rootRouteImport
apps/web/src/routes/__root.tsx CHANGED
@@ -43,9 +43,11 @@ function RootComponent() {
43
  disableTransitionOnChange
44
  storageKey="labas-theme"
45
  >
46
- <div className="min-h-screen bg-background">
47
  <Sidebar />
48
- <main className={`min-h-screen transition-all duration-300 ${collapsed ? "md:ml-16" : "md:ml-64"}`}>
 
 
49
  <Outlet />
50
  </main>
51
  </div>
 
43
  disableTransitionOnChange
44
  storageKey="labas-theme"
45
  >
46
+ <div className="min-h-screen bg-background relative">
47
  <Sidebar />
48
+ <main
49
+ className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
50
+ >
51
  <Outlet />
52
  </main>
53
  </div>
apps/web/src/routes/attempt.$id.tsx CHANGED
@@ -4,6 +4,8 @@ import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
 
 
7
 
8
  export const Route = createFileRoute("/attempt/$id")({
9
  component: AttemptResultComponent,
@@ -16,16 +18,6 @@ export const Route = createFileRoute("/attempt/$id")({
16
  },
17
  });
18
 
19
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
20
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
21
- }
22
-
23
- function formatTime(totalSeconds: number) {
24
- const m = Math.floor(totalSeconds / 60);
25
- const s = totalSeconds % 60;
26
- return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
27
- }
28
-
29
  function AttemptResultComponent() {
30
  const { id: attemptId } = Route.useParams();
31
  const { data: session } = authClient.useSession();
 
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
7
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
+ import { formatTime } from "@/lib/time";
9
 
10
  export const Route = createFileRoute("/attempt/$id")({
11
  component: AttemptResultComponent,
 
18
  },
19
  });
20
 
 
 
 
 
 
 
 
 
 
 
21
  function AttemptResultComponent() {
22
  const { id: attemptId } = Route.useParams();
23
  const { data: session } = authClient.useSession();
apps/web/src/routes/bank.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import { useState, useMemo } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
- import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
@@ -14,6 +14,14 @@ import {
14
  SelectTrigger,
15
  SelectValue,
16
  } from "@labas/ui/components/select";
 
 
 
 
 
 
 
 
17
 
18
  export const Route = createFileRoute("/bank")({
19
  component: BankComponent,
@@ -26,593 +34,11 @@ export const Route = createFileRoute("/bank")({
26
  },
27
  });
28
 
29
- const EXAM_TYPES = [
30
- { id: "IELTS", name: "IELTS" },
31
- { id: "TOEFL", name: "TOEFL" },
32
- { id: "JLPT", name: "JLPT" },
33
- { id: "HSK", name: "HSK" },
34
- { id: "GOETHE", name: "German" },
35
- ];
36
-
37
- const SECTIONS = [
38
- { id: "READING", name: "Reading" },
39
- { id: "WRITING", name: "Writing" },
40
- ];
41
-
42
- const FORMATS = [
43
- "multiple_choice",
44
- "true_false_not_given",
45
- "fill_blank",
46
- "synonym",
47
- "grammar_in_context",
48
- "sentence_completion",
49
- "cloze",
50
- "reference",
51
- "author_view",
52
- "matching_headings",
53
- "kanji_reading",
54
- "particle_choice",
55
- "article_case",
56
- ];
57
-
58
- const DIFFICULTIES = [
59
- { value: 1, label: "Beginner" },
60
- { value: 2, label: "Elementary" },
61
- { value: 3, label: "Intermediate" },
62
- { value: 4, label: "Advanced" },
63
- { value: 5, label: "Expert" },
64
- ];
65
-
66
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
67
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
68
- }
69
-
70
- function formatLabel(fmt: string) {
71
- return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
72
- }
73
-
74
- function StarRating({ value }: { value: number | null }) {
75
- return (
76
- <div className="flex gap-1">
77
- {[1, 2, 3, 4, 5].map((star) => (
78
- <MaterialIcon
79
- key={star}
80
- name={(value ?? 0) >= star ? "star" : "star_outline"}
81
- className={`text-xl ${(value ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
82
- />
83
- ))}
84
- </div>
85
- );
86
- }
87
-
88
- // ── Create Package Modal ────────────────────────────────────
89
-
90
- function CreatePackageModal({
91
- selectedCount,
92
- onClose,
93
- onCreate,
94
- isPending,
95
- examTypeName,
96
- lowCount,
97
- }: {
98
- selectedCount: number;
99
- onClose: () => void;
100
- onCreate: (data: { title: string; description: string; isPublic: boolean; examTypeId: string }) => void;
101
- isPending: boolean;
102
- examTypeName: string;
103
- lowCount: boolean;
104
- }) {
105
- const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
106
- const [title, setTitle] = useState(`${examTypeName} Bundle — ${selectedCount} Soal`);
107
- const [description, setDescription] = useState("");
108
- const [isPublic, setIsPublic] = useState(false);
109
-
110
- return (
111
- <div
112
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
113
- onClick={(e) => {
114
- if (e.target === e.currentTarget) onClose();
115
- }}
116
- >
117
- <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
118
- <div className="flex items-center justify-between mb-6">
119
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
120
- Buat Paket Soal
121
- </h2>
122
- <button
123
- onClick={onClose}
124
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
125
- >
126
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
127
- </button>
128
- </div>
129
-
130
- <div className="flex items-center gap-2 mb-4">
131
- <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
132
- {examTypeName}
133
- </span>
134
- <span className="text-sm text-[var(--warm-charcoal)]">
135
- {selectedCount} soal
136
- </span>
137
- </div>
138
-
139
- {lowCount && (
140
- <div className="mb-4 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">
141
- <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
142
- <span>
143
- Soal yang tersedia sedikit ({selectedCount} soal). Paket tetap bisa dibuat, tapi disarankan untuk menambah soal.
144
- </span>
145
- </div>
146
- )}
147
-
148
- <div className="space-y-4">
149
- <div>
150
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
151
- Judul Paket
152
- </label>
153
- <Input
154
- value={title}
155
- onChange={(e) => setTitle(e.target.value)}
156
- placeholder="e.g. IELTS Reading Bundle"
157
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
158
- />
159
- </div>
160
-
161
- <div>
162
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
163
- Deskripsi
164
- </label>
165
- <Input
166
- value={description}
167
- onChange={(e) => setDescription(e.target.value)}
168
- placeholder="Deskripsi singkat..."
169
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
170
- />
171
- </div>
172
-
173
- <label className="flex items-center gap-2 cursor-pointer">
174
- <input
175
- type="checkbox"
176
- checked={isPublic}
177
- onChange={(e) => setIsPublic(e.target.checked)}
178
- className="w-4 h-4 rounded border-[var(--oat-border)]"
179
- />
180
- <span className="text-sm text-[var(--warm-charcoal)]">
181
- Publikasikan ke Bank Soal
182
- </span>
183
- </label>
184
- </div>
185
-
186
- <div className="mt-6 flex gap-3">
187
- <Button
188
- variant="outline"
189
- onClick={onClose}
190
- className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
191
- >
192
- Batal
193
- </Button>
194
- <Button
195
- onClick={() => onCreate({ title, description, isPublic, examTypeId: "" })}
196
- disabled={!title || isPending}
197
- className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
198
- >
199
- {isPending ? "Membuat..." : "Buat Paket"}
200
- </Button>
201
- </div>
202
- </div>
203
- </div>
204
- );
205
- }
206
-
207
- // ── Auto Bundle Modal ───────────────────────────────────────
208
-
209
- function AutoBundleModal({
210
- availableCount,
211
- examTypeId,
212
- examTypeName,
213
- sectionTypeId,
214
- sectionTypeName,
215
- onClose,
216
- onCreate,
217
- isPending,
218
- }: {
219
- availableCount: number;
220
- examTypeId: string;
221
- examTypeName: string;
222
- sectionTypeId: string;
223
- sectionTypeName: string;
224
- onClose: () => void;
225
- onCreate: (data: {
226
- title: string;
227
- description: string;
228
- isPublic: boolean;
229
- examTypeId: string;
230
- sectionTypeId: string;
231
- count: number;
232
- sortOrder: "random" | "difficulty";
233
- }) => void;
234
- isPending: boolean;
235
- }) {
236
- const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
237
- const [count, setCount] = useState(Math.min(availableCount, 10));
238
- const [title, setTitle] = useState(`${examTypeName} ${sectionTypeName} Bundle — ${dateStr}`);
239
- const [description, setDescription] = useState("");
240
- const [isPublic, setIsPublic] = useState(false);
241
- const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
242
-
243
- return (
244
- <div
245
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
246
- onClick={(e) => {
247
- if (e.target === e.currentTarget) onClose();
248
- }}
249
- >
250
- <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
251
- <div className="flex items-center justify-between mb-6">
252
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
253
- Auto Bundle
254
- </h2>
255
- <button
256
- onClick={onClose}
257
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
258
- >
259
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
260
- </button>
261
- </div>
262
-
263
- <div className="flex items-center gap-2 mb-4">
264
- <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
265
- {examTypeName}
266
- </span>
267
- {sectionTypeName && (
268
- <span className="px-3 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
269
- {sectionTypeName}
270
- </span>
271
- )}
272
- <span className="text-sm text-[var(--warm-charcoal)]">
273
- {availableCount} soal tersedia
274
- </span>
275
- </div>
276
-
277
- {availableCount < 5 && (
278
- <div className="mb-4 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">
279
- <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
280
- <span>
281
- Soal yang tersedia sedikit ({availableCount} soal). Paket tetap bisa dibuat, tapi disarankan untuk menambah soal.
282
- </span>
283
- </div>
284
- )}
285
-
286
- <div className="space-y-4">
287
- <div>
288
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
289
- Judul Paket
290
- </label>
291
- <Input
292
- value={title}
293
- onChange={(e) => setTitle(e.target.value)}
294
- placeholder="e.g. IELTS Reading Bundle"
295
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
296
- />
297
- </div>
298
-
299
- <div>
300
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
301
- Deskripsi
302
- </label>
303
- <Input
304
- value={description}
305
- onChange={(e) => setDescription(e.target.value)}
306
- placeholder="Deskripsi singkat..."
307
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
308
- />
309
- </div>
310
-
311
- <div>
312
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
313
- Jumlah Soal: {count}
314
- </label>
315
- <input
316
- type="range"
317
- min={1}
318
- max={availableCount}
319
- value={count}
320
- onChange={(e) => setCount(Number(e.target.value))}
321
- className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
322
- />
323
- </div>
324
-
325
- <div>
326
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
327
- Urutan Soal
328
- </label>
329
- <div className="flex gap-2">
330
- <button
331
- onClick={() => setSortOrder("random")}
332
- className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
333
- sortOrder === "random"
334
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
335
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
336
- }`}
337
- >
338
- <MaterialIcon name="shuffle" className="text-sm mr-1" />
339
- Acak
340
- </button>
341
- <button
342
- onClick={() => setSortOrder("difficulty")}
343
- className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
344
- sortOrder === "difficulty"
345
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
346
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
347
- }`}
348
- >
349
- <MaterialIcon name="trending_up" className="text-sm mr-1" />
350
- Difficulty
351
- </button>
352
- </div>
353
- </div>
354
-
355
- <label className="flex items-center gap-2 cursor-pointer">
356
- <input
357
- type="checkbox"
358
- checked={isPublic}
359
- onChange={(e) => setIsPublic(e.target.checked)}
360
- className="w-4 h-4 rounded border-[var(--oat-border)]"
361
- />
362
- <span className="text-sm text-[var(--warm-charcoal)]">
363
- Publikasikan ke Bank Soal
364
- </span>
365
- </label>
366
- </div>
367
-
368
- <div className="mt-6 flex gap-3">
369
- <Button
370
- variant="outline"
371
- onClick={onClose}
372
- className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
373
- >
374
- Batal
375
- </Button>
376
- <Button
377
- onClick={() => onCreate({
378
- title,
379
- description,
380
- isPublic,
381
- examTypeId,
382
- sectionTypeId: sectionTypeId || "READING",
383
- count,
384
- sortOrder,
385
- })}
386
- disabled={!title || isPending || count < 1}
387
- className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
388
- >
389
- {isPending ? "Membuat..." : "Buat Paket"}
390
- </Button>
391
- </div>
392
- </div>
393
- </div>
394
- );
395
- }
396
-
397
- // ── Question Detail Modal ───────────────────────────────────
398
-
399
- function QuestionDetailModal({
400
- question,
401
- onClose,
402
- isSelected,
403
- onToggleSelect,
404
- isSelectable,
405
- }: {
406
- question: any;
407
- onClose: () => void;
408
- isSelected: boolean;
409
- onToggleSelect: () => void;
410
- isSelectable: boolean;
411
- }) {
412
- const { data: session } = authClient.useSession();
413
- const isOwner = question.creatorUserId === session?.user.id;
414
-
415
- const ratingQuery = useQuery(
416
- trpc.rating.getQuestionRating.queryOptions({ questionId: question.id }),
417
- );
418
-
419
- const rateMutation = useMutation({
420
- ...trpc.rating.rateQuestion.mutationOptions(),
421
- onSuccess: () => ratingQuery.refetch(),
422
- });
423
-
424
- return (
425
- <div
426
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
427
- onClick={(e) => {
428
- if (e.target === e.currentTarget) onClose();
429
- }}
430
- >
431
- <div className="bg-[var(--warm-cream)] w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
432
- {/* Header */}
433
- <div className="flex items-start justify-between mb-6">
434
- <div className="flex gap-2 flex-wrap">
435
- <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
436
- {question.examTypeName}
437
- </span>
438
- <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
439
- {question.sectionTypeName}
440
- </span>
441
- <span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
442
- {formatLabel(question.format)}
443
- </span>
444
- <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
445
- Level {question.difficulty}
446
- </span>
447
- </div>
448
- <button
449
- onClick={onClose}
450
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
451
- >
452
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
453
- </button>
454
- </div>
455
-
456
- {/* Passage */}
457
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
458
- <CardContent className="p-6">
459
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
460
- <MaterialIcon name="menu_book" />
461
- Teks Bacaan
462
- </h2>
463
- <div className="text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap text-sm">
464
- {question.passageText}
465
- </div>
466
- </CardContent>
467
- </Card>
468
-
469
- {/* Question */}
470
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
471
- <CardContent className="p-6">
472
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
473
- <MaterialIcon name="help_outline" />
474
- Pertanyaan
475
- </h2>
476
- <p className="text-lg text-[var(--clay-black)] font-medium mb-4">
477
- {question.questionText}
478
- </p>
479
-
480
- {!!question.options &&
481
- Array.isArray(question.options as unknown[]) &&
482
- (question.options as unknown[]).length > 0 && (
483
- <div className="space-y-2 mt-4">
484
- {(question.options as Array<{ key: string; text: string }>).map(
485
- (opt) => (
486
- <div
487
- key={opt.key}
488
- className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
489
- >
490
- <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
491
- {opt.key}
492
- </span>
493
- <span className="text-[var(--clay-black)]">{opt.text}</span>
494
- </div>
495
- ),
496
- )}
497
- </div>
498
- )}
499
-
500
- {!question.options && (
501
- <div className="p-4 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)] mt-4 text-sm text-[var(--warm-charcoal)]">
502
- <span className="font-semibold text-[var(--clay-black)]">
503
- Jenis soal:{" "}
504
- </span>
505
- {formatLabel(question.format)}
506
- </div>
507
- )}
508
- </CardContent>
509
- </Card>
510
-
511
- {/* Lock card */}
512
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
513
- <CardContent className="p-6">
514
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 flex items-center gap-2">
515
- <MaterialIcon name="lock" />
516
- Jawaban & Penjelasan
517
- </h2>
518
- <p className="text-sm text-[var(--warm-charcoal)]">
519
- Jawaban dan penjelasan akan tersedia setelah kamu mencoba mengerjakan
520
- soal ini dalam paket latihan.
521
- </p>
522
- </CardContent>
523
- </Card>
524
-
525
- {/* Rating */}
526
- <div className="flex items-center gap-4 mb-6">
527
- <div className="flex gap-1">
528
- {[1, 2, 3, 4, 5].map((star) => (
529
- <button
530
- key={star}
531
- onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
532
- className="transition-transform hover:scale-110"
533
- >
534
- <MaterialIcon
535
- name={(ratingQuery.data?.myRating ?? 0) >= star ? "star" : "star_outline"}
536
- className={`text-xl ${(ratingQuery.data?.myRating ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
537
- />
538
- </button>
539
- ))}
540
- </div>
541
- {ratingQuery.data?.avgRating && (
542
- <span className="text-sm text-[var(--warm-charcoal)]">
543
- {ratingQuery.data.avgRating}/5 ({question.usageCount}x digunakan)
544
- </span>
545
- )}
546
- {!ratingQuery.data?.avgRating && (
547
- <span className="text-sm text-[var(--warm-charcoal)]">
548
- {question.usageCount}x digunakan
549
- </span>
550
- )}
551
- </div>
552
-
553
- {/* Meta & Owner Actions */}
554
- <div className="flex flex-wrap items-center justify-between gap-4 text-sm text-[var(--warm-charcoal)] border-t border-[var(--oat-border)] pt-4">
555
- <div className="flex gap-4">
556
- <span>Dibuat oleh {question.creatorName ?? "Anonim"}</span>
557
- <span>•</span>
558
- <span className="capitalize">{question.source}</span>
559
- </div>
560
- {isOwner && (
561
- <div className="flex gap-2 items-center">
562
- <span
563
- className={`px-3 py-1.5 rounded-full text-xs font-semibold ${
564
- question.isPublic
565
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
566
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
567
- }`}
568
- >
569
- {question.isPublic ? "Publik" : "Privat"}
570
- </span>
571
- </div>
572
- )}
573
- </div>
574
-
575
- {/* Footer Actions */}
576
- <div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3">
577
- <Button
578
- variant="outline"
579
- onClick={onToggleSelect}
580
- disabled={!isSelectable}
581
- className={`rounded-[var(--radius-lg)] border-2 clay-hover ${
582
- !isSelectable
583
- ? "opacity-40 cursor-not-allowed border-[var(--oat-border)] text-[var(--warm-silver)]"
584
- : isSelected
585
- ? "border-[var(--pomegranate-400)] text-[var(--pomegranate-600)] bg-[var(--pomegranate-50)]"
586
- : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
587
- }`}
588
- >
589
- <MaterialIcon name={isSelected ? "remove" : "add"} className="mr-2" />
590
- {!isSelectable
591
- ? "Jenis ujian berbeda"
592
- : isSelected
593
- ? "Hapus dari Pilihan"
594
- : "Tambah ke Paket"}
595
- </Button>
596
- <Button
597
- onClick={onClose}
598
- className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
599
- >
600
- Tutup
601
- </Button>
602
- </div>
603
- </div>
604
- </div>
605
- );
606
- }
607
-
608
- // ── Bank Component ──────────────────────────────────────────
609
-
610
  type Tab = "mine" | "public";
611
 
612
  function BankComponent() {
613
  const { data: session } = authClient.useSession();
614
  const userId = session?.user.id;
615
- const navigate = useNavigate();
616
 
617
  const [tab, setTab] = useState<Tab>("mine");
618
  const [search, setSearch] = useState("");
@@ -623,9 +49,7 @@ function BankComponent() {
623
  const [page, setPage] = useState(0);
624
  const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
625
 
626
- // Multi-select state
627
- const [isSelectMode, setIsSelectMode] = useState(false);
628
- const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
629
  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
630
  const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
631
 
@@ -650,6 +74,19 @@ function BankComponent() {
650
  const total = query.data?.total ?? 0;
651
  const totalPages = Math.ceil(total / limit);
652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  const togglePublic = useMutation({
654
  ...trpc.question.togglePublic.mutationOptions(),
655
  onSuccess: () => query.refetch(),
@@ -660,10 +97,6 @@ function BankComponent() {
660
  onSuccess: () => query.refetch(),
661
  });
662
 
663
- const createPackage = useMutation(trpc.package.create.mutationOptions());
664
- const addSection = useMutation(trpc.package.addSection.mutationOptions());
665
- const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
666
-
667
  const clearFilters = () => {
668
  setSearch("");
669
  setExamType("");
@@ -675,160 +108,43 @@ function BankComponent() {
675
 
676
  const hasFilters = search || examType || section || format || difficulty !== undefined;
677
 
678
- // Strict exam type guard: derive the locked exam type from first selection
679
- const lockedExamType = useMemo(() => {
680
- if (selectedIds.size === 0) return null;
681
- const firstId = Array.from(selectedIds)[0];
682
- const firstQ = questions.find((q) => q.id === firstId);
683
- return firstQ?.examTypeId ?? null;
684
- }, [selectedIds, questions]);
685
-
686
- const toggleSelection = (id: string) => {
687
- const q = questions.find((item) => item.id === id);
688
- if (!q) return;
689
-
690
- // Strict guard: cannot select different exam type
691
- if (lockedExamType && q.examTypeId !== lockedExamType) return;
692
-
693
- setSelectedIds((prev) => {
694
- const next = new Set(prev);
695
- if (next.has(id)) {
696
- next.delete(id);
697
- } else {
698
- next.add(id);
699
- }
700
- return next;
701
- });
702
- };
703
-
704
- const selectAll = () => {
705
- // Only select questions that match the locked exam type
706
- if (lockedExamType) {
707
- setSelectedIds(new Set(questions.filter((q) => q.examTypeId === lockedExamType).map((q) => q.id)));
708
- } else {
709
- setSelectedIds(new Set(questions.map((q) => q.id)));
710
- }
711
- };
712
-
713
- const clearSelection = () => {
714
- setSelectedIds(new Set());
715
- };
716
-
717
- const exitSelectMode = () => {
718
- setIsSelectMode(false);
719
- setSelectedIds(new Set());
720
- };
721
 
722
- const handleCreatePackage = async (data: {
723
  title: string;
724
  description: string;
725
  isPublic: boolean;
726
- examTypeId: string;
727
  }) => {
728
- if (selectedIds.size === 0) return;
729
-
730
- // Derive examTypeId from first selected question
731
- const firstId = Array.from(selectedIds)[0];
732
- const firstQ = questions.find((q) => q.id === firstId);
733
- const examTypeId = firstQ?.examTypeId ?? data.examTypeId;
734
-
735
- try {
736
- const pkg = await createPackage.mutateAsync({
737
- title: data.title,
738
- description: data.description,
739
- examTypeId,
740
- isPublic: data.isPublic,
741
- estimatedDurationMin: selectedIds.size * 2,
742
- });
743
-
744
- const sec = await addSection.mutateAsync({
745
- packageId: pkg.id,
746
- sectionTypeId: firstQ?.sectionTypeId ?? "READING",
747
- title: `${firstQ?.sectionTypeName ?? "Reading"} Section`,
748
- orderIndex: 0,
749
- });
750
-
751
- const ids = Array.from(selectedIds);
752
- for (let i = 0; i < ids.length; i++) {
753
- await addQuestion.mutateAsync({
754
- sectionId: sec.id,
755
- questionId: ids[i],
756
- orderIndex: i,
757
- });
758
- }
759
-
760
- setIsCreateModalOpen(false);
761
- setIsSelectMode(false);
762
- setSelectedIds(new Set());
763
- navigate({ to: "/package/$id", params: { id: pkg.id } });
764
- } catch (err: any) {
765
- alert("Gagal membuat paket: " + err.message);
766
- }
767
  };
768
 
769
- const handleAutoBundle = async (data: {
770
  title: string;
771
  description: string;
772
  isPublic: boolean;
773
- examTypeId: string;
774
- sectionTypeId: string;
775
  count: number;
776
  sortOrder: "random" | "difficulty";
777
  }) => {
778
- // Fetch all questions for this exam type (not just current page)
779
  const allQuestions = await query.refetch();
780
- const available = (allQuestions.data?.questions ?? []).filter(
781
- (q) => q.examTypeId === data.examTypeId,
782
- );
783
-
784
- let picked = available;
785
- if (data.sortOrder === "random") {
786
- picked = [...available].sort(() => Math.random() - 0.5);
787
- } else {
788
- picked = [...available].sort((a, b) => a.difficulty - b.difficulty);
789
- }
790
- picked = picked.slice(0, data.count);
791
-
792
- if (picked.length === 0) {
793
- alert("Tidak ada soal tersedia untuk ujian ini.");
794
- return;
795
- }
796
-
797
- try {
798
- const pkg = await createPackage.mutateAsync({
799
- title: data.title,
800
- description: data.description,
801
- examTypeId: data.examTypeId,
802
- isPublic: data.isPublic,
803
- estimatedDurationMin: picked.length * 2,
804
- });
805
-
806
- const sec = await addSection.mutateAsync({
807
- packageId: pkg.id,
808
- sectionTypeId: data.sectionTypeId,
809
- title: `${data.sectionTypeId} Section`,
810
- orderIndex: 0,
811
- });
812
-
813
- for (let i = 0; i < picked.length; i++) {
814
- await addQuestion.mutateAsync({
815
- sectionId: sec.id,
816
- questionId: picked[i].id,
817
- orderIndex: i,
818
- });
819
- }
820
-
821
- setIsAutoBundleOpen(false);
822
- navigate({ to: "/package/$id", params: { id: pkg.id } });
823
- } catch (err: any) {
824
- alert("Gagal membuat paket: " + err.message);
825
- }
826
  };
827
 
828
- // Auto-bundle: count available questions for current filters
829
- const autoBundleExamType = examType || null;
830
- const autoBundleSectionType = section || null;
831
-
832
  return (
833
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
834
  <section className="mb-8">
@@ -890,10 +206,6 @@ function BankComponent() {
890
 
891
  <div className="flex flex-wrap gap-2">
892
  <Select
893
- items={[
894
- { value: "", label: "Semua Ujian" },
895
- ...EXAM_TYPES.map((t) => ({ value: t.id, label: t.name })),
896
- ]}
897
  value={examType}
898
  onValueChange={(v: string | null) => { setExamType(v ?? ""); setPage(0); }}
899
  >
@@ -911,10 +223,6 @@ function BankComponent() {
911
  </Select>
912
 
913
  <Select
914
- items={[
915
- { value: "", label: "Semua Section" },
916
- ...SECTIONS.map((s) => ({ value: s.id, label: s.name })),
917
- ]}
918
  value={section}
919
  onValueChange={(v: string | null) => { setSection(v ?? ""); setPage(0); }}
920
  >
@@ -932,10 +240,6 @@ function BankComponent() {
932
  </Select>
933
 
934
  <Select
935
- items={[
936
- { value: "", label: "Semua Format" },
937
- ...FORMATS.map((f) => ({ value: f, label: formatLabel(f) })),
938
- ]}
939
  value={format}
940
  onValueChange={(v: string | null) => { setFormat(v ?? ""); setPage(0); }}
941
  >
@@ -953,10 +257,6 @@ function BankComponent() {
953
  </Select>
954
 
955
  <Select
956
- items={[
957
- { value: "", label: "Semua Level" },
958
- ...DIFFICULTIES.map((d) => ({ value: String(d.value), label: d.label })),
959
- ]}
960
  value={difficulty !== undefined ? String(difficulty) : ""}
961
  onValueChange={(v: string | null) => { setDifficulty(v ? Number(v) : undefined); setPage(0); }}
962
  >
@@ -1251,8 +551,8 @@ function BankComponent() {
1251
  <CreatePackageModal
1252
  selectedCount={selectedIds.size}
1253
  onClose={() => setIsCreateModalOpen(false)}
1254
- onCreate={handleCreatePackage}
1255
- isPending={createPackage.isPending || addSection.isPending}
1256
  examTypeName={EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? "Unknown"}
1257
  lowCount={selectedIds.size < 5}
1258
  />
@@ -1262,13 +562,11 @@ function BankComponent() {
1262
  {isAutoBundleOpen && autoBundleExamType && (
1263
  <AutoBundleModal
1264
  availableCount={total}
1265
- examTypeId={autoBundleExamType}
1266
  examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
1267
- sectionTypeId={autoBundleSectionType ?? "READING"}
1268
  sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
1269
  onClose={() => setIsAutoBundleOpen(false)}
1270
- onCreate={handleAutoBundle}
1271
- isPending={createPackage.isPending || addSection.isPending}
1272
  />
1273
  )}
1274
  </div>
 
1
+ import { useState } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
 
14
  SelectTrigger,
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
+ import { EXAM_TYPES, SECTIONS, FORMATS, DIFFICULTIES } from "@/lib/exam-constants";
18
+ import { formatLabel } from "@/lib/format";
19
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
20
+ import { useQuestionSelection } from "@/hooks/use-question-selection";
21
+ import { usePackageBuilder } from "@/hooks/use-package-builder";
22
+ import { CreatePackageModal } from "@/components/bank/CreatePackageModal";
23
+ import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
24
+ import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
25
 
26
  export const Route = createFileRoute("/bank")({
27
  component: BankComponent,
 
34
  },
35
  });
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  type Tab = "mine" | "public";
38
 
39
  function BankComponent() {
40
  const { data: session } = authClient.useSession();
41
  const userId = session?.user.id;
 
42
 
43
  const [tab, setTab] = useState<Tab>("mine");
44
  const [search, setSearch] = useState("");
 
49
  const [page, setPage] = useState(0);
50
  const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
51
 
52
+ // Modals
 
 
53
  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
54
  const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
55
 
 
74
  const total = query.data?.total ?? 0;
75
  const totalPages = Math.ceil(total / limit);
76
 
77
+ const {
78
+ isSelectMode,
79
+ setIsSelectMode,
80
+ selectedIds,
81
+ lockedExamType,
82
+ toggleSelection,
83
+ selectAll,
84
+ clearSelection,
85
+ exitSelectMode,
86
+ } = useQuestionSelection(questions);
87
+
88
+ const { isPending: isPackagePending, handleCreatePackage, handleAutoBundle } = usePackageBuilder();
89
+
90
  const togglePublic = useMutation({
91
  ...trpc.question.togglePublic.mutationOptions(),
92
  onSuccess: () => query.refetch(),
 
97
  onSuccess: () => query.refetch(),
98
  });
99
 
 
 
 
 
100
  const clearFilters = () => {
101
  setSearch("");
102
  setExamType("");
 
108
 
109
  const hasFilters = search || examType || section || format || difficulty !== undefined;
110
 
111
+ const autoBundleExamType = examType || null;
112
+ const autoBundleSectionType = section || null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ const onCreatePackage = async (data: {
115
  title: string;
116
  description: string;
117
  isPublic: boolean;
 
118
  }) => {
119
+ await handleCreatePackage({
120
+ selectedIds,
121
+ questions,
122
+ title: data.title,
123
+ description: data.description,
124
+ isPublic: data.isPublic,
125
+ });
126
+ setIsCreateModalOpen(false);
127
+ setIsSelectMode(false);
128
+ clearSelection();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  };
130
 
131
+ const onAutoBundle = async (data: {
132
  title: string;
133
  description: string;
134
  isPublic: boolean;
 
 
135
  count: number;
136
  sortOrder: "random" | "difficulty";
137
  }) => {
 
138
  const allQuestions = await query.refetch();
139
+ await handleAutoBundle({
140
+ ...data,
141
+ examTypeId: autoBundleExamType ?? "",
142
+ sectionTypeId: autoBundleSectionType ?? "READING",
143
+ allQuestions: allQuestions.data?.questions ?? [],
144
+ });
145
+ setIsAutoBundleOpen(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  };
147
 
 
 
 
 
148
  return (
149
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
150
  <section className="mb-8">
 
206
 
207
  <div className="flex flex-wrap gap-2">
208
  <Select
 
 
 
 
209
  value={examType}
210
  onValueChange={(v: string | null) => { setExamType(v ?? ""); setPage(0); }}
211
  >
 
223
  </Select>
224
 
225
  <Select
 
 
 
 
226
  value={section}
227
  onValueChange={(v: string | null) => { setSection(v ?? ""); setPage(0); }}
228
  >
 
240
  </Select>
241
 
242
  <Select
 
 
 
 
243
  value={format}
244
  onValueChange={(v: string | null) => { setFormat(v ?? ""); setPage(0); }}
245
  >
 
257
  </Select>
258
 
259
  <Select
 
 
 
 
260
  value={difficulty !== undefined ? String(difficulty) : ""}
261
  onValueChange={(v: string | null) => { setDifficulty(v ? Number(v) : undefined); setPage(0); }}
262
  >
 
551
  <CreatePackageModal
552
  selectedCount={selectedIds.size}
553
  onClose={() => setIsCreateModalOpen(false)}
554
+ onCreate={onCreatePackage}
555
+ isPending={isPackagePending}
556
  examTypeName={EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? "Unknown"}
557
  lowCount={selectedIds.size < 5}
558
  />
 
562
  {isAutoBundleOpen && autoBundleExamType && (
563
  <AutoBundleModal
564
  availableCount={total}
 
565
  examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
 
566
  sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
567
  onClose={() => setIsAutoBundleOpen(false)}
568
+ onCreate={onAutoBundle}
569
+ isPending={isPackagePending}
570
  />
571
  )}
572
  </div>
apps/web/src/routes/builder.combo.tsx CHANGED
@@ -6,14 +6,7 @@ import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
7
  import { Button } from "@labas/ui/components/button";
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
- import {
10
- Select,
11
- SelectContent,
12
- SelectGroup,
13
- SelectItem,
14
- SelectTrigger,
15
- SelectValue,
16
- } from "@labas/ui/components/select";
17
 
18
  export const Route = createFileRoute("/builder/combo")({
19
  component: ComboBuilderComponent,
@@ -26,10 +19,6 @@ export const Route = createFileRoute("/builder/combo")({
26
  },
27
  });
28
 
29
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
30
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
31
- }
32
-
33
  interface SelectedSection {
34
  sourcePackageId: string;
35
  sourceSectionId: string;
 
6
  import { Input } from "@labas/ui/components/input";
7
  import { Button } from "@labas/ui/components/button";
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
 
 
 
 
 
 
10
 
11
  export const Route = createFileRoute("/builder/combo")({
12
  component: ComboBuilderComponent,
 
19
  },
20
  });
21
 
 
 
 
 
22
  interface SelectedSection {
23
  sourcePackageId: string;
24
  sourceSectionId: string;
apps/web/src/routes/generate.tsx CHANGED
@@ -1,46 +1,22 @@
1
- import { useState, useEffect } from "react";
2
- import { useMutation, useQuery } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { useApiKey } from "@/hooks/use-api-key";
7
- import { Button } from "@labas/ui/components/button";
8
  import { Input } from "@labas/ui/components/input";
9
- import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
 
 
 
 
 
 
 
 
 
10
  import "flag-icons/css/flag-icons.min.css";
11
- import type { GenerationResult } from "@labas/ai";
12
-
13
- const EXAM_TYPES = [
14
- { id: "IELTS", name: "IELTS Academic", code: "gb" },
15
- { id: "TOEFL", name: "TOEFL iBT", code: "us" },
16
- { id: "JLPT", name: "JLPT", code: "jp" },
17
- { id: "HSK", name: "HSK", code: "cn" },
18
- { id: "GOETHE", name: "Goethe-Zertifikat", code: "de" },
19
- ];
20
-
21
- const SECTIONS = [
22
- { id: "READING", name: "Reading", icon: "menu_book" },
23
- { id: "WRITING", name: "Writing", icon: "edit_note" },
24
- ];
25
-
26
- const FORMATS = [
27
- { id: "multiple_choice", name: "Multiple Choice" },
28
- { id: "true_false_not_given", name: "True / False / Not Given" },
29
- { id: "fill_blank", name: "Fill in Blank" },
30
- { id: "synonym", name: "Synonym / Vocabulary" },
31
- { id: "grammar_in_context", name: "Grammar in Context" },
32
- { id: "sentence_completion", name: "Sentence Completion" },
33
- { id: "cloze", name: "Cloze Test" },
34
- { id: "reference", name: "Reference (Pronoun)" },
35
- { id: "author_view", name: "Author's View" },
36
- { id: "matching_headings", name: "Matching Headings" },
37
- { id: "kanji_reading", name: "Kanji Reading (JLPT)" },
38
- { id: "particle_choice", name: "Particle Choice (JLPT)" },
39
- { id: "article_case", name: "Article / Case (German)" },
40
- ];
41
-
42
- const TOPICS = ["Science & Tech", "Business", "Sociology", "Arts", "History", "Environment", "Health", "Education"];
43
- const DIFFICULTIES = ["Beginner", "Intermediate", "Academic", "Expert"];
44
 
45
  export const Route = createFileRoute("/generate")({
46
  component: RouteComponent,
@@ -53,13 +29,20 @@ export const Route = createFileRoute("/generate")({
53
  },
54
  });
55
 
56
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
57
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
58
- }
59
-
60
  function RouteComponent() {
61
  const { storedKey, hasKey } = useApiKey();
62
 
 
 
 
 
 
 
 
 
 
 
 
63
  const [examType, setExamType] = useState("IELTS");
64
  const [section, setSection] = useState("READING");
65
  const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
@@ -67,52 +50,14 @@ function RouteComponent() {
67
  const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
68
  const [questionCount, setQuestionCount] = useState(5);
69
  const [weaknessAlign, setWeaknessAlign] = useState(75);
70
- const [result, setResult] = useState<GenerationResult | null>(null);
71
- const [error, setError] = useState<string | null>(null);
72
  const [mode, setMode] = useState<"quick" | "agentic">("quick");
73
- const [jobId, setJobIdState] = useState<string | null>(null);
74
- const [generatedPackageId, setGeneratedPackageId] = useState<string | null>(null);
75
-
76
- // Persist jobId to sessionStorage so it survives navigation
77
- const setJobId = (id: string | null) => {
78
- setJobIdState(id);
79
- if (id) {
80
- sessionStorage.setItem("labas_active_job", id);
81
- } else {
82
- sessionStorage.removeItem("labas_active_job");
83
- }
84
- };
85
-
86
- // On mount: recover from sessionStorage
87
- useEffect(() => {
88
- const saved = sessionStorage.getItem("labas_active_job");
89
- if (saved) {
90
- setJobIdState(saved);
91
- }
92
- }, []);
93
-
94
- // Also check myJobs for any pending/running jobs (fallback / cross-tab sync)
95
- const myJobsQuery = useQuery(
96
- trpc.ai.myJobs.queryOptions({ limit: 10, offset: 0 }),
97
- );
98
-
99
- useEffect(() => {
100
- if (!jobId && myJobsQuery.data) {
101
- const active = myJobsQuery.data.find(
102
- (j: any) => j.status === "pending" || j.status === "running",
103
- );
104
- if (active) {
105
- setJobId(active.id);
106
- }
107
- }
108
- }, [myJobsQuery.data, jobId]);
109
 
110
  const generate = useMutation({
111
  ...trpc.ai.generate.mutationOptions(),
112
  onSuccess: (data) => {
113
  setJobId(data.jobId);
114
  setError(null);
115
- setResult(null);
116
  },
117
  onError: (err) => {
118
  setError(err.message);
@@ -120,37 +65,6 @@ function RouteComponent() {
120
  },
121
  });
122
 
123
- // Poll job status
124
- const jobQuery = useQuery({
125
- ...trpc.ai.getJobStatus.queryOptions(
126
- { jobId: jobId! },
127
- { enabled: !!jobId },
128
- ),
129
- refetchInterval: (query) => {
130
- const data = query.state.data;
131
- if (!data) return 1000;
132
- if (data.status === "completed" || data.status === "failed") return false;
133
- return 1000;
134
- },
135
- });
136
-
137
- const isGenerating =
138
- jobId !== null &&
139
- (!jobQuery.data || (jobQuery.data.status !== "completed" && jobQuery.data.status !== "failed"));
140
-
141
- useEffect(() => {
142
- if (jobQuery.data?.status === "completed" && jobQuery.data.resultJson) {
143
- const res = jobQuery.data.resultJson as GenerationResult & { generatedPackageId?: string | null };
144
- setResult(res);
145
- setGeneratedPackageId(res.generatedPackageId ?? null);
146
- setJobId(null);
147
- }
148
- if (jobQuery.data?.status === "failed") {
149
- setError(jobQuery.data.errorMessage ?? "Generation failed");
150
- setJobId(null);
151
- }
152
- }, [jobQuery.data]);
153
-
154
  const toggleFormat = (id: string) => {
155
  setSelectedFormats((prev) =>
156
  prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
@@ -169,9 +83,7 @@ function RouteComponent() {
169
  return;
170
  }
171
 
172
- setResult(null);
173
- setError(null);
174
- setJobId(null);
175
 
176
  generate.mutate({
177
  examType: examType as any,
@@ -302,7 +214,7 @@ function RouteComponent() {
302
  />
303
  <span className="font-semibold text-[var(--clay-black)]">{s.name}</span>
304
  </div>
305
- <input
306
  type="checkbox"
307
  checked={section === s.id}
308
  readOnly
@@ -376,228 +288,27 @@ function RouteComponent() {
376
  </div>
377
 
378
  {/* Live Preview Card (Right) */}
379
- <div className="lg:col-span-4 sticky top-8">
380
- <Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] clay-shadow rounded-[var(--radius-xl)]">
381
- <CardHeader>
382
- <div className="flex items-center gap-3">
383
- <MaterialIcon name="analytics" className="text-[var(--matcha-600)]" />
384
- <CardTitle className="font-headline text-xl text-[var(--clay-black)]">Test Blueprint</CardTitle>
385
- </div>
386
- </CardHeader>
387
- <CardContent className="space-y-6">
388
- <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
389
- <span className="text-[var(--warm-charcoal)]">Estimasi Durasi</span>
390
- <span className="font-bold text-[var(--clay-black)]">{questionCount * 2} Menit</span>
391
- </div>
392
- <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
393
- <span className="text-[var(--warm-charcoal)]">Jumlah Soal</span>
394
- <span className="font-bold text-[var(--clay-black)]">{questionCount} Soal</span>
395
- </div>
396
- <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
397
- <span className="text-[var(--warm-charcoal)]">Format</span>
398
- <span className="font-bold text-[var(--clay-black)]">{selectedFormats.length} Jenis</span>
399
- </div>
400
- <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
401
- <span className="text-[var(--warm-charcoal)]">Ujian</span>
402
- <span className="font-bold text-[var(--clay-black)]">
403
- {EXAM_TYPES.find((t) => t.id === examType)?.name}
404
- </span>
405
- </div>
406
-
407
- {/* AI Confidence Score Orbit */}
408
- <div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-6 flex flex-col items-center gap-4 relative overflow-hidden">
409
- <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--matcha-600)] to-transparent" />
410
- <span className="text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)] font-bold">
411
- AI Confidence Score
412
- </span>
413
- <div className="relative flex items-center justify-center">
414
- <svg className="w-32 h-32 transform -rotate-90">
415
- <circle className="text-[var(--warm-silver)]" cx="64" cy="64" fill="transparent" r="56" stroke="currentColor" strokeWidth="8" />
416
- <circle
417
- className="text-[var(--matcha-600)]"
418
- cx="64"
419
- cy="64"
420
- fill="transparent"
421
- r="56"
422
- stroke="currentColor"
423
- strokeDasharray="351.85"
424
- strokeDashoffset={351.85 - (351.85 * (weaknessAlign / 100))}
425
- strokeWidth="8"
426
- />
427
- </svg>
428
- <span className="absolute text-3xl font-headline font-extrabold text-[var(--clay-black)]">{Math.round(weaknessAlign)}%</span>
429
- </div>
430
- <p className="text-[11px] text-center text-[var(--warm-charcoal)] px-2 leading-tight">
431
- Probabilitas tinggi untuk mengatasi blindspot linguistik utama.
432
- </p>
433
- </div>
434
-
435
- {/* Mode Toggle */}
436
- <div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
437
- <button
438
- onClick={() => setMode("quick")}
439
- className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
440
- mode === "quick"
441
- ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
442
- : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
443
- }`}
444
- >
445
- <MaterialIcon name="flash_on" className="text-sm mr-1" />
446
- Quick
447
- </button>
448
- <button
449
- onClick={() => setMode("agentic")}
450
- className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
451
- mode === "agentic"
452
- ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
453
- : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
454
- }`}
455
- >
456
- <MaterialIcon name="psychology" className="text-sm mr-1" />
457
- Agentic
458
- </button>
459
- </div>
460
-
461
- {mode === "agentic" && (
462
- <div className="p-3 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] border-2 border-[var(--badge-blue-bg)] text-xs text-[var(--badge-blue-text)]">
463
- <div className="flex items-center gap-2 mb-1">
464
- <MaterialIcon name="info" className="text-sm" />
465
- <span className="font-semibold">Mode Agentic</span>
466
- </div>
467
- <p>Multi-step validation: passage → validate → questions → self-check → quality score. Lebih lambat tapi kualitas lebih terjamin.</p>
468
- </div>
469
- )}
470
-
471
- {/* Primary CTA */}
472
- <Button
473
- className="w-full py-5 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg flex items-center justify-center gap-3 clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] transition-all active:scale-95 h-auto"
474
- onClick={handleGenerate}
475
- disabled={isGenerating || generate.isPending || selectedFormats.length === 0 || !hasKey}
476
- >
477
- <MaterialIcon name="auto_awesome" className="group-hover:rotate-12 transition-transform" />
478
- {isGenerating
479
- ? mode === "agentic"
480
- ? `Agentic... ${jobQuery.data?.progress ?? 0}%`
481
- : `Generating... ${jobQuery.data?.progress ?? 0}%`
482
- : "Generate & Launch"}
483
- </Button>
484
-
485
- {/* Progress */}
486
- {isGenerating && (
487
- <div className="space-y-2">
488
- <div className="flex items-center gap-3 p-3 rounded-[var(--radius-md)] bg-[var(--matcha-300)]/20 border-2 border-[var(--matcha-300)]">
489
- <MaterialIcon name="psychology" className="text-[var(--matcha-600)] animate-pulse" />
490
- <div>
491
- <p className="text-sm font-semibold text-[var(--matcha-800)]">
492
- {jobQuery.data?.progressMessage ?? "Sedang berjalan..."}
493
- </p>
494
- <p className="text-xs text-[var(--warm-charcoal)]">
495
- {mode === "agentic"
496
- ? "Passage → Validate → Questions → Self-Check → Score"
497
- : "Quick generation mode"}
498
- </p>
499
- </div>
500
- </div>
501
- <div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
502
- <div
503
- className="h-full bg-[var(--matcha-600)] transition-all duration-500"
504
- style={{ width: `${jobQuery.data?.progress ?? 5}%` }}
505
- />
506
- </div>
507
- <p className="text-xs text-[var(--warm-charcoal)] text-center">
508
- Bisa ditinggal — hasil akan muncul otomatis
509
- </p>
510
- </div>
511
- )}
512
-
513
- {error && (
514
- <div className="p-4 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm border-2 border-[var(--badge-blue-bg)]">
515
- {error}
516
- </div>
517
- )}
518
- </CardContent>
519
- </Card>
520
- </div>
521
  </div>
522
 
523
  {/* Results Section */}
524
  {result && (
525
- <div className="mt-16 space-y-6">
526
- <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
527
- <div>
528
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h2>
529
- <p className="text-sm text-[var(--warm-charcoal)] mt-1">
530
- Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
531
- </p>
532
- </div>
533
- <div className="flex gap-3">
534
- {generatedPackageId && (
535
- <Link to="/package/$id" params={{ id: generatedPackageId }}>
536
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
537
- <MaterialIcon name="play_arrow" className="mr-2" />
538
- Lihat Paket
539
- </Button>
540
- </Link>
541
- )}
542
- <Link to="/bank">
543
- <Button className="bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]">
544
- <MaterialIcon name="database" className="mr-2" />
545
- Bank Soal
546
- </Button>
547
- </Link>
548
- </div>
549
- </div>
550
-
551
- <div className="text-sm text-[var(--warm-charcoal)] mb-4 font-mono">
552
- Model: {result.meta.model} · Tokens: {result.meta.tokensUsed ?? "?"} · Waktu: {result.meta.durationMs}ms · {result.questions.length} soal
553
- </div>
554
-
555
- {result.questions.map((q, idx) => (
556
- <Card key={idx} className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
557
- <CardHeader>
558
- <div className="flex items-center gap-3">
559
- <span className="bg-[var(--matcha-600)] text-[var(--pure-white)] w-8 h-8 flex items-center justify-center rounded-[var(--radius-md)] font-bold text-sm">
560
- {idx + 1}
561
- </span>
562
- <span className="text-sm font-bold text-[var(--matcha-600)] uppercase tracking-tighter">
563
- {q.format.replace(/_/g, " ")}
564
- </span>
565
- </div>
566
- </CardHeader>
567
- <CardContent className="space-y-4">
568
- <div className="p-5 rounded-[var(--radius-lg)] bg-[var(--oat-light)] text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap text-base">
569
- {q.passageText}
570
- </div>
571
- <p className="font-medium text-[var(--clay-black)] text-lg">{q.questionText}</p>
572
- {"options" in q && q.options && (
573
- <div className="space-y-2">
574
- {q.options.map((opt) => (
575
- <div
576
- key={opt.key}
577
- className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
578
- >
579
- <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
580
- {opt.key}
581
- </span>
582
- <span className="text-[var(--clay-black)]">{opt.text}</span>
583
- </div>
584
- ))}
585
- </div>
586
- )}
587
- <div className="flex gap-2 flex-wrap">
588
- {q.skillTags.map((tag) => (
589
- <span
590
- key={tag}
591
- className="px-3 py-1 rounded-full bg-[var(--oat-light)] text-xs font-medium text-[var(--warm-charcoal)]"
592
- >
593
- {tag}
594
- </span>
595
- ))}
596
- </div>
597
- </CardContent>
598
- </Card>
599
- ))}
600
- </div>
601
  )}
602
  </div>
603
  );
 
1
+ import { useState } from "react";
2
+ import { useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { useApiKey } from "@/hooks/use-api-key";
7
+ import { useGenerationJob } from "@/hooks/use-generation-job";
8
  import { Input } from "@labas/ui/components/input";
9
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
+ import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
11
+ import { ResultSection } from "@/components/generate/ResultSection";
12
+ import {
13
+ EXAM_TYPES,
14
+ SECTIONS,
15
+ FORMATS,
16
+ TOPICS,
17
+ DIFFICULTIES,
18
+ } from "@/lib/generate-constants";
19
  import "flag-icons/css/flag-icons.min.css";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  export const Route = createFileRoute("/generate")({
22
  component: RouteComponent,
 
29
  },
30
  });
31
 
 
 
 
 
32
  function RouteComponent() {
33
  const { storedKey, hasKey } = useApiKey();
34
 
35
+ const {
36
+ result,
37
+ error,
38
+ generatedPackageId,
39
+ isGenerating,
40
+ jobQuery,
41
+ setError,
42
+ setJobId,
43
+ reset,
44
+ } = useGenerationJob();
45
+
46
  const [examType, setExamType] = useState("IELTS");
47
  const [section, setSection] = useState("READING");
48
  const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
 
50
  const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
51
  const [questionCount, setQuestionCount] = useState(5);
52
  const [weaknessAlign, setWeaknessAlign] = useState(75);
 
 
53
  const [mode, setMode] = useState<"quick" | "agentic">("quick");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  const generate = useMutation({
56
  ...trpc.ai.generate.mutationOptions(),
57
  onSuccess: (data) => {
58
  setJobId(data.jobId);
59
  setError(null);
60
+ reset();
61
  },
62
  onError: (err) => {
63
  setError(err.message);
 
65
  },
66
  });
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  const toggleFormat = (id: string) => {
69
  setSelectedFormats((prev) =>
70
  prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
 
83
  return;
84
  }
85
 
86
+ reset();
 
 
87
 
88
  generate.mutate({
89
  examType: examType as any,
 
214
  />
215
  <span className="font-semibold text-[var(--clay-black)]">{s.name}</span>
216
  </div>
217
+ <Input
218
  type="checkbox"
219
  checked={section === s.id}
220
  readOnly
 
288
  </div>
289
 
290
  {/* Live Preview Card (Right) */}
291
+ <TestBlueprintCard
292
+ examType={examType}
293
+ section={section}
294
+ selectedFormats={selectedFormats}
295
+ questionCount={questionCount}
296
+ weaknessAlign={weaknessAlign}
297
+ mode={mode}
298
+ setMode={setMode}
299
+ isGenerating={isGenerating}
300
+ generatePending={generate.isPending}
301
+ hasKey={hasKey}
302
+ jobProgress={jobQuery.data?.progress ?? 0}
303
+ jobProgressMessage={jobQuery.data?.progressMessage}
304
+ error={error}
305
+ onGenerate={handleGenerate}
306
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  </div>
308
 
309
  {/* Results Section */}
310
  {result && (
311
+ <ResultSection result={result} generatedPackageId={generatedPackageId} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  )}
313
  </div>
314
  );
apps/web/src/routes/index.tsx CHANGED
@@ -4,6 +4,7 @@ import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
 
7
 
8
  export const Route = createFileRoute("/")({
9
  component: HomeComponent,
@@ -16,10 +17,6 @@ export const Route = createFileRoute("/")({
16
  },
17
  });
18
 
19
- function MaterialIcon({ name, className = "text-xl" }: { name: string; className?: string }) {
20
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
21
- }
22
-
23
  function HomeComponent() {
24
  const { session } = Route.useRouteContext();
25
  const privateData = useQuery(trpc.privateData.queryOptions());
@@ -82,13 +79,13 @@ function HomeComponent() {
82
  <div className="flex flex-col md:flex-row gap-4 mb-10">
83
  <Link to="/generate">
84
  <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 px-6 clay-shadow clay-hover">
85
- <MaterialIcon name="auto_awesome" />
86
  <span className="ml-2">Generate Soal Baru</span>
87
  </Button>
88
  </Link>
89
  <Link to="/settings">
90
  <Button variant="outline" className="rounded-[var(--radius-lg)] h-11 px-6 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover">
91
- <MaterialIcon name="key" />
92
  <span className="ml-2">Atur API Key</span>
93
  </Button>
94
  </Link>
 
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
7
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
 
9
  export const Route = createFileRoute("/")({
10
  component: HomeComponent,
 
17
  },
18
  });
19
 
 
 
 
 
20
  function HomeComponent() {
21
  const { session } = Route.useRouteContext();
22
  const privateData = useQuery(trpc.privateData.queryOptions());
 
79
  <div className="flex flex-col md:flex-row gap-4 mb-10">
80
  <Link to="/generate">
81
  <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 px-6 clay-shadow clay-hover">
82
+ <MaterialIcon name="auto_awesome" className="text-xl" />
83
  <span className="ml-2">Generate Soal Baru</span>
84
  </Button>
85
  </Link>
86
  <Link to="/settings">
87
  <Button variant="outline" className="rounded-[var(--radius-lg)] h-11 px-6 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover">
88
+ <MaterialIcon name="key" className="text-xl" />
89
  <span className="ml-2">Atur API Key</span>
90
  </Button>
91
  </Link>
apps/web/src/routes/jobs.tsx CHANGED
@@ -5,6 +5,7 @@ import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
7
  import { Button } from "@labas/ui/components/button";
 
8
  import type { GenerationResult } from "@labas/ai";
9
 
10
  export const Route = createFileRoute("/jobs")({
@@ -18,10 +19,6 @@ export const Route = createFileRoute("/jobs")({
18
  },
19
  });
20
 
21
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
22
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
23
- }
24
-
25
  const STATUS_COLORS: Record<string, string> = {
26
  pending: "bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)]",
27
  running: "bg-[var(--matcha-300)] text-[var(--matcha-800)]",
 
5
  import { trpc } from "@/utils/trpc";
6
  import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
7
  import { Button } from "@labas/ui/components/button";
8
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
9
  import type { GenerationResult } from "@labas/ai";
10
 
11
  export const Route = createFileRoute("/jobs")({
 
19
  },
20
  });
21
 
 
 
 
 
22
  const STATUS_COLORS: Record<string, string> = {
23
  pending: "bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)]",
24
  running: "bg-[var(--matcha-300)] text-[var(--matcha-800)]",
apps/web/src/routes/package.$id.take.tsx CHANGED
@@ -1,12 +1,12 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { useQuery, useMutation } from "@tanstack/react-query";
3
- import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
 
6
  import { Button } from "@labas/ui/components/button";
7
  import { Card, CardContent } from "@labas/ui/components/card";
8
- import { Input } from "@labas/ui/components/input";
9
- import { Label } from "@labas/ui/components/label";
10
 
11
  export const Route = createFileRoute("/package/$id/take")({
12
  component: TakeTestComponent,
@@ -19,225 +19,27 @@ export const Route = createFileRoute("/package/$id/take")({
19
  },
20
  });
21
 
22
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
23
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
24
- }
25
-
26
- function formatTime(totalSeconds: number) {
27
- const m = Math.floor(totalSeconds / 60);
28
- const s = totalSeconds % 60;
29
- return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
30
- }
31
-
32
- function QuestionInput({
33
- question,
34
- value,
35
- onChange,
36
- disabled,
37
- }: {
38
- question: any;
39
- value: string;
40
- onChange: (val: string) => void;
41
- disabled?: boolean;
42
- }) {
43
- const format = question.format;
44
- const options = question.options as Array<{ key: string; text: string }> | undefined;
45
-
46
- const radioClass =
47
- "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-400)] transition-colors";
48
- const radioSelected = "border-[var(--matcha-600)] bg-[var(--matcha-100)]";
49
- const radioDisabled = "opacity-60 cursor-not-allowed";
50
-
51
- if (
52
- format === "multiple_choice" ||
53
- format === "synonym" ||
54
- format === "grammar_in_context" ||
55
- format === "sentence_completion" ||
56
- format === "reference" ||
57
- format === "kanji_reading" ||
58
- format === "particle_choice" ||
59
- format === "article_case" ||
60
- format === "matching_headings" ||
61
- format === "matching_information" ||
62
- format === "summary_completion" ||
63
- format === "cloze"
64
- ) {
65
- if (!options || options.length === 0) {
66
- return (
67
- <div className="text-sm text-[var(--warm-silver)] italic">
68
- Tidak ada opsi tersedia untuk soal ini.
69
- </div>
70
- );
71
- }
72
- return (
73
- <div className="space-y-2">
74
- {options.map((opt) => (
75
- <label
76
- key={opt.key}
77
- className={`${radioClass} ${value === opt.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
78
- >
79
- <input
80
- type="radio"
81
- name={question.id}
82
- value={opt.key}
83
- checked={value === opt.key}
84
- onChange={() => onChange(opt.key)}
85
- disabled={disabled}
86
- className="hidden"
87
- />
88
- <span className="w-8 h-8 rounded-full bg-[var(--oat-light)] text-[var(--clay-black)] text-sm font-bold flex items-center justify-center shrink-0">
89
- {opt.key}
90
- </span>
91
- <span className="text-sm text-[var(--clay-black)]">{opt.text}</span>
92
- </label>
93
- ))}
94
- </div>
95
- );
96
- }
97
-
98
- if (format === "true_false_not_given") {
99
- const choices = [
100
- { key: "TRUE", label: "True" },
101
- { key: "FALSE", label: "False" },
102
- { key: "NOT_GIVEN", label: "Not Given" },
103
- ];
104
- return (
105
- <div className="space-y-2">
106
- {choices.map((c) => (
107
- <label
108
- key={c.key}
109
- className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
110
- >
111
- <input
112
- type="radio"
113
- name={question.id}
114
- value={c.key}
115
- checked={value === c.key}
116
- onChange={() => onChange(c.key)}
117
- disabled={disabled}
118
- className="hidden"
119
- />
120
- <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
121
- </label>
122
- ))}
123
- </div>
124
- );
125
- }
126
-
127
- if (format === "author_view") {
128
- const choices = [
129
- { key: "YES", label: "Yes" },
130
- { key: "NO", label: "No" },
131
- { key: "NOT_GIVEN", label: "Not Given" },
132
- ];
133
- return (
134
- <div className="space-y-2">
135
- {choices.map((c) => (
136
- <label
137
- key={c.key}
138
- className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
139
- >
140
- <input
141
- type="radio"
142
- name={question.id}
143
- value={c.key}
144
- checked={value === c.key}
145
- onChange={() => onChange(c.key)}
146
- disabled={disabled}
147
- className="hidden"
148
- />
149
- <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
150
- </label>
151
- ))}
152
- </div>
153
- );
154
- }
155
-
156
- if (format === "fill_blank") {
157
- return (
158
- <Input
159
- value={value}
160
- onChange={(e) => onChange(e.target.value)}
161
- disabled={disabled}
162
- placeholder="Ketik jawaban Anda..."
163
- className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
164
- />
165
- );
166
- }
167
-
168
- // Fallback for any unrecognized format
169
- return (
170
- <Input
171
- value={value}
172
- onChange={(e) => onChange(e.target.value)}
173
- disabled={disabled}
174
- placeholder="Ketik jawaban Anda..."
175
- className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
176
- />
177
- );
178
- }
179
-
180
  function TakeTestComponent() {
181
  const { id: packageId } = Route.useParams();
182
- const navigate = useNavigate();
183
  const { data: session } = authClient.useSession();
184
 
185
  const packageQuery = useQuery(trpc.package.getById.queryOptions({ id: packageId }));
186
  const pkg = packageQuery.data;
187
 
188
- const [attemptId, setAttemptId] = useState<string | null>(null);
189
- const [currentSectionIdx, setCurrentSectionIdx] = useState(0);
190
- const [answers, setAnswers] = useState<Record<string, string>>({});
191
- const [timeElapsed, setTimeElapsed] = useState(0);
192
- const [isStarted, setIsStarted] = useState(false);
193
- const [isFinished, setIsFinished] = useState(false);
194
- const [submittingQId, setSubmittingQId] = useState<string | null>(null);
195
-
196
- const startMutation = useMutation(trpc.attempt.start.mutationOptions());
197
- const submitMutation = useMutation(trpc.attempt.submitAnswer.mutationOptions());
198
- const finishMutation = useMutation(trpc.attempt.finish.mutationOptions());
199
-
200
- // Timer
201
- useEffect(() => {
202
- if (!isStarted || isFinished) return;
203
- const interval = setInterval(() => {
204
- setTimeElapsed((t) => t + 1);
205
- }, 1000);
206
- return () => clearInterval(interval);
207
- }, [isStarted, isFinished]);
208
-
209
- const handleStart = useCallback(async () => {
210
- if (!pkg) return;
211
- const res = await startMutation.mutateAsync({ packageId });
212
- setAttemptId(res.attemptId);
213
- setIsStarted(true);
214
- }, [pkg, packageId, startMutation]);
215
-
216
- const handleAnswerChange = useCallback(
217
- async (questionId: string, sectionResultId: string, value: string) => {
218
- if (!attemptId || isFinished) return;
219
- setAnswers((prev) => ({ ...prev, [questionId]: value }));
220
- setSubmittingQId(questionId);
221
- try {
222
- await submitMutation.mutateAsync({
223
- attemptId,
224
- sectionResultId,
225
- questionId,
226
- userAnswer: value,
227
- });
228
- } finally {
229
- setSubmittingQId(null);
230
- }
231
- },
232
- [attemptId, isFinished, submitMutation],
233
- );
234
-
235
- const handleFinish = useCallback(async () => {
236
- if (!attemptId) return;
237
- setIsFinished(true);
238
- const res = await finishMutation.mutateAsync({ attemptId });
239
- navigate({ to: "/attempt/$id", params: { id: attemptId } });
240
- }, [attemptId, finishMutation, navigate]);
241
 
242
  if (packageQuery.isLoading) {
243
  return (
@@ -262,7 +64,7 @@ function TakeTestComponent() {
262
  );
263
  }
264
 
265
- const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
266
  const answeredCount = Object.keys(answers).length;
267
 
268
  // Start screen
@@ -312,11 +114,11 @@ function TakeTestComponent() {
312
 
313
  <Button
314
  onClick={handleStart}
315
- disabled={startMutation.isPending}
316
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] px-8 py-6 text-lg"
317
  >
318
  <MaterialIcon name="play_arrow" />
319
- <span className="ml-2">{startMutation.isPending ? "Memulai..." : "Mulai Latihan"}</span>
320
  </Button>
321
  </div>
322
  </div>
@@ -338,12 +140,6 @@ function TakeTestComponent() {
338
  );
339
  }
340
 
341
- // Find the sectionResultId for this section
342
- // We don't have it directly here since we didn't fetch attempt data.
343
- // We need to fetch attempt data after starting.
344
- // Actually, for the MVP, we can just use a placeholder or fetch attempt data.
345
- // Let me add an attempt query.
346
-
347
  return (
348
  <AttemptTestView
349
  attemptId={attemptId!}
@@ -361,199 +157,3 @@ function TakeTestComponent() {
361
  />
362
  );
363
  }
364
-
365
- function AttemptTestView({
366
- attemptId,
367
- pkg,
368
- currentSectionIdx,
369
- setCurrentSectionIdx,
370
- answers,
371
- onAnswerChange,
372
- timeElapsed,
373
- answeredCount,
374
- totalQuestions,
375
- onFinish,
376
- isFinished,
377
- submittingQId,
378
- }: {
379
- attemptId: string;
380
- pkg: any;
381
- currentSectionIdx: number;
382
- setCurrentSectionIdx: (idx: number) => void;
383
- answers: Record<string, string>;
384
- onAnswerChange: (questionId: string, sectionResultId: string, value: string) => void;
385
- timeElapsed: number;
386
- answeredCount: number;
387
- totalQuestions: number;
388
- onFinish: () => void;
389
- isFinished: boolean;
390
- submittingQId: string | null;
391
- }) {
392
- const attemptQuery = useQuery(trpc.attempt.getById.queryOptions({ id: attemptId }));
393
-
394
- const attempt = attemptQuery.data;
395
- const currentSection = pkg.sections[currentSectionIdx];
396
- const sectionData = attempt?.sections?.[currentSectionIdx];
397
- const sectionResultId = sectionData?.sectionResultId;
398
-
399
- return (
400
- <div className="min-h-screen bg-[var(--warm-cream)]">
401
- {/* Top Bar */}
402
- <div className="sticky top-0 z-50 bg-[var(--pure-white)] border-b-2 border-[var(--oat-border)] px-4 md:px-8 py-3">
403
- <div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
404
- <div className="flex items-center gap-3">
405
- <Link to="/package/$id" params={{ id: pkg.id }} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]">
406
- <MaterialIcon name="close" />
407
- </Link>
408
- <h1 className="font-headline font-bold text-[var(--clay-black)] text-sm md:text-base truncate max-w-[200px] md:max-w-sm">
409
- {pkg.title}
410
- </h1>
411
- </div>
412
-
413
- <div className="flex items-center gap-4">
414
- <div className="flex items-center gap-1.5 bg-[var(--oat-light)] px-3 py-1.5 rounded-full text-sm font-mono text-[var(--clay-black)]">
415
- <MaterialIcon name="timer" className="text-sm" />
416
- {formatTime(timeElapsed)}
417
- </div>
418
- <div className="hidden md:flex items-center gap-1.5 text-sm text-[var(--warm-charcoal)]">
419
- <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
420
- {answeredCount}/{totalQuestions}
421
- </div>
422
- <Button
423
- onClick={onFinish}
424
- disabled={isFinished}
425
- className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)] text-sm px-4 py-2"
426
- >
427
- Selesai
428
- </Button>
429
- </div>
430
- </div>
431
- </div>
432
-
433
- {/* Section Tabs */}
434
- <div className="max-w-5xl mx-auto px-4 md:px-8 py-4">
435
- <div className="flex gap-2 overflow-x-auto pb-2">
436
- {pkg.sections.map((sec: any, idx: number) => (
437
- <button
438
- key={sec.id}
439
- onClick={() => setCurrentSectionIdx(idx)}
440
- className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold whitespace-nowrap border-2 transition-colors ${
441
- idx === currentSectionIdx
442
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] border-[var(--clay-black)]"
443
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-[var(--oat-border)] hover:border-[var(--matcha-400)]"
444
- }`}
445
- >
446
- {sec.title}
447
- </button>
448
- ))}
449
- </div>
450
- </div>
451
-
452
- {/* Main Content */}
453
- <div className="max-w-5xl mx-auto px-4 md:px-8 pb-32">
454
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
455
- {/* Left: Passage */}
456
- <div className="lg:col-span-1">
457
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-24">
458
- <CardContent className="p-5">
459
- <h2 className="font-headline font-bold text-[var(--clay-black)] mb-3 flex items-center gap-2">
460
- <MaterialIcon name="menu_book" className="text-[var(--matcha-600)]" />
461
- Bacaan
462
- </h2>
463
- <div className="prose prose-sm max-w-none text-[var(--clay-black)] whitespace-pre-wrap text-sm leading-relaxed max-h-[60vh] overflow-y-auto pr-2">
464
- {currentSection.questions[0]?.passageText ?? "Tidak ada bacaan untuk section ini."}
465
- </div>
466
- </CardContent>
467
- </Card>
468
- </div>
469
-
470
- {/* Right: Questions */}
471
- <div className="lg:col-span-2 space-y-4">
472
- {currentSection.questions.map((q: any, idx: number) => {
473
- const answerValue = answers[q.id] ?? "";
474
- return (
475
- <Card
476
- key={q.id}
477
- className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]"
478
- >
479
- <CardContent className="p-5">
480
- <div className="flex items-start gap-3 mb-4">
481
- <span className="w-8 h-8 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0">
482
- {idx + 1}
483
- </span>
484
- <div>
485
- <p className="text-[var(--clay-black)] font-medium leading-relaxed">
486
- {q.questionText}
487
- </p>
488
- <div className="flex gap-2 mt-1">
489
- <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
490
- {q.format.replace(/_/g, " ")}
491
- </span>
492
- {q.difficulty && (
493
- <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
494
- Lv.{q.difficulty}
495
- </span>
496
- )}
497
- </div>
498
- </div>
499
- </div>
500
-
501
- <div className="pl-11">
502
- {submittingQId === q.id && (
503
- <div className="text-xs text-[var(--matcha-600)] mb-2 flex items-center gap-1">
504
- <MaterialIcon name="sync" className="text-xs animate-spin" />
505
- Menyimpan...
506
- </div>
507
- )}
508
- <QuestionInput
509
- question={q}
510
- value={answerValue}
511
- onChange={(val) => {
512
- if (sectionResultId) {
513
- onAnswerChange(q.id, sectionResultId, val);
514
- }
515
- }}
516
- disabled={isFinished || !sectionResultId}
517
- />
518
- </div>
519
- </CardContent>
520
- </Card>
521
- );
522
- })}
523
-
524
- {/* Section Navigation */}
525
- <div className="flex justify-between pt-4">
526
- <Button
527
- variant="outline"
528
- onClick={() => setCurrentSectionIdx(Math.max(0, currentSectionIdx - 1))}
529
- disabled={currentSectionIdx === 0}
530
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
531
- >
532
- <MaterialIcon name="arrow_back" />
533
- <span className="ml-2">Sebelumnya</span>
534
- </Button>
535
- {currentSectionIdx < pkg.sections.length - 1 ? (
536
- <Button
537
- onClick={() => setCurrentSectionIdx(currentSectionIdx + 1)}
538
- className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
539
- >
540
- <span className="mr-2">Selanjutnya</span>
541
- <MaterialIcon name="arrow_forward" />
542
- </Button>
543
- ) : (
544
- <Button
545
- onClick={onFinish}
546
- disabled={isFinished}
547
- className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)]"
548
- >
549
- <MaterialIcon name="check_circle" />
550
- <span className="ml-2">Selesaikan</span>
551
- </Button>
552
- )}
553
- </div>
554
- </div>
555
- </div>
556
- </div>
557
- </div>
558
- );
559
- }
 
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
 
3
  import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
+ import { useTestSession } from "@/hooks/use-test-session";
6
  import { Button } from "@labas/ui/components/button";
7
  import { Card, CardContent } from "@labas/ui/components/card";
8
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
9
+ import { AttemptTestView } from "@/components/test/AttemptTestView";
10
 
11
  export const Route = createFileRoute("/package/$id/take")({
12
  component: TakeTestComponent,
 
19
  },
20
  });
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  function TakeTestComponent() {
23
  const { id: packageId } = Route.useParams();
 
24
  const { data: session } = authClient.useSession();
25
 
26
  const packageQuery = useQuery(trpc.package.getById.queryOptions({ id: packageId }));
27
  const pkg = packageQuery.data;
28
 
29
+ const {
30
+ attemptId,
31
+ currentSectionIdx,
32
+ setCurrentSectionIdx,
33
+ answers,
34
+ timeElapsed,
35
+ isStarted,
36
+ isFinished,
37
+ submittingQId,
38
+ startPending,
39
+ handleStart,
40
+ handleAnswerChange,
41
+ handleFinish,
42
+ } = useTestSession(packageId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  if (packageQuery.isLoading) {
45
  return (
 
64
  );
65
  }
66
 
67
+ const totalQuestions = pkg.sections.reduce((sum: number, sec: any) => sum + sec.questions.length, 0);
68
  const answeredCount = Object.keys(answers).length;
69
 
70
  // Start screen
 
114
 
115
  <Button
116
  onClick={handleStart}
117
+ disabled={startPending}
118
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] px-8 py-6 text-lg"
119
  >
120
  <MaterialIcon name="play_arrow" />
121
+ <span className="ml-2">{startPending ? "Memulai..." : "Mulai Latihan"}</span>
122
  </Button>
123
  </div>
124
  </div>
 
140
  );
141
  }
142
 
 
 
 
 
 
 
143
  return (
144
  <AttemptTestView
145
  attemptId={attemptId!}
 
157
  />
158
  );
159
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/package.$id.tsx CHANGED
@@ -4,6 +4,8 @@ import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
 
 
7
 
8
  export const Route = createFileRoute("/package/$id")({
9
  component: PackageDetailComponent,
@@ -16,14 +18,6 @@ export const Route = createFileRoute("/package/$id")({
16
  },
17
  });
18
 
19
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
20
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
21
- }
22
-
23
- function formatLabel(fmt: string) {
24
- return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
25
- }
26
-
27
  function PackageDetailComponent() {
28
  const { id } = Route.useParams();
29
  const { data: session } = authClient.useSession();
 
4
  import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
7
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
+ import { formatLabel } from "@/lib/format";
9
 
10
  export const Route = createFileRoute("/package/$id")({
11
  component: PackageDetailComponent,
 
18
  },
19
  });
20
 
 
 
 
 
 
 
 
 
21
  function PackageDetailComponent() {
22
  const { id } = Route.useParams();
23
  const { data: session } = authClient.useSession();
apps/web/src/routes/packages.tsx CHANGED
@@ -14,6 +14,7 @@ import {
14
  SelectTrigger,
15
  SelectValue,
16
  } from "@labas/ui/components/select";
 
17
 
18
  export const Route = createFileRoute("/packages")({
19
  component: PackagesComponent,
@@ -34,10 +35,6 @@ const EXAM_TYPES = [
34
  { id: "GOETHE", name: "German" },
35
  ];
36
 
37
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
38
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
39
- }
40
-
41
  function PackagesComponent() {
42
  const [search, setSearch] = useState("");
43
  const [examType, setExamType] = useState<string>("");
 
14
  SelectTrigger,
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
18
 
19
  export const Route = createFileRoute("/packages")({
20
  component: PackagesComponent,
 
35
  { id: "GOETHE", name: "German" },
36
  ];
37
 
 
 
 
 
38
  function PackagesComponent() {
39
  const [search, setSearch] = useState("");
40
  const [examType, setExamType] = useState<string>("");
apps/web/src/routes/settings.tsx CHANGED
@@ -14,6 +14,7 @@ import {
14
  SelectTrigger,
15
  SelectValue,
16
  } from "@labas/ui/components/select";
 
17
 
18
  export const Route = createFileRoute("/settings")({
19
  component: RouteComponent,
@@ -26,10 +27,6 @@ export const Route = createFileRoute("/settings")({
26
  },
27
  });
28
 
29
- function MaterialIcon({ name }: { name: string }) {
30
- return <span className="material-symbols-outlined text-xl">{name}</span>;
31
- }
32
-
33
  function RouteComponent() {
34
  const { storedKey, isLoading, saveKey, removeKey, hasKey } = useApiKey();
35
 
@@ -63,7 +60,7 @@ function RouteComponent() {
63
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
64
  <CardHeader>
65
  <div className="flex items-center gap-3">
66
- <MaterialIcon name="key" />
67
  <CardTitle className="font-headline text-[var(--clay-black)]">
68
  {hasKey ? "Update API Key" : "Tambah API Key"}
69
  </CardTitle>
@@ -189,7 +186,7 @@ function RouteComponent() {
189
  ) : hasKey ? (
190
  <div className="space-y-4">
191
  <div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
192
- <MaterialIcon name="check_circle" />
193
  <div>
194
  <p className="font-medium text-[var(--matcha-800)]">Key Tersimpan</p>
195
  <p className="text-sm text-[var(--matcha-800)]/70">
@@ -205,7 +202,7 @@ function RouteComponent() {
205
  </div>
206
  ) : (
207
  <div className="text-center py-8">
208
- <MaterialIcon name="key_off" />
209
  <p className="text-[var(--warm-charcoal)] mt-2">Belum ada API key.</p>
210
  <p className="text-xs text-[var(--warm-charcoal)] mt-1">
211
  Tambahkan key untuk mulai generate soal.
 
14
  SelectTrigger,
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
18
 
19
  export const Route = createFileRoute("/settings")({
20
  component: RouteComponent,
 
27
  },
28
  });
29
 
 
 
 
 
30
  function RouteComponent() {
31
  const { storedKey, isLoading, saveKey, removeKey, hasKey } = useApiKey();
32
 
 
60
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
61
  <CardHeader>
62
  <div className="flex items-center gap-3">
63
+ <MaterialIcon name="key" className="text-xl" />
64
  <CardTitle className="font-headline text-[var(--clay-black)]">
65
  {hasKey ? "Update API Key" : "Tambah API Key"}
66
  </CardTitle>
 
186
  ) : hasKey ? (
187
  <div className="space-y-4">
188
  <div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
189
+ <MaterialIcon name="check_circle" className="text-xl" />
190
  <div>
191
  <p className="font-medium text-[var(--matcha-800)]">Key Tersimpan</p>
192
  <p className="text-sm text-[var(--matcha-800)]/70">
 
202
  </div>
203
  ) : (
204
  <div className="text-center py-8">
205
+ <MaterialIcon name="key_off" className="text-xl" />
206
  <p className="text-[var(--warm-charcoal)] mt-2">Belum ada API key.</p>
207
  <p className="text-xs text-[var(--warm-charcoal)] mt-1">
208
  Tambahkan key untuk mulai generate soal.
skills-lock.json CHANGED
@@ -16,6 +16,11 @@
16
  "sourceType": "github",
17
  "computedHash": "220e5e1b12bbaeec49ec362b6e2262d77632d0536e9758bba9acbbc323fef990"
18
  },
 
 
 
 
 
19
  "shadcn": {
20
  "source": "shadcn/ui",
21
  "sourceType": "github",
 
16
  "sourceType": "github",
17
  "computedHash": "220e5e1b12bbaeec49ec362b6e2262d77632d0536e9758bba9acbbc323fef990"
18
  },
19
+ "react-best-practices": {
20
+ "source": "mastra-ai/mastra",
21
+ "sourceType": "github",
22
+ "computedHash": "78115cffdf4f10d6984325376bd1ba3be93313370bf7ebe0c334b43f1f07e599"
23
+ },
24
  "shadcn": {
25
  "source": "shadcn/ui",
26
  "sourceType": "github",