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 +186 -0
- apps/web/src/components/bank/CreatePackageModal.tsx +121 -0
- apps/web/src/components/bank/QuestionDetailModal.tsx +216 -0
- apps/web/src/components/generate/ResultSection.tsx +91 -0
- apps/web/src/components/generate/TestBlueprintCard.tsx +182 -0
- apps/web/src/components/test/AttemptTestView.tsx +205 -0
- apps/web/src/components/test/QuestionInput.tsx +140 -0
- apps/web/src/components/ui/MaterialIcon.tsx +3 -0
- apps/web/src/components/ui/StarRating.tsx +15 -0
- apps/web/src/hooks/use-generation-job.ts +88 -0
- apps/web/src/hooks/use-package-builder.ts +118 -0
- apps/web/src/hooks/use-question-selection.ts +60 -0
- apps/web/src/hooks/use-test-session.ts +76 -0
- apps/web/src/lib/exam-constants.ts +36 -0
- apps/web/src/lib/format.ts +3 -0
- apps/web/src/lib/generate-constants.ts +31 -0
- apps/web/src/lib/time.ts +5 -0
- apps/web/src/routeTree.gen.ts +7 -5
- apps/web/src/routes/__root.tsx +4 -2
- apps/web/src/routes/attempt.$id.tsx +2 -10
- apps/web/src/routes/bank.tsx +49 -751
- apps/web/src/routes/builder.combo.tsx +1 -12
- apps/web/src/routes/generate.tsx +44 -333
- apps/web/src/routes/index.tsx +3 -6
- apps/web/src/routes/jobs.tsx +1 -4
- apps/web/src/routes/package.$id.take.tsx +22 -422
- apps/web/src/routes/package.$id.tsx +2 -8
- apps/web/src/routes/packages.tsx +1 -4
- apps/web/src/routes/settings.tsx +4 -7
- skills-lock.json +5 -0
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: () =>
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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
|
| 2 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
-
import { createFileRoute, redirect
|
| 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 |
-
//
|
| 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 |
-
|
| 679 |
-
const
|
| 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
|
| 723 |
title: string;
|
| 724 |
description: string;
|
| 725 |
isPublic: boolean;
|
| 726 |
-
examTypeId: string;
|
| 727 |
}) => {
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 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
|
| 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 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 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={
|
| 1255 |
-
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={
|
| 1271 |
-
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
|
| 2 |
-
import { useMutation
|
| 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 {
|
| 8 |
import { Input } from "@labas/ui/components/input";
|
| 9 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
<
|
| 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 |
-
<
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 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 |
-
<
|
| 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 {
|
| 2 |
-
import {
|
| 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 {
|
| 9 |
-
import {
|
| 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
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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={
|
| 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">{
|
| 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",
|