rogasper commited on
Commit
34f01ec
·
1 Parent(s): b87cbc7

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 CHANGED

Git LFS Details

  • SHA256: 53936b4d77d80fa41ea1b5005643ce725c1a5442c37560f855f774130f535af7
  • Pointer size: 129 Bytes
  • Size of remote file: 4.43 kB

Git LFS Details

  • SHA256: e1cdc3c6cd4b582fc724f57be558b3fb03dc72cdc25115185fc2c751b08ada29
  • Pointer size: 132 Bytes
  • Size of remote file: 1.39 MB
apps/web/pwa-assets.config.ts CHANGED
@@ -5,5 +5,5 @@ export default defineConfig({
5
  preset: "2023",
6
  },
7
  preset,
8
- images: ["public/logo.png"],
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
- <h1 className="text-xl font-extrabold text-[var(--clay-black)] font-headline tracking-tight">
38
- {collapsed ? "L" : "Labas"}
39
- </h1>
 
 
 
 
 
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 absolute 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"
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
- if (sectionResultId) {
209
- onAnswerChange(q.id, sectionResultId, val);
210
- }
211
  }}
212
- disabled={isFinished || !sectionResultId}
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-colors";
32
- const radioSelected = "border-[var(--matcha-600)] bg-[#e8f5ed]";
 
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
- <label
88
- key={c.key}
89
- className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
90
- >
91
- <input
92
- type="radio"
93
- name={question.id}
94
- value={c.key}
95
- checked={value === c.key}
96
- onChange={() => onChange(c.key)}
97
- disabled={disabled}
98
- className="hidden"
99
- />
100
- <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
101
- </label>
102
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  </div>
104
  );
105
  }
106
 
107
  if (format === "author_view") {
 
108
  return (
109
  <div className="space-y-2">
110
- {AUTHOR_VIEW_CHOICES.map((c) => (
111
- <label
112
- key={c.key}
113
- className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
114
- >
115
- <input
116
- type="radio"
117
- name={question.id}
118
- value={c.key}
119
- checked={value === c.key}
120
- onChange={() => onChange(c.key)}
121
- disabled={disabled}
122
- className="hidden"
123
- />
124
- <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
125
- </label>
126
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  </div>
128
  );
129
  }
 
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 = "labas_api_key";
5
 
6
- export interface StoredApiKey {
 
 
7
  provider: string;
8
  baseUrl: string;
9
  apiKey: string;
@@ -11,45 +13,99 @@ export interface StoredApiKey {
11
  maxTokens?: number;
12
  }
13
 
14
- export function useApiKey() {
15
- const [storedKey, setStoredKey] = useState<StoredApiKey | null>(null);
 
 
 
 
16
  const [isLoading, setIsLoading] = useState(true);
17
 
18
  useEffect(() => {
19
- loadKey();
20
  }, []);
21
 
22
- const loadKey = async () => {
23
  try {
24
  const encrypted = localStorage.getItem(STORAGE_KEY);
25
  if (encrypted) {
26
  const decrypted = await decryptText(encrypted);
27
- setStoredKey(JSON.parse(decrypted));
28
  }
29
  } catch {
30
- // Invalid or corrupted data
31
  localStorage.removeItem(STORAGE_KEY);
32
  } finally {
33
  setIsLoading(false);
34
  }
35
  };
36
 
37
- const saveKey = useCallback(async (key: StoredApiKey) => {
38
- const encrypted = await encryptText(JSON.stringify(key));
39
  localStorage.setItem(STORAGE_KEY, encrypted);
40
- setStoredKey(key);
41
  }, []);
42
 
43
- const removeKey = useCallback(() => {
44
- localStorage.removeItem(STORAGE_KEY);
45
- setStoredKey(null);
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 [tab, setTab] = useState<Tab>("mine");
44
- const [search, setSearch] = useState("");
45
- const [examType, setExamType] = useState<string>("");
46
- const [section, setSection] = useState<string>("");
47
- const [format, setFormat] = useState<string>("");
48
- const [difficulty, setDifficulty] = useState<number | undefined>();
49
- const [page, setPage] = useState(0);
 
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: search || undefined,
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
- setSearch("");
102
- setExamType("");
103
- setSection("");
104
- setFormat("");
105
- setDifficulty(undefined);
106
- setPage(0);
 
 
 
 
 
107
  };
108
 
109
- const hasFilters = search || examType || section || format || difficulty !== undefined;
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
- const allQuestions = await query.refetch();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  await handleAutoBundle({
140
  ...data,
141
  examTypeId: autoBundleExamType ?? "",
142
  sectionTypeId: autoBundleSectionType ?? "READING",
143
- allQuestions: allQuestions.data?.questions ?? [],
144
  });
145
  setIsAutoBundleOpen(false);
146
  };
@@ -159,7 +248,7 @@ function BankComponent() {
159
  {/* Tabs */}
160
  <div className="flex gap-2 mb-6">
161
  <button
162
- onClick={() => { setTab("mine"); setPage(0); }}
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={() => { setTab("public"); setPage(0); }}
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={search}
191
- onChange={(e) => { setSearch(e.target.value); setPage(0); }}
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) => { setExamType(v ?? ""); setPage(0); }}
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) => { setSection(v ?? ""); setPage(0); }}
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) => { setFormat(v ?? ""); setPage(0); }}
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) => { setDifficulty(v ? Number(v) : undefined); setPage(0); }}
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((p) => Math.max(0, p - 1))}
490
- disabled={page === 0}
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 + 1} dari {totalPages}
497
  </span>
