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