Update PWA assets configuration, replace logo image, and enhance routing with new history and profile routes. Add sidebar navigation for user profile and history, and implement EditPackageModal component for package management. Refactor API key management to support multiple keys and improve test session handling.
Browse files- apps/web/public/logo.png +2 -2
- apps/web/pwa-assets.config.ts +1 -1
- apps/web/src/components/package/EditPackageModal.tsx +106 -0
- apps/web/src/components/sidebar.tsx +11 -4
- apps/web/src/components/test/AttemptTestView.tsx +18 -6
- apps/web/src/components/test/QuestionInput.tsx +95 -36
- apps/web/src/hooks/use-api-key.ts +73 -17
- apps/web/src/hooks/use-test-session.ts +4 -1
- apps/web/src/routeTree.gen.ts +63 -0
- apps/web/src/routes/bank.tsx +121 -32
- apps/web/src/routes/generate.tsx +54 -10
- apps/web/src/routes/history.tsx +236 -0
- apps/web/src/routes/index.tsx +156 -44
- apps/web/src/routes/me.tsx +217 -0
- apps/web/src/routes/package.$id.index.tsx +49 -3
- apps/web/src/routes/packages.tsx +206 -79
- apps/web/src/routes/profile.$userId.tsx +146 -0
- apps/web/src/routes/settings.tsx +313 -134
- packages/ai/src/schemas.ts +1 -1
- packages/api/src/queue.ts +104 -65
- packages/api/src/routers/attempt.ts +6 -0
- packages/api/src/routers/index.ts +2 -0
- packages/api/src/routers/package.ts +72 -1
- packages/api/src/routers/profile.ts +108 -0
- packages/db/src/schema/app.ts +1 -0
apps/web/public/logo.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
apps/web/pwa-assets.config.ts
CHANGED
|
@@ -5,5 +5,5 @@ export default defineConfig({
|
|
| 5 |
preset: "2023",
|
| 6 |
},
|
| 7 |
preset,
|
| 8 |
-
images: ["public/
|
| 9 |
});
|
|
|
|
| 5 |
preset: "2023",
|
| 6 |
},
|
| 7 |
preset,
|
| 8 |
+
images: ["public/labas_icon.png"],
|
| 9 |
});
|
apps/web/src/components/package/EditPackageModal.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 EditPackageModal({
|
| 7 |
+
initialTitle,
|
| 8 |
+
initialDescription,
|
| 9 |
+
initialIsPublic,
|
| 10 |
+
onClose,
|
| 11 |
+
onSave,
|
| 12 |
+
isPending,
|
| 13 |
+
}: {
|
| 14 |
+
initialTitle: string;
|
| 15 |
+
initialDescription: string | null;
|
| 16 |
+
initialIsPublic: boolean;
|
| 17 |
+
onClose: () => void;
|
| 18 |
+
onSave: (data: {
|
| 19 |
+
title: string;
|
| 20 |
+
description: string;
|
| 21 |
+
isPublic: boolean;
|
| 22 |
+
}) => void;
|
| 23 |
+
isPending: boolean;
|
| 24 |
+
}) {
|
| 25 |
+
const [title, setTitle] = useState(initialTitle);
|
| 26 |
+
const [description, setDescription] = useState(initialDescription ?? "");
|
| 27 |
+
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div
|
| 31 |
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
| 32 |
+
onClick={(e) => {
|
| 33 |
+
if (e.target === e.currentTarget) onClose();
|
| 34 |
+
}}
|
| 35 |
+
>
|
| 36 |
+
<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">
|
| 37 |
+
<div className="flex items-center justify-between mb-6">
|
| 38 |
+
<h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
| 39 |
+
Edit Paket
|
| 40 |
+
</h2>
|
| 41 |
+
<button
|
| 42 |
+
onClick={onClose}
|
| 43 |
+
className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
|
| 44 |
+
>
|
| 45 |
+
<MaterialIcon name="close" className="text-[var(--clay-black)]" />
|
| 46 |
+
</button>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div className="space-y-4">
|
| 50 |
+
<div>
|
| 51 |
+
<label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
|
| 52 |
+
Judul Paket
|
| 53 |
+
</label>
|
| 54 |
+
<Input
|
| 55 |
+
value={title}
|
| 56 |
+
onChange={(e) => setTitle(e.target.value)}
|
| 57 |
+
placeholder="Judul paket..."
|
| 58 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
|
| 59 |
+
/>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div>
|
| 63 |
+
<label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
|
| 64 |
+
Deskripsi
|
| 65 |
+
</label>
|
| 66 |
+
<Input
|
| 67 |
+
value={description}
|
| 68 |
+
onChange={(e) => setDescription(e.target.value)}
|
| 69 |
+
placeholder="Deskripsi singkat..."
|
| 70 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
|
| 71 |
+
/>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<label className="flex items-center gap-2 cursor-pointer">
|
| 75 |
+
<input
|
| 76 |
+
type="checkbox"
|
| 77 |
+
checked={isPublic}
|
| 78 |
+
onChange={(e) => setIsPublic(e.target.checked)}
|
| 79 |
+
className="w-4 h-4 rounded border-[var(--oat-border)]"
|
| 80 |
+
/>
|
| 81 |
+
<span className="text-sm text-[var(--warm-charcoal)]">
|
| 82 |
+
Publikasikan ke Bank Soal
|
| 83 |
+
</span>
|
| 84 |
+
</label>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="mt-6 flex gap-3">
|
| 88 |
+
<Button
|
| 89 |
+
variant="outline"
|
| 90 |
+
onClick={onClose}
|
| 91 |
+
className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 92 |
+
>
|
| 93 |
+
Batal
|
| 94 |
+
</Button>
|
| 95 |
+
<Button
|
| 96 |
+
onClick={() => onSave({ title, description, isPublic })}
|
| 97 |
+
disabled={!title || isPending}
|
| 98 |
+
className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
|
| 99 |
+
>
|
| 100 |
+
{isPending ? "Menyimpan..." : "Simpan"}
|
| 101 |
+
</Button>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
);
|
| 106 |
+
}
|
apps/web/src/components/sidebar.tsx
CHANGED
|
@@ -8,11 +8,13 @@ const navItems = [
|
|
| 8 |
{ to: "/jobs", label: "Jobs", icon: "schedule" },
|
| 9 |
{ to: "/bank", label: "Bank Soal", icon: "database" },
|
| 10 |
{ to: "/packages", label: "Paket", icon: "folder" },
|
|
|
|
| 11 |
{ to: "/builder/combo", label: "Combo", icon: "join_inner" },
|
| 12 |
{ to: "/analytics", label: "Analytics", icon: "analytics" },
|
| 13 |
];
|
| 14 |
|
| 15 |
const bottomItems = [
|
|
|
|
| 16 |
{ to: "/settings", label: "Settings", icon: "settings" },
|
| 17 |
];
|
| 18 |
|
|
@@ -34,9 +36,14 @@ export function Sidebar() {
|
|
| 34 |
}`}
|
| 35 |
>
|
| 36 |
<div className={`mb-8 ${collapsed ? "px-0 text-center" : "px-4"}`}>
|
| 37 |
-
<
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
{!collapsed && (
|
| 41 |
<p className="text-xs text-[var(--warm-charcoal)] uppercase tracking-widest font-semibold mt-1">
|
| 42 |
Exam Prep Portal
|
|
@@ -111,7 +118,7 @@ export function Sidebar() {
|
|
| 111 |
onClick={toggle}
|
| 112 |
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
| 113 |
style={{ left: collapsed ? "48px" : "240px" }}
|
| 114 |
-
className="hidden md:flex
|
| 115 |
>
|
| 116 |
<span className="relative inline-flex size-6 shrink-0 items-center justify-center">
|
| 117 |
<span
|
|
|
|
| 8 |
{ to: "/jobs", label: "Jobs", icon: "schedule" },
|
| 9 |
{ to: "/bank", label: "Bank Soal", icon: "database" },
|
| 10 |
{ to: "/packages", label: "Paket", icon: "folder" },
|
| 11 |
+
{ to: "/history", label: "Riwayat", icon: "history" },
|
| 12 |
{ to: "/builder/combo", label: "Combo", icon: "join_inner" },
|
| 13 |
{ to: "/analytics", label: "Analytics", icon: "analytics" },
|
| 14 |
];
|
| 15 |
|
| 16 |
const bottomItems = [
|
| 17 |
+
{ to: "/me", label: "Profil", icon: "person" },
|
| 18 |
{ to: "/settings", label: "Settings", icon: "settings" },
|
| 19 |
];
|
| 20 |
|
|
|
|
| 36 |
}`}
|
| 37 |
>
|
| 38 |
<div className={`mb-8 ${collapsed ? "px-0 text-center" : "px-4"}`}>
|
| 39 |
+
<img
|
| 40 |
+
src="/logo.png"
|
| 41 |
+
alt="Labas"
|
| 42 |
+
width={64}
|
| 43 |
+
height={64}
|
| 44 |
+
className="mt-2 size-16 object-contain select-none"
|
| 45 |
+
decoding="async"
|
| 46 |
+
/>
|
| 47 |
{!collapsed && (
|
| 48 |
<p className="text-xs text-[var(--warm-charcoal)] uppercase tracking-widest font-semibold mt-1">
|
| 49 |
Exam Prep Portal
|
|
|
|
| 118 |
onClick={toggle}
|
| 119 |
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
| 120 |
style={{ left: collapsed ? "48px" : "240px" }}
|
| 121 |
+
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"
|
| 122 |
>
|
| 123 |
<span className="relative inline-flex size-6 shrink-0 items-center justify-center">
|
| 124 |
<span
|
apps/web/src/components/test/AttemptTestView.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
import { Link } from "@tanstack/react-router";
|
| 3 |
import { useQuery } from "@tanstack/react-query";
|
| 4 |
import { Button } from "@labas/ui/components/button";
|
|
@@ -13,7 +13,7 @@ interface AttemptTestViewProps {
|
|
| 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;
|
|
@@ -60,6 +60,20 @@ export function AttemptTestView({
|
|
| 60 |
const sectionData = attempt?.sections?.[currentSectionIdx];
|
| 61 |
const sectionResultId = sectionData?.sectionResultId;
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if (!currentSection) {
|
| 64 |
return (
|
| 65 |
<div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)]">
|
|
@@ -205,11 +219,9 @@ export function AttemptTestView({
|
|
| 205 |
question={q}
|
| 206 |
value={answerValue}
|
| 207 |
onChange={(val) => {
|
| 208 |
-
|
| 209 |
-
onAnswerChange(q.id, sectionResultId, val);
|
| 210 |
-
}
|
| 211 |
}}
|
| 212 |
-
disabled={isFinished
|
| 213 |
/>
|
| 214 |
</div>
|
| 215 |
</div>
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from "react";
|
| 2 |
import { Link } from "@tanstack/react-router";
|
| 3 |
import { useQuery } from "@tanstack/react-query";
|
| 4 |
import { Button } from "@labas/ui/components/button";
|
|
|
|
| 13 |
currentSectionIdx: number;
|
| 14 |
setCurrentSectionIdx: (idx: number) => void;
|
| 15 |
answers: Record<string, string>;
|
| 16 |
+
onAnswerChange: (questionId: string, sectionResultId: string | undefined, value: string) => void;
|
| 17 |
timeElapsed: number;
|
| 18 |
answeredCount: number;
|
| 19 |
totalQuestions: number;
|
|
|
|
| 60 |
const sectionData = attempt?.sections?.[currentSectionIdx];
|
| 61 |
const sectionResultId = sectionData?.sectionResultId;
|
| 62 |
|
| 63 |
+
// If user picks an answer before attempt.getById finishes, persist once sectionResultId exists
|
| 64 |
+
const flushKeyRef = useRef<string | null>(null);
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
if (!sectionResultId || isFinished || !currentSection?.questions?.length) return;
|
| 67 |
+
const key = `${currentSectionIdx}:${sectionResultId}`;
|
| 68 |
+
if (flushKeyRef.current === key) return;
|
| 69 |
+
flushKeyRef.current = key;
|
| 70 |
+
for (const q of currentSection.questions as any[]) {
|
| 71 |
+
const v = answers[q.id];
|
| 72 |
+
if (v) void onAnswerChange(q.id, sectionResultId, v);
|
| 73 |
+
}
|
| 74 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- flush once per section when ids align; `answers` read from committing render
|
| 75 |
+
}, [sectionResultId, currentSectionIdx, isFinished, currentSection?.questions?.length, onAnswerChange]);
|
| 76 |
+
|
| 77 |
if (!currentSection) {
|
| 78 |
return (
|
| 79 |
<div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)]">
|
|
|
|
| 219 |
question={q}
|
| 220 |
value={answerValue}
|
| 221 |
onChange={(val) => {
|
| 222 |
+
onAnswerChange(q.id, sectionResultId, val);
|
|
|
|
|
|
|
| 223 |
}}
|
| 224 |
+
disabled={isFinished}
|
| 225 |
/>
|
| 226 |
</div>
|
| 227 |
</div>
|
apps/web/src/components/test/QuestionInput.tsx
CHANGED
|
@@ -28,10 +28,31 @@ const AUTHOR_VIEW_CHOICES = [
|
|
| 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-300)] transition-
|
| 32 |
-
const radioSelected =
|
|
|
|
| 33 |
const radioDisabled = "opacity-60 cursor-not-allowed";
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
export function QuestionInput({
|
| 36 |
question,
|
| 37 |
value,
|
|
@@ -81,49 +102,87 @@ export function QuestionInput({
|
|
| 81 |
}
|
| 82 |
|
| 83 |
if (format === "true_false_not_given") {
|
|
|
|
| 84 |
return (
|
| 85 |
<div className="space-y-2">
|
| 86 |
-
{TRUE_FALSE_CHOICES.map((c) =>
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</div>
|
| 128 |
);
|
| 129 |
}
|
|
|
|
| 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-300)] transition-all";
|
| 32 |
+
const radioSelected =
|
| 33 |
+
"border-[var(--matcha-600)] bg-[#e8f5ed] ring-2 ring-[var(--matcha-600)]/30 shadow-sm";
|
| 34 |
const radioDisabled = "opacity-60 cursor-not-allowed";
|
| 35 |
|
| 36 |
+
/** Normalize TF / NG style answers from API or loose model output */
|
| 37 |
+
function normalizeTriStateKey(
|
| 38 |
+
value: string,
|
| 39 |
+
choices: readonly { key: string }[],
|
| 40 |
+
): string {
|
| 41 |
+
const raw = (value ?? "").trim();
|
| 42 |
+
if (!raw) return "";
|
| 43 |
+
const u = raw.toUpperCase().replace(/\s+/g, "_");
|
| 44 |
+
if (choices.some((c) => c.key === u)) return u;
|
| 45 |
+
const compact = u.replace(/_/g, "");
|
| 46 |
+
const alias: Record<string, string> = {
|
| 47 |
+
TRUE: "TRUE",
|
| 48 |
+
FALSE: "FALSE",
|
| 49 |
+
NOTGIVEN: "NOT_GIVEN",
|
| 50 |
+
YES: "YES",
|
| 51 |
+
NO: "NO",
|
| 52 |
+
};
|
| 53 |
+
return alias[compact] ?? u;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
export function QuestionInput({
|
| 57 |
question,
|
| 58 |
value,
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
if (format === "true_false_not_given") {
|
| 105 |
+
const normalizedValue = normalizeTriStateKey(value, TRUE_FALSE_CHOICES);
|
| 106 |
return (
|
| 107 |
<div className="space-y-2">
|
| 108 |
+
{TRUE_FALSE_CHOICES.map((c) => {
|
| 109 |
+
const selected = normalizedValue === c.key;
|
| 110 |
+
return (
|
| 111 |
+
<label
|
| 112 |
+
key={c.key}
|
| 113 |
+
className={`${radioClass} ${selected ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
|
| 114 |
+
>
|
| 115 |
+
<input
|
| 116 |
+
type="radio"
|
| 117 |
+
name={question.id}
|
| 118 |
+
value={c.key}
|
| 119 |
+
checked={selected}
|
| 120 |
+
onChange={() => onChange(c.key)}
|
| 121 |
+
disabled={disabled}
|
| 122 |
+
className="sr-only"
|
| 123 |
+
/>
|
| 124 |
+
<span
|
| 125 |
+
aria-hidden
|
| 126 |
+
className={`w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${
|
| 127 |
+
selected
|
| 128 |
+
? "border-[var(--matcha-600)] bg-[var(--matcha-600)]"
|
| 129 |
+
: "border-[var(--oat-border)] bg-[var(--pure-white)]"
|
| 130 |
+
}`}
|
| 131 |
+
>
|
| 132 |
+
{selected ? <span className="w-2 h-2 rounded-full bg-[var(--pure-white)]" /> : null}
|
| 133 |
+
</span>
|
| 134 |
+
<span
|
| 135 |
+
className={`text-sm font-semibold ${
|
| 136 |
+
selected ? "text-[var(--matcha-800)]" : "text-[var(--clay-black)]"
|
| 137 |
+
}`}
|
| 138 |
+
>
|
| 139 |
+
{c.label}
|
| 140 |
+
</span>
|
| 141 |
+
</label>
|
| 142 |
+
);
|
| 143 |
+
})}
|
| 144 |
</div>
|
| 145 |
);
|
| 146 |
}
|
| 147 |
|
| 148 |
if (format === "author_view") {
|
| 149 |
+
const normalizedValue = normalizeTriStateKey(value, AUTHOR_VIEW_CHOICES);
|
| 150 |
return (
|
| 151 |
<div className="space-y-2">
|
| 152 |
+
{AUTHOR_VIEW_CHOICES.map((c) => {
|
| 153 |
+
const selected = normalizedValue === c.key;
|
| 154 |
+
return (
|
| 155 |
+
<label
|
| 156 |
+
key={c.key}
|
| 157 |
+
className={`${radioClass} ${selected ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
|
| 158 |
+
>
|
| 159 |
+
<input
|
| 160 |
+
type="radio"
|
| 161 |
+
name={question.id}
|
| 162 |
+
value={c.key}
|
| 163 |
+
checked={selected}
|
| 164 |
+
onChange={() => onChange(c.key)}
|
| 165 |
+
disabled={disabled}
|
| 166 |
+
className="sr-only"
|
| 167 |
+
/>
|
| 168 |
+
<span
|
| 169 |
+
aria-hidden
|
| 170 |
+
className={`w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${
|
| 171 |
+
selected
|
| 172 |
+
? "border-[var(--matcha-600)] bg-[var(--matcha-600)]"
|
| 173 |
+
: "border-[var(--oat-border)] bg-[var(--pure-white)]"
|
| 174 |
+
}`}
|
| 175 |
+
>
|
| 176 |
+
{selected ? <span className="w-2 h-2 rounded-full bg-[var(--pure-white)]" /> : null}
|
| 177 |
+
</span>
|
| 178 |
+
<span
|
| 179 |
+
className={`text-sm font-semibold ${selected ? "text-[var(--matcha-800)]" : "text-[var(--clay-black)]"}`}
|
| 180 |
+
>
|
| 181 |
+
{c.label}
|
| 182 |
+
</span>
|
| 183 |
+
</label>
|
| 184 |
+
);
|
| 185 |
+
})}
|
| 186 |
</div>
|
| 187 |
);
|
| 188 |
}
|
apps/web/src/hooks/use-api-key.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
import { useState, useEffect, useCallback } from "react";
|
| 2 |
import { encryptText, decryptText } from "@/lib/crypto";
|
| 3 |
|
| 4 |
-
const STORAGE_KEY = "
|
| 5 |
|
| 6 |
-
export interface
|
|
|
|
|
|
|
| 7 |
provider: string;
|
| 8 |
baseUrl: string;
|
| 9 |
apiKey: string;
|
|
@@ -11,45 +13,99 @@ export interface StoredApiKey {
|
|
| 11 |
maxTokens?: number;
|
| 12 |
}
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const [isLoading, setIsLoading] = useState(true);
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
-
|
| 20 |
}, []);
|
| 21 |
|
| 22 |
-
const
|
| 23 |
try {
|
| 24 |
const encrypted = localStorage.getItem(STORAGE_KEY);
|
| 25 |
if (encrypted) {
|
| 26 |
const decrypted = await decryptText(encrypted);
|
| 27 |
-
|
| 28 |
}
|
| 29 |
} catch {
|
| 30 |
-
// Invalid or corrupted data
|
| 31 |
localStorage.removeItem(STORAGE_KEY);
|
| 32 |
} finally {
|
| 33 |
setIsLoading(false);
|
| 34 |
}
|
| 35 |
};
|
| 36 |
|
| 37 |
-
const
|
| 38 |
-
const encrypted = await encryptText(JSON.stringify(
|
| 39 |
localStorage.setItem(STORAGE_KEY, encrypted);
|
| 40 |
-
|
| 41 |
}, []);
|
| 42 |
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
return {
|
| 49 |
storedKey,
|
| 50 |
isLoading,
|
| 51 |
-
saveKey,
|
| 52 |
-
removeKey,
|
| 53 |
hasKey: !!storedKey,
|
| 54 |
};
|
| 55 |
}
|
|
|
|
| 1 |
import { useState, useEffect, useCallback } from "react";
|
| 2 |
import { encryptText, decryptText } from "@/lib/crypto";
|
| 3 |
|
| 4 |
+
const STORAGE_KEY = "labas_api_keys_v2";
|
| 5 |
|
| 6 |
+
export interface ApiKeyConfig {
|
| 7 |
+
id: string;
|
| 8 |
+
name: string;
|
| 9 |
provider: string;
|
| 10 |
baseUrl: string;
|
| 11 |
apiKey: string;
|
|
|
|
| 13 |
maxTokens?: number;
|
| 14 |
}
|
| 15 |
|
| 16 |
+
function generateId() {
|
| 17 |
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function useApiKeys() {
|
| 21 |
+
const [configs, setConfigs] = useState<ApiKeyConfig[]>([]);
|
| 22 |
const [isLoading, setIsLoading] = useState(true);
|
| 23 |
|
| 24 |
useEffect(() => {
|
| 25 |
+
loadConfigs();
|
| 26 |
}, []);
|
| 27 |
|
| 28 |
+
const loadConfigs = async () => {
|
| 29 |
try {
|
| 30 |
const encrypted = localStorage.getItem(STORAGE_KEY);
|
| 31 |
if (encrypted) {
|
| 32 |
const decrypted = await decryptText(encrypted);
|
| 33 |
+
setConfigs(JSON.parse(decrypted));
|
| 34 |
}
|
| 35 |
} catch {
|
|
|
|
| 36 |
localStorage.removeItem(STORAGE_KEY);
|
| 37 |
} finally {
|
| 38 |
setIsLoading(false);
|
| 39 |
}
|
| 40 |
};
|
| 41 |
|
| 42 |
+
const persist = useCallback(async (next: ApiKeyConfig[]) => {
|
| 43 |
+
const encrypted = await encryptText(JSON.stringify(next));
|
| 44 |
localStorage.setItem(STORAGE_KEY, encrypted);
|
| 45 |
+
setConfigs(next);
|
| 46 |
}, []);
|
| 47 |
|
| 48 |
+
const addConfig = useCallback(
|
| 49 |
+
async (config: Omit<ApiKeyConfig, "id">) => {
|
| 50 |
+
const next = [...configs, { ...config, id: generateId() }];
|
| 51 |
+
await persist(next);
|
| 52 |
+
return next[next.length - 1].id;
|
| 53 |
+
},
|
| 54 |
+
[configs, persist],
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
const updateConfig = useCallback(
|
| 58 |
+
async (id: string, updates: Partial<Omit<ApiKeyConfig, "id">>) => {
|
| 59 |
+
const next = configs.map((c) => {
|
| 60 |
+
if (c.id !== id) return c;
|
| 61 |
+
// If apiKey is empty string, keep the old one (don't overwrite)
|
| 62 |
+
const apiKey =
|
| 63 |
+
updates.apiKey === "" || updates.apiKey === undefined
|
| 64 |
+
? c.apiKey
|
| 65 |
+
: updates.apiKey;
|
| 66 |
+
return { ...c, ...updates, apiKey };
|
| 67 |
+
});
|
| 68 |
+
await persist(next);
|
| 69 |
+
},
|
| 70 |
+
[configs, persist],
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
const removeConfig = useCallback(
|
| 74 |
+
async (id: string) => {
|
| 75 |
+
const next = configs.filter((c) => c.id !== id);
|
| 76 |
+
await persist(next);
|
| 77 |
+
},
|
| 78 |
+
[configs, persist],
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
const getConfig = useCallback(
|
| 82 |
+
(id: string) => configs.find((c) => c.id === id),
|
| 83 |
+
[configs],
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
configs,
|
| 88 |
+
isLoading,
|
| 89 |
+
addConfig,
|
| 90 |
+
updateConfig,
|
| 91 |
+
removeConfig,
|
| 92 |
+
getConfig,
|
| 93 |
+
hasConfigs: configs.length > 0,
|
| 94 |
+
};
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Backward-compatible hook for places that only need the first (default) key
|
| 98 |
+
export function useApiKey() {
|
| 99 |
+
const { configs, isLoading, addConfig, updateConfig, removeConfig } =
|
| 100 |
+
useApiKeys();
|
| 101 |
+
|
| 102 |
+
const storedKey = configs[0] ?? null;
|
| 103 |
|
| 104 |
return {
|
| 105 |
storedKey,
|
| 106 |
isLoading,
|
| 107 |
+
saveKey: addConfig,
|
| 108 |
+
removeKey: removeConfig,
|
| 109 |
hasKey: !!storedKey,
|
| 110 |
};
|
| 111 |
}
|
apps/web/src/hooks/use-test-session.ts
CHANGED
|
@@ -79,9 +79,12 @@ export function useTestSession(packageId: string) {
|
|
| 79 |
}, [packageId, startMutation]);
|
| 80 |
|
| 81 |
const handleAnswerChange = useCallback(
|
| 82 |
-
async (questionId: string, sectionResultId: string, value: string) => {
|
| 83 |
if (!attemptId || isFinished) return;
|
|
|
|
| 84 |
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
|
|
|
|
|
|
| 85 |
setSubmittingQId(questionId);
|
| 86 |
|
| 87 |
// Start timer if first interaction
|
|
|
|
| 79 |
}, [packageId, startMutation]);
|
| 80 |
|
| 81 |
const handleAnswerChange = useCallback(
|
| 82 |
+
async (questionId: string, sectionResultId: string | undefined, value: string) => {
|
| 83 |
if (!attemptId || isFinished) return;
|
| 84 |
+
// Always update UI immediately — sectionResultId may load a tick after attempt starts
|
| 85 |
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
| 86 |
+
if (!sectionResultId) return;
|
| 87 |
+
|
| 88 |
setSubmittingQId(questionId);
|
| 89 |
|
| 90 |
// Start timer if first interaction
|
apps/web/src/routeTree.gen.ts
CHANGED
|
@@ -11,12 +11,15 @@
|
|
| 11 |
import { Route as rootRouteImport } from './routes/__root'
|
| 12 |
import { Route as SettingsRouteImport } from './routes/settings'
|
| 13 |
import { Route as PackagesRouteImport } from './routes/packages'
|
|
|
|
| 14 |
import { Route as LoginRouteImport } from './routes/login'
|
| 15 |
import { Route as JobsRouteImport } from './routes/jobs'
|
|
|
|
| 16 |
import { Route as GenerateRouteImport } from './routes/generate'
|
| 17 |
import { Route as BankRouteImport } from './routes/bank'
|
| 18 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 19 |
import { Route as IndexRouteImport } from './routes/index'
|
|
|
|
| 20 |
import { Route as PackageIdRouteImport } from './routes/package.$id'
|
| 21 |
import { Route as BuilderComboRouteImport } from './routes/builder.combo'
|
| 22 |
import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
|
|
@@ -33,6 +36,11 @@ const PackagesRoute = PackagesRouteImport.update({
|
|
| 33 |
path: '/packages',
|
| 34 |
getParentRoute: () => rootRouteImport,
|
| 35 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const LoginRoute = LoginRouteImport.update({
|
| 37 |
id: '/login',
|
| 38 |
path: '/login',
|
|
@@ -43,6 +51,11 @@ const JobsRoute = JobsRouteImport.update({
|
|
| 43 |
path: '/jobs',
|
| 44 |
getParentRoute: () => rootRouteImport,
|
| 45 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const GenerateRoute = GenerateRouteImport.update({
|
| 47 |
id: '/generate',
|
| 48 |
path: '/generate',
|
|
@@ -63,6 +76,11 @@ const IndexRoute = IndexRouteImport.update({
|
|
| 63 |
path: '/',
|
| 64 |
getParentRoute: () => rootRouteImport,
|
| 65 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
const PackageIdRoute = PackageIdRouteImport.update({
|
| 67 |
id: '/package/$id',
|
| 68 |
path: '/package/$id',
|
|
@@ -94,13 +112,16 @@ export interface FileRoutesByFullPath {
|
|
| 94 |
'/analytics': typeof AnalyticsRoute
|
| 95 |
'/bank': typeof BankRoute
|
| 96 |
'/generate': typeof GenerateRoute
|
|
|
|
| 97 |
'/jobs': typeof JobsRoute
|
| 98 |
'/login': typeof LoginRoute
|
|
|
|
| 99 |
'/packages': typeof PackagesRoute
|
| 100 |
'/settings': typeof SettingsRoute
|
| 101 |
'/attempt/$id': typeof AttemptIdRoute
|
| 102 |
'/builder/combo': typeof BuilderComboRoute
|
| 103 |
'/package/$id': typeof PackageIdRouteWithChildren
|
|
|
|
| 104 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 105 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 106 |
}
|
|
@@ -109,12 +130,15 @@ export interface FileRoutesByTo {
|
|
| 109 |
'/analytics': typeof AnalyticsRoute
|
| 110 |
'/bank': typeof BankRoute
|
| 111 |
'/generate': typeof GenerateRoute
|
|
|
|
| 112 |
'/jobs': typeof JobsRoute
|
| 113 |
'/login': typeof LoginRoute
|
|
|
|
| 114 |
'/packages': typeof PackagesRoute
|
| 115 |
'/settings': typeof SettingsRoute
|
| 116 |
'/attempt/$id': typeof AttemptIdRoute
|
| 117 |
'/builder/combo': typeof BuilderComboRoute
|
|
|
|
| 118 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 119 |
'/package/$id': typeof PackageIdIndexRoute
|
| 120 |
}
|
|
@@ -124,13 +148,16 @@ export interface FileRoutesById {
|
|
| 124 |
'/analytics': typeof AnalyticsRoute
|
| 125 |
'/bank': typeof BankRoute
|
| 126 |
'/generate': typeof GenerateRoute
|
|
|
|
| 127 |
'/jobs': typeof JobsRoute
|
| 128 |
'/login': typeof LoginRoute
|
|
|
|
| 129 |
'/packages': typeof PackagesRoute
|
| 130 |
'/settings': typeof SettingsRoute
|
| 131 |
'/attempt/$id': typeof AttemptIdRoute
|
| 132 |
'/builder/combo': typeof BuilderComboRoute
|
| 133 |
'/package/$id': typeof PackageIdRouteWithChildren
|
|
|
|
| 134 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 135 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 136 |
}
|
|
@@ -141,13 +168,16 @@ export interface FileRouteTypes {
|
|
| 141 |
| '/analytics'
|
| 142 |
| '/bank'
|
| 143 |
| '/generate'
|
|
|
|
| 144 |
| '/jobs'
|
| 145 |
| '/login'
|
|
|
|
| 146 |
| '/packages'
|
| 147 |
| '/settings'
|
| 148 |
| '/attempt/$id'
|
| 149 |
| '/builder/combo'
|
| 150 |
| '/package/$id'
|
|
|
|
| 151 |
| '/package/$id/take'
|
| 152 |
| '/package/$id/'
|
| 153 |
fileRoutesByTo: FileRoutesByTo
|
|
@@ -156,12 +186,15 @@ export interface FileRouteTypes {
|
|
| 156 |
| '/analytics'
|
| 157 |
| '/bank'
|
| 158 |
| '/generate'
|
|
|
|
| 159 |
| '/jobs'
|
| 160 |
| '/login'
|
|
|
|
| 161 |
| '/packages'
|
| 162 |
| '/settings'
|
| 163 |
| '/attempt/$id'
|
| 164 |
| '/builder/combo'
|
|
|
|
| 165 |
| '/package/$id/take'
|
| 166 |
| '/package/$id'
|
| 167 |
id:
|
|
@@ -170,13 +203,16 @@ export interface FileRouteTypes {
|
|
| 170 |
| '/analytics'
|
| 171 |
| '/bank'
|
| 172 |
| '/generate'
|
|
|
|
| 173 |
| '/jobs'
|
| 174 |
| '/login'
|
|
|
|
| 175 |
| '/packages'
|
| 176 |
| '/settings'
|
| 177 |
| '/attempt/$id'
|
| 178 |
| '/builder/combo'
|
| 179 |
| '/package/$id'
|
|
|
|
| 180 |
| '/package/$id/take'
|
| 181 |
| '/package/$id/'
|
| 182 |
fileRoutesById: FileRoutesById
|
|
@@ -186,13 +222,16 @@ export interface RootRouteChildren {
|
|
| 186 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 187 |
BankRoute: typeof BankRoute
|
| 188 |
GenerateRoute: typeof GenerateRoute
|
|
|
|
| 189 |
JobsRoute: typeof JobsRoute
|
| 190 |
LoginRoute: typeof LoginRoute
|
|
|
|
| 191 |
PackagesRoute: typeof PackagesRoute
|
| 192 |
SettingsRoute: typeof SettingsRoute
|
| 193 |
AttemptIdRoute: typeof AttemptIdRoute
|
| 194 |
BuilderComboRoute: typeof BuilderComboRoute
|
| 195 |
PackageIdRoute: typeof PackageIdRouteWithChildren
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
declare module '@tanstack/react-router' {
|
|
@@ -211,6 +250,13 @@ declare module '@tanstack/react-router' {
|
|
| 211 |
preLoaderRoute: typeof PackagesRouteImport
|
| 212 |
parentRoute: typeof rootRouteImport
|
| 213 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
'/login': {
|
| 215 |
id: '/login'
|
| 216 |
path: '/login'
|
|
@@ -225,6 +271,13 @@ declare module '@tanstack/react-router' {
|
|
| 225 |
preLoaderRoute: typeof JobsRouteImport
|
| 226 |
parentRoute: typeof rootRouteImport
|
| 227 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
'/generate': {
|
| 229 |
id: '/generate'
|
| 230 |
path: '/generate'
|
|
@@ -253,6 +306,13 @@ declare module '@tanstack/react-router' {
|
|
| 253 |
preLoaderRoute: typeof IndexRouteImport
|
| 254 |
parentRoute: typeof rootRouteImport
|
| 255 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
'/package/$id': {
|
| 257 |
id: '/package/$id'
|
| 258 |
path: '/package/$id'
|
|
@@ -310,13 +370,16 @@ const rootRouteChildren: RootRouteChildren = {
|
|
| 310 |
AnalyticsRoute: AnalyticsRoute,
|
| 311 |
BankRoute: BankRoute,
|
| 312 |
GenerateRoute: GenerateRoute,
|
|
|
|
| 313 |
JobsRoute: JobsRoute,
|
| 314 |
LoginRoute: LoginRoute,
|
|
|
|
| 315 |
PackagesRoute: PackagesRoute,
|
| 316 |
SettingsRoute: SettingsRoute,
|
| 317 |
AttemptIdRoute: AttemptIdRoute,
|
| 318 |
BuilderComboRoute: BuilderComboRoute,
|
| 319 |
PackageIdRoute: PackageIdRouteWithChildren,
|
|
|
|
| 320 |
}
|
| 321 |
export const routeTree = rootRouteImport
|
| 322 |
._addFileChildren(rootRouteChildren)
|
|
|
|
| 11 |
import { Route as rootRouteImport } from './routes/__root'
|
| 12 |
import { Route as SettingsRouteImport } from './routes/settings'
|
| 13 |
import { Route as PackagesRouteImport } from './routes/packages'
|
| 14 |
+
import { Route as MeRouteImport } from './routes/me'
|
| 15 |
import { Route as LoginRouteImport } from './routes/login'
|
| 16 |
import { Route as JobsRouteImport } from './routes/jobs'
|
| 17 |
+
import { Route as HistoryRouteImport } from './routes/history'
|
| 18 |
import { Route as GenerateRouteImport } from './routes/generate'
|
| 19 |
import { Route as BankRouteImport } from './routes/bank'
|
| 20 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 21 |
import { Route as IndexRouteImport } from './routes/index'
|
| 22 |
+
import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
|
| 23 |
import { Route as PackageIdRouteImport } from './routes/package.$id'
|
| 24 |
import { Route as BuilderComboRouteImport } from './routes/builder.combo'
|
| 25 |
import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
|
|
|
|
| 36 |
path: '/packages',
|
| 37 |
getParentRoute: () => rootRouteImport,
|
| 38 |
} as any)
|
| 39 |
+
const MeRoute = MeRouteImport.update({
|
| 40 |
+
id: '/me',
|
| 41 |
+
path: '/me',
|
| 42 |
+
getParentRoute: () => rootRouteImport,
|
| 43 |
+
} as any)
|
| 44 |
const LoginRoute = LoginRouteImport.update({
|
| 45 |
id: '/login',
|
| 46 |
path: '/login',
|
|
|
|
| 51 |
path: '/jobs',
|
| 52 |
getParentRoute: () => rootRouteImport,
|
| 53 |
} as any)
|
| 54 |
+
const HistoryRoute = HistoryRouteImport.update({
|
| 55 |
+
id: '/history',
|
| 56 |
+
path: '/history',
|
| 57 |
+
getParentRoute: () => rootRouteImport,
|
| 58 |
+
} as any)
|
| 59 |
const GenerateRoute = GenerateRouteImport.update({
|
| 60 |
id: '/generate',
|
| 61 |
path: '/generate',
|
|
|
|
| 76 |
path: '/',
|
| 77 |
getParentRoute: () => rootRouteImport,
|
| 78 |
} as any)
|
| 79 |
+
const ProfileUserIdRoute = ProfileUserIdRouteImport.update({
|
| 80 |
+
id: '/profile/$userId',
|
| 81 |
+
path: '/profile/$userId',
|
| 82 |
+
getParentRoute: () => rootRouteImport,
|
| 83 |
+
} as any)
|
| 84 |
const PackageIdRoute = PackageIdRouteImport.update({
|
| 85 |
id: '/package/$id',
|
| 86 |
path: '/package/$id',
|
|
|
|
| 112 |
'/analytics': typeof AnalyticsRoute
|
| 113 |
'/bank': typeof BankRoute
|
| 114 |
'/generate': typeof GenerateRoute
|
| 115 |
+
'/history': typeof HistoryRoute
|
| 116 |
'/jobs': typeof JobsRoute
|
| 117 |
'/login': typeof LoginRoute
|
| 118 |
+
'/me': typeof MeRoute
|
| 119 |
'/packages': typeof PackagesRoute
|
| 120 |
'/settings': typeof SettingsRoute
|
| 121 |
'/attempt/$id': typeof AttemptIdRoute
|
| 122 |
'/builder/combo': typeof BuilderComboRoute
|
| 123 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 124 |
+
'/profile/$userId': typeof ProfileUserIdRoute
|
| 125 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 126 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 127 |
}
|
|
|
|
| 130 |
'/analytics': typeof AnalyticsRoute
|
| 131 |
'/bank': typeof BankRoute
|
| 132 |
'/generate': typeof GenerateRoute
|
| 133 |
+
'/history': typeof HistoryRoute
|
| 134 |
'/jobs': typeof JobsRoute
|
| 135 |
'/login': typeof LoginRoute
|
| 136 |
+
'/me': typeof MeRoute
|
| 137 |
'/packages': typeof PackagesRoute
|
| 138 |
'/settings': typeof SettingsRoute
|
| 139 |
'/attempt/$id': typeof AttemptIdRoute
|
| 140 |
'/builder/combo': typeof BuilderComboRoute
|
| 141 |
+
'/profile/$userId': typeof ProfileUserIdRoute
|
| 142 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 143 |
'/package/$id': typeof PackageIdIndexRoute
|
| 144 |
}
|
|
|
|
| 148 |
'/analytics': typeof AnalyticsRoute
|
| 149 |
'/bank': typeof BankRoute
|
| 150 |
'/generate': typeof GenerateRoute
|
| 151 |
+
'/history': typeof HistoryRoute
|
| 152 |
'/jobs': typeof JobsRoute
|
| 153 |
'/login': typeof LoginRoute
|
| 154 |
+
'/me': typeof MeRoute
|
| 155 |
'/packages': typeof PackagesRoute
|
| 156 |
'/settings': typeof SettingsRoute
|
| 157 |
'/attempt/$id': typeof AttemptIdRoute
|
| 158 |
'/builder/combo': typeof BuilderComboRoute
|
| 159 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 160 |
+
'/profile/$userId': typeof ProfileUserIdRoute
|
| 161 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 162 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 163 |
}
|
|
|
|
| 168 |
| '/analytics'
|
| 169 |
| '/bank'
|
| 170 |
| '/generate'
|
| 171 |
+
| '/history'
|
| 172 |
| '/jobs'
|
| 173 |
| '/login'
|
| 174 |
+
| '/me'
|
| 175 |
| '/packages'
|
| 176 |
| '/settings'
|
| 177 |
| '/attempt/$id'
|
| 178 |
| '/builder/combo'
|
| 179 |
| '/package/$id'
|
| 180 |
+
| '/profile/$userId'
|
| 181 |
| '/package/$id/take'
|
| 182 |
| '/package/$id/'
|
| 183 |
fileRoutesByTo: FileRoutesByTo
|
|
|
|
| 186 |
| '/analytics'
|
| 187 |
| '/bank'
|
| 188 |
| '/generate'
|
| 189 |
+
| '/history'
|
| 190 |
| '/jobs'
|
| 191 |
| '/login'
|
| 192 |
+
| '/me'
|
| 193 |
| '/packages'
|
| 194 |
| '/settings'
|
| 195 |
| '/attempt/$id'
|
| 196 |
| '/builder/combo'
|
| 197 |
+
| '/profile/$userId'
|
| 198 |
| '/package/$id/take'
|
| 199 |
| '/package/$id'
|
| 200 |
id:
|
|
|
|
| 203 |
| '/analytics'
|
| 204 |
| '/bank'
|
| 205 |
| '/generate'
|
| 206 |
+
| '/history'
|
| 207 |
| '/jobs'
|
| 208 |
| '/login'
|
| 209 |
+
| '/me'
|
| 210 |
| '/packages'
|
| 211 |
| '/settings'
|
| 212 |
| '/attempt/$id'
|
| 213 |
| '/builder/combo'
|
| 214 |
| '/package/$id'
|
| 215 |
+
| '/profile/$userId'
|
| 216 |
| '/package/$id/take'
|
| 217 |
| '/package/$id/'
|
| 218 |
fileRoutesById: FileRoutesById
|
|
|
|
| 222 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 223 |
BankRoute: typeof BankRoute
|
| 224 |
GenerateRoute: typeof GenerateRoute
|
| 225 |
+
HistoryRoute: typeof HistoryRoute
|
| 226 |
JobsRoute: typeof JobsRoute
|
| 227 |
LoginRoute: typeof LoginRoute
|
| 228 |
+
MeRoute: typeof MeRoute
|
| 229 |
PackagesRoute: typeof PackagesRoute
|
| 230 |
SettingsRoute: typeof SettingsRoute
|
| 231 |
AttemptIdRoute: typeof AttemptIdRoute
|
| 232 |
BuilderComboRoute: typeof BuilderComboRoute
|
| 233 |
PackageIdRoute: typeof PackageIdRouteWithChildren
|
| 234 |
+
ProfileUserIdRoute: typeof ProfileUserIdRoute
|
| 235 |
}
|
| 236 |
|
| 237 |
declare module '@tanstack/react-router' {
|
|
|
|
| 250 |
preLoaderRoute: typeof PackagesRouteImport
|
| 251 |
parentRoute: typeof rootRouteImport
|
| 252 |
}
|
| 253 |
+
'/me': {
|
| 254 |
+
id: '/me'
|
| 255 |
+
path: '/me'
|
| 256 |
+
fullPath: '/me'
|
| 257 |
+
preLoaderRoute: typeof MeRouteImport
|
| 258 |
+
parentRoute: typeof rootRouteImport
|
| 259 |
+
}
|
| 260 |
'/login': {
|
| 261 |
id: '/login'
|
| 262 |
path: '/login'
|
|
|
|
| 271 |
preLoaderRoute: typeof JobsRouteImport
|
| 272 |
parentRoute: typeof rootRouteImport
|
| 273 |
}
|
| 274 |
+
'/history': {
|
| 275 |
+
id: '/history'
|
| 276 |
+
path: '/history'
|
| 277 |
+
fullPath: '/history'
|
| 278 |
+
preLoaderRoute: typeof HistoryRouteImport
|
| 279 |
+
parentRoute: typeof rootRouteImport
|
| 280 |
+
}
|
| 281 |
'/generate': {
|
| 282 |
id: '/generate'
|
| 283 |
path: '/generate'
|
|
|
|
| 306 |
preLoaderRoute: typeof IndexRouteImport
|
| 307 |
parentRoute: typeof rootRouteImport
|
| 308 |
}
|
| 309 |
+
'/profile/$userId': {
|
| 310 |
+
id: '/profile/$userId'
|
| 311 |
+
path: '/profile/$userId'
|
| 312 |
+
fullPath: '/profile/$userId'
|
| 313 |
+
preLoaderRoute: typeof ProfileUserIdRouteImport
|
| 314 |
+
parentRoute: typeof rootRouteImport
|
| 315 |
+
}
|
| 316 |
'/package/$id': {
|
| 317 |
id: '/package/$id'
|
| 318 |
path: '/package/$id'
|
|
|
|
| 370 |
AnalyticsRoute: AnalyticsRoute,
|
| 371 |
BankRoute: BankRoute,
|
| 372 |
GenerateRoute: GenerateRoute,
|
| 373 |
+
HistoryRoute: HistoryRoute,
|
| 374 |
JobsRoute: JobsRoute,
|
| 375 |
LoginRoute: LoginRoute,
|
| 376 |
+
MeRoute: MeRoute,
|
| 377 |
PackagesRoute: PackagesRoute,
|
| 378 |
SettingsRoute: SettingsRoute,
|
| 379 |
AttemptIdRoute: AttemptIdRoute,
|
| 380 |
BuilderComboRoute: BuilderComboRoute,
|
| 381 |
PackageIdRoute: PackageIdRouteWithChildren,
|
| 382 |
+
ProfileUserIdRoute: ProfileUserIdRoute,
|
| 383 |
}
|
| 384 |
export const routeTree = rootRouteImport
|
| 385 |
._addFileChildren(rootRouteChildren)
|
apps/web/src/routes/bank.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 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";
|
| 7 |
import { Button } from "@labas/ui/components/button";
|
| 8 |
import { Card, CardContent } from "@labas/ui/components/card";
|
|
@@ -25,6 +26,15 @@ import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
|
|
| 25 |
|
| 26 |
export const Route = createFileRoute("/bank")({
|
| 27 |
component: BankComponent,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
beforeLoad: async () => {
|
| 29 |
const session = await authClient.getSession();
|
| 30 |
if (!session.data) {
|
|
@@ -37,16 +47,19 @@ export const Route = createFileRoute("/bank")({
|
|
| 37 |
type Tab = "mine" | "public";
|
| 38 |
|
| 39 |
function BankComponent() {
|
|
|
|
|
|
|
| 40 |
const { data: session } = authClient.useSession();
|
| 41 |
const userId = session?.user.id;
|
| 42 |
|
| 43 |
-
const
|
| 44 |
-
const
|
| 45 |
-
const
|
| 46 |
-
const
|
| 47 |
-
const
|
| 48 |
-
const
|
| 49 |
-
const
|
|
|
|
| 50 |
const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
|
| 51 |
|
| 52 |
// Modals
|
|
@@ -57,7 +70,7 @@ function BankComponent() {
|
|
| 57 |
|
| 58 |
const query = useQuery(
|
| 59 |
trpc.question.list.queryOptions({
|
| 60 |
-
search:
|
| 61 |
examTypeId: examType || undefined,
|
| 62 |
sectionTypeId: section || undefined,
|
| 63 |
format: format || undefined,
|
|
@@ -66,7 +79,7 @@ function BankComponent() {
|
|
| 66 |
? { creatorUserId: userId }
|
| 67 |
: { isPublic: true }),
|
| 68 |
limit,
|
| 69 |
-
offset: page * limit,
|
| 70 |
}),
|
| 71 |
);
|
| 72 |
|
|
@@ -97,16 +110,59 @@ function BankComponent() {
|
|
| 97 |
onSuccess: () => query.refetch(),
|
| 98 |
});
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
const clearFilters = () => {
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
};
|
| 108 |
|
| 109 |
-
const hasFilters =
|
| 110 |
|
| 111 |
const autoBundleExamType = examType || null;
|
| 112 |
const autoBundleSectionType = section || null;
|
|
@@ -135,12 +191,45 @@ function BankComponent() {
|
|
| 135 |
count: number;
|
| 136 |
sortOrder: "random" | "difficulty";
|
| 137 |
}) => {
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
await handleAutoBundle({
|
| 140 |
...data,
|
| 141 |
examTypeId: autoBundleExamType ?? "",
|
| 142 |
sectionTypeId: autoBundleSectionType ?? "READING",
|
| 143 |
-
allQuestions
|
| 144 |
});
|
| 145 |
setIsAutoBundleOpen(false);
|
| 146 |
};
|
|
@@ -159,7 +248,7 @@ function BankComponent() {
|
|
| 159 |
{/* Tabs */}
|
| 160 |
<div className="flex gap-2 mb-6">
|
| 161 |
<button
|
| 162 |
-
onClick={() =>
|
| 163 |
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 164 |
tab === "mine"
|
| 165 |
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
|
@@ -169,7 +258,7 @@ function BankComponent() {
|
|
| 169 |
Soal Saya
|
| 170 |
</button>
|
| 171 |
<button
|
| 172 |
-
onClick={() =>
|
| 173 |
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 174 |
tab === "public"
|
| 175 |
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
|
@@ -187,8 +276,8 @@ function BankComponent() {
|
|
| 187 |
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 188 |
<Input
|
| 189 |
placeholder="Cari soal..."
|
| 190 |
-
value={
|
| 191 |
-
onChange={(e) =>
|
| 192 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 193 |
/>
|
| 194 |
</div>
|
|
@@ -207,7 +296,7 @@ function BankComponent() {
|
|
| 207 |
<div className="flex flex-wrap gap-2">
|
| 208 |
<Select
|
| 209 |
value={examType}
|
| 210 |
-
onValueChange={(v: string | null) =>
|
| 211 |
>
|
| 212 |
<SelectTrigger className="w-36">
|
| 213 |
<SelectValue placeholder="Semua Ujian" />
|
|
@@ -224,7 +313,7 @@ function BankComponent() {
|
|
| 224 |
|
| 225 |
<Select
|
| 226 |
value={section}
|
| 227 |
-
onValueChange={(v: string | null) =>
|
| 228 |
>
|
| 229 |
<SelectTrigger className="w-38">
|
| 230 |
<SelectValue placeholder="Semua Section" />
|
|
@@ -241,7 +330,7 @@ function BankComponent() {
|
|
| 241 |
|
| 242 |
<Select
|
| 243 |
value={format}
|
| 244 |
-
onValueChange={(v: string | null) =>
|
| 245 |
>
|
| 246 |
<SelectTrigger className="w-52">
|
| 247 |
<SelectValue placeholder="Semua Format" />
|
|
@@ -258,7 +347,7 @@ function BankComponent() {
|
|
| 258 |
|
| 259 |
<Select
|
| 260 |
value={difficulty !== undefined ? String(difficulty) : ""}
|
| 261 |
-
onValueChange={(v: string | null) =>
|
| 262 |
>
|
| 263 |
<SelectTrigger className="w-36">
|
| 264 |
<SelectValue placeholder="Semua Level" />
|
|
@@ -486,19 +575,19 @@ function BankComponent() {
|
|
| 486 |
<div className="flex items-center justify-center gap-2 mt-10">
|
| 487 |
<Button
|
| 488 |
variant="outline"
|
| 489 |
-
onClick={() => setPage(
|
| 490 |
-
disabled={page =
|
| 491 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 492 |
>
|
| 493 |
<MaterialIcon name="chevron_left" />
|
| 494 |
</Button>
|
| 495 |
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 496 |
-
Halaman {page
|
| 497 |
</span>
|
| 498 |
<Button
|
| 499 |
variant="outline"
|
| 500 |
-
onClick={() => setPage(
|
| 501 |
-
disabled={page >= totalPages
|
| 502 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 503 |
>
|
| 504 |
<MaterialIcon name="chevron_right" />
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 4 |
+
import { z } from "zod";
|
| 5 |
import { authClient } from "@/lib/auth-client";
|
| 6 |
+
import { trpc, queryClient } from "@/utils/trpc";
|
| 7 |
import { Input } from "@labas/ui/components/input";
|
| 8 |
import { Button } from "@labas/ui/components/button";
|
| 9 |
import { Card, CardContent } from "@labas/ui/components/card";
|
|
|
|
| 26 |
|
| 27 |
export const Route = createFileRoute("/bank")({
|
| 28 |
component: BankComponent,
|
| 29 |
+
validateSearch: z.object({
|
| 30 |
+
tab: z.enum(["mine", "public"]).optional(),
|
| 31 |
+
search: z.string().optional(),
|
| 32 |
+
examType: z.string().optional(),
|
| 33 |
+
section: z.string().optional(),
|
| 34 |
+
format: z.string().optional(),
|
| 35 |
+
difficulty: z.coerce.number().optional(),
|
| 36 |
+
page: z.coerce.number().optional(),
|
| 37 |
+
}).parse,
|
| 38 |
beforeLoad: async () => {
|
| 39 |
const session = await authClient.getSession();
|
| 40 |
if (!session.data) {
|
|
|
|
| 47 |
type Tab = "mine" | "public";
|
| 48 |
|
| 49 |
function BankComponent() {
|
| 50 |
+
const search = Route.useSearch();
|
| 51 |
+
const navigate = Route.useNavigate();
|
| 52 |
const { data: session } = authClient.useSession();
|
| 53 |
const userId = session?.user.id;
|
| 54 |
|
| 55 |
+
const tab = search.tab ?? "mine";
|
| 56 |
+
const searchText = search.search ?? "";
|
| 57 |
+
const examType = search.examType ?? "";
|
| 58 |
+
const section = search.section ?? "";
|
| 59 |
+
const format = search.format ?? "";
|
| 60 |
+
const difficulty = search.difficulty;
|
| 61 |
+
const page = search.page ?? 1;
|
| 62 |
+
|
| 63 |
const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
|
| 64 |
|
| 65 |
// Modals
|
|
|
|
| 70 |
|
| 71 |
const query = useQuery(
|
| 72 |
trpc.question.list.queryOptions({
|
| 73 |
+
search: searchText || undefined,
|
| 74 |
examTypeId: examType || undefined,
|
| 75 |
sectionTypeId: section || undefined,
|
| 76 |
format: format || undefined,
|
|
|
|
| 79 |
? { creatorUserId: userId }
|
| 80 |
: { isPublic: true }),
|
| 81 |
limit,
|
| 82 |
+
offset: (page - 1) * limit,
|
| 83 |
}),
|
| 84 |
);
|
| 85 |
|
|
|
|
| 110 |
onSuccess: () => query.refetch(),
|
| 111 |
});
|
| 112 |
|
| 113 |
+
const setTab = (newTab: Tab) => {
|
| 114 |
+
navigate({
|
| 115 |
+
search: {
|
| 116 |
+
tab: newTab,
|
| 117 |
+
search: "",
|
| 118 |
+
examType: "",
|
| 119 |
+
section: "",
|
| 120 |
+
format: "",
|
| 121 |
+
difficulty: undefined,
|
| 122 |
+
page: 1,
|
| 123 |
+
},
|
| 124 |
+
});
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const setSearch = (value: string) => {
|
| 128 |
+
navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const setExamType = (value: string) => {
|
| 132 |
+
navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const setSection = (value: string) => {
|
| 136 |
+
navigate({ search: (prev) => ({ ...prev, section: value, page: 1 }) });
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
const setFormat = (value: string) => {
|
| 140 |
+
navigate({ search: (prev) => ({ ...prev, format: value, page: 1 }) });
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
const setDifficulty = (value: number | undefined) => {
|
| 144 |
+
navigate({ search: (prev) => ({ ...prev, difficulty: value, page: 1 }) });
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const setPage = (newPage: number) => {
|
| 148 |
+
navigate({ search: (prev) => ({ ...prev, page: newPage }) });
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
const clearFilters = () => {
|
| 152 |
+
navigate({
|
| 153 |
+
search: (prev) => ({
|
| 154 |
+
...prev,
|
| 155 |
+
search: "",
|
| 156 |
+
examType: "",
|
| 157 |
+
section: "",
|
| 158 |
+
format: "",
|
| 159 |
+
difficulty: undefined,
|
| 160 |
+
page: 1,
|
| 161 |
+
}),
|
| 162 |
+
});
|
| 163 |
};
|
| 164 |
|
| 165 |
+
const hasFilters = searchText || examType || section || format || difficulty !== undefined;
|
| 166 |
|
| 167 |
const autoBundleExamType = examType || null;
|
| 168 |
const autoBundleSectionType = section || null;
|
|
|
|
| 191 |
count: number;
|
| 192 |
sortOrder: "random" | "difficulty";
|
| 193 |
}) => {
|
| 194 |
+
// Auto-bundle needs a full candidate pool, not only current page (12 items).
|
| 195 |
+
const batchSize = 50;
|
| 196 |
+
const baseInput = {
|
| 197 |
+
search: searchText || undefined,
|
| 198 |
+
examTypeId: examType || undefined,
|
| 199 |
+
sectionTypeId: section || undefined,
|
| 200 |
+
format: format || undefined,
|
| 201 |
+
difficulty,
|
| 202 |
+
...(tab === "mine" && userId
|
| 203 |
+
? { creatorUserId: userId }
|
| 204 |
+
: { isPublic: true }),
|
| 205 |
+
limit: batchSize,
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
const firstPage = await queryClient.fetchQuery(
|
| 209 |
+
trpc.question.list.queryOptions({
|
| 210 |
+
...baseInput,
|
| 211 |
+
offset: 0,
|
| 212 |
+
}),
|
| 213 |
+
);
|
| 214 |
+
|
| 215 |
+
const allQuestions = [...(firstPage.questions ?? [])];
|
| 216 |
+
const totalAvailable = firstPage.total ?? allQuestions.length;
|
| 217 |
+
|
| 218 |
+
for (let offset = batchSize; offset < totalAvailable; offset += batchSize) {
|
| 219 |
+
const pageData = await queryClient.fetchQuery(
|
| 220 |
+
trpc.question.list.queryOptions({
|
| 221 |
+
...baseInput,
|
| 222 |
+
offset,
|
| 223 |
+
}),
|
| 224 |
+
);
|
| 225 |
+
allQuestions.push(...(pageData.questions ?? []));
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
await handleAutoBundle({
|
| 229 |
...data,
|
| 230 |
examTypeId: autoBundleExamType ?? "",
|
| 231 |
sectionTypeId: autoBundleSectionType ?? "READING",
|
| 232 |
+
allQuestions,
|
| 233 |
});
|
| 234 |
setIsAutoBundleOpen(false);
|
| 235 |
};
|
|
|
|
| 248 |
{/* Tabs */}
|
| 249 |
<div className="flex gap-2 mb-6">
|
| 250 |
<button
|
| 251 |
+
onClick={() => setTab("mine")}
|
| 252 |
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 253 |
tab === "mine"
|
| 254 |
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
|
|
|
| 258 |
Soal Saya
|
| 259 |
</button>
|
| 260 |
<button
|
| 261 |
+
onClick={() => setTab("public")}
|
| 262 |
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 263 |
tab === "public"
|
| 264 |
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
|
|
|
| 276 |
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 277 |
<Input
|
| 278 |
placeholder="Cari soal..."
|
| 279 |
+
value={searchText}
|
| 280 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 281 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 282 |
/>
|
| 283 |
</div>
|
|
|
|
| 296 |
<div className="flex flex-wrap gap-2">
|
| 297 |
<Select
|
| 298 |
value={examType}
|
| 299 |
+
onValueChange={(v: string | null) => setExamType(v ?? "")}
|
| 300 |
>
|
| 301 |
<SelectTrigger className="w-36">
|
| 302 |
<SelectValue placeholder="Semua Ujian" />
|
|
|
|
| 313 |
|
| 314 |
<Select
|
| 315 |
value={section}
|
| 316 |
+
onValueChange={(v: string | null) => setSection(v ?? "")}
|
| 317 |
>
|
| 318 |
<SelectTrigger className="w-38">
|
| 319 |
<SelectValue placeholder="Semua Section" />
|
|
|
|
| 330 |
|
| 331 |
<Select
|
| 332 |
value={format}
|
| 333 |
+
onValueChange={(v: string | null) => setFormat(v ?? "")}
|
| 334 |
>
|
| 335 |
<SelectTrigger className="w-52">
|
| 336 |
<SelectValue placeholder="Semua Format" />
|
|
|
|
| 347 |
|
| 348 |
<Select
|
| 349 |
value={difficulty !== undefined ? String(difficulty) : ""}
|
| 350 |
+
onValueChange={(v: string | null) => setDifficulty(v ? Number(v) : undefined)}
|
| 351 |
>
|
| 352 |
<SelectTrigger className="w-36">
|
| 353 |
<SelectValue placeholder="Semua Level" />
|
|
|
|
| 575 |
<div className="flex items-center justify-center gap-2 mt-10">
|
| 576 |
<Button
|
| 577 |
variant="outline"
|
| 578 |
+
onClick={() => setPage(Math.max(1, page - 1))}
|
| 579 |
+
disabled={page <= 1}
|
| 580 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 581 |
>
|
| 582 |
<MaterialIcon name="chevron_left" />
|
| 583 |
</Button>
|
| 584 |
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 585 |
+
Halaman {page} dari {totalPages}
|
| 586 |
</span>
|
| 587 |
<Button
|
| 588 |
variant="outline"
|
| 589 |
+
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
| 590 |
+
disabled={page >= totalPages}
|
| 591 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 592 |
>
|
| 593 |
<MaterialIcon name="chevron_right" />
|
apps/web/src/routes/generate.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 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 { queryClient, trpc } from "@/utils/trpc";
|
| 6 |
-
import {
|
| 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";
|
|
@@ -30,7 +31,20 @@ export const Route = createFileRoute("/generate")({
|
|
| 30 |
});
|
| 31 |
|
| 32 |
function RouteComponent() {
|
| 33 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
const {
|
| 36 |
result,
|
|
@@ -90,7 +104,7 @@ function RouteComponent() {
|
|
| 90 |
};
|
| 91 |
|
| 92 |
const handleGenerate = () => {
|
| 93 |
-
if (!
|
| 94 |
setError("API key belum dikonfigurasi. Tambahkan di Settings.");
|
| 95 |
return;
|
| 96 |
}
|
|
@@ -106,10 +120,10 @@ function RouteComponent() {
|
|
| 106 |
questionCount,
|
| 107 |
mode,
|
| 108 |
apiKeyConfig: {
|
| 109 |
-
baseUrl:
|
| 110 |
-
apiKey:
|
| 111 |
-
model:
|
| 112 |
-
maxTokens:
|
| 113 |
},
|
| 114 |
});
|
| 115 |
};
|
|
@@ -127,7 +141,7 @@ function RouteComponent() {
|
|
| 127 |
</p>
|
| 128 |
</section>
|
| 129 |
|
| 130 |
-
{!
|
| 131 |
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
|
| 132 |
<MaterialIcon name="warning" />
|
| 133 |
<span>API key belum dikonfigurasi.</span>
|
|
@@ -137,6 +151,36 @@ function RouteComponent() {
|
|
| 137 |
</div>
|
| 138 |
)}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
|
| 141 |
{/* Configuration Panel (Left) */}
|
| 142 |
<div className="lg:col-span-8 flex flex-col gap-8">
|
|
@@ -310,7 +354,7 @@ function RouteComponent() {
|
|
| 310 |
setMode={setMode}
|
| 311 |
isGenerating={isGenerating}
|
| 312 |
generatePending={generate.isPending}
|
| 313 |
-
hasKey={
|
| 314 |
jobProgress={jobQuery.data?.progress ?? 0}
|
| 315 |
jobProgressMessage={jobQuery.data?.progressMessage}
|
| 316 |
error={error}
|
|
|
|
| 1 |
+
import { useState, useEffect } 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 { queryClient, trpc } from "@/utils/trpc";
|
| 6 |
+
import { useApiKeys } from "@/hooks/use-api-key";
|
| 7 |
import { useGenerationJob } from "@/hooks/use-generation-job";
|
| 8 |
+
import { Button } from "@labas/ui/components/button";
|
| 9 |
import { Input } from "@labas/ui/components/input";
|
| 10 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 11 |
import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
|
|
|
|
| 31 |
});
|
| 32 |
|
| 33 |
function RouteComponent() {
|
| 34 |
+
const { configs, hasConfigs } = useApiKeys();
|
| 35 |
+
|
| 36 |
+
const [selectedKeyId, setSelectedKeyId] = useState<string>(
|
| 37 |
+
configs[0]?.id ?? "",
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
// keep selectedKeyId in sync when configs load
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
|
| 43 |
+
setSelectedKeyId(configs[0].id);
|
| 44 |
+
}
|
| 45 |
+
}, [configs, selectedKeyId]);
|
| 46 |
+
|
| 47 |
+
const selectedConfig = configs.find((c) => c.id === selectedKeyId);
|
| 48 |
|
| 49 |
const {
|
| 50 |
result,
|
|
|
|
| 104 |
};
|
| 105 |
|
| 106 |
const handleGenerate = () => {
|
| 107 |
+
if (!hasConfigs || !selectedConfig) {
|
| 108 |
setError("API key belum dikonfigurasi. Tambahkan di Settings.");
|
| 109 |
return;
|
| 110 |
}
|
|
|
|
| 120 |
questionCount,
|
| 121 |
mode,
|
| 122 |
apiKeyConfig: {
|
| 123 |
+
baseUrl: selectedConfig.baseUrl,
|
| 124 |
+
apiKey: selectedConfig.apiKey,
|
| 125 |
+
model: selectedConfig.modelName,
|
| 126 |
+
maxTokens: selectedConfig.maxTokens ?? 16384,
|
| 127 |
},
|
| 128 |
});
|
| 129 |
};
|
|
|
|
| 141 |
</p>
|
| 142 |
</section>
|
| 143 |
|
| 144 |
+
{!hasConfigs && (
|
| 145 |
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
|
| 146 |
<MaterialIcon name="warning" />
|
| 147 |
<span>API key belum dikonfigurasi.</span>
|
|
|
|
| 151 |
</div>
|
| 152 |
)}
|
| 153 |
|
| 154 |
+
{hasConfigs && (
|
| 155 |
+
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--warm-cream)] border-2 border-[var(--oat-border)]">
|
| 156 |
+
<label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">
|
| 157 |
+
Provider / API Key
|
| 158 |
+
</label>
|
| 159 |
+
<div className="flex gap-3">
|
| 160 |
+
<select
|
| 161 |
+
value={selectedKeyId}
|
| 162 |
+
onChange={(e) => setSelectedKeyId(e.target.value)}
|
| 163 |
+
className="flex-1 h-11 px-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] text-sm focus:outline-none focus:border-[var(--matcha-400)]"
|
| 164 |
+
>
|
| 165 |
+
{configs.map((c) => (
|
| 166 |
+
<option key={c.id} value={c.id}>
|
| 167 |
+
{c.name} · {c.modelName}
|
| 168 |
+
</option>
|
| 169 |
+
))}
|
| 170 |
+
</select>
|
| 171 |
+
<Link to="/settings">
|
| 172 |
+
<Button
|
| 173 |
+
variant="outline"
|
| 174 |
+
className="h-11 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 175 |
+
>
|
| 176 |
+
<MaterialIcon name="settings" className="mr-1" />
|
| 177 |
+
Kelola
|
| 178 |
+
</Button>
|
| 179 |
+
</Link>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
|
| 184 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
|
| 185 |
{/* Configuration Panel (Left) */}
|
| 186 |
<div className="lg:col-span-8 flex flex-col gap-8">
|
|
|
|
| 354 |
setMode={setMode}
|
| 355 |
isGenerating={isGenerating}
|
| 356 |
generatePending={generate.isPending}
|
| 357 |
+
hasKey={hasConfigs}
|
| 358 |
jobProgress={jobQuery.data?.progress ?? 0}
|
| 359 |
jobProgressMessage={jobQuery.data?.progressMessage}
|
| 360 |
error={error}
|
apps/web/src/routes/history.tsx
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useQuery } from "@tanstack/react-query";
|
| 2 |
+
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
+
import { z } from "zod";
|
| 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 { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 9 |
+
|
| 10 |
+
export const Route = createFileRoute("/history")({
|
| 11 |
+
component: HistoryComponent,
|
| 12 |
+
validateSearch: z.object({
|
| 13 |
+
page: z.coerce.number().optional(),
|
| 14 |
+
}).parse,
|
| 15 |
+
beforeLoad: async () => {
|
| 16 |
+
const session = await authClient.getSession();
|
| 17 |
+
if (!session.data) {
|
| 18 |
+
redirect({ to: "/login", throw: true });
|
| 19 |
+
}
|
| 20 |
+
return { session };
|
| 21 |
+
},
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
function formatDate(dateStr: string | Date | null) {
|
| 25 |
+
if (!dateStr) return "-";
|
| 26 |
+
const d = new Date(dateStr);
|
| 27 |
+
return d.toLocaleDateString("id-ID", {
|
| 28 |
+
day: "numeric",
|
| 29 |
+
month: "short",
|
| 30 |
+
year: "numeric",
|
| 31 |
+
hour: "2-digit",
|
| 32 |
+
minute: "2-digit",
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function getStatusBadge(status: string) {
|
| 37 |
+
switch (status) {
|
| 38 |
+
case "completed":
|
| 39 |
+
return (
|
| 40 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 41 |
+
<MaterialIcon name="check_circle" className="text-xs" />
|
| 42 |
+
Selesai
|
| 43 |
+
</span>
|
| 44 |
+
);
|
| 45 |
+
case "abandoned":
|
| 46 |
+
return (
|
| 47 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
|
| 48 |
+
<MaterialIcon name="cancel" className="text-xs" />
|
| 49 |
+
Ditinggalkan
|
| 50 |
+
</span>
|
| 51 |
+
);
|
| 52 |
+
default:
|
| 53 |
+
return (
|
| 54 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
|
| 55 |
+
<MaterialIcon name="hourglass_top" className="text-xs" />
|
| 56 |
+
Berlangsung
|
| 57 |
+
</span>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function HistoryComponent() {
|
| 63 |
+
const search = Route.useSearch();
|
| 64 |
+
const navigate = Route.useNavigate();
|
| 65 |
+
const page = search.page ?? 1;
|
| 66 |
+
const limit = 12;
|
| 67 |
+
|
| 68 |
+
const query = useQuery(
|
| 69 |
+
trpc.attempt.myAttempts.queryOptions({ limit, offset: (page - 1) * limit }),
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
const attempts = query.data?.attempts ?? [];
|
| 73 |
+
const total = query.data?.total ?? 0;
|
| 74 |
+
const totalPages = Math.ceil(total / limit);
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 78 |
+
{/* Header */}
|
| 79 |
+
<div className="mb-8">
|
| 80 |
+
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 81 |
+
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 82 |
+
Beranda
|
| 83 |
+
</Link>
|
| 84 |
+
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 85 |
+
<span className="text-[var(--clay-black)] font-medium">Riwayat Latihan</span>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div className="flex items-center justify-between">
|
| 89 |
+
<div>
|
| 90 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 91 |
+
Riwayat Latihan
|
| 92 |
+
</h1>
|
| 93 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 94 |
+
Semua attempt latihan Anda.
|
| 95 |
+
</p>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{/* Results */}
|
| 101 |
+
{query.isLoading ? (
|
| 102 |
+
<div className="space-y-4">
|
| 103 |
+
{Array.from({ length: 5 }).map((_, i) => (
|
| 104 |
+
<Card key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
) : attempts.length === 0 ? (
|
| 108 |
+
<div className="text-center py-20">
|
| 109 |
+
<MaterialIcon name="history" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 110 |
+
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Belum ada riwayat latihan</p>
|
| 111 |
+
<p className="text-sm text-[var(--warm-silver)] mt-1">
|
| 112 |
+
Mulai latihan dari paket soal untuk melihat riwayat di sini.
|
| 113 |
+
</p>
|
| 114 |
+
<Link to="/packages" className="inline-block mt-6">
|
| 115 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
|
| 116 |
+
<MaterialIcon name="folder" className="mr-2" />
|
| 117 |
+
Lihat Paket Soal
|
| 118 |
+
</Button>
|
| 119 |
+
</Link>
|
| 120 |
+
</div>
|
| 121 |
+
) : (
|
| 122 |
+
<>
|
| 123 |
+
<div className="space-y-4">
|
| 124 |
+
{attempts.map((attempt) => {
|
| 125 |
+
const pct =
|
| 126 |
+
attempt.maxScore && attempt.maxScore > 0 && attempt.totalScore != null
|
| 127 |
+
? Math.round((attempt.totalScore / attempt.maxScore) * 100)
|
| 128 |
+
: null;
|
| 129 |
+
|
| 130 |
+
return (
|
| 131 |
+
<Card
|
| 132 |
+
key={attempt.id}
|
| 133 |
+
className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
|
| 134 |
+
>
|
| 135 |
+
<CardContent className="p-5">
|
| 136 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 137 |
+
<div className="flex-1 min-w-0">
|
| 138 |
+
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
| 139 |
+
{getStatusBadge(attempt.status)}
|
| 140 |
+
{attempt.examTypeName && (
|
| 141 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 142 |
+
{attempt.examTypeName}
|
| 143 |
+
</span>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)] truncate">
|
| 148 |
+
{attempt.packageTitle ?? "Paket tidak diketahui"}
|
| 149 |
+
</h3>
|
| 150 |
+
|
| 151 |
+
<div className="flex items-center gap-4 mt-1 text-xs text-[var(--warm-charcoal)]">
|
| 152 |
+
<span className="flex items-center gap-1">
|
| 153 |
+
<MaterialIcon name="event" className="text-xs" />
|
| 154 |
+
{formatDate(attempt.startedAt)}
|
| 155 |
+
</span>
|
| 156 |
+
{attempt.finishedAt && (
|
| 157 |
+
<span className="flex items-center gap-1">
|
| 158 |
+
<MaterialIcon name="check" className="text-xs" />
|
| 159 |
+
{formatDate(attempt.finishedAt)}
|
| 160 |
+
</span>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div className="flex items-center gap-6 shrink-0">
|
| 166 |
+
{pct != null && (
|
| 167 |
+
<div className="text-center">
|
| 168 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 169 |
+
{pct}%
|
| 170 |
+
</div>
|
| 171 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 172 |
+
{attempt.totalScore}/{attempt.maxScore}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
|
| 177 |
+
<div className="flex gap-2">
|
| 178 |
+
{attempt.status === "completed" && (
|
| 179 |
+
<Link to="/attempt/$id" params={{ id: attempt.id }}>
|
| 180 |
+
<Button
|
| 181 |
+
variant="outline"
|
| 182 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 183 |
+
>
|
| 184 |
+
<MaterialIcon name="visibility" className="mr-1" />
|
| 185 |
+
Hasil
|
| 186 |
+
</Button>
|
| 187 |
+
</Link>
|
| 188 |
+
)}
|
| 189 |
+
{attempt.packageId && (
|
| 190 |
+
<Link to="/package/$id" params={{ id: attempt.packageId }}>
|
| 191 |
+
<Button
|
| 192 |
+
variant="outline"
|
| 193 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 194 |
+
>
|
| 195 |
+
<MaterialIcon name="folder" className="mr-1" />
|
| 196 |
+
Paket
|
| 197 |
+
</Button>
|
| 198 |
+
</Link>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</CardContent>
|
| 204 |
+
</Card>
|
| 205 |
+
);
|
| 206 |
+
})}
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
{totalPages > 1 && (
|
| 210 |
+
<div className="flex items-center justify-center gap-2 mt-10">
|
| 211 |
+
<Button
|
| 212 |
+
variant="outline"
|
| 213 |
+
onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.max(1, page - 1) }) })}
|
| 214 |
+
disabled={page <= 1}
|
| 215 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 216 |
+
>
|
| 217 |
+
<MaterialIcon name="chevron_left" />
|
| 218 |
+
</Button>
|
| 219 |
+
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 220 |
+
Halaman {page} dari {totalPages}
|
| 221 |
+
</span>
|
| 222 |
+
<Button
|
| 223 |
+
variant="outline"
|
| 224 |
+
onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.min(totalPages, page + 1) }) })}
|
| 225 |
+
disabled={page >= totalPages}
|
| 226 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 227 |
+
>
|
| 228 |
+
<MaterialIcon name="chevron_right" />
|
| 229 |
+
</Button>
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
</>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
);
|
| 236 |
+
}
|
apps/web/src/routes/index.tsx
CHANGED
|
@@ -5,6 +5,7 @@ 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,9 +18,21 @@ export const Route = createFileRoute("/")({
|
|
| 17 |
},
|
| 18 |
});
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
function HomeComponent() {
|
| 21 |
const { session } = Route.useRouteContext();
|
| 22 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
return (
|
| 25 |
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-6xl mx-auto bg-[var(--warm-cream)]">
|
|
@@ -41,8 +54,10 @@ function HomeComponent() {
|
|
| 41 |
<MaterialIcon name="assignment" className="text-xl text-[var(--matcha-800)]" />
|
| 42 |
</div>
|
| 43 |
<div>
|
| 44 |
-
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 45 |
-
<p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
</CardContent>
|
|
@@ -55,7 +70,9 @@ function HomeComponent() {
|
|
| 55 |
</div>
|
| 56 |
<div>
|
| 57 |
<p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
|
| 58 |
-
<p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
|
|
|
|
|
|
|
| 59 |
</div>
|
| 60 |
</div>
|
| 61 |
</CardContent>
|
|
@@ -68,7 +85,9 @@ function HomeComponent() {
|
|
| 68 |
</div>
|
| 69 |
<div>
|
| 70 |
<p className="text-sm text-[var(--warm-charcoal)]">Akurasi Rata-rata</p>
|
| 71 |
-
<p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
</div>
|
| 74 |
</CardContent>
|
|
@@ -83,14 +102,115 @@ function HomeComponent() {
|
|
| 83 |
<span className="ml-2">Generate Soal Baru</span>
|
| 84 |
</Button>
|
| 85 |
</Link>
|
| 86 |
-
<Link to="/
|
| 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="
|
| 89 |
-
<span className="ml-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</Button>
|
| 91 |
</Link>
|
| 92 |
</div>
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
{/* Feature Cards */}
|
| 95 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 96 |
<Link to="/generate" className="block">
|
|
@@ -109,45 +229,37 @@ function HomeComponent() {
|
|
| 109 |
</Card>
|
| 110 |
</Link>
|
| 111 |
|
| 112 |
-
<
|
| 113 |
-
<
|
| 114 |
-
<
|
| 115 |
-
<div className="
|
| 116 |
-
<
|
|
|
|
|
|
|
|
|
|
| 117 |
</div>
|
| 118 |
-
<
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
</Card>
|
| 125 |
|
| 126 |
-
<
|
| 127 |
-
<
|
| 128 |
-
<
|
| 129 |
-
<div className="
|
| 130 |
-
<
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
-
<
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
</Card>
|
| 139 |
-
</div>
|
| 140 |
-
|
| 141 |
-
{/* API Status */}
|
| 142 |
-
<div className="mt-10 flex items-center gap-2 text-sm text-[var(--warm-charcoal)]">
|
| 143 |
-
<div className={`h-2 w-2 rounded-full ${privateData.data ? "bg-[var(--matcha-600)]" : "bg-[var(--pomegranate-400)]"}`} />
|
| 144 |
-
<span>
|
| 145 |
-
{privateData.isLoading
|
| 146 |
-
? "Checking..."
|
| 147 |
-
: privateData.data
|
| 148 |
-
? "Server Connected"
|
| 149 |
-
: "Server Disconnected"}
|
| 150 |
-
</span>
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
);
|
|
|
|
| 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("/")({
|
| 11 |
component: HomeComponent,
|
|
|
|
| 18 |
},
|
| 19 |
});
|
| 20 |
|
| 21 |
+
function formatDateShort(dateStr: string | Date | null) {
|
| 22 |
+
if (!dateStr) return "-";
|
| 23 |
+
const d = new Date(dateStr);
|
| 24 |
+
return d.toLocaleDateString("id-ID", { day: "numeric", month: "short" });
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
function HomeComponent() {
|
| 28 |
const { session } = Route.useRouteContext();
|
| 29 |
+
const overview = useQuery(trpc.stats.overview.queryOptions());
|
| 30 |
+
const recentAttempts = useQuery(
|
| 31 |
+
trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
const stats = overview.data;
|
| 35 |
+
const attempts = recentAttempts.data?.attempts ?? [];
|
| 36 |
|
| 37 |
return (
|
| 38 |
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-6xl mx-auto bg-[var(--warm-cream)]">
|
|
|
|
| 54 |
<MaterialIcon name="assignment" className="text-xl text-[var(--matcha-800)]" />
|
| 55 |
</div>
|
| 56 |
<div>
|
| 57 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
|
| 58 |
+
<p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
|
| 59 |
+
{stats?.completedAttempts ?? 0}
|
| 60 |
+
</p>
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
</CardContent>
|
|
|
|
| 70 |
</div>
|
| 71 |
<div>
|
| 72 |
<p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
|
| 73 |
+
<p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
|
| 74 |
+
{stats ? `${Math.round(stats.totalTimeSpentSec / 60)} menit` : "0 menit"}
|
| 75 |
+
</p>
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
</CardContent>
|
|
|
|
| 85 |
</div>
|
| 86 |
<div>
|
| 87 |
<p className="text-sm text-[var(--warm-charcoal)]">Akurasi Rata-rata</p>
|
| 88 |
+
<p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
|
| 89 |
+
{stats ? `${stats.overallAccuracyPct}%` : "-"}
|
| 90 |
+
</p>
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
</CardContent>
|
|
|
|
| 102 |
<span className="ml-2">Generate Soal Baru</span>
|
| 103 |
</Button>
|
| 104 |
</Link>
|
| 105 |
+
<Link to="/packages">
|
| 106 |
<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">
|
| 107 |
+
<MaterialIcon name="folder" className="text-xl" />
|
| 108 |
+
<span className="ml-2">Mulai Latihan</span>
|
| 109 |
+
</Button>
|
| 110 |
+
</Link>
|
| 111 |
+
<Link to="/analytics">
|
| 112 |
+
<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">
|
| 113 |
+
<MaterialIcon name="analytics" className="text-xl" />
|
| 114 |
+
<span className="ml-2">Lihat Analitik</span>
|
| 115 |
</Button>
|
| 116 |
</Link>
|
| 117 |
</div>
|
| 118 |
|
| 119 |
+
{/* Recent Attempts */}
|
| 120 |
+
<section className="mb-10">
|
| 121 |
+
<div className="flex items-center justify-between mb-4">
|
| 122 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 123 |
+
Latihan Terakhir
|
| 124 |
+
</h2>
|
| 125 |
+
<Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 126 |
+
Lihat Semua
|
| 127 |
+
</Link>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{attempts.length === 0 ? (
|
| 131 |
+
<Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 132 |
+
<CardContent className="p-6 text-center">
|
| 133 |
+
<MaterialIcon name="history" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 134 |
+
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada latihan</p>
|
| 135 |
+
<p className="text-sm text-[var(--warm-silver)] mt-1">
|
| 136 |
+
Pilih paket soal dan mulai latihan pertama Anda.
|
| 137 |
+
</p>
|
| 138 |
+
<Link to="/packages" className="inline-block mt-4">
|
| 139 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
|
| 140 |
+
<MaterialIcon name="folder" className="mr-2" />
|
| 141 |
+
Lihat Paket
|
| 142 |
+
</Button>
|
| 143 |
+
</Link>
|
| 144 |
+
</CardContent>
|
| 145 |
+
</Card>
|
| 146 |
+
) : (
|
| 147 |
+
<div className="space-y-3">
|
| 148 |
+
{attempts.map((a) => {
|
| 149 |
+
const pct =
|
| 150 |
+
a.maxScore && a.maxScore > 0 && a.totalScore != null
|
| 151 |
+
? Math.round((a.totalScore / a.maxScore) * 100)
|
| 152 |
+
: null;
|
| 153 |
+
return (
|
| 154 |
+
<Card
|
| 155 |
+
key={a.id}
|
| 156 |
+
className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
|
| 157 |
+
>
|
| 158 |
+
<CardContent className="p-4">
|
| 159 |
+
<div className="flex items-center justify-between gap-4">
|
| 160 |
+
<div className="flex-1 min-w-0">
|
| 161 |
+
<div className="flex items-center gap-2 mb-1">
|
| 162 |
+
{a.status === "completed" ? (
|
| 163 |
+
<MaterialIcon name="check_circle" className="text-[var(--matcha-600)] text-sm" />
|
| 164 |
+
) : a.status === "abandoned" ? (
|
| 165 |
+
<MaterialIcon name="cancel" className="text-[var(--warm-silver)] text-sm" />
|
| 166 |
+
) : (
|
| 167 |
+
<MaterialIcon name="hourglass_top" className="text-[var(--slushie-600)] text-sm" />
|
| 168 |
+
)}
|
| 169 |
+
<span className="text-sm font-semibold text-[var(--clay-black)] truncate">
|
| 170 |
+
{a.packageTitle ?? "Paket tidak diketahui"}
|
| 171 |
+
</span>
|
| 172 |
+
{a.examTypeName && (
|
| 173 |
+
<span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold shrink-0">
|
| 174 |
+
{a.examTypeName}
|
| 175 |
+
</span>
|
| 176 |
+
)}
|
| 177 |
+
</div>
|
| 178 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 179 |
+
{formatDateShort(a.startedAt)}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div className="flex items-center gap-4 shrink-0">
|
| 184 |
+
{pct != null && (
|
| 185 |
+
<div className="text-right">
|
| 186 |
+
<div className="text-lg font-headline font-bold text-[var(--clay-black)]">
|
| 187 |
+
{pct}%
|
| 188 |
+
</div>
|
| 189 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 190 |
+
{a.totalScore}/{a.maxScore}
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
{a.status === "completed" && (
|
| 195 |
+
<Link to="/attempt/$id" params={{ id: a.id }}>
|
| 196 |
+
<Button
|
| 197 |
+
variant="outline"
|
| 198 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 199 |
+
>
|
| 200 |
+
Hasil
|
| 201 |
+
</Button>
|
| 202 |
+
</Link>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</CardContent>
|
| 207 |
+
</Card>
|
| 208 |
+
);
|
| 209 |
+
})}
|
| 210 |
+
</div>
|
| 211 |
+
)}
|
| 212 |
+
</section>
|
| 213 |
+
|
| 214 |
{/* Feature Cards */}
|
| 215 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 216 |
<Link to="/generate" className="block">
|
|
|
|
| 229 |
</Card>
|
| 230 |
</Link>
|
| 231 |
|
| 232 |
+
<Link to="/bank" className="block">
|
| 233 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 234 |
+
<CardContent className="p-6">
|
| 235 |
+
<div className="flex items-center gap-3 mb-4">
|
| 236 |
+
<div className="h-10 w-10 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 237 |
+
<MaterialIcon name="database" className="text-xl text-[var(--clay-black)]" />
|
| 238 |
+
</div>
|
| 239 |
+
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)]">Bank Soal</h3>
|
| 240 |
</div>
|
| 241 |
+
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 242 |
+
Simpan, publikasikan, dan gunakan ulang soal latihan Anda. Buat paket dari soal pilihan.
|
| 243 |
+
</p>
|
| 244 |
+
</CardContent>
|
| 245 |
+
</Card>
|
| 246 |
+
</Link>
|
|
|
|
| 247 |
|
| 248 |
+
<Link to="/analytics" className="block">
|
| 249 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 250 |
+
<CardContent className="p-6">
|
| 251 |
+
<div className="flex items-center gap-3 mb-4">
|
| 252 |
+
<div className="h-10 w-10 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 253 |
+
<MaterialIcon name="analytics" className="text-xl text-[var(--lemon-800)]" />
|
| 254 |
+
</div>
|
| 255 |
+
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)]">Analytics</h3>
|
| 256 |
</div>
|
| 257 |
+
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 258 |
+
Evaluasi per-section, identifikasi kelemahan, dan pantau tren perkembangan Anda.
|
| 259 |
+
</p>
|
| 260 |
+
</CardContent>
|
| 261 |
+
</Card>
|
| 262 |
+
</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
</div>
|
| 264 |
</div>
|
| 265 |
);
|
apps/web/src/routes/me.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { useQuery, 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 { Button } from "@labas/ui/components/button";
|
| 7 |
+
import { Input } from "@labas/ui/components/input";
|
| 8 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 9 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
+
|
| 11 |
+
export const Route = createFileRoute("/me")({
|
| 12 |
+
component: MeComponent,
|
| 13 |
+
beforeLoad: async () => {
|
| 14 |
+
const session = await authClient.getSession();
|
| 15 |
+
if (!session.data) {
|
| 16 |
+
redirect({ to: "/login", throw: true });
|
| 17 |
+
}
|
| 18 |
+
return { session };
|
| 19 |
+
},
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
function MeComponent() {
|
| 23 |
+
const { session } = Route.useRouteContext();
|
| 24 |
+
const userId = session.data?.user.id ?? "";
|
| 25 |
+
|
| 26 |
+
const profile = useQuery(trpc.profile.getById.queryOptions({ userId }));
|
| 27 |
+
const updateMutation = useMutation(trpc.profile.update.mutationOptions());
|
| 28 |
+
|
| 29 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 30 |
+
const [name, setName] = useState(session.data?.user.name ?? "");
|
| 31 |
+
const [image, setImage] = useState(session.data?.user.image ?? "");
|
| 32 |
+
|
| 33 |
+
const handleSave = async () => {
|
| 34 |
+
await updateMutation.mutateAsync({
|
| 35 |
+
name: name || undefined,
|
| 36 |
+
image: image || null,
|
| 37 |
+
});
|
| 38 |
+
setIsEditing(false);
|
| 39 |
+
profile.refetch();
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
if (!profile.data) {
|
| 43 |
+
return (
|
| 44 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-3xl mx-auto bg-[var(--warm-cream)]">
|
| 45 |
+
<div className="h-32 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const { stats } = profile.data;
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-3xl mx-auto bg-[var(--warm-cream)]">
|
| 54 |
+
{/* Header */}
|
| 55 |
+
<div className="mb-8">
|
| 56 |
+
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 57 |
+
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 58 |
+
Beranda
|
| 59 |
+
</Link>
|
| 60 |
+
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 61 |
+
<span className="text-[var(--clay-black)] font-medium">Profil Saya</span>
|
| 62 |
+
</div>
|
| 63 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 64 |
+
Profil Saya
|
| 65 |
+
</h1>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
{/* Profile Card */}
|
| 69 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-8">
|
| 70 |
+
<CardContent className="p-6 md:p-8">
|
| 71 |
+
{isEditing ? (
|
| 72 |
+
<div className="space-y-4">
|
| 73 |
+
<div>
|
| 74 |
+
<label className="block text-sm font-medium text-[var(--clay-black)] mb-1">Nama</label>
|
| 75 |
+
<Input
|
| 76 |
+
value={name}
|
| 77 |
+
onChange={(e) => setName(e.target.value)}
|
| 78 |
+
className="bg-[var(--warm-cream)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
<div>
|
| 82 |
+
<label className="block text-sm font-medium text-[var(--clay-black)] mb-1">URL Avatar</label>
|
| 83 |
+
<Input
|
| 84 |
+
value={image}
|
| 85 |
+
onChange={(e) => setImage(e.target.value)}
|
| 86 |
+
placeholder="https://..."
|
| 87 |
+
className="bg-[var(--warm-cream)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
|
| 88 |
+
/>
|
| 89 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 90 |
+
Masukkan URL gambar. Kosongkan untuk menggunakan inisial.
|
| 91 |
+
</p>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="flex gap-3 pt-2">
|
| 94 |
+
<Button
|
| 95 |
+
onClick={handleSave}
|
| 96 |
+
disabled={updateMutation.isPending}
|
| 97 |
+
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 98 |
+
>
|
| 99 |
+
{updateMutation.isPending ? "Menyimpan..." : "Simpan"}
|
| 100 |
+
</Button>
|
| 101 |
+
<Button
|
| 102 |
+
variant="outline"
|
| 103 |
+
onClick={() => {
|
| 104 |
+
setName(session.data?.user.name ?? "");
|
| 105 |
+
setImage(session.data?.user.image ?? "");
|
| 106 |
+
setIsEditing(false);
|
| 107 |
+
}}
|
| 108 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
|
| 109 |
+
>
|
| 110 |
+
Batal
|
| 111 |
+
</Button>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
) : (
|
| 115 |
+
<div className="flex items-center justify-between">
|
| 116 |
+
<div className="flex items-center gap-4 md:gap-6">
|
| 117 |
+
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-[var(--matcha-300)] flex items-center justify-center text-2xl md:text-3xl font-bold text-[var(--matcha-800)] shrink-0">
|
| 118 |
+
{session.data?.user.image ? (
|
| 119 |
+
<img
|
| 120 |
+
src={session.data.user.image}
|
| 121 |
+
alt={session.data.user.name}
|
| 122 |
+
className="w-full h-full rounded-full object-cover"
|
| 123 |
+
/>
|
| 124 |
+
) : (
|
| 125 |
+
(session.data?.user.name?.[0] ?? "?").toUpperCase()
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
<div>
|
| 129 |
+
<h2 className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 130 |
+
{session.data?.user.name ?? "Pengguna"}
|
| 131 |
+
</h2>
|
| 132 |
+
<p className="text-sm text-[var(--warm-charcoal)]">{session.data?.user.email}</p>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
<Button
|
| 136 |
+
variant="outline"
|
| 137 |
+
onClick={() => setIsEditing(true)}
|
| 138 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 139 |
+
>
|
| 140 |
+
<MaterialIcon name="edit" className="mr-2" />
|
| 141 |
+
Edit
|
| 142 |
+
</Button>
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
</CardContent>
|
| 146 |
+
</Card>
|
| 147 |
+
|
| 148 |
+
{/* Stats */}
|
| 149 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Statistik Kreator</h2>
|
| 150 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
| 151 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 152 |
+
<CardContent className="p-4 text-center">
|
| 153 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 154 |
+
{stats.totalPackages}
|
| 155 |
+
</div>
|
| 156 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Paket Dibuat</div>
|
| 157 |
+
</CardContent>
|
| 158 |
+
</Card>
|
| 159 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 160 |
+
<CardContent className="p-4 text-center">
|
| 161 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 162 |
+
{stats.totalPackageUsage}
|
| 163 |
+
</div>
|
| 164 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Paket Digunakan</div>
|
| 165 |
+
</CardContent>
|
| 166 |
+
</Card>
|
| 167 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 168 |
+
<CardContent className="p-4 text-center">
|
| 169 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 170 |
+
{stats.totalQuestions}
|
| 171 |
+
</div>
|
| 172 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Soal Dibuat</div>
|
| 173 |
+
</CardContent>
|
| 174 |
+
</Card>
|
| 175 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 176 |
+
<CardContent className="p-4 text-center">
|
| 177 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 178 |
+
{stats.avgPackageRating > 0 ? stats.avgPackageRating : "-"}
|
| 179 |
+
</div>
|
| 180 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Rating Rata-rata</div>
|
| 181 |
+
</CardContent>
|
| 182 |
+
</Card>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Quick Links */}
|
| 186 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Tautan Cepat</h2>
|
| 187 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 188 |
+
<Link to="/history" className="block">
|
| 189 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 190 |
+
<CardContent className="p-5 flex items-center gap-4">
|
| 191 |
+
<div className="w-10 h-10 rounded-[var(--radius-lg)] bg-[var(--slushie-500)] flex items-center justify-center">
|
| 192 |
+
<MaterialIcon name="history" className="text-[var(--clay-black)]" />
|
| 193 |
+
</div>
|
| 194 |
+
<div>
|
| 195 |
+
<h3 className="font-semibold text-[var(--clay-black)]">Riwayat Latihan</h3>
|
| 196 |
+
<p className="text-xs text-[var(--warm-charcoal)]">Lihat semua attempt latihan Anda</p>
|
| 197 |
+
</div>
|
| 198 |
+
</CardContent>
|
| 199 |
+
</Card>
|
| 200 |
+
</Link>
|
| 201 |
+
<Link to="/bank" className="block">
|
| 202 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 203 |
+
<CardContent className="p-5 flex items-center gap-4">
|
| 204 |
+
<div className="w-10 h-10 rounded-[var(--radius-lg)] bg-[var(--matcha-300)] flex items-center justify-center">
|
| 205 |
+
<MaterialIcon name="database" className="text-[var(--matcha-800)]" />
|
| 206 |
+
</div>
|
| 207 |
+
<div>
|
| 208 |
+
<h3 className="font-semibold text-[var(--clay-black)]">Bank Soal Saya</h3>
|
| 209 |
+
<p className="text-xs text-[var(--warm-charcoal)]">Kelola soal yang Anda buat</p>
|
| 210 |
+
</div>
|
| 211 |
+
</CardContent>
|
| 212 |
+
</Card>
|
| 213 |
+
</Link>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
);
|
| 217 |
+
}
|
apps/web/src/routes/package.$id.index.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
import {
|
|
|
|
| 2 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
import { trpc } from "@/utils/trpc";
|
|
@@ -6,6 +7,7 @@ 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,
|
|
@@ -21,9 +23,18 @@ export const Route = createFileRoute("/package/$id/")({
|
|
| 21 |
function PackageDetailComponent() {
|
| 22 |
const { id } = Route.useParams();
|
| 23 |
const { data: session } = authClient.useSession();
|
|
|
|
| 24 |
|
| 25 |
const packageQuery = useQuery(trpc.package.getById.queryOptions({ id }));
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const pkg = packageQuery.data;
|
| 28 |
|
| 29 |
if (packageQuery.isLoading) {
|
|
@@ -89,10 +100,14 @@ function PackageDetailComponent() {
|
|
| 89 |
</div>
|
| 90 |
|
| 91 |
<div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)]">
|
| 92 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
<MaterialIcon name="person" className="text-sm" />
|
| 94 |
{pkg.creatorName ?? "Anonim"}
|
| 95 |
-
</
|
| 96 |
<span className="flex items-center gap-1">
|
| 97 |
<MaterialIcon name="quiz" className="text-sm" />
|
| 98 |
{totalQuestions} soal
|
|
@@ -173,9 +188,24 @@ function PackageDetailComponent() {
|
|
| 173 |
<span className="ml-2">Mulai Latihan</span>
|
| 174 |
</Button>
|
| 175 |
</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
{isOwner && (
|
| 177 |
<Button
|
| 178 |
variant="outline"
|
|
|
|
| 179 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 180 |
>
|
| 181 |
<MaterialIcon name="edit" />
|
|
@@ -183,6 +213,22 @@ function PackageDetailComponent() {
|
|
| 183 |
</Button>
|
| 184 |
)}
|
| 185 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
);
|
| 188 |
}
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { useQuery, 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";
|
|
|
|
| 7 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 8 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 9 |
import { formatLabel } from "@/lib/format";
|
| 10 |
+
import { EditPackageModal } from "@/components/package/EditPackageModal";
|
| 11 |
|
| 12 |
export const Route = createFileRoute("/package/$id/")({
|
| 13 |
component: PackageDetailComponent,
|
|
|
|
| 23 |
function PackageDetailComponent() {
|
| 24 |
const { id } = Route.useParams();
|
| 25 |
const { data: session } = authClient.useSession();
|
| 26 |
+
const [isEditOpen, setIsEditOpen] = useState(false);
|
| 27 |
|
| 28 |
const packageQuery = useQuery(trpc.package.getById.queryOptions({ id }));
|
| 29 |
|
| 30 |
+
const updateMutation = useMutation({
|
| 31 |
+
...trpc.package.update.mutationOptions(),
|
| 32 |
+
onSuccess: () => {
|
| 33 |
+
packageQuery.refetch();
|
| 34 |
+
setIsEditOpen(false);
|
| 35 |
+
},
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
const pkg = packageQuery.data;
|
| 39 |
|
| 40 |
if (packageQuery.isLoading) {
|
|
|
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)]">
|
| 103 |
+
<Link
|
| 104 |
+
to="/profile/$userId"
|
| 105 |
+
params={{ userId: pkg.creatorUserId }}
|
| 106 |
+
className="flex items-center gap-1 hover:text-[var(--clay-black)] transition-colors"
|
| 107 |
+
>
|
| 108 |
<MaterialIcon name="person" className="text-sm" />
|
| 109 |
{pkg.creatorName ?? "Anonim"}
|
| 110 |
+
</Link>
|
| 111 |
<span className="flex items-center gap-1">
|
| 112 |
<MaterialIcon name="quiz" className="text-sm" />
|
| 113 |
{totalQuestions} soal
|
|
|
|
| 188 |
<span className="ml-2">Mulai Latihan</span>
|
| 189 |
</Button>
|
| 190 |
</Link>
|
| 191 |
+
{pkg.isPublic && (
|
| 192 |
+
<Button
|
| 193 |
+
variant="outline"
|
| 194 |
+
onClick={() => {
|
| 195 |
+
const url = `${window.location.origin}/package/${id}`;
|
| 196 |
+
navigator.clipboard.writeText(url);
|
| 197 |
+
alert("Link paket disalin ke clipboard!");
|
| 198 |
+
}}
|
| 199 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 200 |
+
>
|
| 201 |
+
<MaterialIcon name="share" />
|
| 202 |
+
<span className="ml-2">Bagikan</span>
|
| 203 |
+
</Button>
|
| 204 |
+
)}
|
| 205 |
{isOwner && (
|
| 206 |
<Button
|
| 207 |
variant="outline"
|
| 208 |
+
onClick={() => setIsEditOpen(true)}
|
| 209 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 210 |
>
|
| 211 |
<MaterialIcon name="edit" />
|
|
|
|
| 213 |
</Button>
|
| 214 |
)}
|
| 215 |
</div>
|
| 216 |
+
|
| 217 |
+
{isEditOpen && pkg && (
|
| 218 |
+
<EditPackageModal
|
| 219 |
+
initialTitle={pkg.title}
|
| 220 |
+
initialDescription={pkg.description}
|
| 221 |
+
initialIsPublic={pkg.isPublic}
|
| 222 |
+
onClose={() => setIsEditOpen(false)}
|
| 223 |
+
onSave={(data) =>
|
| 224 |
+
updateMutation.mutate({
|
| 225 |
+
id,
|
| 226 |
+
...data,
|
| 227 |
+
})
|
| 228 |
+
}
|
| 229 |
+
isPending={updateMutation.isPending}
|
| 230 |
+
/>
|
| 231 |
+
)}
|
| 232 |
</div>
|
| 233 |
);
|
| 234 |
}
|
apps/web/src/routes/packages.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { useQuery } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
|
|
|
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
| 6 |
import { Input } from "@labas/ui/components/input";
|
|
@@ -18,6 +18,12 @@ import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
| 18 |
|
| 19 |
export const Route = createFileRoute("/packages")({
|
| 20 |
component: PackagesComponent,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
beforeLoad: async () => {
|
| 22 |
const session = await authClient.getSession();
|
| 23 |
if (!session.data) {
|
|
@@ -35,27 +41,78 @@ const EXAM_TYPES = [
|
|
| 35 |
{ id: "GOETHE", name: "German" },
|
| 36 |
];
|
| 37 |
|
|
|
|
|
|
|
| 38 |
function PackagesComponent() {
|
| 39 |
-
const
|
| 40 |
-
const
|
| 41 |
-
const
|
| 42 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
const limit = 12;
|
| 44 |
|
| 45 |
-
const
|
| 46 |
-
trpc.package.list.queryOptions(
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
);
|
| 54 |
|
|
|
|
| 55 |
const packages = query.data?.packages ?? [];
|
| 56 |
const total = query.data?.total ?? 0;
|
| 57 |
const totalPages = Math.ceil(total / limit);
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return (
|
| 60 |
<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)]">
|
| 61 |
<section className="mb-8">
|
|
@@ -77,24 +134,44 @@ function PackagesComponent() {
|
|
| 77 |
</div>
|
| 78 |
</section>
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
{/* Filters */}
|
| 81 |
<div className="flex flex-col md:flex-row gap-3 mb-8">
|
| 82 |
<div className="relative flex-1 max-w-md">
|
| 83 |
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 84 |
<Input
|
| 85 |
placeholder="Cari paket..."
|
| 86 |
-
value={
|
| 87 |
-
onChange={(e) =>
|
| 88 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 89 |
/>
|
| 90 |
</div>
|
| 91 |
<Select
|
| 92 |
-
items={[
|
| 93 |
-
{ value: "", label: "Semua Ujian" },
|
| 94 |
-
...EXAM_TYPES.map((t) => ({ value: t.id, label: t.name })),
|
| 95 |
-
]}
|
| 96 |
value={examType}
|
| 97 |
-
onValueChange={(v: string | null) =>
|
| 98 |
>
|
| 99 |
<SelectTrigger className="w-36">
|
| 100 |
<SelectValue placeholder="Semua Ujian" />
|
|
@@ -121,91 +198,141 @@ function PackagesComponent() {
|
|
| 121 |
<div className="text-center py-20">
|
| 122 |
<MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 123 |
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
|
| 124 |
-
<p className="text-sm text-[var(--warm-silver)] mt-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
</div>
|
| 126 |
) : (
|
| 127 |
<>
|
| 128 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 129 |
-
{packages.map((pkg) =>
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
<
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
</div>
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
<span className="flex items-center gap-1">
|
| 158 |
-
<MaterialIcon name="quiz" className="text-xs" />
|
| 159 |
-
{pkg.totalQuestions}
|
| 160 |
-
</span>
|
| 161 |
-
<span className="flex items-center gap-1">
|
| 162 |
-
<MaterialIcon name="folder" className="text-xs" />
|
| 163 |
-
{pkg.totalSections}
|
| 164 |
-
</span>
|
| 165 |
-
{pkg.estimatedDurationMin && (
|
| 166 |
<span className="flex items-center gap-1">
|
| 167 |
-
<MaterialIcon name="
|
| 168 |
-
{pkg.
|
| 169 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
)}
|
| 171 |
</div>
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
</div>
|
| 176 |
-
</
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 181 |
-
onClick={() => navigate({ to: '/package/$id/take', params: { id: pkg.id } })}
|
| 182 |
-
>
|
| 183 |
-
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 184 |
-
Mulai Latihan
|
| 185 |
-
</Button>
|
| 186 |
-
</div>
|
| 187 |
-
</CardContent>
|
| 188 |
-
</Card>
|
| 189 |
-
))}
|
| 190 |
</div>
|
| 191 |
|
| 192 |
{totalPages > 1 && (
|
| 193 |
<div className="flex items-center justify-center gap-2 mt-10">
|
| 194 |
<Button
|
| 195 |
variant="outline"
|
| 196 |
-
onClick={() => setPage(
|
| 197 |
-
disabled={page =
|
| 198 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 199 |
>
|
| 200 |
<MaterialIcon name="chevron_left" />
|
| 201 |
</Button>
|
| 202 |
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 203 |
-
Halaman {page
|
| 204 |
</span>
|
| 205 |
<Button
|
| 206 |
variant="outline"
|
| 207 |
-
onClick={() => setPage(
|
| 208 |
-
disabled={page >= totalPages
|
| 209 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 210 |
>
|
| 211 |
<MaterialIcon name="chevron_right" />
|
|
|
|
| 1 |
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
|
|
| 2 |
import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
|
| 3 |
+
import { z } from "zod";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
| 6 |
import { Input } from "@labas/ui/components/input";
|
|
|
|
| 18 |
|
| 19 |
export const Route = createFileRoute("/packages")({
|
| 20 |
component: PackagesComponent,
|
| 21 |
+
validateSearch: z.object({
|
| 22 |
+
tab: z.enum(["all", "mine"]).optional(),
|
| 23 |
+
search: z.string().optional(),
|
| 24 |
+
examType: z.string().optional(),
|
| 25 |
+
page: z.coerce.number().optional(),
|
| 26 |
+
}).parse,
|
| 27 |
beforeLoad: async () => {
|
| 28 |
const session = await authClient.getSession();
|
| 29 |
if (!session.data) {
|
|
|
|
| 41 |
{ id: "GOETHE", name: "German" },
|
| 42 |
];
|
| 43 |
|
| 44 |
+
type Tab = "all" | "mine";
|
| 45 |
+
|
| 46 |
function PackagesComponent() {
|
| 47 |
+
const routerNavigate = useNavigate();
|
| 48 |
+
const search = Route.useSearch();
|
| 49 |
+
const navigate = Route.useNavigate();
|
| 50 |
+
const { data: session } = authClient.useSession();
|
| 51 |
+
const userId = session?.user.id;
|
| 52 |
+
|
| 53 |
+
const tab = search.tab ?? "all";
|
| 54 |
+
const searchText = search.search ?? "";
|
| 55 |
+
const examType = search.examType ?? "";
|
| 56 |
+
const page = search.page ?? 1;
|
| 57 |
const limit = 12;
|
| 58 |
|
| 59 |
+
const allQuery = useQuery(
|
| 60 |
+
trpc.package.list.queryOptions(
|
| 61 |
+
{
|
| 62 |
+
isPublic: true,
|
| 63 |
+
examTypeId: examType || undefined,
|
| 64 |
+
search: searchText || undefined,
|
| 65 |
+
limit,
|
| 66 |
+
offset: (page - 1) * limit,
|
| 67 |
+
},
|
| 68 |
+
{ enabled: tab === "all" },
|
| 69 |
+
),
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
const mineQuery = useQuery(
|
| 73 |
+
trpc.package.myPackages.queryOptions(
|
| 74 |
+
{
|
| 75 |
+
search: searchText || undefined,
|
| 76 |
+
examTypeId: examType || undefined,
|
| 77 |
+
limit,
|
| 78 |
+
offset: (page - 1) * limit,
|
| 79 |
+
},
|
| 80 |
+
{ enabled: tab === "mine" },
|
| 81 |
+
),
|
| 82 |
);
|
| 83 |
|
| 84 |
+
const query = tab === "all" ? allQuery : mineQuery;
|
| 85 |
const packages = query.data?.packages ?? [];
|
| 86 |
const total = query.data?.total ?? 0;
|
| 87 |
const totalPages = Math.ceil(total / limit);
|
| 88 |
|
| 89 |
+
const updateMutation = useMutation({
|
| 90 |
+
...trpc.package.update.mutationOptions(),
|
| 91 |
+
onSuccess: () => {
|
| 92 |
+
query.refetch();
|
| 93 |
+
},
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
const togglePublic = (pkgId: string, current: boolean) => {
|
| 97 |
+
updateMutation.mutate({ id: pkgId, isPublic: !current });
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const setTab = (newTab: Tab) => {
|
| 101 |
+
navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const setSearch = (value: string) => {
|
| 105 |
+
navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const setExamType = (value: string) => {
|
| 109 |
+
navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const setPage = (newPage: number) => {
|
| 113 |
+
navigate({ search: (prev) => ({ ...prev, page: newPage }) });
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
return (
|
| 117 |
<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)]">
|
| 118 |
<section className="mb-8">
|
|
|
|
| 134 |
</div>
|
| 135 |
</section>
|
| 136 |
|
| 137 |
+
{/* Tabs */}
|
| 138 |
+
<div className="flex gap-2 mb-6">
|
| 139 |
+
<button
|
| 140 |
+
onClick={() => setTab("all")}
|
| 141 |
+
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 142 |
+
tab === "all"
|
| 143 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 144 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 145 |
+
}`}
|
| 146 |
+
>
|
| 147 |
+
Semua Paket
|
| 148 |
+
</button>
|
| 149 |
+
<button
|
| 150 |
+
onClick={() => setTab("mine")}
|
| 151 |
+
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 152 |
+
tab === "mine"
|
| 153 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 154 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 155 |
+
}`}
|
| 156 |
+
>
|
| 157 |
+
Paket Saya
|
| 158 |
+
</button>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
{/* Filters */}
|
| 162 |
<div className="flex flex-col md:flex-row gap-3 mb-8">
|
| 163 |
<div className="relative flex-1 max-w-md">
|
| 164 |
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 165 |
<Input
|
| 166 |
placeholder="Cari paket..."
|
| 167 |
+
value={searchText}
|
| 168 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 169 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 170 |
/>
|
| 171 |
</div>
|
| 172 |
<Select
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
value={examType}
|
| 174 |
+
onValueChange={(v: string | null) => setExamType(v ?? "")}
|
| 175 |
>
|
| 176 |
<SelectTrigger className="w-36">
|
| 177 |
<SelectValue placeholder="Semua Ujian" />
|
|
|
|
| 198 |
<div className="text-center py-20">
|
| 199 |
<MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 200 |
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
|
| 201 |
+
<p className="text-sm text-[var(--warm-silver)] mt-1">
|
| 202 |
+
{tab === "mine"
|
| 203 |
+
? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
|
| 204 |
+
: "Buat paket soal pertama Anda"}
|
| 205 |
+
</p>
|
| 206 |
</div>
|
| 207 |
) : (
|
| 208 |
<>
|
| 209 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 210 |
+
{packages.map((pkg) => {
|
| 211 |
+
const isOwner = pkg.creatorUserId === userId;
|
| 212 |
+
return (
|
| 213 |
+
<Card key={pkg.id} className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full flex flex-col">
|
| 214 |
+
<CardContent className="p-5 flex flex-col h-full">
|
| 215 |
+
<Link to="/package/$id" params={{ id: pkg.id }} className="block flex-1">
|
| 216 |
+
<div className="flex items-start justify-between mb-3">
|
| 217 |
+
<div className="flex gap-2 flex-wrap">
|
| 218 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 219 |
+
{pkg.examTypeName}
|
| 220 |
+
</span>
|
| 221 |
+
{isOwner && (
|
| 222 |
+
<span
|
| 223 |
+
className={`px-2 py-1 rounded-full text-[10px] font-semibold ${
|
| 224 |
+
pkg.isPublic
|
| 225 |
+
? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
|
| 226 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 227 |
+
}`}
|
| 228 |
+
>
|
| 229 |
+
{pkg.isPublic ? "Publik" : "Privat"}
|
| 230 |
+
</span>
|
| 231 |
+
)}
|
| 232 |
</div>
|
| 233 |
+
{pkg.avgRating && (
|
| 234 |
+
<div className="flex items-center gap-1 text-[var(--lemon-700)]">
|
| 235 |
+
<MaterialIcon name="star" className="text-sm" />
|
| 236 |
+
<span className="text-xs font-bold">{pkg.avgRating}</span>
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
|
| 241 |
+
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
|
| 242 |
+
{pkg.title}
|
| 243 |
+
</h3>
|
| 244 |
|
| 245 |
+
{pkg.description && (
|
| 246 |
+
<p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
|
| 247 |
+
{pkg.description}
|
| 248 |
+
</p>
|
| 249 |
+
)}
|
| 250 |
|
| 251 |
+
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 252 |
+
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
<span className="flex items-center gap-1">
|
| 254 |
+
<MaterialIcon name="quiz" className="text-xs" />
|
| 255 |
+
{pkg.totalQuestions}
|
| 256 |
</span>
|
| 257 |
+
<span className="flex items-center gap-1">
|
| 258 |
+
<MaterialIcon name="folder" className="text-xs" />
|
| 259 |
+
{pkg.totalSections}
|
| 260 |
+
</span>
|
| 261 |
+
{pkg.estimatedDurationMin && (
|
| 262 |
+
<span className="flex items-center gap-1">
|
| 263 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 264 |
+
{pkg.estimatedDurationMin}m
|
| 265 |
+
</span>
|
| 266 |
+
)}
|
| 267 |
+
</div>
|
| 268 |
+
<span className="text-xs text-[var(--warm-silver)]">
|
| 269 |
+
{pkg.usageCount}x digunakan
|
| 270 |
+
</span>
|
| 271 |
+
</div>
|
| 272 |
+
</Link>
|
| 273 |
+
|
| 274 |
+
{/* Owner actions */}
|
| 275 |
+
{isOwner && (
|
| 276 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 277 |
+
<button
|
| 278 |
+
onClick={() => togglePublic(pkg.id, pkg.isPublic)}
|
| 279 |
+
disabled={updateMutation.isPending}
|
| 280 |
+
className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors ${
|
| 281 |
+
pkg.isPublic
|
| 282 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 283 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 284 |
+
}`}
|
| 285 |
+
>
|
| 286 |
+
{pkg.isPublic ? "Publik" : "Privat"}
|
| 287 |
+
</button>
|
| 288 |
+
{pkg.isPublic && (
|
| 289 |
+
<button
|
| 290 |
+
onClick={() => {
|
| 291 |
+
const url = `${window.location.origin}/package/${pkg.id}`;
|
| 292 |
+
navigator.clipboard.writeText(url);
|
| 293 |
+
alert("Link paket disalin!");
|
| 294 |
+
}}
|
| 295 |
+
className="text-xs text-[var(--matcha-600)] hover:bg-[var(--matcha-300)]/20 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1"
|
| 296 |
+
>
|
| 297 |
+
<MaterialIcon name="share" className="text-xs" />
|
| 298 |
+
Bagikan
|
| 299 |
+
</button>
|
| 300 |
)}
|
| 301 |
</div>
|
| 302 |
+
)}
|
| 303 |
+
|
| 304 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
|
| 305 |
+
<Button
|
| 306 |
+
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 307 |
+
onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
|
| 308 |
+
>
|
| 309 |
+
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 310 |
+
Mulai Latihan
|
| 311 |
+
</Button>
|
| 312 |
</div>
|
| 313 |
+
</CardContent>
|
| 314 |
+
</Card>
|
| 315 |
+
);
|
| 316 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
</div>
|
| 318 |
|
| 319 |
{totalPages > 1 && (
|
| 320 |
<div className="flex items-center justify-center gap-2 mt-10">
|
| 321 |
<Button
|
| 322 |
variant="outline"
|
| 323 |
+
onClick={() => setPage(Math.max(1, page - 1))}
|
| 324 |
+
disabled={page <= 1}
|
| 325 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 326 |
>
|
| 327 |
<MaterialIcon name="chevron_left" />
|
| 328 |
</Button>
|
| 329 |
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 330 |
+
Halaman {page} dari {totalPages}
|
| 331 |
</span>
|
| 332 |
<Button
|
| 333 |
variant="outline"
|
| 334 |
+
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
| 335 |
+
disabled={page >= totalPages}
|
| 336 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 337 |
>
|
| 338 |
<MaterialIcon name="chevron_right" />
|
apps/web/src/routes/profile.$userId.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useQuery } from "@tanstack/react-query";
|
| 2 |
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
| 3 |
+
import { trpc } from "@/utils/trpc";
|
| 4 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 5 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 6 |
+
|
| 7 |
+
export const Route = createFileRoute("/profile/$userId")({
|
| 8 |
+
component: ProfileComponent,
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
function ProfileComponent() {
|
| 12 |
+
const { userId } = Route.useParams();
|
| 13 |
+
const query = useQuery(trpc.profile.getById.queryOptions({ userId }));
|
| 14 |
+
|
| 15 |
+
if (query.isLoading) {
|
| 16 |
+
return (
|
| 17 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 18 |
+
<div className="h-32 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)] mb-8" />
|
| 19 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
| 20 |
+
{Array.from({ length: 4 }).map((_, i) => (
|
| 21 |
+
<div key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 22 |
+
))}
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if (!query.data) {
|
| 29 |
+
return (
|
| 30 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 31 |
+
<div className="text-center py-20">
|
| 32 |
+
<MaterialIcon name="person_off" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 33 |
+
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Pengguna tidak ditemukan</p>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const { user, stats, recentPackages } = query.data;
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 43 |
+
{/* Profile Header */}
|
| 44 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-8">
|
| 45 |
+
<CardContent className="p-6 md:p-8">
|
| 46 |
+
<div className="flex items-center gap-4 md:gap-6">
|
| 47 |
+
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-[var(--matcha-300)] flex items-center justify-center text-2xl md:text-3xl font-bold text-[var(--matcha-800)] shrink-0">
|
| 48 |
+
{user.image ? (
|
| 49 |
+
<img src={user.image} alt={user.name} className="w-full h-full rounded-full object-cover" />
|
| 50 |
+
) : (
|
| 51 |
+
(user.name?.[0] ?? "?").toUpperCase()
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
<div>
|
| 55 |
+
<h1 className="text-2xl md:text-3xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 56 |
+
{user.name ?? "Pengguna"}
|
| 57 |
+
</h1>
|
| 58 |
+
<p className="text-sm text-[var(--warm-charcoal)] mt-1">
|
| 59 |
+
Bergabung {new Date(user.createdAt).toLocaleDateString("id-ID", { month: "long", year: "numeric" })}
|
| 60 |
+
</p>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</CardContent>
|
| 64 |
+
</Card>
|
| 65 |
+
|
| 66 |
+
{/* Stats */}
|
| 67 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
| 68 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 69 |
+
<CardContent className="p-4 text-center">
|
| 70 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 71 |
+
{stats.totalPackages}
|
| 72 |
+
</div>
|
| 73 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Paket Dibuat</div>
|
| 74 |
+
</CardContent>
|
| 75 |
+
</Card>
|
| 76 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 77 |
+
<CardContent className="p-4 text-center">
|
| 78 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 79 |
+
{stats.totalPackageUsage}
|
| 80 |
+
</div>
|
| 81 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Paket Digunakan</div>
|
| 82 |
+
</CardContent>
|
| 83 |
+
</Card>
|
| 84 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 85 |
+
<CardContent className="p-4 text-center">
|
| 86 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 87 |
+
{stats.totalQuestions}
|
| 88 |
+
</div>
|
| 89 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Soal Dibuat</div>
|
| 90 |
+
</CardContent>
|
| 91 |
+
</Card>
|
| 92 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 93 |
+
<CardContent className="p-4 text-center">
|
| 94 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 95 |
+
{stats.avgPackageRating > 0 ? stats.avgPackageRating : "-"}
|
| 96 |
+
</div>
|
| 97 |
+
<div className="text-xs text-[var(--warm-charcoal)] mt-1">Rating Rata-rata</div>
|
| 98 |
+
</CardContent>
|
| 99 |
+
</Card>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Recent Public Packages */}
|
| 103 |
+
<section>
|
| 104 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">
|
| 105 |
+
Paket Publik Terbaru
|
| 106 |
+
</h2>
|
| 107 |
+
|
| 108 |
+
{recentPackages.length === 0 ? (
|
| 109 |
+
<div className="text-center py-12 text-[var(--warm-charcoal)]">
|
| 110 |
+
<MaterialIcon name="folder_open" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 111 |
+
<p>Belum ada paket publik</p>
|
| 112 |
+
</div>
|
| 113 |
+
) : (
|
| 114 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 115 |
+
{recentPackages.map((pkg) => (
|
| 116 |
+
<Link to="/package/$id" params={{ id: pkg.id }} key={pkg.id} className="block">
|
| 117 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 118 |
+
<CardContent className="p-5">
|
| 119 |
+
<div className="flex items-start justify-between mb-3">
|
| 120 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 121 |
+
{pkg.examTypeName}
|
| 122 |
+
</span>
|
| 123 |
+
{pkg.avgRating && (
|
| 124 |
+
<div className="flex items-center gap-1 text-[var(--lemon-700)]">
|
| 125 |
+
<MaterialIcon name="star" className="text-sm" />
|
| 126 |
+
<span className="text-xs font-bold">{pkg.avgRating}</span>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
<h3 className="font-headline text-base font-bold text-[var(--clay-black)] line-clamp-2 mb-2">
|
| 131 |
+
{pkg.title}
|
| 132 |
+
</h3>
|
| 133 |
+
<div className="flex items-center justify-between text-xs text-[var(--warm-charcoal)]">
|
| 134 |
+
<span>{pkg.totalQuestions} soal</span>
|
| 135 |
+
<span>{pkg.usageCount}x digunakan</span>
|
| 136 |
+
</div>
|
| 137 |
+
</CardContent>
|
| 138 |
+
</Card>
|
| 139 |
+
</Link>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</section>
|
| 144 |
+
</div>
|
| 145 |
+
);
|
| 146 |
+
}
|
apps/web/src/routes/settings.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
-
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
-
import {
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { Input } from "@labas/ui/components/input";
|
| 7 |
import { Label } from "@labas/ui/components/label";
|
|
@@ -27,188 +27,367 @@ export const Route = createFileRoute("/settings")({
|
|
| 27 |
},
|
| 28 |
});
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
function RouteComponent() {
|
| 31 |
-
const {
|
|
|
|
| 32 |
|
| 33 |
-
const [
|
| 34 |
-
const [
|
| 35 |
-
const [apiKey, setApiKey] = useState("");
|
| 36 |
-
const [modelName, setModelName] = useState(storedKey?.modelName ?? "gpt-4o-mini");
|
| 37 |
-
const [maxTokens, setMaxTokens] = useState<number>(storedKey?.maxTokens ?? 16384);
|
| 38 |
const [isSaving, setIsSaving] = useState(false);
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const handleSave = async () => {
|
| 41 |
-
if (!
|
| 42 |
setIsSaving(true);
|
| 43 |
try {
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
} finally {
|
| 47 |
setIsSaving(false);
|
| 48 |
}
|
| 49 |
};
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return (
|
| 52 |
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 53 |
<section className="mb-10">
|
| 54 |
-
<
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</section>
|
| 57 |
|
| 58 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 59 |
<div className="lg:col-span-7 space-y-8">
|
|
|
|
| 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
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</div>
|
| 68 |
-
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 69 |
-
API key disimpan terenkripsi di browser (localStorage + IndexedDB). Server tidak pernah menyimpan key.
|
| 70 |
-
</CardDescription>
|
| 71 |
</CardHeader>
|
| 72 |
-
<CardContent className="space-y-
|
| 73 |
-
|
| 74 |
-
<div className="
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
]
|
| 85 |
-
value={provider}
|
| 86 |
-
onValueChange={(v: string | null) => setProvider(v ?? "openai")}
|
| 87 |
>
|
| 88 |
-
<
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
<SelectContent>
|
| 92 |
-
<SelectGroup>
|
| 93 |
-
<SelectItem value="openai">OpenAI</SelectItem>
|
| 94 |
-
<SelectItem value="anthropic">Anthropic</SelectItem>
|
| 95 |
-
<SelectItem value="google">Google</SelectItem>
|
| 96 |
-
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
| 97 |
-
<SelectItem value="groq">Groq</SelectItem>
|
| 98 |
-
<SelectItem value="custom">Custom</SelectItem>
|
| 99 |
-
</SelectGroup>
|
| 100 |
-
</SelectContent>
|
| 101 |
-
</Select>
|
| 102 |
</div>
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</div>
|
| 113 |
-
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<div className="space-y-2">
|
| 117 |
-
<Label htmlFor="
|
| 118 |
<Input
|
| 119 |
-
id="
|
| 120 |
-
value={
|
| 121 |
-
onChange={(e) =>
|
| 122 |
-
placeholder="
|
| 123 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 124 |
/>
|
| 125 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
<div className="space-y-2">
|
| 127 |
-
<Label htmlFor="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
<Input
|
| 129 |
-
id="
|
| 130 |
-
type="
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
onChange={(e) => setMaxTokens(Number(e.target.value))}
|
| 135 |
-
placeholder="16384"
|
| 136 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 137 |
/>
|
| 138 |
</div>
|
| 139 |
-
</div>
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
onChange={(e) => setApiKey(e.target.value)}
|
| 150 |
-
placeholder={hasKey ? "••••••••" : "sk-..."}
|
| 151 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 152 |
-
/>
|
| 153 |
-
</div>
|
| 154 |
-
|
| 155 |
-
<div className="flex gap-3">
|
| 156 |
-
<Button
|
| 157 |
-
onClick={handleSave}
|
| 158 |
-
disabled={(!apiKey && !hasKey) || isSaving}
|
| 159 |
-
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
|
| 160 |
-
>
|
| 161 |
-
{isSaving ? "Menyimpan..." : hasKey ? "Update Key" : "Simpan API Key"}
|
| 162 |
-
</Button>
|
| 163 |
-
{hasKey && (
|
| 164 |
<Button
|
| 165 |
variant="outline"
|
| 166 |
-
onClick={
|
| 167 |
-
className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)]
|
| 168 |
>
|
| 169 |
-
|
| 170 |
</Button>
|
| 171 |
-
|
| 172 |
-
</
|
| 173 |
-
</
|
| 174 |
-
|
| 175 |
</div>
|
| 176 |
|
| 177 |
<div className="lg:col-span-5">
|
| 178 |
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-8">
|
| 179 |
<CardHeader>
|
| 180 |
-
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 181 |
-
<CardDescription className="text-[var(--warm-charcoal)]">
|
|
|
|
|
|
|
| 182 |
</CardHeader>
|
| 183 |
-
<CardContent>
|
| 184 |
-
|
| 185 |
-
<
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
<
|
| 189 |
-
|
| 190 |
-
<div>
|
| 191 |
-
<p className="font-medium text-[var(--matcha-800)]">Key Tersimpan</p>
|
| 192 |
-
<p className="text-sm text-[var(--matcha-800)]/70">
|
| 193 |
-
{storedKey?.provider} · {storedKey?.modelName}
|
| 194 |
-
</p>
|
| 195 |
-
</div>
|
| 196 |
-
</div>
|
| 197 |
-
<div className="text-xs text-[var(--warm-charcoal)] space-y-1">
|
| 198 |
-
<p>• Disimpan terenkripsi di localStorage</p>
|
| 199 |
-
<p>• Key enkripsi disimpan di IndexedDB</p>
|
| 200 |
-
<p>• Server tidak pernah menyimpan key</p>
|
| 201 |
-
</div>
|
| 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.
|
| 209 |
</p>
|
| 210 |
</div>
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
</CardContent>
|
| 213 |
</Card>
|
| 214 |
</div>
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
+
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
+
import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { Input } from "@labas/ui/components/input";
|
| 7 |
import { Label } from "@labas/ui/components/label";
|
|
|
|
| 27 |
},
|
| 28 |
});
|
| 29 |
|
| 30 |
+
const PROVIDERS = [
|
| 31 |
+
{ value: "openai", label: "OpenAI" },
|
| 32 |
+
{ value: "anthropic", label: "Anthropic" },
|
| 33 |
+
{ value: "google", label: "Google" },
|
| 34 |
+
{ value: "openrouter", label: "OpenRouter" },
|
| 35 |
+
{ value: "groq", label: "Groq" },
|
| 36 |
+
{ value: "custom", label: "Custom" },
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
|
| 40 |
+
return {
|
| 41 |
+
name: "",
|
| 42 |
+
provider: "openai",
|
| 43 |
+
baseUrl: "https://api.openai.com/v1",
|
| 44 |
+
modelName: "gpt-4o-mini",
|
| 45 |
+
maxTokens: 16384,
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
function RouteComponent() {
|
| 50 |
+
const { configs, isLoading, addConfig, updateConfig, removeConfig } =
|
| 51 |
+
useApiKeys();
|
| 52 |
|
| 53 |
+
const [editingId, setEditingId] = useState<string | null>(null);
|
| 54 |
+
const [isAdding, setIsAdding] = useState(false);
|
|
|
|
|
|
|
|
|
|
| 55 |
const [isSaving, setIsSaving] = useState(false);
|
| 56 |
|
| 57 |
+
// Form state
|
| 58 |
+
const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>({
|
| 59 |
+
...defaultConfig(),
|
| 60 |
+
apiKey: "",
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
const resetForm = () => {
|
| 64 |
+
setForm({ ...defaultConfig(), apiKey: "" });
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const startAdd = () => {
|
| 68 |
+
resetForm();
|
| 69 |
+
setIsAdding(true);
|
| 70 |
+
setEditingId(null);
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const startEdit = (config: ApiKeyConfig) => {
|
| 74 |
+
setForm({
|
| 75 |
+
name: config.name,
|
| 76 |
+
provider: config.provider,
|
| 77 |
+
baseUrl: config.baseUrl,
|
| 78 |
+
modelName: config.modelName,
|
| 79 |
+
maxTokens: config.maxTokens ?? 16384,
|
| 80 |
+
apiKey: "", // kosong = tidak ubah
|
| 81 |
+
});
|
| 82 |
+
setEditingId(config.id);
|
| 83 |
+
setIsAdding(false);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const cancelEdit = () => {
|
| 87 |
+
setEditingId(null);
|
| 88 |
+
setIsAdding(false);
|
| 89 |
+
resetForm();
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
const handleSave = async () => {
|
| 93 |
+
if (!form.name.trim()) return;
|
| 94 |
setIsSaving(true);
|
| 95 |
try {
|
| 96 |
+
if (isAdding) {
|
| 97 |
+
if (!form.apiKey) return;
|
| 98 |
+
await addConfig(form);
|
| 99 |
+
setIsAdding(false);
|
| 100 |
+
} else if (editingId) {
|
| 101 |
+
await updateConfig(editingId, form);
|
| 102 |
+
setEditingId(null);
|
| 103 |
+
}
|
| 104 |
+
resetForm();
|
| 105 |
} finally {
|
| 106 |
setIsSaving(false);
|
| 107 |
}
|
| 108 |
};
|
| 109 |
|
| 110 |
+
const isFormOpen = isAdding || editingId !== null;
|
| 111 |
+
const canSave =
|
| 112 |
+
form.name.trim() &&
|
| 113 |
+
form.baseUrl.trim() &&
|
| 114 |
+
form.modelName.trim() &&
|
| 115 |
+
(isAdding ? !!form.apiKey : true);
|
| 116 |
+
|
| 117 |
return (
|
| 118 |
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 119 |
<section className="mb-10">
|
| 120 |
+
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 121 |
+
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
|
| 122 |
+
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 123 |
+
<span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
|
| 124 |
+
</div>
|
| 125 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 126 |
+
Pengaturan
|
| 127 |
+
</h1>
|
| 128 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 129 |
+
Kelola API key dan preferensi akun.
|
| 130 |
+
</p>
|
| 131 |
</section>
|
| 132 |
|
| 133 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 134 |
<div className="lg:col-span-7 space-y-8">
|
| 135 |
+
{/* API Key List */}
|
| 136 |
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 137 |
<CardHeader>
|
| 138 |
+
<div className="flex items-center justify-between">
|
| 139 |
+
<div className="flex items-center gap-3">
|
| 140 |
+
<MaterialIcon name="key" className="text-xl" />
|
| 141 |
+
<div>
|
| 142 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">API Keys</CardTitle>
|
| 143 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 144 |
+
{configs.length} konfigurasi tersimpan
|
| 145 |
+
</CardDescription>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
{!isFormOpen && (
|
| 149 |
+
<Button
|
| 150 |
+
onClick={startAdd}
|
| 151 |
+
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] clay-shadow clay-hover text-sm"
|
| 152 |
+
>
|
| 153 |
+
<MaterialIcon name="add" className="mr-1" />
|
| 154 |
+
Tambah
|
| 155 |
+
</Button>
|
| 156 |
+
)}
|
| 157 |
</div>
|
|
|
|
|
|
|
|
|
|
| 158 |
</CardHeader>
|
| 159 |
+
<CardContent className="space-y-4">
|
| 160 |
+
{isLoading ? (
|
| 161 |
+
<div className="h-20 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 162 |
+
) : configs.length === 0 ? (
|
| 163 |
+
<div className="text-center py-8">
|
| 164 |
+
<MaterialIcon name="key_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 165 |
+
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada API key</p>
|
| 166 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 167 |
+
Tambahkan minimal satu key untuk generate soal.
|
| 168 |
+
</p>
|
| 169 |
+
<Button
|
| 170 |
+
onClick={startAdd}
|
| 171 |
+
className="mt-4 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
|
|
|
|
|
|
| 172 |
>
|
| 173 |
+
<MaterialIcon name="add" className="mr-1" />
|
| 174 |
+
Tambah API Key
|
| 175 |
+
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
</div>
|
| 177 |
+
) : (
|
| 178 |
+
<div className="space-y-3">
|
| 179 |
+
{configs.map((config) => (
|
| 180 |
+
<div
|
| 181 |
+
key={config.id}
|
| 182 |
+
className={`p-4 rounded-[var(--radius-lg)] border-2 transition-colors ${
|
| 183 |
+
editingId === config.id
|
| 184 |
+
? "border-[var(--matcha-600)] bg-[var(--matcha-100)]"
|
| 185 |
+
: "border-[var(--oat-border)] bg-[var(--warm-cream)]"
|
| 186 |
+
}`}
|
| 187 |
+
>
|
| 188 |
+
<div className="flex items-center justify-between">
|
| 189 |
+
<div className="min-w-0">
|
| 190 |
+
<div className="flex items-center gap-2 mb-1">
|
| 191 |
+
<span className="font-semibold text-[var(--clay-black)] truncate">
|
| 192 |
+
{config.name}
|
| 193 |
+
</span>
|
| 194 |
+
<span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold">
|
| 195 |
+
{PROVIDERS.find((p) => p.value === config.provider)?.label ?? config.provider}
|
| 196 |
+
</span>
|
| 197 |
+
</div>
|
| 198 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 199 |
+
{config.modelName} · {config.baseUrl.replace("https://", "").replace("/v1", "")}
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
<div className="flex gap-2 shrink-0">
|
| 203 |
+
<button
|
| 204 |
+
onClick={() => startEdit(config)}
|
| 205 |
+
className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--oat-light)] text-[var(--warm-charcoal)] transition-colors"
|
| 206 |
+
title="Edit"
|
| 207 |
+
>
|
| 208 |
+
<MaterialIcon name="edit" className="text-sm" />
|
| 209 |
+
</button>
|
| 210 |
+
<button
|
| 211 |
+
onClick={() => {
|
| 212 |
+
if (confirm("Yakin mau hapus konfigurasi ini?")) {
|
| 213 |
+
removeConfig(config.id);
|
| 214 |
+
}
|
| 215 |
+
}}
|
| 216 |
+
className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] transition-colors"
|
| 217 |
+
title="Hapus"
|
| 218 |
+
>
|
| 219 |
+
<MaterialIcon name="delete" className="text-sm" />
|
| 220 |
+
</button>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
))}
|
| 225 |
</div>
|
| 226 |
+
)}
|
| 227 |
+
</CardContent>
|
| 228 |
+
</Card>
|
| 229 |
|
| 230 |
+
{/* Add/Edit Form */}
|
| 231 |
+
{isFormOpen && (
|
| 232 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
|
| 233 |
+
<CardHeader>
|
| 234 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 235 |
+
{isAdding ? "Tambah API Key Baru" : "Edit API Key"}
|
| 236 |
+
</CardTitle>
|
| 237 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 238 |
+
{isAdding
|
| 239 |
+
? "Tambahkan konfigurasi provider baru."
|
| 240 |
+
: "Kosongkan field API Key jika tidak ingin mengubahnya."}
|
| 241 |
+
</CardDescription>
|
| 242 |
+
</CardHeader>
|
| 243 |
+
<CardContent className="space-y-5">
|
| 244 |
<div className="space-y-2">
|
| 245 |
+
<Label htmlFor="name" className="text-[var(--clay-black)]">Nama Config</Label>
|
| 246 |
<Input
|
| 247 |
+
id="name"
|
| 248 |
+
value={form.name}
|
| 249 |
+
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
| 250 |
+
placeholder="Contoh: OpenAI GPT-4o"
|
| 251 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 252 |
/>
|
| 253 |
</div>
|
| 254 |
+
|
| 255 |
+
<div className="grid grid-cols-2 gap-4">
|
| 256 |
+
<div className="space-y-2">
|
| 257 |
+
<Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
|
| 258 |
+
<Select
|
| 259 |
+
items={PROVIDERS}
|
| 260 |
+
value={form.provider}
|
| 261 |
+
onValueChange={(v: string | null) =>
|
| 262 |
+
setForm((f) => ({ ...f, provider: v ?? "openai" }))
|
| 263 |
+
}
|
| 264 |
+
>
|
| 265 |
+
<SelectTrigger id="provider">
|
| 266 |
+
<SelectValue placeholder="Pilih provider" />
|
| 267 |
+
</SelectTrigger>
|
| 268 |
+
<SelectContent>
|
| 269 |
+
<SelectGroup>
|
| 270 |
+
{PROVIDERS.map((p) => (
|
| 271 |
+
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
| 272 |
+
))}
|
| 273 |
+
</SelectGroup>
|
| 274 |
+
</SelectContent>
|
| 275 |
+
</Select>
|
| 276 |
+
</div>
|
| 277 |
+
<div className="space-y-2">
|
| 278 |
+
<Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
|
| 279 |
+
<Input
|
| 280 |
+
id="model"
|
| 281 |
+
value={form.modelName}
|
| 282 |
+
onChange={(e) => setForm((f) => ({ ...f, modelName: e.target.value }))}
|
| 283 |
+
placeholder="gpt-4o-mini"
|
| 284 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 285 |
+
/>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<div className="grid grid-cols-2 gap-4">
|
| 290 |
+
<div className="space-y-2">
|
| 291 |
+
<Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
|
| 292 |
+
<Input
|
| 293 |
+
id="baseUrl"
|
| 294 |
+
value={form.baseUrl}
|
| 295 |
+
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
|
| 296 |
+
placeholder="https://api.openai.com/v1"
|
| 297 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 298 |
+
/>
|
| 299 |
+
</div>
|
| 300 |
+
<div className="space-y-2">
|
| 301 |
+
<Label htmlFor="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
|
| 302 |
+
<Input
|
| 303 |
+
id="maxTokens"
|
| 304 |
+
type="number"
|
| 305 |
+
min={1}
|
| 306 |
+
max={1_000_000}
|
| 307 |
+
value={form.maxTokens}
|
| 308 |
+
onChange={(e) => setForm((f) => ({ ...f, maxTokens: Number(e.target.value) }))}
|
| 309 |
+
placeholder="16384"
|
| 310 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 311 |
+
/>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
<div className="space-y-2">
|
| 316 |
+
<Label htmlFor="apiKey" className="text-[var(--clay-black)]">
|
| 317 |
+
API Key
|
| 318 |
+
{!isAdding && (
|
| 319 |
+
<span className="text-[var(--warm-silver)] font-normal ml-1">
|
| 320 |
+
(kosongkan jika tidak diubah)
|
| 321 |
+
</span>
|
| 322 |
+
)}
|
| 323 |
+
</Label>
|
| 324 |
<Input
|
| 325 |
+
id="apiKey"
|
| 326 |
+
type="password"
|
| 327 |
+
value={form.apiKey}
|
| 328 |
+
onChange={(e) => setForm((f) => ({ ...f, apiKey: e.target.value }))}
|
| 329 |
+
placeholder={isAdding ? "sk-..." : "•••••••• (kosongkan untuk tidak mengubah)"}
|
|
|
|
|
|
|
| 330 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 331 |
/>
|
| 332 |
</div>
|
|
|
|
| 333 |
|
| 334 |
+
<div className="flex gap-3">
|
| 335 |
+
<Button
|
| 336 |
+
onClick={handleSave}
|
| 337 |
+
disabled={!canSave || isSaving}
|
| 338 |
+
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
|
| 339 |
+
>
|
| 340 |
+
{isSaving ? "Menyimpan..." : isAdding ? "Simpan" : "Update"}
|
| 341 |
+
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
<Button
|
| 343 |
variant="outline"
|
| 344 |
+
onClick={cancelEdit}
|
| 345 |
+
className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)]"
|
| 346 |
>
|
| 347 |
+
Batal
|
| 348 |
</Button>
|
| 349 |
+
</div>
|
| 350 |
+
</CardContent>
|
| 351 |
+
</Card>
|
| 352 |
+
)}
|
| 353 |
</div>
|
| 354 |
|
| 355 |
<div className="lg:col-span-5">
|
| 356 |
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-8">
|
| 357 |
<CardHeader>
|
| 358 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Keamanan</CardTitle>
|
| 359 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 360 |
+
Cara penyimpanan API key Anda.
|
| 361 |
+
</CardDescription>
|
| 362 |
</CardHeader>
|
| 363 |
+
<CardContent className="space-y-4">
|
| 364 |
+
<div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 365 |
+
<MaterialIcon name="lock" className="text-xl text-[var(--matcha-800)]" />
|
| 366 |
+
<div>
|
| 367 |
+
<p className="font-medium text-[var(--matcha-800)]">Enkripsi Lokal</p>
|
| 368 |
+
<p className="text-sm text-[var(--matcha-800)]/70">
|
| 369 |
+
AES-GCM-256 dengan key di IndexedDB
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
</p>
|
| 371 |
</div>
|
| 372 |
+
</div>
|
| 373 |
+
<div className="text-xs text-[var(--warm-charcoal)] space-y-2">
|
| 374 |
+
<p className="flex items-start gap-2">
|
| 375 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 376 |
+
API key tidak pernah dikirim ke server kami
|
| 377 |
+
</p>
|
| 378 |
+
<p className="flex items-start gap-2">
|
| 379 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 380 |
+
Data terenkripsi di localStorage browser Anda
|
| 381 |
+
</p>
|
| 382 |
+
<p className="flex items-start gap-2">
|
| 383 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 384 |
+
Key enkripsi tersimpan aman di IndexedDB
|
| 385 |
+
</p>
|
| 386 |
+
<p className="flex items-start gap-2">
|
| 387 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 388 |
+
BYOK — Bring Your Own Key, platform tidak menyimpan key
|
| 389 |
+
</p>
|
| 390 |
+
</div>
|
| 391 |
</CardContent>
|
| 392 |
</Card>
|
| 393 |
</div>
|
packages/ai/src/schemas.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const aiKeyConfigSchema = z.object({
|
|
| 29 |
baseUrl: z.string().url(),
|
| 30 |
apiKey: z.string().min(1),
|
| 31 |
model: z.string().min(1),
|
| 32 |
-
maxTokens: z.number().int().min(1).max(
|
| 33 |
});
|
| 34 |
|
| 35 |
// ── Question Option Schemas ────────────────────────────────
|
|
|
|
| 29 |
baseUrl: z.string().url(),
|
| 30 |
apiKey: z.string().min(1),
|
| 31 |
model: z.string().min(1),
|
| 32 |
+
maxTokens: z.number().int().min(1).max(1_000_000).default(16384),
|
| 33 |
});
|
| 34 |
|
| 35 |
// ── Question Option Schemas ────────────────────────────────
|
packages/api/src/queue.ts
CHANGED
|
@@ -155,7 +155,8 @@ export const generationWorker = new Worker(
|
|
| 155 |
};
|
| 156 |
|
| 157 |
try {
|
| 158 |
-
|
|
|
|
| 159 |
await updateProgress(10, "Generating passage...");
|
| 160 |
}
|
| 161 |
|
|
@@ -166,32 +167,59 @@ export const generationWorker = new Worker(
|
|
| 166 |
approxTokens += Math.ceil(token.length / 4);
|
| 167 |
};
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
10 + Math.round((p.currentStep / p.steps.length) * 80),
|
| 175 |
-
90,
|
| 176 |
-
);
|
| 177 |
-
const msg = p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
|
| 178 |
-
await updateProgress(stepProgress, msg);
|
| 179 |
-
})
|
| 180 |
-
: await generateQuestionsQuick(input, {
|
| 181 |
-
onToken: (token) => {
|
| 182 |
cancelPoll.check();
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
cancelPoll.check();
|
| 197 |
await updateProgress(95, "Saving to bank...");
|
|
@@ -246,51 +274,62 @@ export const generationWorker = new Worker(
|
|
| 246 |
|
| 247 |
savedQuestionIds = inserted.map((r) => r.id);
|
| 248 |
|
| 249 |
-
// Auto-create a package from generated questions
|
|
|
|
| 250 |
if (savedQuestionIds.length > 0) {
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
title: pkgTitle,
|
| 262 |
-
description: `Paket latihan AI-generated dengan ${savedQuestionIds.length} soal ${input.examType} ${input.section}.`,
|
| 263 |
-
examTypeId: input.examType,
|
| 264 |
-
creatorUserId: userId,
|
| 265 |
-
isPublic: false,
|
| 266 |
-
totalQuestions: savedQuestionIds.length,
|
| 267 |
-
totalSections: 1,
|
| 268 |
-
estimatedDurationMin: Math.ceil(savedQuestionIds.length * 1.5),
|
| 269 |
-
})
|
| 270 |
-
.returning();
|
| 271 |
-
|
| 272 |
-
if (pkg) {
|
| 273 |
-
generatedPackageId = pkg.id;
|
| 274 |
-
|
| 275 |
-
const [sec] = await db
|
| 276 |
-
.insert(packageSection)
|
| 277 |
.values({
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
})
|
| 283 |
.returning();
|
| 284 |
|
| 285 |
-
if (
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
}
|
| 295 |
}
|
| 296 |
}
|
|
|
|
| 155 |
};
|
| 156 |
|
| 157 |
try {
|
| 158 |
+
const selectedMode = input.mode;
|
| 159 |
+
if (selectedMode === "agentic") {
|
| 160 |
await updateProgress(10, "Generating passage...");
|
| 161 |
}
|
| 162 |
|
|
|
|
| 167 |
approxTokens += Math.ceil(token.length / 4);
|
| 168 |
};
|
| 169 |
|
| 170 |
+
let result;
|
| 171 |
+
try {
|
| 172 |
+
result =
|
| 173 |
+
selectedMode === "agentic"
|
| 174 |
+
? await generateQuestionsAgentic(input, async (p) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
cancelPoll.check();
|
| 176 |
+
const stepProgress = Math.min(
|
| 177 |
+
10 + Math.round((p.currentStep / p.steps.length) * 80),
|
| 178 |
+
90,
|
| 179 |
+
);
|
| 180 |
+
const msg =
|
| 181 |
+
p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
|
| 182 |
+
await updateProgress(stepProgress, msg);
|
| 183 |
+
})
|
| 184 |
+
: await generateQuestionsQuick(input, {
|
| 185 |
+
onToken: (token) => {
|
| 186 |
+
cancelPoll.check();
|
| 187 |
+
countToken(token);
|
| 188 |
+
// Update progress message with token count every ~500 chars
|
| 189 |
+
if (approxTokens % 20 === 0) {
|
| 190 |
+
job.updateProgress(job.progress ?? 5).catch(() => {});
|
| 191 |
+
db
|
| 192 |
+
.update(generationJob)
|
| 193 |
+
.set({ progressMessage: `Generating... (~${approxTokens} tokens)` })
|
| 194 |
+
.where(eq(generationJob.id, jobId))
|
| 195 |
+
.catch(() => {});
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
});
|
| 199 |
+
} catch (quickErr: any) {
|
| 200 |
+
const quickErrorMessage = quickErr?.message ?? String(quickErr);
|
| 201 |
+
const shouldFallbackToAgentic =
|
| 202 |
+
selectedMode === "quick" &&
|
| 203 |
+
(/Failed to parse AI response as JSON/i.test(quickErrorMessage) ||
|
| 204 |
+
/Unterminated string/i.test(quickErrorMessage) ||
|
| 205 |
+
/Missing 'questions' array/i.test(quickErrorMessage));
|
| 206 |
+
|
| 207 |
+
if (!shouldFallbackToAgentic) {
|
| 208 |
+
throw quickErr;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
await updateProgress(25, "Quick mode JSON invalid, retrying with agentic mode...");
|
| 212 |
+
result = await generateQuestionsAgentic(
|
| 213 |
+
{ ...input, mode: "agentic" },
|
| 214 |
+
async (p) => {
|
| 215 |
+
cancelPoll.check();
|
| 216 |
+
const stepProgress = Math.min(25 + Math.round((p.currentStep / p.steps.length) * 65), 90);
|
| 217 |
+
const msg =
|
| 218 |
+
p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
|
| 219 |
+
await updateProgress(stepProgress, msg);
|
| 220 |
+
},
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
|
| 224 |
cancelPoll.check();
|
| 225 |
await updateProgress(95, "Saving to bank...");
|
|
|
|
| 274 |
|
| 275 |
savedQuestionIds = inserted.map((r) => r.id);
|
| 276 |
|
| 277 |
+
// Auto-create a package from generated questions.
|
| 278 |
+
// Non-fatal: if this fails, generation should still succeed with savedQuestionIds.
|
| 279 |
if (savedQuestionIds.length > 0) {
|
| 280 |
+
try {
|
| 281 |
+
const dateStr = new Date().toLocaleDateString("id-ID", {
|
| 282 |
+
day: "numeric",
|
| 283 |
+
month: "short",
|
| 284 |
+
year: "numeric",
|
| 285 |
+
});
|
| 286 |
+
const pkgTitle = `AI Generated - ${input.examType} ${input.section} - ${dateStr}`;
|
| 287 |
+
|
| 288 |
+
const [pkg] = await db
|
| 289 |
+
.insert(testPackage)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
.values({
|
| 291 |
+
title: pkgTitle,
|
| 292 |
+
description: `Paket latihan AI-generated dengan ${savedQuestionIds.length} soal ${input.examType} ${input.section}.`,
|
| 293 |
+
examTypeId: input.examType,
|
| 294 |
+
creatorUserId: userId,
|
| 295 |
+
isPublic: false,
|
| 296 |
+
totalQuestions: savedQuestionIds.length,
|
| 297 |
+
totalSections: 1,
|
| 298 |
+
estimatedDurationMin: Math.ceil(savedQuestionIds.length * 1.5),
|
| 299 |
})
|
| 300 |
.returning();
|
| 301 |
|
| 302 |
+
if (pkg) {
|
| 303 |
+
generatedPackageId = pkg.id;
|
| 304 |
+
|
| 305 |
+
const [sec] = await db
|
| 306 |
+
.insert(packageSection)
|
| 307 |
+
.values({
|
| 308 |
+
packageId: pkg.id,
|
| 309 |
+
sectionTypeId: input.section,
|
| 310 |
+
title: `${input.section} Section`,
|
| 311 |
+
orderIndex: 0,
|
| 312 |
+
})
|
| 313 |
+
.returning();
|
| 314 |
+
|
| 315 |
+
if (sec) {
|
| 316 |
+
await db.insert(sectionQuestion).values(
|
| 317 |
+
savedQuestionIds.map((qid, idx) => ({
|
| 318 |
+
sectionId: sec.id,
|
| 319 |
+
questionId: qid,
|
| 320 |
+
orderIndex: idx,
|
| 321 |
+
})),
|
| 322 |
+
);
|
| 323 |
+
}
|
| 324 |
}
|
| 325 |
+
} catch (packageErr: any) {
|
| 326 |
+
// eslint-disable-next-line no-console
|
| 327 |
+
console.warn("[GENERATION] Failed to auto-create package, but questions were saved.", {
|
| 328 |
+
error: packageErr?.message ?? String(packageErr),
|
| 329 |
+
jobId,
|
| 330 |
+
examType: input.examType,
|
| 331 |
+
section: input.section,
|
| 332 |
+
});
|
| 333 |
}
|
| 334 |
}
|
| 335 |
}
|
packages/api/src/routers/attempt.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
packageSection,
|
| 11 |
sectionQuestion,
|
| 12 |
question,
|
|
|
|
| 13 |
} from "@labas/db";
|
| 14 |
|
| 15 |
function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string): boolean {
|
|
@@ -447,8 +448,13 @@ export const attemptRouter = router({
|
|
| 447 |
maxScore: testAttempt.maxScore,
|
| 448 |
status: testAttempt.status,
|
| 449 |
createdAt: testAttempt.createdAt,
|
|
|
|
|
|
|
|
|
|
| 450 |
})
|
| 451 |
.from(testAttempt)
|
|
|
|
|
|
|
| 452 |
.where(where)
|
| 453 |
.orderBy(desc(testAttempt.createdAt))
|
| 454 |
.limit(input?.limit ?? 20)
|
|
|
|
| 10 |
packageSection,
|
| 11 |
sectionQuestion,
|
| 12 |
question,
|
| 13 |
+
examType,
|
| 14 |
} from "@labas/db";
|
| 15 |
|
| 16 |
function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string): boolean {
|
|
|
|
| 448 |
maxScore: testAttempt.maxScore,
|
| 449 |
status: testAttempt.status,
|
| 450 |
createdAt: testAttempt.createdAt,
|
| 451 |
+
packageTitle: testPackage.title,
|
| 452 |
+
packageExamTypeId: testPackage.examTypeId,
|
| 453 |
+
examTypeName: examType.name,
|
| 454 |
})
|
| 455 |
.from(testAttempt)
|
| 456 |
+
.leftJoin(testPackage, eq(testAttempt.packageId, testPackage.id))
|
| 457 |
+
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
|
| 458 |
.where(where)
|
| 459 |
.orderBy(desc(testAttempt.createdAt))
|
| 460 |
.limit(input?.limit ?? 20)
|
packages/api/src/routers/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { attemptRouter } from "./attempt";
|
|
| 4 |
import { comboRouter } from "./combo";
|
| 5 |
import { questionRouter } from "./question";
|
| 6 |
import { packageRouter } from "./package";
|
|
|
|
| 7 |
import { ratingRouter } from "./rating";
|
| 8 |
import { settingsRouter } from "./settings";
|
| 9 |
import { statsRouter } from "./stats";
|
|
@@ -23,6 +24,7 @@ export const appRouter = router({
|
|
| 23 |
combo: comboRouter,
|
| 24 |
question: questionRouter,
|
| 25 |
package: packageRouter,
|
|
|
|
| 26 |
rating: ratingRouter,
|
| 27 |
settings: settingsRouter,
|
| 28 |
stats: statsRouter,
|
|
|
|
| 4 |
import { comboRouter } from "./combo";
|
| 5 |
import { questionRouter } from "./question";
|
| 6 |
import { packageRouter } from "./package";
|
| 7 |
+
import { profileRouter } from "./profile";
|
| 8 |
import { ratingRouter } from "./rating";
|
| 9 |
import { settingsRouter } from "./settings";
|
| 10 |
import { statsRouter } from "./stats";
|
|
|
|
| 24 |
combo: comboRouter,
|
| 25 |
question: questionRouter,
|
| 26 |
package: packageRouter,
|
| 27 |
+
profile: profileRouter,
|
| 28 |
rating: ratingRouter,
|
| 29 |
settings: settingsRouter,
|
| 30 |
stats: statsRouter,
|
packages/api/src/routers/package.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { z } from "zod";
|
| 2 |
-
import { eq, and, desc, sql, inArray } from "drizzle-orm";
|
| 3 |
import { router, protectedProcedure, publicProcedure } from "../index";
|
| 4 |
import { db } from "@labas/db";
|
| 5 |
import {
|
|
@@ -34,7 +34,13 @@ export const packageRouter = router({
|
|
| 34 |
if (input?.isPublic !== undefined) {
|
| 35 |
conditions.push(eq(testPackage.isPublic, input.isPublic));
|
| 36 |
} else if (!userId) {
|
|
|
|
| 37 |
conditions.push(eq(testPackage.isPublic, true));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
@@ -79,6 +85,7 @@ export const packageRouter = router({
|
|
| 79 |
z
|
| 80 |
.object({
|
| 81 |
search: z.string().optional(),
|
|
|
|
| 82 |
limit: z.number().min(1).max(50).default(20),
|
| 83 |
offset: z.number().min(0).default(0),
|
| 84 |
})
|
|
@@ -88,6 +95,15 @@ export const packageRouter = router({
|
|
| 88 |
const userId = ctx.session.user.id;
|
| 89 |
const conditions = [eq(testPackage.creatorUserId, userId)];
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const where = and(...conditions);
|
| 92 |
|
| 93 |
const rows = await db
|
|
@@ -96,6 +112,7 @@ export const packageRouter = router({
|
|
| 96 |
title: testPackage.title,
|
| 97 |
description: testPackage.description,
|
| 98 |
examTypeId: testPackage.examTypeId,
|
|
|
|
| 99 |
isPublic: testPackage.isPublic,
|
| 100 |
totalQuestions: testPackage.totalQuestions,
|
| 101 |
totalSections: testPackage.totalSections,
|
|
@@ -448,4 +465,58 @@ export const packageRouter = router({
|
|
| 448 |
|
| 449 |
return { success: true };
|
| 450 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
});
|
|
|
|
| 1 |
import { z } from "zod";
|
| 2 |
+
import { eq, and, or, desc, sql, inArray } from "drizzle-orm";
|
| 3 |
import { router, protectedProcedure, publicProcedure } from "../index";
|
| 4 |
import { db } from "@labas/db";
|
| 5 |
import {
|
|
|
|
| 34 |
if (input?.isPublic !== undefined) {
|
| 35 |
conditions.push(eq(testPackage.isPublic, input.isPublic));
|
| 36 |
} else if (!userId) {
|
| 37 |
+
// Guests: only public packages
|
| 38 |
conditions.push(eq(testPackage.isPublic, true));
|
| 39 |
+
} else {
|
| 40 |
+
// Logged-in users: public packages + their own private packages
|
| 41 |
+
conditions.push(
|
| 42 |
+
or(eq(testPackage.isPublic, true), eq(testPackage.creatorUserId, userId)),
|
| 43 |
+
);
|
| 44 |
}
|
| 45 |
|
| 46 |
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
| 85 |
z
|
| 86 |
.object({
|
| 87 |
search: z.string().optional(),
|
| 88 |
+
examTypeId: z.string().optional(),
|
| 89 |
limit: z.number().min(1).max(50).default(20),
|
| 90 |
offset: z.number().min(0).default(0),
|
| 91 |
})
|
|
|
|
| 95 |
const userId = ctx.session.user.id;
|
| 96 |
const conditions = [eq(testPackage.creatorUserId, userId)];
|
| 97 |
|
| 98 |
+
if (input?.search) {
|
| 99 |
+
conditions.push(
|
| 100 |
+
sql`LOWER(${testPackage.title}) LIKE LOWER(${"%" + input.search + "%"})`,
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
if (input?.examTypeId) {
|
| 104 |
+
conditions.push(eq(testPackage.examTypeId, input.examTypeId));
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
const where = and(...conditions);
|
| 108 |
|
| 109 |
const rows = await db
|
|
|
|
| 112 |
title: testPackage.title,
|
| 113 |
description: testPackage.description,
|
| 114 |
examTypeId: testPackage.examTypeId,
|
| 115 |
+
creatorUserId: testPackage.creatorUserId,
|
| 116 |
isPublic: testPackage.isPublic,
|
| 117 |
totalQuestions: testPackage.totalQuestions,
|
| 118 |
totalSections: testPackage.totalSections,
|
|
|
|
| 465 |
|
| 466 |
return { success: true };
|
| 467 |
}),
|
| 468 |
+
|
| 469 |
+
featured: publicProcedure
|
| 470 |
+
.query(async () => {
|
| 471 |
+
const rows = await db
|
| 472 |
+
.select({
|
| 473 |
+
id: testPackage.id,
|
| 474 |
+
title: testPackage.title,
|
| 475 |
+
description: testPackage.description,
|
| 476 |
+
examTypeId: testPackage.examTypeId,
|
| 477 |
+
isPublic: testPackage.isPublic,
|
| 478 |
+
totalQuestions: testPackage.totalQuestions,
|
| 479 |
+
totalSections: testPackage.totalSections,
|
| 480 |
+
estimatedDurationMin: testPackage.estimatedDurationMin,
|
| 481 |
+
usageCount: testPackage.usageCount,
|
| 482 |
+
avgRating: testPackage.avgRating,
|
| 483 |
+
createdAt: testPackage.createdAt,
|
| 484 |
+
examTypeName: examType.name,
|
| 485 |
+
})
|
| 486 |
+
.from(testPackage)
|
| 487 |
+
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
|
| 488 |
+
.where(
|
| 489 |
+
and(eq(testPackage.isFeatured, true), eq(testPackage.isPublic, true)),
|
| 490 |
+
)
|
| 491 |
+
.orderBy(desc(testPackage.createdAt))
|
| 492 |
+
.limit(6);
|
| 493 |
+
|
| 494 |
+
return rows;
|
| 495 |
+
}),
|
| 496 |
+
|
| 497 |
+
trending: publicProcedure
|
| 498 |
+
.query(async () => {
|
| 499 |
+
const rows = await db
|
| 500 |
+
.select({
|
| 501 |
+
id: testPackage.id,
|
| 502 |
+
title: testPackage.title,
|
| 503 |
+
description: testPackage.description,
|
| 504 |
+
examTypeId: testPackage.examTypeId,
|
| 505 |
+
isPublic: testPackage.isPublic,
|
| 506 |
+
totalQuestions: testPackage.totalQuestions,
|
| 507 |
+
totalSections: testPackage.totalSections,
|
| 508 |
+
estimatedDurationMin: testPackage.estimatedDurationMin,
|
| 509 |
+
usageCount: testPackage.usageCount,
|
| 510 |
+
avgRating: testPackage.avgRating,
|
| 511 |
+
createdAt: testPackage.createdAt,
|
| 512 |
+
examTypeName: examType.name,
|
| 513 |
+
})
|
| 514 |
+
.from(testPackage)
|
| 515 |
+
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
|
| 516 |
+
.where(eq(testPackage.isPublic, true))
|
| 517 |
+
.orderBy(desc(testPackage.usageCount))
|
| 518 |
+
.limit(6);
|
| 519 |
+
|
| 520 |
+
return rows;
|
| 521 |
+
}),
|
| 522 |
});
|
packages/api/src/routers/profile.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from "zod";
|
| 2 |
+
import { eq, and, desc, sql } from "drizzle-orm";
|
| 3 |
+
import { router, protectedProcedure, publicProcedure } from "../index";
|
| 4 |
+
import { db } from "@labas/db";
|
| 5 |
+
import { testPackage, question, examType, user } from "@labas/db";
|
| 6 |
+
|
| 7 |
+
export const profileRouter = router({
|
| 8 |
+
getById: publicProcedure
|
| 9 |
+
.input(z.object({ userId: z.string() }))
|
| 10 |
+
.query(async ({ input }) => {
|
| 11 |
+
const [u] = await db
|
| 12 |
+
.select({
|
| 13 |
+
id: user.id,
|
| 14 |
+
name: user.name,
|
| 15 |
+
email: user.email,
|
| 16 |
+
image: user.image,
|
| 17 |
+
createdAt: user.createdAt,
|
| 18 |
+
})
|
| 19 |
+
.from(user)
|
| 20 |
+
.where(eq(user.id, input.userId))
|
| 21 |
+
.limit(1);
|
| 22 |
+
|
| 23 |
+
if (!u) return null;
|
| 24 |
+
|
| 25 |
+
// Creator stats
|
| 26 |
+
const [pkgStats] = await db
|
| 27 |
+
.select({
|
| 28 |
+
totalPackages: sql<number>`count(*)`,
|
| 29 |
+
totalUsage: sql<number>`coalesce(sum(${testPackage.usageCount}), 0)`,
|
| 30 |
+
avgRating: sql<number>`round(avg(${testPackage.avgRating})::numeric, 1)`,
|
| 31 |
+
})
|
| 32 |
+
.from(testPackage)
|
| 33 |
+
.where(eq(testPackage.creatorUserId, input.userId));
|
| 34 |
+
|
| 35 |
+
const [questionStats] = await db
|
| 36 |
+
.select({
|
| 37 |
+
totalQuestions: sql<number>`count(*)`,
|
| 38 |
+
totalUsage: sql<number>`coalesce(sum(${question.usageCount}), 0)`,
|
| 39 |
+
avgRating: sql<number>`round(avg(${question.avgRating})::numeric, 1)`,
|
| 40 |
+
})
|
| 41 |
+
.from(question)
|
| 42 |
+
.where(eq(question.creatorUserId, input.userId));
|
| 43 |
+
|
| 44 |
+
// Recent public packages
|
| 45 |
+
const recentPackages = await db
|
| 46 |
+
.select({
|
| 47 |
+
id: testPackage.id,
|
| 48 |
+
title: testPackage.title,
|
| 49 |
+
examTypeId: testPackage.examTypeId,
|
| 50 |
+
totalQuestions: testPackage.totalQuestions,
|
| 51 |
+
usageCount: testPackage.usageCount,
|
| 52 |
+
avgRating: testPackage.avgRating,
|
| 53 |
+
isPublic: testPackage.isPublic,
|
| 54 |
+
createdAt: testPackage.createdAt,
|
| 55 |
+
examTypeName: examType.name,
|
| 56 |
+
})
|
| 57 |
+
.from(testPackage)
|
| 58 |
+
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
|
| 59 |
+
.where(
|
| 60 |
+
and(
|
| 61 |
+
eq(testPackage.creatorUserId, input.userId),
|
| 62 |
+
eq(testPackage.isPublic, true),
|
| 63 |
+
),
|
| 64 |
+
)
|
| 65 |
+
.orderBy(desc(testPackage.createdAt))
|
| 66 |
+
.limit(6);
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
user: u,
|
| 70 |
+
stats: {
|
| 71 |
+
totalPackages: Number(pkgStats?.totalPackages ?? 0),
|
| 72 |
+
totalPackageUsage: Number(pkgStats?.totalUsage ?? 0),
|
| 73 |
+
avgPackageRating: Number(pkgStats?.avgRating ?? 0),
|
| 74 |
+
totalQuestions: Number(questionStats?.totalQuestions ?? 0),
|
| 75 |
+
totalQuestionUsage: Number(questionStats?.totalUsage ?? 0),
|
| 76 |
+
avgQuestionRating: Number(questionStats?.avgRating ?? 0),
|
| 77 |
+
},
|
| 78 |
+
recentPackages,
|
| 79 |
+
};
|
| 80 |
+
}),
|
| 81 |
+
|
| 82 |
+
update: protectedProcedure
|
| 83 |
+
.input(
|
| 84 |
+
z.object({
|
| 85 |
+
name: z.string().min(1).max(100).optional(),
|
| 86 |
+
image: z.string().url().optional().nullable(),
|
| 87 |
+
}),
|
| 88 |
+
)
|
| 89 |
+
.mutation(async ({ ctx, input }) => {
|
| 90 |
+
const userId = ctx.session.user.id;
|
| 91 |
+
const [updated] = await db
|
| 92 |
+
.update(user)
|
| 93 |
+
.set({
|
| 94 |
+
...(input.name ? { name: input.name } : {}),
|
| 95 |
+
...(input.image !== undefined ? { image: input.image } : {}),
|
| 96 |
+
})
|
| 97 |
+
.where(eq(user.id, userId))
|
| 98 |
+
.returning({
|
| 99 |
+
id: user.id,
|
| 100 |
+
name: user.name,
|
| 101 |
+
email: user.email,
|
| 102 |
+
image: user.image,
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
if (!updated) throw new Error("Failed to update profile");
|
| 106 |
+
return updated;
|
| 107 |
+
}),
|
| 108 |
+
});
|
packages/db/src/schema/app.ts
CHANGED
|
@@ -98,6 +98,7 @@ export const testPackage = pgTable(
|
|
| 98 |
estimatedDurationMin: integer("estimated_duration_min"),
|
| 99 |
usageCount: integer("usage_count").default(0).notNull(),
|
| 100 |
avgRating: integer("avg_rating"),
|
|
|
|
| 101 |
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 102 |
updatedAt: timestamp("updated_at")
|
| 103 |
.defaultNow()
|
|
|
|
| 98 |
estimatedDurationMin: integer("estimated_duration_min"),
|
| 99 |
usageCount: integer("usage_count").default(0).notNull(),
|
| 100 |
avgRating: integer("avg_rating"),
|
| 101 |
+
isFeatured: boolean("is_featured").default(false).notNull(),
|
| 102 |
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 103 |
updatedAt: timestamp("updated_at")
|
| 104 |
.defaultNow()
|