498
  <Button
499
  variant="outline"
500
- onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
501
- disabled={page >= totalPages - 1}
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 { useApiKey } from "@/hooks/use-api-key";
7
  import { useGenerationJob } from "@/hooks/use-generation-job";
 
8
  import { Input } from "@labas/ui/components/input";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
  import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
@@ -30,7 +31,20 @@ export const Route = createFileRoute("/generate")({
30
  });
31
 
32
  function RouteComponent() {
33
- const { storedKey, hasKey } = useApiKey();
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  const {
36
  result,
@@ -90,7 +104,7 @@ function RouteComponent() {
90
  };
91
 
92
  const handleGenerate = () => {
93
- if (!hasKey || !storedKey) {
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: storedKey.baseUrl,
110
- apiKey: storedKey.apiKey,
111
- model: storedKey.modelName,
112
- maxTokens: storedKey.maxTokens ?? 16384,
113
  },
114
  });
115
  };
@@ -127,7 +141,7 @@ function RouteComponent() {
127
  </p>
128
  </section>
129
 
130
- {!hasKey && (
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={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 privateData = useQuery(trpc.privateData.queryOptions());
 
 
 
 
 
 
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)]">Soal Dikerjakan</p>
45
- <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">0</p>
 
 
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">0 jam</p>
 
 
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">-</p>
 
 
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="/settings">
87
  <Button variant="outline" className="rounded-[var(--radius-lg)] h-11 px-6 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover">
88
- <MaterialIcon name="key" className="text-xl" />
89
- <span className="ml-2">Atur API Key</span>
 
 
 
 
 
 
90
  </Button>
91
  </Link>
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
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full opacity-70">
113
- <CardContent className="p-6">
114
- <div className="flex items-center gap-3 mb-4">
115
- <div className="h-10 w-10 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
116
- <MaterialIcon name="database" className="text-xl text-[var(--warm-silver)]" />
 
 
 
117
  </div>
118
- <h3 className="font-headline text-lg font-bold text-[var(--warm-silver)]">Bank Soal</h3>
119
- </div>
120
- <p className="text-sm text-[var(--warm-silver)]">
121
- Simpan, publikasikan, dan gunakan ulang soal. Segera hadir.
122
- </p>
123
- </CardContent>
124
- </Card>
125
 
126
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full opacity-70">
127
- <CardContent className="p-6">
128
- <div className="flex items-center gap-3 mb-4">
129
- <div className="h-10 w-10 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
130
- <MaterialIcon name="analytics" className="text-xl text-[var(--warm-silver)]" />
 
 
 
131
  </div>
132
- <h3 className="font-headline text-lg font-bold text-[var(--warm-silver)]">Analytics</h3>
133
- </div>
134
- <p className="text-sm text-[var(--warm-silver)]">
135
- Evaluasi per-section, identifikasi kelemahan. Segera hadir.
136
- </p>
137
- </CardContent>
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 { useQuery } from "@tanstack/react-query";
 
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
@@ -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
- <span className="flex items-center gap-1">
 
 
 
 
93
  <MaterialIcon name="person" className="text-sm" />
94
  {pkg.creatorName ?? "Anonim"}
95
- </span>
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 { useState } from "react";
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 navigate = useNavigate();
40
- const [search, setSearch] = useState("");
41
- const [examType, setExamType] = useState<string>("");
42
- const [page, setPage] = useState(0);
 
 
 
 
 
 
43
  const limit = 12;
44
 
45
- const query = useQuery(
46
- trpc.package.list.queryOptions({
47
- examTypeId: examType || undefined,
48
- isPublic: true,
49
- search: search || undefined,
50
- limit,
51
- offset: page * limit,
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={search}
87
- onChange={(e) => { setSearch(e.target.value); setPage(0); }}
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) => { setExamType(v ?? ""); setPage(0); }}
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">Buat paket soal pertama Anda</p>
 
 
 
 
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
- <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">
131
- <CardContent className="p-5 flex flex-col h-full">
132
- <Link to="/package/$id" params={{ id: pkg.id }} className="block flex-1">
133
- <div className="flex items-start justify-between mb-3">
134
- <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
135
- {pkg.examTypeName}
136
- </span>
137
- {pkg.avgRating && (
138
- <div className="flex items-center gap-1 text-[var(--lemon-700)]">
139
- <MaterialIcon name="star" className="text-sm" />
140
- <span className="text-xs font-bold">{pkg.avgRating}</span>
 
 
 
 
 
 
 
 
 
 
141
  </div>
142
- )}
143
- </div>
 
 
 
 
 
144
 
145
- <h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
146
- {pkg.title}
147
- </h3>
148
 
149
- {pkg.description && (
150
- <p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
151
- {pkg.description}
152
- </p>
153
- )}
154
 
155
- <div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
156
- <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
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="timer" className="text-xs" />
168
- {pkg.estimatedDurationMin}m
169
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  )}
171
  </div>
172
- <span className="text-xs text-[var(--warm-silver)]">
173
- {pkg.usageCount}x digunakan
174
- </span>
 
 
 
 
 
 
 
175
  </div>
176
- </Link>
177
-
178
- <div className="mt-4 pt-3 border-t border-[var(--oat-border)]">
179
- <Button
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((p) => Math.max(0, p - 1))}
197
- disabled={page === 0}
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 + 1} dari {totalPages}
204
  </span>
205
  <Button
206
  variant="outline"
207
- onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
208
- disabled={page >= totalPages - 1}
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 { useApiKey } 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,188 +27,367 @@ export const Route = createFileRoute("/settings")({
27
  },
28
  });
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  function RouteComponent() {
31
- const { storedKey, isLoading, saveKey, removeKey, hasKey } = useApiKey();
 
32
 
33
- const [provider, setProvider] = useState(storedKey?.provider ?? "openai");
34
- const [baseUrl, setBaseUrl] = useState(storedKey?.baseUrl ?? "https://api.openai.com/v1");
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 (!apiKey) return;
42
  setIsSaving(true);
43
  try {
44
- await saveKey({ provider, baseUrl, apiKey, modelName, maxTokens });
45
- setApiKey("");
 
 
 
 
 
 
 
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
- <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">Pengaturan</h1>
55
- <p className="text-lg text-[var(--warm-charcoal)] mt-2">Kelola API key dan preferensi akun.</p>
 
 
 
 
 
 
 
 
 
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 gap-3">
63
- <MaterialIcon name="key" className="text-xl" />
64
- <CardTitle className="font-headline text-[var(--clay-black)]">
65
- {hasKey ? "Update API Key" : "Tambah API Key"}
66
- </CardTitle>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-5">
73
- <div className="grid grid-cols-2 gap-4">
74
- <div className="space-y-2">
75
- <Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
76
- <Select
77
- items={[
78
- { value: "openai", label: "OpenAI" },
79
- { value: "anthropic", label: "Anthropic" },
80
- { value: "google", label: "Google" },
81
- { value: "openrouter", label: "OpenRouter" },
82
- { value: "groq", label: "Groq" },
83
- { value: "custom", label: "Custom" },
84
- ]}
85
- value={provider}
86
- onValueChange={(v: string | null) => setProvider(v ?? "openai")}
87
  >
88
- <SelectTrigger id="provider">
89
- <SelectValue placeholder="Pilih provider" />
90
- </SelectTrigger>
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
- <div className="space-y-2">
104
- <Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
105
- <Input
106
- id="model"
107
- value={modelName}
108
- onChange={(e) => setModelName(e.target.value)}
109
- placeholder="gpt-4o-mini"
110
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
111
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  </div>
113
- </div>
 
 
114
 
115
- <div className="grid grid-cols-2 gap-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  <div className="space-y-2">
117
- <Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
118
  <Input
119
- id="baseUrl"
120
- value={baseUrl}
121
- onChange={(e) => setBaseUrl(e.target.value)}
122
- placeholder="https://api.openai.com/v1"
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="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
 
 
 
 
 
 
 
128
  <Input
129
- id="maxTokens"
130
- type="number"
131
- min={1}
132
- max={65536}
133
- value={maxTokens}
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
- <div className="space-y-2">
142
- <Label htmlFor="apiKey" className="text-[var(--clay-black)]">
143
- {hasKey ? "API Key Baru (kosongkan jika tidak diubah)" : "API Key"}
144
- </Label>
145
- <Input
146
- id="apiKey"
147
- type="password"
148
- value={apiKey}
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={removeKey}
167
- className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)] text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 clay-hover"
168
  >
169
- Hapus Key
170
  </Button>
171
- )}
172
- </div>
173
- </CardContent>
174
- </Card>
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)]">Status API Key</CardTitle>
181
- <CardDescription className="text-[var(--warm-charcoal)]">Informasi penyimpanan key.</CardDescription>
 
 
182
  </CardHeader>
183
- <CardContent>
184
- {isLoading ? (
185
- <p className="text-[var(--warm-charcoal)]">Memuat...</p>
186
- ) : hasKey ? (
187
- <div className="space-y-4">
188
- <div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
189
- <MaterialIcon name="check_circle" className="text-xl" />
190
- <div>
191
- <p className="font-medium text-[var(--matcha-800)]">Key Tersimpan</p>
192
- <p className="text-sm text-[var(--matcha-800)]/70">
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(65536).default(16384),
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
- if (input.mode === "agentic") {
 
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
- const result =
170
- input.mode === "agentic"
171
- ? await generateQuestionsAgentic(input, async (p) => {
172
- cancelPoll.check();
173
- const stepProgress = Math.min(
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
- countToken(token);
184
- // Update progress message with token count every ~500 chars
185
- if (approxTokens % 20 === 0) {
186
- job.updateProgress(job.progress ?? 5).catch(() => {});
187
- db
188
- .update(generationJob)
189
- .set({ progressMessage: `Generating... (~${approxTokens} tokens)` })
190
- .where(eq(generationJob.id, jobId))
191
- .catch(() => {});
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
- const dateStr = new Date().toLocaleDateString("id-ID", {
252
- day: "numeric",
253
- month: "short",
254
- year: "numeric",
255
- });
256
- const pkgTitle = `AI Generated — ${input.examType} ${input.section} — ${dateStr}`;
257
-
258
- const [pkg] = await db
259
- .insert(testPackage)
260
- .values({
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
- packageId: pkg.id,
279
- sectionTypeId: input.section,
280
- title: `${input.section} Section`,
281
- orderIndex: 0,
 
 
 
 
282
  })
283
  .returning();
284
 
285
- if (sec) {
286
- await db.insert(sectionQuestion).values(
287
- savedQuestionIds.map((qid, idx) => ({
288
- sectionId: sec.id,
289
- questionId: qid,
290
- orderIndex: idx,
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()