feat: add support section to README, implement donation modal, and enhance routing with new dashboard and not found pages. Update sitemap for improved SEO and adjust navigation links to point to the dashboard. Introduce new package card component for better package management and enhance error handling in various components.
Browse files- README.md +8 -0
- apps/server/src/index.ts +1 -1
- apps/web/public/not_found.png +3 -0
- apps/web/src/components/DonationModal.tsx +58 -0
- apps/web/src/components/ErrorFallback.tsx +1 -1
- apps/web/src/components/NotFoundPage.tsx +37 -0
- apps/web/src/components/bank/CalloutCard.tsx +3 -2
- apps/web/src/components/bank/QuestionCard.tsx +22 -6
- apps/web/src/components/bank/SoalBrowser.tsx +60 -66
- apps/web/src/components/packages/PackageCard.tsx +189 -0
- apps/web/src/components/routes/BankPage.tsx +32 -2
- apps/web/src/components/routes/HistoryPage.tsx +1 -1
- apps/web/src/components/routes/LandingPage.tsx +13 -15
- apps/web/src/components/routes/PackagesPage.tsx +42 -157
- apps/web/src/components/routes/SettingsPage.tsx +1 -1
- apps/web/src/components/sidebar.tsx +4 -4
- apps/web/src/components/sign-in-form.tsx +2 -2
- apps/web/src/hooks/use-generation-jobs.ts +2 -0
- apps/web/src/hooks/use-test-session.ts +1 -0
- apps/web/src/lib/route-shell.ts +7 -0
- apps/web/src/routeTree.gen.ts +44 -2
- apps/web/src/routes/$.tsx +8 -0
- apps/web/src/routes/__root.tsx +12 -14
- apps/web/src/routes/admin.tsx +4 -2
- apps/web/src/routes/analytics.tsx +1 -1
- apps/web/src/routes/attempt.$id.tsx +218 -190
- apps/web/src/routes/dashboard.tsx +429 -0
- apps/web/src/routes/forgot-password.tsx +2 -0
- apps/web/src/routes/{landing.lazy.tsx → index.lazy.tsx} +1 -1
- apps/web/src/routes/index.tsx +22 -390
- apps/web/src/routes/landing.tsx +4 -22
- apps/web/src/routes/leaderboard.tsx +1 -1
- apps/web/src/routes/login.tsx +2 -0
- apps/web/src/routes/me.tsx +1 -1
- apps/web/src/routes/package.$id.attempt.$attemptId.tsx +2 -0
- apps/web/src/routes/package.$id.index.tsx +94 -90
- apps/web/src/routes/package.$id.take.tsx +2 -0
- apps/web/src/routes/setup-avatar.tsx +4 -2
- apps/web/src/routes/verify-email.tsx +3 -1
README.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
<a href="#features">Features</a> ·
|
| 15 |
<a href="#supported-exams">Exams</a> ·
|
| 16 |
<a href="#project-structure">Structure</a> ·
|
|
|
|
| 17 |
<a href="#license">License</a>
|
| 18 |
</p>
|
| 19 |
|
|
@@ -277,6 +278,13 @@ Contributions are welcome! Before opening a PR:
|
|
| 277 |
|
| 278 |
For AI-related changes, see the **AI Generation Context** section in `AGENTS.md` — adding a new question format touches schemas, prompts, attempt normalization, and frontend constants.
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
## License
|
| 281 |
|
| 282 |
This project is licensed under the **[GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE)**.
|
|
|
|
| 14 |
<a href="#features">Features</a> ·
|
| 15 |
<a href="#supported-exams">Exams</a> ·
|
| 16 |
<a href="#project-structure">Structure</a> ·
|
| 17 |
+
<a href="#support">Support</a> ·
|
| 18 |
<a href="#license">License</a>
|
| 19 |
</p>
|
| 20 |
|
|
|
|
| 278 |
|
| 279 |
For AI-related changes, see the **AI Generation Context** section in `AGENTS.md` — adding a new question format touches schemas, prompts, attempt normalization, and frontend constants.
|
| 280 |
|
| 281 |
+
## Support
|
| 282 |
+
|
| 283 |
+
If you find Labas helpful and want to support its ongoing development, consider buying the creator a coffee! Your support helps cover API and hosting costs. ☕
|
| 284 |
+
|
| 285 |
+
- **[Saweria](https://saweria.co/rogasper)** (Indonesian supporters)
|
| 286 |
+
- **[Ko-fi](https://ko-fi.com/rogasper)** (International)
|
| 287 |
+
|
| 288 |
## License
|
| 289 |
|
| 290 |
This project is licensed under the **[GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE)**.
|
apps/server/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ app.use(
|
|
| 59 |
app.get("/sitemap.xml", (c) => {
|
| 60 |
const baseUrl = "https://labas.rogasper.com";
|
| 61 |
const urls = [
|
| 62 |
-
{ loc: `${baseUrl}/
|
| 63 |
];
|
| 64 |
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
| 65 |
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
|
|
| 59 |
app.get("/sitemap.xml", (c) => {
|
| 60 |
const baseUrl = "https://labas.rogasper.com";
|
| 61 |
const urls = [
|
| 62 |
+
{ loc: `${baseUrl}/`, changefreq: "weekly", priority: "1.0" },
|
| 63 |
];
|
| 64 |
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
| 65 |
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
apps/web/public/not_found.png
ADDED
|
Git LFS Details
|
apps/web/src/components/DonationModal.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@labas/ui/components/dialog";
|
| 2 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
|
| 4 |
+
export function DonationModal({
|
| 5 |
+
isOpen,
|
| 6 |
+
onOpenChange,
|
| 7 |
+
triggerAction
|
| 8 |
+
}: {
|
| 9 |
+
isOpen: boolean;
|
| 10 |
+
onOpenChange: (open: boolean) => void;
|
| 11 |
+
triggerAction: "exam" | "generate" | null;
|
| 12 |
+
}) {
|
| 13 |
+
return (
|
| 14 |
+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
| 15 |
+
<DialogContent className="sm:max-w-[425px] p-6 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
|
| 16 |
+
<DialogHeader>
|
| 17 |
+
<div className="mx-auto w-16 h-16 bg-[var(--pomegranate-400)]/10 rounded-full flex items-center justify-center mb-2">
|
| 18 |
+
<MaterialIcon name="favorite" className="text-4xl text-[var(--pomegranate-400)]" />
|
| 19 |
+
</div>
|
| 20 |
+
<DialogTitle className="text-center text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 21 |
+
Dukung Labas!
|
| 22 |
+
</DialogTitle>
|
| 23 |
+
<DialogDescription className="text-center text-base text-[var(--warm-charcoal)] pt-2 leading-relaxed">
|
| 24 |
+
Selamat, kamu baru saja menyelesaikan {triggerAction === "exam" ? "latihan soal" : "generate soal AI"}! 🎉
|
| 25 |
+
<br/><br/>
|
| 26 |
+
Jika Labas membantumu, dukung kami untuk menutupi biaya server & mengembangkan fitur baru dengan mentraktir kopi. ☕
|
| 27 |
+
</DialogDescription>
|
| 28 |
+
</DialogHeader>
|
| 29 |
+
<div className="flex flex-col gap-3 mt-6">
|
| 30 |
+
<a
|
| 31 |
+
href="https://saweria.co/rogasper"
|
| 32 |
+
target="_blank"
|
| 33 |
+
rel="noopener noreferrer"
|
| 34 |
+
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-[var(--radius-lg)] font-bold transition-all bg-[#F3B63A] text-black hover:brightness-95 clay-shadow clay-hover"
|
| 35 |
+
>
|
| 36 |
+
<MaterialIcon name="favorite" className="text-xl" />
|
| 37 |
+
Support via Saweria
|
| 38 |
+
</a>
|
| 39 |
+
<a
|
| 40 |
+
href="https://ko-fi.com/rogasper"
|
| 41 |
+
target="_blank"
|
| 42 |
+
rel="noopener noreferrer"
|
| 43 |
+
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-[var(--radius-lg)] font-bold transition-all bg-[#13C3FF] text-white hover:brightness-95 clay-shadow clay-hover"
|
| 44 |
+
>
|
| 45 |
+
<MaterialIcon name="local_cafe" className="text-xl" />
|
| 46 |
+
Support via Ko-fi
|
| 47 |
+
</a>
|
| 48 |
+
<button
|
| 49 |
+
onClick={() => onOpenChange(false)}
|
| 50 |
+
className="mt-3 text-sm text-[var(--warm-silver)] hover:text-[var(--clay-black)] font-semibold transition-colors"
|
| 51 |
+
>
|
| 52 |
+
Mungkin Nanti Saja
|
| 53 |
+
</button>
|
| 54 |
+
</div>
|
| 55 |
+
</DialogContent>
|
| 56 |
+
</Dialog>
|
| 57 |
+
);
|
| 58 |
+
}
|
apps/web/src/components/ErrorFallback.tsx
CHANGED
|
@@ -25,7 +25,7 @@ export function ErrorFallback({ error, reset }: { error: Error; reset?: () => vo
|
|
| 25 |
</Button>
|
| 26 |
)}
|
| 27 |
<Button
|
| 28 |
-
onClick={() => window.location.href = "/"}
|
| 29 |
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 30 |
>
|
| 31 |
Ke Dashboard
|
|
|
|
| 25 |
</Button>
|
| 26 |
)}
|
| 27 |
<Button
|
| 28 |
+
onClick={() => { window.location.href = "/dashboard"; }}
|
| 29 |
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 30 |
>
|
| 31 |
Ke Dashboard
|
apps/web/src/components/NotFoundPage.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Link } from "@tanstack/react-router";
|
| 2 |
+
import { Button } from "@labas/ui/components/button";
|
| 3 |
+
|
| 4 |
+
export function NotFoundPage() {
|
| 5 |
+
return (
|
| 6 |
+
<div className="min-h-screen flex flex-col items-center justify-center bg-[var(--warm-cream)] px-6 py-12">
|
| 7 |
+
<img
|
| 8 |
+
src="/not_found.png"
|
| 9 |
+
alt=""
|
| 10 |
+
className="mb-8 w-full max-w-sm h-auto object-contain"
|
| 11 |
+
/>
|
| 12 |
+
<div className="max-w-md text-center">
|
| 13 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-3">
|
| 14 |
+
Halaman Tidak Ditemukan
|
| 15 |
+
</h1>
|
| 16 |
+
<p className="text-base text-[var(--warm-charcoal)] mb-8">
|
| 17 |
+
Halaman yang kamu cari tidak ada atau sudah dipindahkan.
|
| 18 |
+
</p>
|
| 19 |
+
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
| 20 |
+
<Button
|
| 21 |
+
render={<Link to="/" />}
|
| 22 |
+
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-12 px-8"
|
| 23 |
+
>
|
| 24 |
+
Ke Beranda
|
| 25 |
+
</Button>
|
| 26 |
+
<Button
|
| 27 |
+
render={<Link to="/login" />}
|
| 28 |
+
variant="outline"
|
| 29 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] h-12 px-8 bg-[var(--pure-white)]"
|
| 30 |
+
>
|
| 31 |
+
Masuk
|
| 32 |
+
</Button>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
);
|
| 37 |
+
}
|
apps/web/src/components/bank/CalloutCard.tsx
CHANGED
|
@@ -3,11 +3,12 @@ import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
| 3 |
|
| 4 |
interface CalloutCardProps {
|
| 5 |
privateCount: number;
|
|
|
|
| 6 |
onPublishAll: () => void;
|
| 7 |
onDismiss: () => void;
|
| 8 |
}
|
| 9 |
|
| 10 |
-
export function CalloutCard({ privateCount, onPublishAll, onDismiss }: CalloutCardProps) {
|
| 11 |
if (privateCount <= 0) return null;
|
| 12 |
|
| 13 |
return (
|
|
@@ -20,7 +21,7 @@ export function CalloutCard({ privateCount, onPublishAll, onDismiss }: CalloutCa
|
|
| 20 |
</div>
|
| 21 |
<div className="flex-1 min-w-0">
|
| 22 |
<p className="text-sm font-semibold text-[var(--clay-black)]">
|
| 23 |
-
Kamu punya <span className="text-[var(--slushie-800)]">{privateCount}
|
| 24 |
</p>
|
| 25 |
<p className="text-xs text-[var(--warm-charcoal)] mt-1 leading-relaxed">
|
| 26 |
Soal privat hanya bisa kamu lihat. Jadikan publik agar soal bisa muncul di daftar paket soal dan diakses pengguna lain.
|
|
|
|
| 3 |
|
| 4 |
interface CalloutCardProps {
|
| 5 |
privateCount: number;
|
| 6 |
+
label?: string;
|
| 7 |
onPublishAll: () => void;
|
| 8 |
onDismiss: () => void;
|
| 9 |
}
|
| 10 |
|
| 11 |
+
export function CalloutCard({ privateCount, label = "soal", onPublishAll, onDismiss }: CalloutCardProps) {
|
| 12 |
if (privateCount <= 0) return null;
|
| 13 |
|
| 14 |
return (
|
|
|
|
| 21 |
</div>
|
| 22 |
<div className="flex-1 min-w-0">
|
| 23 |
<p className="text-sm font-semibold text-[var(--clay-black)]">
|
| 24 |
+
Kamu punya <span className="text-[var(--slushie-800)]">{privateCount} {label} privat</span>
|
| 25 |
</p>
|
| 26 |
<p className="text-xs text-[var(--warm-charcoal)] mt-1 leading-relaxed">
|
| 27 |
Soal privat hanya bisa kamu lihat. Jadikan publik agar soal bisa muncul di daftar paket soal dan diakses pengguna lain.
|
apps/web/src/components/bank/QuestionCard.tsx
CHANGED
|
@@ -117,15 +117,31 @@ export function QuestionCard({
|
|
| 117 |
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
| 118 |
<button
|
| 119 |
onClick={onTogglePublic}
|
| 120 |
-
|
| 121 |
-
className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-
|
| 122 |
q.isPublic
|
| 123 |
-
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 124 |
-
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 125 |
}`}
|
| 126 |
>
|
| 127 |
-
{
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</button>
|
| 130 |
<button
|
| 131 |
onClick={onDelete}
|
|
|
|
| 117 |
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
| 118 |
<button
|
| 119 |
onClick={onTogglePublic}
|
| 120 |
+
aria-label={q.isPublic ? "Jadikan privat" : "Jadikan publik"}
|
| 121 |
+
className={`group text-xs font-semibold px-3 py-1.5 rounded-full transition-all cursor-pointer flex items-center gap-1 ${
|
| 122 |
q.isPublic
|
| 123 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]"
|
| 124 |
+
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
|
| 125 |
}`}
|
| 126 |
>
|
| 127 |
+
{/* Default state icon */}
|
| 128 |
+
<MaterialIcon
|
| 129 |
+
name={q.isPublic ? "public" : "lock"}
|
| 130 |
+
className="text-xs group-hover:hidden"
|
| 131 |
+
/>
|
| 132 |
+
{/* Hover state icon (shows intent) */}
|
| 133 |
+
<MaterialIcon
|
| 134 |
+
name={q.isPublic ? "lock" : "public"}
|
| 135 |
+
className="text-xs hidden group-hover:inline"
|
| 136 |
+
/>
|
| 137 |
+
{/* Default label */}
|
| 138 |
+
<span className="group-hover:hidden">
|
| 139 |
+
{q.isPublic ? "Publik" : "Privat"}
|
| 140 |
+
</span>
|
| 141 |
+
{/* Hover label (shows what will happen) */}
|
| 142 |
+
<span className="hidden group-hover:inline">
|
| 143 |
+
{q.isPublic ? "Jadikan Privat" : "Jadikan Publik"}
|
| 144 |
+
</span>
|
| 145 |
</button>
|
| 146 |
<button
|
| 147 |
onClick={onDelete}
|
apps/web/src/components/bank/SoalBrowser.tsx
CHANGED
|
@@ -24,7 +24,8 @@ interface SoalBrowserProps {
|
|
| 24 |
onLoadMore: () => void;
|
| 25 |
onClearFilters: () => void;
|
| 26 |
onBulkPublish?: (ids: string[]) => void;
|
| 27 |
-
onPublishAllPrivate?: (
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
export function SoalBrowser({
|
|
@@ -46,6 +47,7 @@ export function SoalBrowser({
|
|
| 46 |
onClearFilters,
|
| 47 |
onBulkPublish,
|
| 48 |
onPublishAllPrivate,
|
|
|
|
| 49 |
}: SoalBrowserProps) {
|
| 50 |
const sentinelRef = useRef<HTMLDivElement>(null);
|
| 51 |
const isLocked = (q: Question) => !!lockedExamType && q.examTypeId !== lockedExamType;
|
|
@@ -64,19 +66,16 @@ export function SoalBrowser({
|
|
| 64 |
? localStorage.getItem("labas-bank-private-callout-dismissed") === "true"
|
| 65 |
: false;
|
| 66 |
|
| 67 |
-
const
|
| 68 |
-
(q) => !q.isPublic && q.creatorUserId === userId,
|
| 69 |
-
);
|
| 70 |
|
| 71 |
const handleDismissCallout = () => {
|
| 72 |
localStorage.setItem("labas-bank-private-callout-dismissed", "true");
|
| 73 |
-
// Force re-render by toggling a state
|
| 74 |
setBulkMode((prev) => prev);
|
| 75 |
};
|
| 76 |
|
| 77 |
const handlePublishAllPrivate = () => {
|
| 78 |
-
if (onPublishAllPrivate &&
|
| 79 |
-
onPublishAllPrivate(
|
| 80 |
}
|
| 81 |
};
|
| 82 |
|
|
@@ -148,74 +147,69 @@ export function SoalBrowser({
|
|
| 148 |
// ── Bulk mode toolbar ──
|
| 149 |
const renderBulkToolbar = () => {
|
| 150 |
if (!onBulkPublish || tab !== "mine") return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
return (
|
| 152 |
<div className="flex items-center justify-between mb-4 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)] border-2 border-[var(--oat-border)]">
|
| 153 |
-
|
| 154 |
-
<
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
</button>
|
| 185 |
-
</div>
|
| 186 |
-
</>
|
| 187 |
-
) : (
|
| 188 |
-
<>
|
| 189 |
-
<span className="text-xs font-semibold text-[var(--warm-charcoal)]">
|
| 190 |
-
{questions.length} soal
|
| 191 |
-
</span>
|
| 192 |
-
<button
|
| 193 |
-
onClick={() => setBulkMode(true)}
|
| 194 |
-
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer flex items-center gap-1"
|
| 195 |
-
>
|
| 196 |
-
<MaterialIcon name="select_all" className="text-sm" />
|
| 197 |
-
Pilih Banyak
|
| 198 |
-
</button>
|
| 199 |
-
</>
|
| 200 |
-
)}
|
| 201 |
</div>
|
| 202 |
);
|
| 203 |
};
|
| 204 |
|
| 205 |
return (
|
| 206 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 208 |
-
{renderBulkToolbar()}
|
| 209 |
-
<div className="md:col-span-2" />
|
| 210 |
-
|
| 211 |
-
{tab === "mine" && privateQuestions.length > 0 && !calloutDismissed && (
|
| 212 |
-
<CalloutCard
|
| 213 |
-
privateCount={privateQuestions.length}
|
| 214 |
-
onPublishAll={handlePublishAllPrivate}
|
| 215 |
-
onDismiss={handleDismissCallout}
|
| 216 |
-
/>
|
| 217 |
-
)}
|
| 218 |
-
|
| 219 |
{questions[0] && (
|
| 220 |
<div className="md:col-span-2">
|
| 221 |
<QuestionCard
|
|
|
|
| 24 |
onLoadMore: () => void;
|
| 25 |
onClearFilters: () => void;
|
| 26 |
onBulkPublish?: (ids: string[]) => void;
|
| 27 |
+
onPublishAllPrivate?: () => void;
|
| 28 |
+
totalPrivateCount?: number;
|
| 29 |
}
|
| 30 |
|
| 31 |
export function SoalBrowser({
|
|
|
|
| 47 |
onClearFilters,
|
| 48 |
onBulkPublish,
|
| 49 |
onPublishAllPrivate,
|
| 50 |
+
totalPrivateCount,
|
| 51 |
}: SoalBrowserProps) {
|
| 52 |
const sentinelRef = useRef<HTMLDivElement>(null);
|
| 53 |
const isLocked = (q: Question) => !!lockedExamType && q.examTypeId !== lockedExamType;
|
|
|
|
| 66 |
? localStorage.getItem("labas-bank-private-callout-dismissed") === "true"
|
| 67 |
: false;
|
| 68 |
|
| 69 |
+
const privateCount = totalPrivateCount ?? 0;
|
|
|
|
|
|
|
| 70 |
|
| 71 |
const handleDismissCallout = () => {
|
| 72 |
localStorage.setItem("labas-bank-private-callout-dismissed", "true");
|
|
|
|
| 73 |
setBulkMode((prev) => prev);
|
| 74 |
};
|
| 75 |
|
| 76 |
const handlePublishAllPrivate = () => {
|
| 77 |
+
if (onPublishAllPrivate && privateCount > 0) {
|
| 78 |
+
onPublishAllPrivate();
|
| 79 |
}
|
| 80 |
};
|
| 81 |
|
|
|
|
| 147 |
// ── Bulk mode toolbar ──
|
| 148 |
const renderBulkToolbar = () => {
|
| 149 |
if (!onBulkPublish || tab !== "mine") return null;
|
| 150 |
+
if (!bulkMode) {
|
| 151 |
+
return (
|
| 152 |
+
<button
|
| 153 |
+
onClick={() => setBulkMode(true)}
|
| 154 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer flex items-center gap-1 mb-2"
|
| 155 |
+
>
|
| 156 |
+
<MaterialIcon name="select_all" className="text-sm" />
|
| 157 |
+
Pilih Banyak
|
| 158 |
+
</button>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
return (
|
| 162 |
<div className="flex items-center justify-between mb-4 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)] border-2 border-[var(--oat-border)]">
|
| 163 |
+
<div className="flex items-center gap-3">
|
| 164 |
+
<button
|
| 165 |
+
onClick={clearSelection}
|
| 166 |
+
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
|
| 167 |
+
>
|
| 168 |
+
Batalkan ({selectedIds.size})
|
| 169 |
+
</button>
|
| 170 |
+
<button
|
| 171 |
+
onClick={selectAll}
|
| 172 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer"
|
| 173 |
+
>
|
| 174 |
+
Pilih Semua
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
<div className="flex items-center gap-2">
|
| 178 |
+
<Button
|
| 179 |
+
size="sm"
|
| 180 |
+
disabled={selectedIds.size === 0}
|
| 181 |
+
onClick={handleBulkPublish}
|
| 182 |
+
className="rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] text-xs cursor-pointer"
|
| 183 |
+
>
|
| 184 |
+
<MaterialIcon name="public" className="text-xs mr-1" />
|
| 185 |
+
Jadikan Publik ({selectedIds.size})
|
| 186 |
+
</Button>
|
| 187 |
+
<button
|
| 188 |
+
onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
|
| 189 |
+
className="text-xs text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer font-semibold ml-2"
|
| 190 |
+
>
|
| 191 |
+
Selesai
|
| 192 |
+
</button>
|
| 193 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</div>
|
| 195 |
);
|
| 196 |
};
|
| 197 |
|
| 198 |
return (
|
| 199 |
<>
|
| 200 |
+
{(onBulkPublish && tab === "mine") || (tab === "mine" && privateCount > 0 && !calloutDismissed) ? (
|
| 201 |
+
<div className="flex flex-col gap-3 mb-2">
|
| 202 |
+
{renderBulkToolbar()}
|
| 203 |
+
{tab === "mine" && privateCount > 0 && !calloutDismissed && (
|
| 204 |
+
<CalloutCard
|
| 205 |
+
privateCount={privateCount}
|
| 206 |
+
onPublishAll={handlePublishAllPrivate}
|
| 207 |
+
onDismiss={handleDismissCallout}
|
| 208 |
+
/>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
) : null}
|
| 212 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
{questions[0] && (
|
| 214 |
<div className="md:col-span-2">
|
| 215 |
<QuestionCard
|
apps/web/src/components/packages/PackageCard.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Link, useNavigate } from "@tanstack/react-router";
|
| 2 |
+
import { Button } from "@labas/ui/components/button";
|
| 3 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
+
import { toast } from "sonner";
|
| 6 |
+
|
| 7 |
+
export interface PackageItem {
|
| 8 |
+
id: string;
|
| 9 |
+
title: string;
|
| 10 |
+
description?: string | null;
|
| 11 |
+
examTypeName?: string | null;
|
| 12 |
+
isPublic: boolean;
|
| 13 |
+
creatorUserId?: string | null;
|
| 14 |
+
totalQuestions: number;
|
| 15 |
+
totalSections: number;
|
| 16 |
+
estimatedDurationMin?: number | null;
|
| 17 |
+
usageCount: number;
|
| 18 |
+
avgRating?: number | null;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface PackageCardProps {
|
| 22 |
+
pkg: PackageItem;
|
| 23 |
+
isOwner: boolean;
|
| 24 |
+
bulkMode: boolean;
|
| 25 |
+
isSelected: boolean;
|
| 26 |
+
isTogglePending?: boolean;
|
| 27 |
+
onToggleSelect: () => void;
|
| 28 |
+
onTogglePublic: () => void;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function PackageCard({
|
| 32 |
+
pkg,
|
| 33 |
+
isOwner,
|
| 34 |
+
bulkMode,
|
| 35 |
+
isSelected,
|
| 36 |
+
isTogglePending = false,
|
| 37 |
+
onToggleSelect,
|
| 38 |
+
onTogglePublic,
|
| 39 |
+
}: PackageCardProps) {
|
| 40 |
+
const navigate = useNavigate();
|
| 41 |
+
const isPrivate = isOwner && !pkg.isPublic;
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<Card
|
| 45 |
+
className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
|
| 46 |
+
bulkMode && isSelected
|
| 47 |
+
? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
|
| 48 |
+
: isPrivate && !bulkMode
|
| 49 |
+
? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
|
| 50 |
+
: "border-[var(--oat-border)]"
|
| 51 |
+
}`}
|
| 52 |
+
>
|
| 53 |
+
<CardContent className="p-5 flex flex-col h-full">
|
| 54 |
+
{/* Clickable content area */}
|
| 55 |
+
<div
|
| 56 |
+
className="block flex-1 cursor-pointer"
|
| 57 |
+
onClick={bulkMode ? onToggleSelect : undefined}
|
| 58 |
+
>
|
| 59 |
+
<Link
|
| 60 |
+
to="/package/$id"
|
| 61 |
+
params={{ id: pkg.id }}
|
| 62 |
+
className={bulkMode ? "pointer-events-none" : ""}
|
| 63 |
+
>
|
| 64 |
+
<div className="flex items-start justify-between mb-3">
|
| 65 |
+
<div className="flex gap-2 flex-wrap">
|
| 66 |
+
{bulkMode && (
|
| 67 |
+
<span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 68 |
+
isSelected
|
| 69 |
+
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 70 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 71 |
+
}`}>
|
| 72 |
+
<MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
|
| 73 |
+
{isSelected ? "Terpilih" : "Pilih"}
|
| 74 |
+
</span>
|
| 75 |
+
)}
|
| 76 |
+
{pkg.examTypeName && (
|
| 77 |
+
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold leading-none whitespace-nowrap">
|
| 78 |
+
{pkg.examTypeName}
|
| 79 |
+
</span>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
{pkg.avgRating && (
|
| 83 |
+
<div className="flex items-center gap-1 text-[var(--lemon-700)] shrink-0">
|
| 84 |
+
<MaterialIcon name="star" className="text-sm" />
|
| 85 |
+
<span className="text-xs font-bold">{pkg.avgRating}</span>
|
| 86 |
+
</div>
|
| 87 |
+
)}
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
|
| 91 |
+
{pkg.title}
|
| 92 |
+
</h3>
|
| 93 |
+
|
| 94 |
+
{pkg.description && (
|
| 95 |
+
<p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
|
| 96 |
+
{pkg.description}
|
| 97 |
+
</p>
|
| 98 |
+
)}
|
| 99 |
+
</Link>
|
| 100 |
+
|
| 101 |
+
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 102 |
+
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
| 103 |
+
<span className="flex items-center gap-1">
|
| 104 |
+
<MaterialIcon name="quiz" className="text-xs" />
|
| 105 |
+
{pkg.totalQuestions}
|
| 106 |
+
</span>
|
| 107 |
+
<span className="flex items-center gap-1">
|
| 108 |
+
<MaterialIcon name="folder" className="text-xs" />
|
| 109 |
+
{pkg.totalSections}
|
| 110 |
+
</span>
|
| 111 |
+
{pkg.estimatedDurationMin && (
|
| 112 |
+
<span className="flex items-center gap-1">
|
| 113 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 114 |
+
{pkg.estimatedDurationMin}m
|
| 115 |
+
</span>
|
| 116 |
+
)}
|
| 117 |
+
</div>
|
| 118 |
+
<span className="text-xs text-[var(--warm-silver)]">
|
| 119 |
+
{pkg.usageCount}x digunakan
|
| 120 |
+
</span>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{/* Owner actions */}
|
| 125 |
+
{isOwner && !bulkMode && (
|
| 126 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 127 |
+
<button
|
| 128 |
+
onClick={onTogglePublic}
|
| 129 |
+
disabled={isTogglePending}
|
| 130 |
+
aria-label={pkg.isPublic ? "Jadikan privat" : "Jadikan publik"}
|
| 131 |
+
className={`group text-xs font-semibold px-3 py-1.5 rounded-full transition-all cursor-pointer flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed ${
|
| 132 |
+
pkg.isPublic
|
| 133 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]"
|
| 134 |
+
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
|
| 135 |
+
}`}
|
| 136 |
+
>
|
| 137 |
+
{/* Default icon */}
|
| 138 |
+
<MaterialIcon
|
| 139 |
+
name={pkg.isPublic ? "public" : "lock"}
|
| 140 |
+
className="text-xs group-hover:hidden"
|
| 141 |
+
/>
|
| 142 |
+
{/* Hover icon (shows intent) */}
|
| 143 |
+
<MaterialIcon
|
| 144 |
+
name={pkg.isPublic ? "lock" : "public"}
|
| 145 |
+
className="text-xs hidden group-hover:inline"
|
| 146 |
+
/>
|
| 147 |
+
{/* Default label */}
|
| 148 |
+
<span className="group-hover:hidden">
|
| 149 |
+
{pkg.isPublic ? "Publik" : "Privat"}
|
| 150 |
+
</span>
|
| 151 |
+
{/* Hover label */}
|
| 152 |
+
<span className="hidden group-hover:inline">
|
| 153 |
+
{pkg.isPublic ? "Jadikan Privat" : "Jadikan Publik"}
|
| 154 |
+
</span>
|
| 155 |
+
</button>
|
| 156 |
+
|
| 157 |
+
{pkg.isPublic && (
|
| 158 |
+
<button
|
| 159 |
+
onClick={() => {
|
| 160 |
+
const url = `${window.location.origin}/package/${pkg.id}`;
|
| 161 |
+
navigator.clipboard.writeText(url);
|
| 162 |
+
toast.success("Link paket disalin!");
|
| 163 |
+
}}
|
| 164 |
+
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 cursor-pointer"
|
| 165 |
+
>
|
| 166 |
+
<MaterialIcon name="share" className="text-xs" />
|
| 167 |
+
Bagikan
|
| 168 |
+
</button>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
)}
|
| 172 |
+
|
| 173 |
+
{/* CTA */}
|
| 174 |
+
{!bulkMode && (
|
| 175 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
|
| 176 |
+
<Button
|
| 177 |
+
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 178 |
+
onClick={() => navigate({ to: "/package/$id/take", params: { id: pkg.id } })}
|
| 179 |
+
size="xl"
|
| 180 |
+
>
|
| 181 |
+
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 182 |
+
Mulai Latihan
|
| 183 |
+
</Button>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
</CardContent>
|
| 187 |
+
</Card>
|
| 188 |
+
);
|
| 189 |
+
}
|
apps/web/src/components/routes/BankPage.tsx
CHANGED
|
@@ -89,6 +89,14 @@ export function BankComponent() {
|
|
| 89 |
? { isPublic: visibilityFilter === "public" }
|
| 90 |
: {};
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
const questionQuery = useQuery(
|
| 93 |
trpc.question.list.queryOptions(
|
| 94 |
{
|
|
@@ -163,7 +171,14 @@ export function BankComponent() {
|
|
| 163 |
const bulkPublish = useMutation(
|
| 164 |
trpc.question.bulkPublish.mutationOptions({
|
| 165 |
onSuccess: (data) => {
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
if (data.skipped > 0) {
|
| 168 |
toast.success(
|
| 169 |
`${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
|
|
@@ -179,6 +194,20 @@ export function BankComponent() {
|
|
| 179 |
}),
|
| 180 |
);
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
// ── Navigation helpers ──
|
| 183 |
const setMode = (newMode: Mode) => {
|
| 184 |
navigate({
|
|
@@ -480,7 +509,8 @@ export function BankComponent() {
|
|
| 480 |
}}
|
| 481 |
onClearFilters={clearFilters}
|
| 482 |
onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
|
| 483 |
-
onPublishAllPrivate={
|
|
|
|
| 484 |
/>
|
| 485 |
) : (
|
| 486 |
<SectionBrowser
|
|
|
|
| 89 |
? { isPublic: visibilityFilter === "public" }
|
| 90 |
: {};
|
| 91 |
|
| 92 |
+
const privateCountQuery = useQuery(
|
| 93 |
+
trpc.question.list.queryOptions(
|
| 94 |
+
{ creatorUserId: userId ?? "", isPublic: false, limit: 1 },
|
| 95 |
+
{ enabled: !!userId && mode === "soal" },
|
| 96 |
+
),
|
| 97 |
+
);
|
| 98 |
+
const totalPrivateCount = privateCountQuery.data?.total ?? 0;
|
| 99 |
+
|
| 100 |
const questionQuery = useQuery(
|
| 101 |
trpc.question.list.queryOptions(
|
| 102 |
{
|
|
|
|
| 171 |
const bulkPublish = useMutation(
|
| 172 |
trpc.question.bulkPublish.mutationOptions({
|
| 173 |
onSuccess: (data) => {
|
| 174 |
+
// Reset accumulated infinite-scroll state so refetch shows fresh data
|
| 175 |
+
setAllQuestions([]);
|
| 176 |
+
if (offset !== 0) {
|
| 177 |
+
setOffset(0);
|
| 178 |
+
} else {
|
| 179 |
+
questionQuery.refetch();
|
| 180 |
+
}
|
| 181 |
+
privateCountQuery.refetch();
|
| 182 |
if (data.skipped > 0) {
|
| 183 |
toast.success(
|
| 184 |
`${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
|
|
|
|
| 194 |
}),
|
| 195 |
);
|
| 196 |
|
| 197 |
+
const handlePublishAllPrivate = async () => {
|
| 198 |
+
if (!userId || totalPrivateCount === 0) return;
|
| 199 |
+
const allPrivate = await queryClient.fetchQuery(
|
| 200 |
+
trpc.question.list.queryOptions({
|
| 201 |
+
creatorUserId: userId,
|
| 202 |
+
isPublic: false,
|
| 203 |
+
limit: totalPrivateCount,
|
| 204 |
+
offset: 0,
|
| 205 |
+
}),
|
| 206 |
+
);
|
| 207 |
+
const ids = (allPrivate.questions ?? []).map((q) => q.id);
|
| 208 |
+
if (ids.length > 0) bulkPublish.mutate({ ids });
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
// ── Navigation helpers ──
|
| 212 |
const setMode = (newMode: Mode) => {
|
| 213 |
navigate({
|
|
|
|
| 509 |
}}
|
| 510 |
onClearFilters={clearFilters}
|
| 511 |
onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
|
| 512 |
+
onPublishAllPrivate={handlePublishAllPrivate}
|
| 513 |
+
totalPrivateCount={totalPrivateCount}
|
| 514 |
/>
|
| 515 |
) : (
|
| 516 |
<SectionBrowser
|
apps/web/src/components/routes/HistoryPage.tsx
CHANGED
|
@@ -118,7 +118,7 @@ export function HistoryComponent() {
|
|
| 118 |
{/* Header */}
|
| 119 |
<div className="mb-8">
|
| 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">
|
| 122 |
Beranda
|
| 123 |
</Link>
|
| 124 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
|
|
|
| 118 |
{/* Header */}
|
| 119 |
<div className="mb-8">
|
| 120 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 121 |
+
<Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
|
| 122 |
Beranda
|
| 123 |
</Link>
|
| 124 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
apps/web/src/components/routes/LandingPage.tsx
CHANGED
|
@@ -1,22 +1,8 @@
|
|
| 1 |
-
import {
|
| 2 |
import { Button } from "@labas/ui/components/button";
|
| 3 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { useState } from "react";
|
| 6 |
-
import { authClient } from "@/lib/auth-client";
|
| 7 |
-
|
| 8 |
-
export const Route = createFileRoute("/landing")({
|
| 9 |
-
beforeLoad: async () => {
|
| 10 |
-
try {
|
| 11 |
-
const session = await authClient.getSession();
|
| 12 |
-
if (session?.data) {
|
| 13 |
-
throw redirect({ to: "/", replace: true });
|
| 14 |
-
}
|
| 15 |
-
} catch (e) {
|
| 16 |
-
if (e instanceof Response) throw e;
|
| 17 |
-
}
|
| 18 |
-
},
|
| 19 |
-
});
|
| 20 |
|
| 21 |
function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
|
| 22 |
const [isOpen, setIsOpen] = useState(false);
|
|
@@ -60,6 +46,10 @@ export function LandingPage() {
|
|
| 60 |
<span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
|
| 61 |
</div>
|
| 62 |
<div className="flex items-center gap-2 sm:gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<a href="https://github.com/rogasper/labas-bahasa" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors p-2 hidden sm:flex items-center gap-2" aria-label="GitHub Repository">
|
| 64 |
<svg viewBox="0 0 24 24" className="w-6 h-6 fill-current" aria-hidden="true">
|
| 65 |
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
|
@@ -497,6 +487,14 @@ export function LandingPage() {
|
|
| 497 |
<MaterialIcon name="code" />
|
| 498 |
<span className="font-semibold">GitHub</span>
|
| 499 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
<a href="https://rogasper.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--matcha-700)] transition-colors">
|
| 501 |
<MaterialIcon name="language" />
|
| 502 |
<span className="font-semibold">rogasper.com</span>
|
|
|
|
| 1 |
+
import { Link } from "@tanstack/react-router";
|
| 2 |
import { Button } from "@labas/ui/components/button";
|
| 3 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { useState } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
|
| 8 |
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
| 46 |
<span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
|
| 47 |
</div>
|
| 48 |
<div className="flex items-center gap-2 sm:gap-4">
|
| 49 |
+
<a href="https://saweria.co/rogasper" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors p-2 hidden md:flex items-center gap-2" aria-label="Support via Saweria">
|
| 50 |
+
<MaterialIcon name="favorite" className="text-xl" />
|
| 51 |
+
<span className="font-semibold text-base">Support</span>
|
| 52 |
+
</a>
|
| 53 |
<a href="https://github.com/rogasper/labas-bahasa" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors p-2 hidden sm:flex items-center gap-2" aria-label="GitHub Repository">
|
| 54 |
<svg viewBox="0 0 24 24" className="w-6 h-6 fill-current" aria-hidden="true">
|
| 55 |
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
|
|
|
| 487 |
<MaterialIcon name="code" />
|
| 488 |
<span className="font-semibold">GitHub</span>
|
| 489 |
</a>
|
| 490 |
+
<a href="https://saweria.co/rogasper" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--pomegranate-400)] transition-colors">
|
| 491 |
+
<MaterialIcon name="favorite" />
|
| 492 |
+
<span className="font-semibold">Saweria</span>
|
| 493 |
+
</a>
|
| 494 |
+
<a href="https://ko-fi.com/rogasper" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--pomegranate-400)] transition-colors">
|
| 495 |
+
<MaterialIcon name="local_cafe" />
|
| 496 |
+
<span className="font-semibold">Ko-fi</span>
|
| 497 |
+
</a>
|
| 498 |
<a href="https://rogasper.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--matcha-700)] transition-colors">
|
| 499 |
<MaterialIcon name="language" />
|
| 500 |
<span className="font-semibold">rogasper.com</span>
|
apps/web/src/components/routes/PackagesPage.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
import { useState, useEffect, useCallback } from "react";
|
| 2 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
-
import { createFileRoute, redirect, Link
|
| 4 |
import { z } from "zod";
|
| 5 |
import { authClient } from "@/lib/auth-client";
|
| 6 |
-
import { trpc } from "@/utils/trpc";
|
| 7 |
import { Input } from "@labas/ui/components/input";
|
| 8 |
import { Button } from "@labas/ui/components/button";
|
| 9 |
-
import { Card
|
| 10 |
import {
|
| 11 |
Tabs,
|
| 12 |
TabsList,
|
|
@@ -23,6 +23,7 @@ import {
|
|
| 23 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 24 |
import { GettingStartedCard } from "@/components/GettingStartedCard";
|
| 25 |
import { CalloutCard } from "@/components/bank/CalloutCard";
|
|
|
|
| 26 |
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 27 |
import type { Step } from "react-joyride";
|
| 28 |
import { toast } from "sonner";
|
|
@@ -49,7 +50,6 @@ export const Route = createFileRoute("/packages")({
|
|
| 49 |
type Tab = "all" | "mine";
|
| 50 |
|
| 51 |
export function PackagesComponent() {
|
| 52 |
-
const routerNavigate = useNavigate();
|
| 53 |
const search = Route.useSearch();
|
| 54 |
const navigate = Route.useNavigate();
|
| 55 |
const { data: session } = authClient.useSession();
|
|
@@ -92,6 +92,14 @@ export function PackagesComponent() {
|
|
| 92 |
),
|
| 93 |
);
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
const query = tab === "all" ? allQuery : mineQuery;
|
| 96 |
const packages = query.data?.packages ?? [];
|
| 97 |
const total = query.data?.total ?? 0;
|
|
@@ -113,6 +121,7 @@ export function PackagesComponent() {
|
|
| 113 |
trpc.package.bulkPublish.mutationOptions({
|
| 114 |
onSuccess: (data) => {
|
| 115 |
query.refetch();
|
|
|
|
| 116 |
setBulkMode(false);
|
| 117 |
setSelectedIds(new Set());
|
| 118 |
if (data.skipped > 0) {
|
|
@@ -159,17 +168,22 @@ export function PackagesComponent() {
|
|
| 159 |
const [calloutDismissed, setCalloutDismissed] = useState(
|
| 160 |
typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
|
| 161 |
);
|
| 162 |
-
const privatePackages = packages.filter(
|
| 163 |
-
(p) => !p.isPublic && p.creatorUserId === userId,
|
| 164 |
-
);
|
| 165 |
|
| 166 |
const handleDismissCallout = () => {
|
| 167 |
localStorage.setItem("labas-packages-private-callout-dismissed", "true");
|
| 168 |
setCalloutDismissed(true);
|
| 169 |
};
|
| 170 |
|
| 171 |
-
const handlePublishAllPrivate = () => {
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
if (ids.length > 0) bulkPublish.mutate({ ids });
|
| 174 |
};
|
| 175 |
|
|
@@ -317,25 +331,23 @@ export function PackagesComponent() {
|
|
| 317 |
</div>
|
| 318 |
</>
|
| 319 |
) : (
|
| 320 |
-
<
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
Pilih Banyak
|
| 328 |
-
</button>
|
| 329 |
-
</>
|
| 330 |
)}
|
| 331 |
</div>
|
| 332 |
)}
|
| 333 |
|
| 334 |
{/* Private package callout */}
|
| 335 |
-
{tab === "mine" &&
|
| 336 |
<div className="mb-6">
|
| 337 |
<CalloutCard
|
| 338 |
-
privateCount={
|
|
|
|
| 339 |
onPublishAll={handlePublishAllPrivate}
|
| 340 |
onDismiss={handleDismissCallout}
|
| 341 |
/>
|
|
@@ -378,144 +390,17 @@ export function PackagesComponent() {
|
|
| 378 |
<div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 379 |
{packages.map((pkg) => {
|
| 380 |
const isOwner = pkg.creatorUserId === userId;
|
| 381 |
-
const isSelected = selectedIds.has(pkg.id);
|
| 382 |
return (
|
| 383 |
-
<
|
| 384 |
key={pkg.id}
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
}
|
| 392 |
-
>
|
| 393 |
-
<CardContent className="p-5 flex flex-col h-full">
|
| 394 |
-
<div
|
| 395 |
-
className="block flex-1 cursor-pointer"
|
| 396 |
-
onClick={bulkMode ? () => toggleSelect(pkg.id) : undefined}
|
| 397 |
-
>
|
| 398 |
-
<Link
|
| 399 |
-
to="/package/$id"
|
| 400 |
-
params={{ id: pkg.id }}
|
| 401 |
-
className={bulkMode ? "pointer-events-none" : ""}
|
| 402 |
-
>
|
| 403 |
-
<div className="flex items-start justify-between mb-3">
|
| 404 |
-
<div className="flex gap-2 flex-wrap">
|
| 405 |
-
{bulkMode && (
|
| 406 |
-
<span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 407 |
-
isSelected
|
| 408 |
-
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 409 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 410 |
-
}`}>
|
| 411 |
-
<MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
|
| 412 |
-
{isSelected ? "Terpilih" : "Pilih"}
|
| 413 |
-
</span>
|
| 414 |
-
)}
|
| 415 |
-
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold leading-none whitespace-nowrap">
|
| 416 |
-
{pkg.examTypeName}
|
| 417 |
-
</span>
|
| 418 |
-
{isOwner && !bulkMode && (
|
| 419 |
-
<span
|
| 420 |
-
className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 421 |
-
pkg.isPublic
|
| 422 |
-
? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
|
| 423 |
-
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 424 |
-
}`}
|
| 425 |
-
>
|
| 426 |
-
{!pkg.isPublic && <MaterialIcon name="lock" className="text-[10px]" />}
|
| 427 |
-
{pkg.isPublic ? "Publik" : "Privat"}
|
| 428 |
-
</span>
|
| 429 |
-
)}
|
| 430 |
-
</div>
|
| 431 |
-
{pkg.avgRating && (
|
| 432 |
-
<div className="flex items-center gap-1 text-[var(--lemon-700)]">
|
| 433 |
-
<MaterialIcon name="star" className="text-sm" />
|
| 434 |
-
<span className="text-xs font-bold">{pkg.avgRating}</span>
|
| 435 |
-
</div>
|
| 436 |
-
)}
|
| 437 |
-
</div>
|
| 438 |
-
|
| 439 |
-
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
|
| 440 |
-
{pkg.title}
|
| 441 |
-
</h3>
|
| 442 |
-
|
| 443 |
-
{pkg.description && (
|
| 444 |
-
<p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
|
| 445 |
-
{pkg.description}
|
| 446 |
-
</p>
|
| 447 |
-
)}
|
| 448 |
-
</Link>
|
| 449 |
-
|
| 450 |
-
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 451 |
-
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
| 452 |
-
<span className="flex items-center gap-1">
|
| 453 |
-
<MaterialIcon name="quiz" className="text-xs" />
|
| 454 |
-
{pkg.totalQuestions}
|
| 455 |
-
</span>
|
| 456 |
-
<span className="flex items-center gap-1">
|
| 457 |
-
<MaterialIcon name="folder" className="text-xs" />
|
| 458 |
-
{pkg.totalSections}
|
| 459 |
-
</span>
|
| 460 |
-
{pkg.estimatedDurationMin && (
|
| 461 |
-
<span className="flex items-center gap-1">
|
| 462 |
-
<MaterialIcon name="timer" className="text-xs" />
|
| 463 |
-
{pkg.estimatedDurationMin}m
|
| 464 |
-
</span>
|
| 465 |
-
)}
|
| 466 |
-
</div>
|
| 467 |
-
<span className="text-xs text-[var(--warm-silver)]">
|
| 468 |
-
{pkg.usageCount}x digunakan
|
| 469 |
-
</span>
|
| 470 |
-
</div>
|
| 471 |
-
</div>
|
| 472 |
-
|
| 473 |
-
{/* Owner actions */}
|
| 474 |
-
{isOwner && !bulkMode && (
|
| 475 |
-
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 476 |
-
<button
|
| 477 |
-
onClick={() => togglePublic(pkg.id, pkg.isPublic)}
|
| 478 |
-
disabled={updateMutation.isPending}
|
| 479 |
-
title={pkg.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
|
| 480 |
-
className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer ${
|
| 481 |
-
pkg.isPublic
|
| 482 |
-
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 483 |
-
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 484 |
-
}`}
|
| 485 |
-
>
|
| 486 |
-
{!pkg.isPublic && <MaterialIcon name="lock" className="text-xs" />}
|
| 487 |
-
{pkg.isPublic ? "Publik" : "Privat"}
|
| 488 |
-
</button>
|
| 489 |
-
{pkg.isPublic && (
|
| 490 |
-
<button
|
| 491 |
-
onClick={() => {
|
| 492 |
-
const url = `${window.location.origin}/package/${pkg.id}`;
|
| 493 |
-
navigator.clipboard.writeText(url);
|
| 494 |
-
toast.success("Link paket disalin!");
|
| 495 |
-
}}
|
| 496 |
-
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"
|
| 497 |
-
>
|
| 498 |
-
<MaterialIcon name="share" className="text-xs" />
|
| 499 |
-
Bagikan
|
| 500 |
-
</button>
|
| 501 |
-
)}
|
| 502 |
-
</div>
|
| 503 |
-
)}
|
| 504 |
-
|
| 505 |
-
{!bulkMode && (
|
| 506 |
-
<div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
|
| 507 |
-
<Button
|
| 508 |
-
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 509 |
-
onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
|
| 510 |
-
size="xl"
|
| 511 |
-
>
|
| 512 |
-
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 513 |
-
Mulai Latihan
|
| 514 |
-
</Button>
|
| 515 |
-
</div>
|
| 516 |
-
)}
|
| 517 |
-
</CardContent>
|
| 518 |
-
</Card>
|
| 519 |
);
|
| 520 |
})}
|
| 521 |
</div>
|
|
|
|
| 1 |
import { useState, useEffect, useCallback } from "react";
|
| 2 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
+
import { createFileRoute, redirect, Link } 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 } from "@labas/ui/components/card";
|
| 10 |
import {
|
| 11 |
Tabs,
|
| 12 |
TabsList,
|
|
|
|
| 23 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 24 |
import { GettingStartedCard } from "@/components/GettingStartedCard";
|
| 25 |
import { CalloutCard } from "@/components/bank/CalloutCard";
|
| 26 |
+
import { PackageCard } from "@/components/packages/PackageCard";
|
| 27 |
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 28 |
import type { Step } from "react-joyride";
|
| 29 |
import { toast } from "sonner";
|
|
|
|
| 50 |
type Tab = "all" | "mine";
|
| 51 |
|
| 52 |
export function PackagesComponent() {
|
|
|
|
| 53 |
const search = Route.useSearch();
|
| 54 |
const navigate = Route.useNavigate();
|
| 55 |
const { data: session } = authClient.useSession();
|
|
|
|
| 92 |
),
|
| 93 |
);
|
| 94 |
|
| 95 |
+
const privateCountQuery = useQuery(
|
| 96 |
+
trpc.package.myPackages.queryOptions(
|
| 97 |
+
{ isPublic: false, limit: 1 },
|
| 98 |
+
{ enabled: !!userId && tab === "mine" },
|
| 99 |
+
),
|
| 100 |
+
);
|
| 101 |
+
const totalPrivateCount = privateCountQuery.data?.total ?? 0;
|
| 102 |
+
|
| 103 |
const query = tab === "all" ? allQuery : mineQuery;
|
| 104 |
const packages = query.data?.packages ?? [];
|
| 105 |
const total = query.data?.total ?? 0;
|
|
|
|
| 121 |
trpc.package.bulkPublish.mutationOptions({
|
| 122 |
onSuccess: (data) => {
|
| 123 |
query.refetch();
|
| 124 |
+
privateCountQuery.refetch();
|
| 125 |
setBulkMode(false);
|
| 126 |
setSelectedIds(new Set());
|
| 127 |
if (data.skipped > 0) {
|
|
|
|
| 168 |
const [calloutDismissed, setCalloutDismissed] = useState(
|
| 169 |
typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
|
| 170 |
);
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
const handleDismissCallout = () => {
|
| 173 |
localStorage.setItem("labas-packages-private-callout-dismissed", "true");
|
| 174 |
setCalloutDismissed(true);
|
| 175 |
};
|
| 176 |
|
| 177 |
+
const handlePublishAllPrivate = async () => {
|
| 178 |
+
if (!userId || totalPrivateCount === 0) return;
|
| 179 |
+
const allPrivate = await queryClient.fetchQuery(
|
| 180 |
+
trpc.package.myPackages.queryOptions({
|
| 181 |
+
isPublic: false,
|
| 182 |
+
limit: totalPrivateCount,
|
| 183 |
+
offset: 0,
|
| 184 |
+
}),
|
| 185 |
+
);
|
| 186 |
+
const ids = (allPrivate.packages ?? []).map((p) => p.id);
|
| 187 |
if (ids.length > 0) bulkPublish.mutate({ ids });
|
| 188 |
};
|
| 189 |
|
|
|
|
| 331 |
</div>
|
| 332 |
</>
|
| 333 |
) : (
|
| 334 |
+
<button
|
| 335 |
+
onClick={() => setBulkMode(true)}
|
| 336 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1 ml-auto"
|
| 337 |
+
>
|
| 338 |
+
<MaterialIcon name="select_all" className="text-sm" />
|
| 339 |
+
Pilih Banyak
|
| 340 |
+
</button>
|
|
|
|
|
|
|
|
|
|
| 341 |
)}
|
| 342 |
</div>
|
| 343 |
)}
|
| 344 |
|
| 345 |
{/* Private package callout */}
|
| 346 |
+
{tab === "mine" && totalPrivateCount > 0 && !calloutDismissed && (
|
| 347 |
<div className="mb-6">
|
| 348 |
<CalloutCard
|
| 349 |
+
privateCount={totalPrivateCount}
|
| 350 |
+
label="paket"
|
| 351 |
onPublishAll={handlePublishAllPrivate}
|
| 352 |
onDismiss={handleDismissCallout}
|
| 353 |
/>
|
|
|
|
| 390 |
<div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 391 |
{packages.map((pkg) => {
|
| 392 |
const isOwner = pkg.creatorUserId === userId;
|
|
|
|
| 393 |
return (
|
| 394 |
+
<PackageCard
|
| 395 |
key={pkg.id}
|
| 396 |
+
pkg={pkg}
|
| 397 |
+
isOwner={isOwner}
|
| 398 |
+
bulkMode={bulkMode}
|
| 399 |
+
isSelected={selectedIds.has(pkg.id)}
|
| 400 |
+
isTogglePending={updateMutation.isPending}
|
| 401 |
+
onToggleSelect={() => toggleSelect(pkg.id)}
|
| 402 |
+
onTogglePublic={() => togglePublic(pkg.id, pkg.isPublic)}
|
| 403 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
);
|
| 405 |
})}
|
| 406 |
</div>
|
apps/web/src/components/routes/SettingsPage.tsx
CHANGED
|
@@ -280,7 +280,7 @@ export function RouteComponent() {
|
|
| 280 |
<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)]">
|
| 281 |
<section className="mb-10">
|
| 282 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 283 |
-
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
|
| 284 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 285 |
<span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
|
| 286 |
</div>
|
|
|
|
| 280 |
<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)]">
|
| 281 |
<section className="mb-10">
|
| 282 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 283 |
+
<Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
|
| 284 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 285 |
<span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
|
| 286 |
</div>
|
apps/web/src/components/sidebar.tsx
CHANGED
|
@@ -19,7 +19,7 @@ interface NavGroup {
|
|
| 19 |
|
| 20 |
const navGroups: NavGroup[] = [
|
| 21 |
{
|
| 22 |
-
items: [{ to: "/", label: "Dashboard", icon: "dashboard" }],
|
| 23 |
},
|
| 24 |
{
|
| 25 |
label: "Generate",
|
|
@@ -54,7 +54,7 @@ const bottomItems: NavItem[] = [
|
|
| 54 |
];
|
| 55 |
|
| 56 |
const mobileNavItems: NavItem[] = [
|
| 57 |
-
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
| 58 |
{ to: "/generate", label: "AI Lab", icon: "auto_awesome" },
|
| 59 |
{ to: "/jobs", label: "Jobs", icon: "schedule" },
|
| 60 |
{ to: "/bank", label: "Buat", icon: "database" },
|
|
@@ -66,7 +66,7 @@ function NavIcon({ name }: { name: string }) {
|
|
| 66 |
}
|
| 67 |
|
| 68 |
function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boolean; collapsed: boolean }) {
|
| 69 |
-
const tourAttr = item.to !== "/" ? { "data-tour": `nav-${item.to.replace("/", "")}` } : {};
|
| 70 |
return (
|
| 71 |
<Link
|
| 72 |
to={item.to}
|
|
@@ -99,7 +99,7 @@ export function Sidebar() {
|
|
| 99 |
|
| 100 |
async function handleSignOut() {
|
| 101 |
await authClient.signOut();
|
| 102 |
-
navigate({ to: "/
|
| 103 |
}
|
| 104 |
|
| 105 |
return (
|
|
|
|
| 19 |
|
| 20 |
const navGroups: NavGroup[] = [
|
| 21 |
{
|
| 22 |
+
items: [{ to: "/dashboard", label: "Dashboard", icon: "dashboard" }],
|
| 23 |
},
|
| 24 |
{
|
| 25 |
label: "Generate",
|
|
|
|
| 54 |
];
|
| 55 |
|
| 56 |
const mobileNavItems: NavItem[] = [
|
| 57 |
+
{ to: "/dashboard", label: "Dashboard", icon: "dashboard" },
|
| 58 |
{ to: "/generate", label: "AI Lab", icon: "auto_awesome" },
|
| 59 |
{ to: "/jobs", label: "Jobs", icon: "schedule" },
|
| 60 |
{ to: "/bank", label: "Buat", icon: "database" },
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boolean; collapsed: boolean }) {
|
| 69 |
+
const tourAttr = item.to !== "/dashboard" ? { "data-tour": `nav-${item.to.replace("/", "")}` } : {};
|
| 70 |
return (
|
| 71 |
<Link
|
| 72 |
to={item.to}
|
|
|
|
| 99 |
|
| 100 |
async function handleSignOut() {
|
| 101 |
await authClient.signOut();
|
| 102 |
+
navigate({ to: "/" });
|
| 103 |
}
|
| 104 |
|
| 105 |
return (
|
apps/web/src/components/sign-in-form.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import Loader from "./loader";
|
|
| 13 |
|
| 14 |
export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
|
| 15 |
const navigate = useNavigate({
|
| 16 |
-
from: "/",
|
| 17 |
});
|
| 18 |
const { isPending } = authClient.useSession();
|
| 19 |
|
|
@@ -30,7 +30,7 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
onSuccess: () => {
|
| 33 |
-
navigate({ to: "/" });
|
| 34 |
toast.success("Sign in successful");
|
| 35 |
},
|
| 36 |
onError: (error) => {
|
|
|
|
| 13 |
|
| 14 |
export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
|
| 15 |
const navigate = useNavigate({
|
| 16 |
+
from: "/login",
|
| 17 |
});
|
| 18 |
const { isPending } = authClient.useSession();
|
| 19 |
|
|
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
onSuccess: () => {
|
| 33 |
+
navigate({ to: "/dashboard" });
|
| 34 |
toast.success("Sign in successful");
|
| 35 |
},
|
| 36 |
onError: (error) => {
|
apps/web/src/hooks/use-generation-jobs.ts
CHANGED
|
@@ -92,6 +92,7 @@ export function useGenerationJobs() {
|
|
| 92 |
|
| 93 |
if (status === "completed_partial" && data.resultJson) {
|
| 94 |
setResult(baseResult);
|
|
|
|
| 95 |
if (data.errorMessage) {
|
| 96 |
setError(data.errorMessage as string);
|
| 97 |
}
|
|
@@ -99,6 +100,7 @@ export function useGenerationJobs() {
|
|
| 99 |
|
| 100 |
if (status === "completed" && data.resultJson) {
|
| 101 |
setResult(baseResult);
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
if (status === "failed") {
|
|
|
|
| 92 |
|
| 93 |
if (status === "completed_partial" && data.resultJson) {
|
| 94 |
setResult(baseResult);
|
| 95 |
+
localStorage.setItem("pendingDonationPrompt", "generate");
|
| 96 |
if (data.errorMessage) {
|
| 97 |
setError(data.errorMessage as string);
|
| 98 |
}
|
|
|
|
| 100 |
|
| 101 |
if (status === "completed" && data.resultJson) {
|
| 102 |
setResult(baseResult);
|
| 103 |
+
localStorage.setItem("pendingDonationPrompt", "generate");
|
| 104 |
}
|
| 105 |
|
| 106 |
if (status === "failed") {
|
apps/web/src/hooks/use-test-session.ts
CHANGED
|
@@ -211,6 +211,7 @@ export function useTestSession(packageId: string, existingAttemptId?: string) {
|
|
| 211 |
const handleFinish = useCallback(async () => {
|
| 212 |
if (!attemptId) return;
|
| 213 |
setIsFinished(true);
|
|
|
|
| 214 |
await finishMutation.mutateAsync({ attemptId });
|
| 215 |
clearElapsedTime(attemptId);
|
| 216 |
clearMarkedQuestions(attemptId);
|
|
|
|
| 211 |
const handleFinish = useCallback(async () => {
|
| 212 |
if (!attemptId) return;
|
| 213 |
setIsFinished(true);
|
| 214 |
+
localStorage.setItem("pendingDonationPrompt", "exam");
|
| 215 |
await finishMutation.mutateAsync({ attemptId });
|
| 216 |
clearElapsedTime(attemptId);
|
| 217 |
clearMarkedQuestions(attemptId);
|
apps/web/src/lib/route-shell.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type RouteShell = "public" | "fullscreen" | "app";
|
| 2 |
+
|
| 3 |
+
export const routeShell = {
|
| 4 |
+
public: { shell: "public" as const },
|
| 5 |
+
fullscreen: { shell: "fullscreen" as const },
|
| 6 |
+
app: { shell: "app" as const },
|
| 7 |
+
};
|
apps/web/src/routeTree.gen.ts
CHANGED
|
@@ -21,9 +21,11 @@ import { Route as JobsRouteImport } from './routes/jobs'
|
|
| 21 |
import { Route as HistoryRouteImport } from './routes/history'
|
| 22 |
import { Route as GenerateRouteImport } from './routes/generate'
|
| 23 |
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
|
|
|
| 24 |
import { Route as BankRouteImport } from './routes/bank'
|
| 25 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 26 |
import { Route as AdminRouteImport } from './routes/admin'
|
|
|
|
| 27 |
import { Route as IndexRouteImport } from './routes/index'
|
| 28 |
import { Route as AdminIndexRouteImport } from './routes/admin.index'
|
| 29 |
import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
|
|
@@ -77,7 +79,7 @@ const LandingRoute = LandingRouteImport.update({
|
|
| 77 |
id: '/landing',
|
| 78 |
path: '/landing',
|
| 79 |
getParentRoute: () => rootRouteImport,
|
| 80 |
-
} as any)
|
| 81 |
const JobsRoute = JobsRouteImport.update({
|
| 82 |
id: '/jobs',
|
| 83 |
path: '/jobs',
|
|
@@ -98,6 +100,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
|
| 98 |
path: '/forgot-password',
|
| 99 |
getParentRoute: () => rootRouteImport,
|
| 100 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
const BankRoute = BankRouteImport.update({
|
| 102 |
id: '/bank',
|
| 103 |
path: '/bank',
|
|
@@ -113,11 +120,16 @@ const AdminRoute = AdminRouteImport.update({
|
|
| 113 |
path: '/admin',
|
| 114 |
getParentRoute: () => rootRouteImport,
|
| 115 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const IndexRoute = IndexRouteImport.update({
|
| 117 |
id: '/',
|
| 118 |
path: '/',
|
| 119 |
getParentRoute: () => rootRouteImport,
|
| 120 |
-
} as any)
|
| 121 |
const AdminIndexRoute = AdminIndexRouteImport.update({
|
| 122 |
id: '/',
|
| 123 |
path: '/',
|
|
@@ -182,9 +194,11 @@ const PackageIdAttemptAttemptIdRoute =
|
|
| 182 |
|
| 183 |
export interface FileRoutesByFullPath {
|
| 184 |
'/': typeof IndexRoute
|
|
|
|
| 185 |
'/admin': typeof AdminRouteWithChildren
|
| 186 |
'/analytics': typeof AnalyticsRoute
|
| 187 |
'/bank': typeof BankRoute
|
|
|
|
| 188 |
'/forgot-password': typeof ForgotPasswordRoute
|
| 189 |
'/generate': typeof GenerateRoute
|
| 190 |
'/history': typeof HistoryRoute
|
|
@@ -212,8 +226,10 @@ export interface FileRoutesByFullPath {
|
|
| 212 |
}
|
| 213 |
export interface FileRoutesByTo {
|
| 214 |
'/': typeof IndexRoute
|
|
|
|
| 215 |
'/analytics': typeof AnalyticsRoute
|
| 216 |
'/bank': typeof BankRoute
|
|
|
|
| 217 |
'/forgot-password': typeof ForgotPasswordRoute
|
| 218 |
'/generate': typeof GenerateRoute
|
| 219 |
'/history': typeof HistoryRoute
|
|
@@ -241,9 +257,11 @@ export interface FileRoutesByTo {
|
|
| 241 |
export interface FileRoutesById {
|
| 242 |
__root__: typeof rootRouteImport
|
| 243 |
'/': typeof IndexRoute
|
|
|
|
| 244 |
'/admin': typeof AdminRouteWithChildren
|
| 245 |
'/analytics': typeof AnalyticsRoute
|
| 246 |
'/bank': typeof BankRoute
|
|
|
|
| 247 |
'/forgot-password': typeof ForgotPasswordRoute
|
| 248 |
'/generate': typeof GenerateRoute
|
| 249 |
'/history': typeof HistoryRoute
|
|
@@ -273,9 +291,11 @@ export interface FileRouteTypes {
|
|
| 273 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 274 |
fullPaths:
|
| 275 |
| '/'
|
|
|
|
| 276 |
| '/admin'
|
| 277 |
| '/analytics'
|
| 278 |
| '/bank'
|
|
|
|
| 279 |
| '/forgot-password'
|
| 280 |
| '/generate'
|
| 281 |
| '/history'
|
|
@@ -303,8 +323,10 @@ export interface FileRouteTypes {
|
|
| 303 |
fileRoutesByTo: FileRoutesByTo
|
| 304 |
to:
|
| 305 |
| '/'
|
|
|
|
| 306 |
| '/analytics'
|
| 307 |
| '/bank'
|
|
|
|
| 308 |
| '/forgot-password'
|
| 309 |
| '/generate'
|
| 310 |
| '/history'
|
|
@@ -331,9 +353,11 @@ export interface FileRouteTypes {
|
|
| 331 |
id:
|
| 332 |
| '__root__'
|
| 333 |
| '/'
|
|
|
|
| 334 |
| '/admin'
|
| 335 |
| '/analytics'
|
| 336 |
| '/bank'
|
|
|
|
| 337 |
| '/forgot-password'
|
| 338 |
| '/generate'
|
| 339 |
| '/history'
|
|
@@ -362,9 +386,11 @@ export interface FileRouteTypes {
|
|
| 362 |
}
|
| 363 |
export interface RootRouteChildren {
|
| 364 |
IndexRoute: typeof IndexRoute
|
|
|
|
| 365 |
AdminRoute: typeof AdminRouteWithChildren
|
| 366 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 367 |
BankRoute: typeof BankRoute
|
|
|
|
| 368 |
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
| 369 |
GenerateRoute: typeof GenerateRoute
|
| 370 |
HistoryRoute: typeof HistoryRoute
|
|
@@ -468,6 +494,13 @@ declare module '@tanstack/react-router' {
|
|
| 468 |
preLoaderRoute: typeof ForgotPasswordRouteImport
|
| 469 |
parentRoute: typeof rootRouteImport
|
| 470 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
'/bank': {
|
| 472 |
id: '/bank'
|
| 473 |
path: '/bank'
|
|
@@ -489,6 +522,13 @@ declare module '@tanstack/react-router' {
|
|
| 489 |
preLoaderRoute: typeof AdminRouteImport
|
| 490 |
parentRoute: typeof rootRouteImport
|
| 491 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
'/': {
|
| 493 |
id: '/'
|
| 494 |
path: '/'
|
|
@@ -621,9 +661,11 @@ const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
|
|
| 621 |
|
| 622 |
const rootRouteChildren: RootRouteChildren = {
|
| 623 |
IndexRoute: IndexRoute,
|
|
|
|
| 624 |
AdminRoute: AdminRouteWithChildren,
|
| 625 |
AnalyticsRoute: AnalyticsRoute,
|
| 626 |
BankRoute: BankRoute,
|
|
|
|
| 627 |
ForgotPasswordRoute: ForgotPasswordRoute,
|
| 628 |
GenerateRoute: GenerateRoute,
|
| 629 |
HistoryRoute: HistoryRoute,
|
|
|
|
| 21 |
import { Route as HistoryRouteImport } from './routes/history'
|
| 22 |
import { Route as GenerateRouteImport } from './routes/generate'
|
| 23 |
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
| 24 |
+
import { Route as DashboardRouteImport } from './routes/dashboard'
|
| 25 |
import { Route as BankRouteImport } from './routes/bank'
|
| 26 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 27 |
import { Route as AdminRouteImport } from './routes/admin'
|
| 28 |
+
import { Route as SplatRouteImport } from './routes/$'
|
| 29 |
import { Route as IndexRouteImport } from './routes/index'
|
| 30 |
import { Route as AdminIndexRouteImport } from './routes/admin.index'
|
| 31 |
import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
|
|
|
|
| 79 |
id: '/landing',
|
| 80 |
path: '/landing',
|
| 81 |
getParentRoute: () => rootRouteImport,
|
| 82 |
+
} as any)
|
| 83 |
const JobsRoute = JobsRouteImport.update({
|
| 84 |
id: '/jobs',
|
| 85 |
path: '/jobs',
|
|
|
|
| 100 |
path: '/forgot-password',
|
| 101 |
getParentRoute: () => rootRouteImport,
|
| 102 |
} as any)
|
| 103 |
+
const DashboardRoute = DashboardRouteImport.update({
|
| 104 |
+
id: '/dashboard',
|
| 105 |
+
path: '/dashboard',
|
| 106 |
+
getParentRoute: () => rootRouteImport,
|
| 107 |
+
} as any)
|
| 108 |
const BankRoute = BankRouteImport.update({
|
| 109 |
id: '/bank',
|
| 110 |
path: '/bank',
|
|
|
|
| 120 |
path: '/admin',
|
| 121 |
getParentRoute: () => rootRouteImport,
|
| 122 |
} as any)
|
| 123 |
+
const SplatRoute = SplatRouteImport.update({
|
| 124 |
+
id: '/$',
|
| 125 |
+
path: '/$',
|
| 126 |
+
getParentRoute: () => rootRouteImport,
|
| 127 |
+
} as any)
|
| 128 |
const IndexRoute = IndexRouteImport.update({
|
| 129 |
id: '/',
|
| 130 |
path: '/',
|
| 131 |
getParentRoute: () => rootRouteImport,
|
| 132 |
+
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
|
| 133 |
const AdminIndexRoute = AdminIndexRouteImport.update({
|
| 134 |
id: '/',
|
| 135 |
path: '/',
|
|
|
|
| 194 |
|
| 195 |
export interface FileRoutesByFullPath {
|
| 196 |
'/': typeof IndexRoute
|
| 197 |
+
'/$': typeof SplatRoute
|
| 198 |
'/admin': typeof AdminRouteWithChildren
|
| 199 |
'/analytics': typeof AnalyticsRoute
|
| 200 |
'/bank': typeof BankRoute
|
| 201 |
+
'/dashboard': typeof DashboardRoute
|
| 202 |
'/forgot-password': typeof ForgotPasswordRoute
|
| 203 |
'/generate': typeof GenerateRoute
|
| 204 |
'/history': typeof HistoryRoute
|
|
|
|
| 226 |
}
|
| 227 |
export interface FileRoutesByTo {
|
| 228 |
'/': typeof IndexRoute
|
| 229 |
+
'/$': typeof SplatRoute
|
| 230 |
'/analytics': typeof AnalyticsRoute
|
| 231 |
'/bank': typeof BankRoute
|
| 232 |
+
'/dashboard': typeof DashboardRoute
|
| 233 |
'/forgot-password': typeof ForgotPasswordRoute
|
| 234 |
'/generate': typeof GenerateRoute
|
| 235 |
'/history': typeof HistoryRoute
|
|
|
|
| 257 |
export interface FileRoutesById {
|
| 258 |
__root__: typeof rootRouteImport
|
| 259 |
'/': typeof IndexRoute
|
| 260 |
+
'/$': typeof SplatRoute
|
| 261 |
'/admin': typeof AdminRouteWithChildren
|
| 262 |
'/analytics': typeof AnalyticsRoute
|
| 263 |
'/bank': typeof BankRoute
|
| 264 |
+
'/dashboard': typeof DashboardRoute
|
| 265 |
'/forgot-password': typeof ForgotPasswordRoute
|
| 266 |
'/generate': typeof GenerateRoute
|
| 267 |
'/history': typeof HistoryRoute
|
|
|
|
| 291 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 292 |
fullPaths:
|
| 293 |
| '/'
|
| 294 |
+
| '/$'
|
| 295 |
| '/admin'
|
| 296 |
| '/analytics'
|
| 297 |
| '/bank'
|
| 298 |
+
| '/dashboard'
|
| 299 |
| '/forgot-password'
|
| 300 |
| '/generate'
|
| 301 |
| '/history'
|
|
|
|
| 323 |
fileRoutesByTo: FileRoutesByTo
|
| 324 |
to:
|
| 325 |
| '/'
|
| 326 |
+
| '/$'
|
| 327 |
| '/analytics'
|
| 328 |
| '/bank'
|
| 329 |
+
| '/dashboard'
|
| 330 |
| '/forgot-password'
|
| 331 |
| '/generate'
|
| 332 |
| '/history'
|
|
|
|
| 353 |
id:
|
| 354 |
| '__root__'
|
| 355 |
| '/'
|
| 356 |
+
| '/$'
|
| 357 |
| '/admin'
|
| 358 |
| '/analytics'
|
| 359 |
| '/bank'
|
| 360 |
+
| '/dashboard'
|
| 361 |
| '/forgot-password'
|
| 362 |
| '/generate'
|
| 363 |
| '/history'
|
|
|
|
| 386 |
}
|
| 387 |
export interface RootRouteChildren {
|
| 388 |
IndexRoute: typeof IndexRoute
|
| 389 |
+
SplatRoute: typeof SplatRoute
|
| 390 |
AdminRoute: typeof AdminRouteWithChildren
|
| 391 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 392 |
BankRoute: typeof BankRoute
|
| 393 |
+
DashboardRoute: typeof DashboardRoute
|
| 394 |
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
| 395 |
GenerateRoute: typeof GenerateRoute
|
| 396 |
HistoryRoute: typeof HistoryRoute
|
|
|
|
| 494 |
preLoaderRoute: typeof ForgotPasswordRouteImport
|
| 495 |
parentRoute: typeof rootRouteImport
|
| 496 |
}
|
| 497 |
+
'/dashboard': {
|
| 498 |
+
id: '/dashboard'
|
| 499 |
+
path: '/dashboard'
|
| 500 |
+
fullPath: '/dashboard'
|
| 501 |
+
preLoaderRoute: typeof DashboardRouteImport
|
| 502 |
+
parentRoute: typeof rootRouteImport
|
| 503 |
+
}
|
| 504 |
'/bank': {
|
| 505 |
id: '/bank'
|
| 506 |
path: '/bank'
|
|
|
|
| 522 |
preLoaderRoute: typeof AdminRouteImport
|
| 523 |
parentRoute: typeof rootRouteImport
|
| 524 |
}
|
| 525 |
+
'/$': {
|
| 526 |
+
id: '/$'
|
| 527 |
+
path: '/$'
|
| 528 |
+
fullPath: '/$'
|
| 529 |
+
preLoaderRoute: typeof SplatRouteImport
|
| 530 |
+
parentRoute: typeof rootRouteImport
|
| 531 |
+
}
|
| 532 |
'/': {
|
| 533 |
id: '/'
|
| 534 |
path: '/'
|
|
|
|
| 661 |
|
| 662 |
const rootRouteChildren: RootRouteChildren = {
|
| 663 |
IndexRoute: IndexRoute,
|
| 664 |
+
SplatRoute: SplatRoute,
|
| 665 |
AdminRoute: AdminRouteWithChildren,
|
| 666 |
AnalyticsRoute: AnalyticsRoute,
|
| 667 |
BankRoute: BankRoute,
|
| 668 |
+
DashboardRoute: DashboardRoute,
|
| 669 |
ForgotPasswordRoute: ForgotPasswordRoute,
|
| 670 |
GenerateRoute: GenerateRoute,
|
| 671 |
HistoryRoute: HistoryRoute,
|
apps/web/src/routes/$.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { routeShell } from "@/lib/route-shell";
|
| 3 |
+
import { NotFoundPage } from "@/components/NotFoundPage";
|
| 4 |
+
|
| 5 |
+
export const Route = createFileRoute("/$")({
|
| 6 |
+
staticData: routeShell.public,
|
| 7 |
+
component: NotFoundPage,
|
| 8 |
+
});
|
apps/web/src/routes/__root.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|
| 9 |
import { useSidebar } from "@/hooks/use-sidebar";
|
| 10 |
import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
|
| 11 |
import { ErrorFallback } from "@/components/ErrorFallback";
|
|
|
|
| 12 |
import type { trpc } from "@/utils/trpc";
|
| 13 |
|
| 14 |
function SkipLink() {
|
|
@@ -29,6 +30,14 @@ export interface RouterAppContext {
|
|
| 29 |
queryClient: QueryClient;
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
export const Route = createRootRouteWithContext<RouterAppContext>()({
|
| 33 |
component: RootComponent,
|
| 34 |
errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
|
|
@@ -82,18 +91,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
|
|
| 82 |
|
| 83 |
function RootComponent() {
|
| 84 |
const { collapsed } = useSidebar();
|
| 85 |
-
const
|
| 86 |
-
const isFullScreen = matches.some(
|
| 87 |
-
(m) =>
|
| 88 |
-
m.routeId === "/package/$id/take" ||
|
| 89 |
-
m.routeId === "/package/$id/attempt/$attemptId" ||
|
| 90 |
-
m.routeId === "/login" ||
|
| 91 |
-
m.routeId === "/setup-avatar" ||
|
| 92 |
-
m.routeId === "/verify-email" ||
|
| 93 |
-
m.routeId === "/forgot-password" ||
|
| 94 |
-
m.routeId.startsWith("/admin"),
|
| 95 |
-
);
|
| 96 |
-
const isLanding = matches.some((m) => m.routeId === "/landing");
|
| 97 |
|
| 98 |
return (
|
| 99 |
<>
|
|
@@ -105,11 +103,11 @@ function RootComponent() {
|
|
| 105 |
disableTransitionOnChange
|
| 106 |
storageKey="labas-theme"
|
| 107 |
>
|
| 108 |
-
{
|
| 109 |
<div className="h-screen bg-background text-on-surface flex flex-col overflow-y-auto relative">
|
| 110 |
<Outlet />
|
| 111 |
</div>
|
| 112 |
-
) :
|
| 113 |
<div className="min-h-screen bg-background text-on-surface relative">
|
| 114 |
<Outlet />
|
| 115 |
</div>
|
|
|
|
| 9 |
import { useSidebar } from "@/hooks/use-sidebar";
|
| 10 |
import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
|
| 11 |
import { ErrorFallback } from "@/components/ErrorFallback";
|
| 12 |
+
import type { RouteShell } from "@/lib/route-shell";
|
| 13 |
import type { trpc } from "@/utils/trpc";
|
| 14 |
|
| 15 |
function SkipLink() {
|
|
|
|
| 30 |
queryClient: QueryClient;
|
| 31 |
}
|
| 32 |
|
| 33 |
+
function resolveShell(matches: { staticData?: unknown }[]): RouteShell {
|
| 34 |
+
for (let i = matches.length - 1; i >= 0; i--) {
|
| 35 |
+
const shell = (matches[i]?.staticData as { shell?: RouteShell } | undefined)?.shell;
|
| 36 |
+
if (shell) return shell;
|
| 37 |
+
}
|
| 38 |
+
return "app";
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
export const Route = createRootRouteWithContext<RouterAppContext>()({
|
| 42 |
component: RootComponent,
|
| 43 |
errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
|
|
|
|
| 91 |
|
| 92 |
function RootComponent() {
|
| 93 |
const { collapsed } = useSidebar();
|
| 94 |
+
const shell = useRouterState({ select: (s) => resolveShell(s.matches) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
return (
|
| 97 |
<>
|
|
|
|
| 103 |
disableTransitionOnChange
|
| 104 |
storageKey="labas-theme"
|
| 105 |
>
|
| 106 |
+
{shell === "fullscreen" ? (
|
| 107 |
<div className="h-screen bg-background text-on-surface flex flex-col overflow-y-auto relative">
|
| 108 |
<Outlet />
|
| 109 |
</div>
|
| 110 |
+
) : shell === "public" ? (
|
| 111 |
<div className="min-h-screen bg-background text-on-surface relative">
|
| 112 |
<Outlet />
|
| 113 |
</div>
|
apps/web/src/routes/admin.tsx
CHANGED
|
@@ -3,8 +3,10 @@ import { useQuery } from "@tanstack/react-query";
|
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
import { trpc } from "@/utils/trpc";
|
| 5 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
|
|
| 6 |
|
| 7 |
export const Route = createFileRoute("/admin")({
|
|
|
|
| 8 |
component: AdminLayout,
|
| 9 |
beforeLoad: async () => {
|
| 10 |
const session = await authClient.getSession();
|
|
@@ -45,7 +47,7 @@ function AdminLayout() {
|
|
| 45 |
<MaterialIcon name="gpp_bad" className="text-6xl text-[var(--clay-red)]" />
|
| 46 |
<h1 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Access Denied</h1>
|
| 47 |
<p className="text-[var(--warm-charcoal)]">Only admins can access this page.</p>
|
| 48 |
-
<Link to="/" className="text-[var(--matcha-700)] underline">Back to Dashboard</Link>
|
| 49 |
</div>
|
| 50 |
);
|
| 51 |
}
|
|
@@ -55,7 +57,7 @@ function AdminLayout() {
|
|
| 55 |
<div className="flex">
|
| 56 |
<aside className="w-56 shrink-0 min-h-screen border-r border-[var(--oat-border)] bg-[var(--pure-white)] px-4 py-6 flex flex-col">
|
| 57 |
<div className="mb-6 px-2">
|
| 58 |
-
<Link to="/" className="flex items-center gap-2">
|
| 59 |
<span className="material-symbols-outlined text-[var(--clay-black)]">arrow_back</span>
|
| 60 |
<span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
|
| 61 |
</Link>
|
|
|
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
import { trpc } from "@/utils/trpc";
|
| 5 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 6 |
+
import { routeShell } from "@/lib/route-shell";
|
| 7 |
|
| 8 |
export const Route = createFileRoute("/admin")({
|
| 9 |
+
staticData: routeShell.fullscreen,
|
| 10 |
component: AdminLayout,
|
| 11 |
beforeLoad: async () => {
|
| 12 |
const session = await authClient.getSession();
|
|
|
|
| 47 |
<MaterialIcon name="gpp_bad" className="text-6xl text-[var(--clay-red)]" />
|
| 48 |
<h1 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Access Denied</h1>
|
| 49 |
<p className="text-[var(--warm-charcoal)]">Only admins can access this page.</p>
|
| 50 |
+
<Link to="/dashboard" className="text-[var(--matcha-700)] underline">Back to Dashboard</Link>
|
| 51 |
</div>
|
| 52 |
);
|
| 53 |
}
|
|
|
|
| 57 |
<div className="flex">
|
| 58 |
<aside className="w-56 shrink-0 min-h-screen border-r border-[var(--oat-border)] bg-[var(--pure-white)] px-4 py-6 flex flex-col">
|
| 59 |
<div className="mb-6 px-2">
|
| 60 |
+
<Link to="/dashboard" className="flex items-center gap-2">
|
| 61 |
<span className="material-symbols-outlined text-[var(--clay-black)]">arrow_back</span>
|
| 62 |
<span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
|
| 63 |
</Link>
|
apps/web/src/routes/analytics.tsx
CHANGED
|
@@ -59,7 +59,7 @@ function AnalyticsComponent() {
|
|
| 59 |
{/* Header */}
|
| 60 |
<div className="mb-8">
|
| 61 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 62 |
-
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 63 |
Beranda
|
| 64 |
</Link>
|
| 65 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
|
|
|
| 59 |
{/* Header */}
|
| 60 |
<div className="mb-8">
|
| 61 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 62 |
+
<Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
|
| 63 |
Beranda
|
| 64 |
</Link>
|
| 65 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
apps/web/src/routes/attempt.$id.tsx
CHANGED
|
@@ -168,216 +168,244 @@ function AttemptResultComponent() {
|
|
| 168 |
}
|
| 169 |
|
| 170 |
return (
|
| 171 |
-
<div className="min-h-screen
|
| 172 |
-
{/*
|
| 173 |
-
<div className="
|
| 174 |
-
<
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
<h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight mb-4">
|
| 184 |
-
Hasil Latihan
|
| 185 |
-
</h1>
|
| 186 |
|
| 187 |
-
|
| 188 |
-
<
|
| 189 |
-
<div className="flex
|
| 190 |
-
{
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
</div>
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
? "Bagus, terus berlatih!"
|
| 206 |
-
: percentage >= 40
|
| 207 |
-
? "Perlu latihan lebih"
|
| 208 |
-
: "Jangan menyerah, coba lagi!"}
|
| 209 |
</p>
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
</span>
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
</span>
|
| 223 |
-
<span className="
|
| 224 |
-
|
| 225 |
-
{questionStats.wrong} salah
|
| 226 |
</span>
|
| 227 |
</div>
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
<span className="
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
</span>
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
</div>
|
| 253 |
</div>
|
| 254 |
-
</
|
| 255 |
-
</
|
| 256 |
-
</
|
| 257 |
-
</div>
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
</p>
|
| 276 |
-
{sec.timeSpentSec != null && (
|
| 277 |
-
<p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
|
| 278 |
-
<MaterialIcon name="timer" className="text-xs" />
|
| 279 |
-
{formatTime(sec.timeSpentSec)}
|
| 280 |
</p>
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
</div>
|
| 286 |
-
</
|
| 287 |
-
</
|
| 288 |
-
|
| 289 |
-
)
|
| 290 |
-
|
| 291 |
</div>
|
| 292 |
-
</div>
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
<
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
<
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
| 360 |
</div>
|
| 361 |
</div>
|
| 362 |
-
|
| 363 |
-
{/* Actions */}
|
| 364 |
-
<div className="flex gap-3">
|
| 365 |
-
<Link to="/package/$id/take" params={{ id: attempt.packageId! }}>
|
| 366 |
-
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
|
| 367 |
-
<MaterialIcon name="replay" />
|
| 368 |
-
<span className="ml-2">Coba Lagi</span>
|
| 369 |
-
</Button>
|
| 370 |
-
</Link>
|
| 371 |
-
<Link to="/packages">
|
| 372 |
-
<Button
|
| 373 |
-
variant="outline"
|
| 374 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 375 |
-
>
|
| 376 |
-
<MaterialIcon name="arrow_back" />
|
| 377 |
-
<span className="ml-2">Kembali ke Paket</span>
|
| 378 |
-
</Button>
|
| 379 |
-
</Link>
|
| 380 |
-
</div>
|
| 381 |
</div>
|
| 382 |
);
|
| 383 |
}
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
return (
|
| 171 |
+
<div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
|
| 172 |
+
{/* Sticky header */}
|
| 173 |
+
<div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm">
|
| 174 |
+
<div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4">
|
| 175 |
+
{/* Breadcrumb */}
|
| 176 |
+
<div className="flex items-center gap-2 text-xs text-[var(--warm-charcoal)] mb-3">
|
| 177 |
+
<Link to="/packages" className="hover:text-[var(--clay-black)] transition-colors">
|
| 178 |
+
Paket
|
| 179 |
+
</Link>
|
| 180 |
+
<MaterialIcon name="chevron_right" className="text-[10px]" />
|
| 181 |
+
<span className="text-[var(--clay-black)] font-medium">Hasil Latihan</span>
|
| 182 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
{/* Title + score summary + actions */}
|
| 185 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 186 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 187 |
+
<div className={`w-12 h-12 rounded-full border-2 flex items-center justify-center shrink-0 font-headline font-extrabold text-sm ${
|
| 188 |
+
percentage >= 80
|
| 189 |
+
? "border-[var(--matcha-500)] bg-[var(--matcha-100)] text-[var(--matcha-800)]"
|
| 190 |
+
: percentage >= 60
|
| 191 |
+
? "border-[var(--lemon-500)] bg-[var(--lemon-100)] text-[var(--lemon-800)]"
|
| 192 |
+
: "border-[var(--pomegranate-400)] bg-[var(--pomegranate-50)] text-[var(--pomegranate-700)]"
|
| 193 |
+
}`}>
|
| 194 |
+
{percentage}%
|
| 195 |
</div>
|
| 196 |
+
<div>
|
| 197 |
+
<h1 className="text-lg font-headline font-extrabold text-[var(--clay-black)] leading-tight">
|
| 198 |
+
Hasil Latihan
|
| 199 |
+
</h1>
|
| 200 |
+
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 201 |
+
{questionStats.correct}/{questionStats.total} benar · {formatTime(durationSec)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
</p>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
{/* Actions */}
|
| 207 |
+
<div className="flex items-center gap-2 shrink-0">
|
| 208 |
+
<Link to="/package/$id/take" params={{ id: attempt.packageId! }}>
|
| 209 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
|
| 210 |
+
<MaterialIcon name="replay" />
|
| 211 |
+
<span className="ml-1.5">Coba Lagi</span>
|
| 212 |
+
</Button>
|
| 213 |
+
</Link>
|
| 214 |
+
<Link to="/packages">
|
| 215 |
+
<Button
|
| 216 |
+
variant="outline"
|
| 217 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 218 |
+
>
|
| 219 |
+
<MaterialIcon name="arrow_back" />
|
| 220 |
+
<span className="ml-1.5 hidden sm:inline">Kembali</span>
|
| 221 |
+
</Button>
|
| 222 |
+
</Link>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8">
|
| 229 |
+
{/* Score Card */}
|
| 230 |
+
<div className="mb-8">
|
| 231 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] overflow-hidden">
|
| 232 |
+
<CardContent className="p-6">
|
| 233 |
+
<div className="flex flex-col md:flex-row items-center gap-6">
|
| 234 |
+
{/* Big Score Circle */}
|
| 235 |
+
<div className="w-32 h-32 rounded-full border-4 border-[var(--matcha-500)] flex flex-col items-center justify-center bg-[var(--matcha-100)]">
|
| 236 |
+
<span className="text-3xl font-headline font-extrabold text-[var(--matcha-800)]">
|
| 237 |
+
{percentage}%
|
| 238 |
</span>
|
| 239 |
+
<span className="text-xs text-[var(--matcha-700)] font-medium">
|
| 240 |
+
{questionStats.correct}/{questionStats.total}
|
|
|
|
| 241 |
</span>
|
| 242 |
</div>
|
| 243 |
|
| 244 |
+
<div className="flex-1 space-y-2 text-center md:text-left">
|
| 245 |
+
<p className="text-lg font-semibold text-[var(--clay-black)]">
|
| 246 |
+
{percentage >= 80
|
| 247 |
+
? "Sempurna!"
|
| 248 |
+
: percentage >= 60
|
| 249 |
+
? "Bagus, terus berlatih!"
|
| 250 |
+
: percentage >= 40
|
| 251 |
+
? "Perlu latihan lebih"
|
| 252 |
+
: "Jangan menyerah, coba lagi!"}
|
| 253 |
+
</p>
|
| 254 |
+
<div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)] justify-center md:justify-start">
|
| 255 |
+
<span className="flex items-center gap-1">
|
| 256 |
+
<MaterialIcon name="timer" className="text-sm" />
|
| 257 |
+
{formatTime(durationSec)}
|
| 258 |
+
</span>
|
| 259 |
+
<span className="flex items-center gap-1">
|
| 260 |
+
<MaterialIcon name="quiz" className="text-sm" />
|
| 261 |
+
{questionStats.total} soal
|
| 262 |
+
</span>
|
| 263 |
+
<span className="flex items-center gap-1">
|
| 264 |
+
<MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
|
| 265 |
+
{questionStats.correct} benar
|
| 266 |
+
</span>
|
| 267 |
+
<span className="flex items-center gap-1">
|
| 268 |
+
<MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
|
| 269 |
+
{questionStats.wrong} salah
|
| 270 |
</span>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{/* Package Rating */}
|
| 274 |
+
<div className="flex items-center gap-2 justify-center md:justify-start pt-2">
|
| 275 |
+
<span className="text-xs text-[var(--warm-charcoal)]">Nilai paket:</span>
|
| 276 |
+
<div className="flex gap-0.5">
|
| 277 |
+
{[1, 2, 3, 4, 5].map((star) => (
|
| 278 |
+
<button
|
| 279 |
+
key={star}
|
| 280 |
+
onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
|
| 281 |
+
aria-label={`Nilai ${star} bintang`}
|
| 282 |
+
className="p-1 transition-transform hover:scale-110 cursor-pointer"
|
| 283 |
+
>
|
| 284 |
+
<MaterialIcon
|
| 285 |
+
name={(packageRatingQuery.data?.myRating ?? 0) >= star ? "star" : "star_outline"}
|
| 286 |
+
className={`text-xl ${(packageRatingQuery.data?.myRating ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
|
| 287 |
+
/>
|
| 288 |
+
</button>
|
| 289 |
+
))}
|
| 290 |
+
</div>
|
| 291 |
+
{packageRatingQuery.data?.avgRating && (
|
| 292 |
+
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 293 |
+
{packageRatingQuery.data.avgRating}/5
|
| 294 |
+
</span>
|
| 295 |
+
)}
|
| 296 |
+
</div>
|
| 297 |
</div>
|
| 298 |
</div>
|
| 299 |
+
</CardContent>
|
| 300 |
+
</Card>
|
| 301 |
+
</div>
|
|
|
|
| 302 |
|
| 303 |
+
{/* Section Breakdown */}
|
| 304 |
+
<div className="mb-8">
|
| 305 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
|
| 306 |
+
<div className="space-y-3">
|
| 307 |
+
{attempt.sections.map((sec) => {
|
| 308 |
+
const secPct = sec.maxScore && sec.maxScore > 0
|
| 309 |
+
? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
|
| 310 |
+
: 0;
|
| 311 |
+
return (
|
| 312 |
+
<Card key={sec.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 313 |
+
<CardContent className="p-4">
|
| 314 |
+
<div className="flex items-center justify-between">
|
| 315 |
+
<div>
|
| 316 |
+
<p className="font-semibold text-[var(--clay-black)]">{sec.title}</p>
|
| 317 |
+
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 318 |
+
{sec.score ?? 0}/{sec.maxScore ?? 0} benar
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
</p>
|
| 320 |
+
{sec.timeSpentSec != null && (
|
| 321 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
|
| 322 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 323 |
+
{formatTime(sec.timeSpentSec)}
|
| 324 |
+
</p>
|
| 325 |
+
)}
|
| 326 |
+
</div>
|
| 327 |
+
<div className="w-16 h-16 rounded-full border-2 border-[var(--matcha-400)] flex items-center justify-center bg-[var(--matcha-50)]">
|
| 328 |
+
<span className="text-lg font-bold text-[var(--matcha-700)]">{secPct}%</span>
|
| 329 |
+
</div>
|
| 330 |
</div>
|
| 331 |
+
</CardContent>
|
| 332 |
+
</Card>
|
| 333 |
+
);
|
| 334 |
+
})}
|
| 335 |
+
</div>
|
| 336 |
</div>
|
|
|
|
| 337 |
|
| 338 |
+
{/* Skill Breakdown */}
|
| 339 |
+
<div className="mb-8">
|
| 340 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
|
| 341 |
+
Kemampuan per Topik
|
| 342 |
+
</h2>
|
| 343 |
+
<p className="text-sm text-[var(--warm-charcoal)] mb-4">Klik topik untuk filter soal</p>
|
| 344 |
+
<SkillBreakdown
|
| 345 |
+
questions={allQuestions.map(({ q, ans }) => ({
|
| 346 |
+
skillTags: q.skillTags ?? [],
|
| 347 |
+
isCorrect: ans?.isCorrect ?? null,
|
| 348 |
+
}))}
|
| 349 |
+
onSkillClick={toggleSkill}
|
| 350 |
+
activeSkills={filterSkills}
|
| 351 |
+
/>
|
| 352 |
+
</div>
|
| 353 |
|
| 354 |
+
{/* Question Review */}
|
| 355 |
+
<div className="mb-8">
|
| 356 |
+
<div className="flex items-baseline gap-2 mb-4">
|
| 357 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 358 |
+
Pembahasan Soal
|
| 359 |
+
</h2>
|
| 360 |
+
{filterStatus !== "all" && (
|
| 361 |
+
<span className="text-sm text-[var(--warm-charcoal)]">
|
| 362 |
+
{filteredQuestions.length} soal
|
| 363 |
+
</span>
|
| 364 |
+
)}
|
| 365 |
+
</div>
|
| 366 |
|
| 367 |
+
<ReviewFilterBar
|
| 368 |
+
filterStatus={filterStatus}
|
| 369 |
+
setFilterStatus={setFilterStatus}
|
| 370 |
+
filterSkills={filterSkills}
|
| 371 |
+
setFilterSkills={setFilterSkills}
|
| 372 |
+
questions={allQuestions.map(({ q, ans }) => ({
|
| 373 |
+
skillTags: q.skillTags ?? [],
|
| 374 |
+
isCorrect: ans?.isCorrect,
|
| 375 |
+
}))}
|
| 376 |
+
markedCount={partialCount}
|
| 377 |
+
allExpanded={allExpanded}
|
| 378 |
+
onToggleAllExpanded={toggleAllExpanded}
|
| 379 |
+
/>
|
| 380 |
|
| 381 |
+
<div className="space-y-3">
|
| 382 |
+
{filteredQuestions.map(({ q, secIdx, qIdx, ans }) => (
|
| 383 |
+
<QuestionReviewCard
|
| 384 |
+
key={q.id}
|
| 385 |
+
q={q}
|
| 386 |
+
secIdx={secIdx}
|
| 387 |
+
qIdx={qIdx}
|
| 388 |
+
ans={ans}
|
| 389 |
+
userId={session?.user.id}
|
| 390 |
+
isExpanded={expandedIds.has(q.id)}
|
| 391 |
+
onToggleExpand={() => toggleExpand(q.id)}
|
| 392 |
+
/>
|
| 393 |
+
))}
|
| 394 |
+
{filteredQuestions.length === 0 && (
|
| 395 |
+
<div className="text-center py-12">
|
| 396 |
+
<MaterialIcon name="search_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 397 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Tidak ada soal yang sesuai filter.</p>
|
| 398 |
+
<button
|
| 399 |
+
onClick={() => { setFilterStatus("all"); setFilterSkills([]); }}
|
| 400 |
+
className="text-sm text-[var(--matcha-600)] font-semibold mt-2 hover:underline cursor-pointer"
|
| 401 |
+
>
|
| 402 |
+
Reset filter
|
| 403 |
+
</button>
|
| 404 |
+
</div>
|
| 405 |
+
)}
|
| 406 |
+
</div>
|
| 407 |
</div>
|
| 408 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
</div>
|
| 410 |
);
|
| 411 |
}
|
apps/web/src/routes/dashboard.tsx
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
+
import { authClient } from "@/lib/auth-client";
|
| 5 |
+
import { routeShell } from "@/lib/route-shell";
|
| 6 |
+
import { trpc } from "@/utils/trpc";
|
| 7 |
+
import { Button } from "@labas/ui/components/button";
|
| 8 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 9 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
+
import { TourGuide } from "@/components/TourGuide";
|
| 11 |
+
import { DonationModal } from "@/components/DonationModal";
|
| 12 |
+
|
| 13 |
+
export const Route = createFileRoute("/dashboard")({
|
| 14 |
+
staticData: routeShell.app,
|
| 15 |
+
component: HomeComponent,
|
| 16 |
+
beforeLoad: async () => {
|
| 17 |
+
const session = await authClient.getSession();
|
| 18 |
+
if (!session.data) {
|
| 19 |
+
throw redirect({ to: "/login" });
|
| 20 |
+
}
|
| 21 |
+
if (!session.data.user.emailVerified) {
|
| 22 |
+
throw redirect({ to: "/verify-email", search: { email: session.data.user.email } });
|
| 23 |
+
}
|
| 24 |
+
if (!session.data.user.image) {
|
| 25 |
+
throw redirect({ to: "/setup-avatar" });
|
| 26 |
+
}
|
| 27 |
+
return { session };
|
| 28 |
+
},
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
function formatDateShort(dateStr: string | Date | null) {
|
| 32 |
+
if (!dateStr) return "-";
|
| 33 |
+
const d = new Date(dateStr);
|
| 34 |
+
return d.toLocaleDateString("id-ID", { day: "numeric", month: "short" });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function HomeComponent() {
|
| 38 |
+
const { session } = Route.useRouteContext();
|
| 39 |
+
const [donationModalOpen, setDonationModalOpen] = useState(false);
|
| 40 |
+
const [donationTrigger, setDonationTrigger] = useState<"exam" | "generate" | null>(null);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const prompt = localStorage.getItem("pendingDonationPrompt") as "exam" | "generate" | null;
|
| 44 |
+
const lastPromptStr = localStorage.getItem("lastDonationPromptTime");
|
| 45 |
+
const lastPromptTime = lastPromptStr ? parseInt(lastPromptStr, 10) : 0;
|
| 46 |
+
|
| 47 |
+
// 24 hours cooldown
|
| 48 |
+
const COOLDOWN = 24 * 60 * 60 * 1000;
|
| 49 |
+
const now = Date.now();
|
| 50 |
+
|
| 51 |
+
if (prompt) {
|
| 52 |
+
localStorage.removeItem("pendingDonationPrompt");
|
| 53 |
+
|
| 54 |
+
if (now - lastPromptTime > COOLDOWN) {
|
| 55 |
+
setDonationTrigger(prompt);
|
| 56 |
+
setDonationModalOpen(true);
|
| 57 |
+
localStorage.setItem("lastDonationPromptTime", now.toString());
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
const overview = useQuery(trpc.stats.overview.queryOptions());
|
| 63 |
+
const recentAttempts = useQuery(
|
| 64 |
+
trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
|
| 65 |
+
);
|
| 66 |
+
const featured = useQuery(
|
| 67 |
+
trpc.admin.listFeatured.queryOptions(),
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
const stats = overview.data;
|
| 71 |
+
const attempts = recentAttempts.data?.attempts ?? [];
|
| 72 |
+
const featuredPkg = featured.data?.packages ?? [];
|
| 73 |
+
const featuredQ = featured.data?.questions ?? [];
|
| 74 |
+
const hasFeatured = featuredPkg.length > 0 || featuredQ.length > 0;
|
| 75 |
+
|
| 76 |
+
const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<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)]">
|
| 80 |
+
<DonationModal
|
| 81 |
+
isOpen={donationModalOpen}
|
| 82 |
+
onOpenChange={setDonationModalOpen}
|
| 83 |
+
triggerAction={donationTrigger}
|
| 84 |
+
/>
|
| 85 |
+
{/* Header */}
|
| 86 |
+
<section className="mb-8">
|
| 87 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 88 |
+
Halo, {session.data?.user.name ?? "Pengguna"}!
|
| 89 |
+
</h1>
|
| 90 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-1">
|
| 91 |
+
{isNewUser ? "Siap mulai belajar? Pilih langkah di bawah ini." : "Lanjutkan latihanmu!"}
|
| 92 |
+
</p>
|
| 93 |
+
</section>
|
| 94 |
+
|
| 95 |
+
{/* Quick Start Banner for New Users */}
|
| 96 |
+
{isNewUser && (
|
| 97 |
+
<Card className="mb-8 bg-gradient-to-br from-[var(--matcha-300)]/40 to-[var(--slushie-500)]/30 border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
|
| 98 |
+
<CardContent className="p-6 md:p-8">
|
| 99 |
+
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-8">
|
| 100 |
+
<div className="shrink-0">
|
| 101 |
+
<div className="h-16 w-16 bg-[var(--clay-black)] rounded-[var(--radius-2xl)] flex items-center justify-center">
|
| 102 |
+
<MaterialIcon name="rocket_launch" className="text-3xl text-[var(--pure-white)]" />
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
<div className="flex-1">
|
| 106 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
|
| 107 |
+
Selamat Datang di Labas
|
| 108 |
+
</h2>
|
| 109 |
+
<p className="text-sm text-[var(--warm-charcoal)] mb-4">
|
| 110 |
+
Platform latihan ujian bahasa berbasis AI. Ikuti 3 langkah mudah untuk memulai:
|
| 111 |
+
</p>
|
| 112 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
| 113 |
+
<Link to="/generate" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--matcha-400)] transition-all group clay-hover">
|
| 114 |
+
<div className="h-9 w-9 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
|
| 115 |
+
<span className="text-xs font-black text-[var(--matcha-800)]">1</span>
|
| 116 |
+
</div>
|
| 117 |
+
<div className="flex-1 min-w-0">
|
| 118 |
+
<p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--matcha-800)]">Generate Soal</p>
|
| 119 |
+
<p className="text-xs text-[var(--warm-charcoal)]">Buat soal dengan AI</p>
|
| 120 |
+
</div>
|
| 121 |
+
<MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--matcha-800)]" />
|
| 122 |
+
</Link>
|
| 123 |
+
<Link to="/bank" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--slushie-500)] transition-all group clay-hover">
|
| 124 |
+
<div className="h-9 w-9 bg-[var(--slushie-500)]/30 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
|
| 125 |
+
<span className="text-xs font-black text-[var(--slushie-800)]">2</span>
|
| 126 |
+
</div>
|
| 127 |
+
<div className="flex-1 min-w-0">
|
| 128 |
+
<p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--slushie-800)]">Buat Paket</p>
|
| 129 |
+
<p className="text-xs text-[var(--warm-charcoal)]">Kumpulkan soal latihan</p>
|
| 130 |
+
</div>
|
| 131 |
+
<MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--slushie-800)]" />
|
| 132 |
+
</Link>
|
| 133 |
+
<Link to="/packages" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--lemon-600)] transition-all group clay-hover">
|
| 134 |
+
<div className="h-9 w-9 bg-[var(--lemon-400)]/50 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
|
| 135 |
+
<span className="text-xs font-black text-[var(--lemon-800)]">3</span>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="flex-1 min-w-0">
|
| 138 |
+
<p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--lemon-800)]">Mulai Latihan</p>
|
| 139 |
+
<p className="text-xs text-[var(--warm-charcoal)]">Kerjakan paket soal</p>
|
| 140 |
+
</div>
|
| 141 |
+
<MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--lemon-800)]" />
|
| 142 |
+
</Link>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</CardContent>
|
| 147 |
+
</Card>
|
| 148 |
+
)}
|
| 149 |
+
|
| 150 |
+
{/* Quick Stats */}
|
| 151 |
+
<div data-tour="dashboard-stats" className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-10">
|
| 152 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 153 |
+
<CardContent className="p-5">
|
| 154 |
+
<div className="flex items-center gap-3">
|
| 155 |
+
<div className="h-11 w-11 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 156 |
+
<MaterialIcon name="assignment" className="text-lg text-[var(--matcha-800)]" />
|
| 157 |
+
</div>
|
| 158 |
+
<div>
|
| 159 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
|
| 160 |
+
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 161 |
+
{stats?.completedAttempts ?? 0}
|
| 162 |
+
</p>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</CardContent>
|
| 166 |
+
</Card>
|
| 167 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 168 |
+
<CardContent className="p-5">
|
| 169 |
+
<div className="flex items-center gap-3">
|
| 170 |
+
<div className="h-11 w-11 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 171 |
+
<MaterialIcon name="timer" className="text-lg text-[var(--clay-black)]" />
|
| 172 |
+
</div>
|
| 173 |
+
<div>
|
| 174 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
|
| 175 |
+
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 176 |
+
{stats ? `${Math.round(stats.totalTimeSpentSec / 60)}m` : "0m"}
|
| 177 |
+
</p>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</CardContent>
|
| 181 |
+
</Card>
|
| 182 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 183 |
+
<CardContent className="p-5">
|
| 184 |
+
<div className="flex items-center gap-3">
|
| 185 |
+
<div className="h-11 w-11 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 186 |
+
<MaterialIcon name="quiz" className="text-lg text-[var(--lemon-800)]" />
|
| 187 |
+
</div>
|
| 188 |
+
<div>
|
| 189 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Soal Terjawab</p>
|
| 190 |
+
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 191 |
+
{stats?.totalQuestionsAnswered ?? 0}
|
| 192 |
+
</p>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</CardContent>
|
| 196 |
+
</Card>
|
| 197 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 198 |
+
<CardContent className="p-5">
|
| 199 |
+
<div className="flex items-center gap-3">
|
| 200 |
+
<div className="h-11 w-11 bg-[var(--pomegranate-400)]/30 rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 201 |
+
<MaterialIcon name="trending_up" className="text-lg text-[var(--pomegranate-600)]" />
|
| 202 |
+
</div>
|
| 203 |
+
<div>
|
| 204 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Akurasi</p>
|
| 205 |
+
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 206 |
+
{stats ? `${stats.overallAccuracyPct}%` : "-"}
|
| 207 |
+
</p>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</CardContent>
|
| 211 |
+
</Card>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
{/* Quick Actions */}
|
| 215 |
+
<div className="flex flex-col sm:flex-row gap-3 mb-10">
|
| 216 |
+
<Link to="/generate" className="flex-1">
|
| 217 |
+
<Button className="w-full bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-12 clay-shadow clay-hover text-base">
|
| 218 |
+
<MaterialIcon name="auto_awesome" className="text-xl" />
|
| 219 |
+
<span className="ml-2">Generate Soal Baru</span>
|
| 220 |
+
</Button>
|
| 221 |
+
</Link>
|
| 222 |
+
<Link to="/packages" className="flex-1">
|
| 223 |
+
<Button variant="outline" className="w-full rounded-[var(--radius-lg)] h-12 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover text-base">
|
| 224 |
+
<MaterialIcon name="folder" className="text-xl" />
|
| 225 |
+
<span className="ml-2">Mulai Latihan</span>
|
| 226 |
+
</Button>
|
| 227 |
+
</Link>
|
| 228 |
+
<Link to="/analytics" className="flex-1">
|
| 229 |
+
<Button variant="outline" className="w-full rounded-[var(--radius-lg)] h-12 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover text-base">
|
| 230 |
+
<MaterialIcon name="analytics" className="text-xl" />
|
| 231 |
+
<span className="ml-2">Lihat Analitik</span>
|
| 232 |
+
</Button>
|
| 233 |
+
</Link>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
{/* Editor's Pick */}
|
| 237 |
+
{hasFeatured && (
|
| 238 |
+
<section className="mb-10">
|
| 239 |
+
<div className="flex items-center justify-between mb-4">
|
| 240 |
+
<div className="flex items-center gap-2">
|
| 241 |
+
<MaterialIcon name="star" className="text-[var(--sunbeam-600)]" />
|
| 242 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 243 |
+
Editor's Pick
|
| 244 |
+
</h2>
|
| 245 |
+
</div>
|
| 246 |
+
<Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 247 |
+
Lihat Semua
|
| 248 |
+
</Link>
|
| 249 |
+
</div>
|
| 250 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 251 |
+
{featuredPkg.slice(0, 3).map((pkg) => (
|
| 252 |
+
<Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
|
| 253 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full transition-all hover:border-[var(--matcha-400)]">
|
| 254 |
+
<CardContent className="p-5">
|
| 255 |
+
<div className="flex items-start justify-between gap-2 mb-3">
|
| 256 |
+
<span className="px-2 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold shrink-0">
|
| 257 |
+
{pkg.examTypeName}
|
| 258 |
+
</span>
|
| 259 |
+
<span className="text-xs text-[var(--warm-silver)] whitespace-nowrap">
|
| 260 |
+
{pkg.usageCount}x digunakan
|
| 261 |
+
</span>
|
| 262 |
+
</div>
|
| 263 |
+
<h3 className="font-headline text-base font-bold text-[var(--clay-black)] mb-1 line-clamp-2">
|
| 264 |
+
{pkg.title}
|
| 265 |
+
</h3>
|
| 266 |
+
{pkg.description && (
|
| 267 |
+
<p className="text-xs text-[var(--warm-charcoal)] line-clamp-2 mb-3">
|
| 268 |
+
{pkg.description}
|
| 269 |
+
</p>
|
| 270 |
+
)}
|
| 271 |
+
<div className="flex items-center justify-between pt-3 border-t border-[var(--oat-border)]">
|
| 272 |
+
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
| 273 |
+
<span className="flex items-center gap-1">
|
| 274 |
+
<MaterialIcon name="quiz" className="text-xs" />
|
| 275 |
+
{pkg.totalQuestions}
|
| 276 |
+
</span>
|
| 277 |
+
<span className="flex items-center gap-1">
|
| 278 |
+
<MaterialIcon name="folder" className="text-xs" />
|
| 279 |
+
{pkg.totalSections}
|
| 280 |
+
</span>
|
| 281 |
+
{pkg.estimatedDurationMin && (
|
| 282 |
+
<span className="flex items-center gap-1">
|
| 283 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 284 |
+
{pkg.estimatedDurationMin}m
|
| 285 |
+
</span>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
<MaterialIcon name="play_circle" className="text-xl text-[var(--matcha-600)]" />
|
| 289 |
+
</div>
|
| 290 |
+
</CardContent>
|
| 291 |
+
</Card>
|
| 292 |
+
</Link>
|
| 293 |
+
))}
|
| 294 |
+
</div>
|
| 295 |
+
</section>
|
| 296 |
+
)}
|
| 297 |
+
|
| 298 |
+
{/* Recent Attempts */}
|
| 299 |
+
{attempts.length > 0 && (
|
| 300 |
+
<section className="mb-10">
|
| 301 |
+
<div className="flex items-center justify-between mb-4">
|
| 302 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 303 |
+
Latihan Terakhir
|
| 304 |
+
</h2>
|
| 305 |
+
<Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 306 |
+
Lihat Semua
|
| 307 |
+
</Link>
|
| 308 |
+
</div>
|
| 309 |
+
<div className="space-y-3">
|
| 310 |
+
{attempts.map((a) => {
|
| 311 |
+
const pct =
|
| 312 |
+
a.maxScore && a.maxScore > 0 && a.totalScore != null
|
| 313 |
+
? Math.round((a.totalScore / a.maxScore) * 100)
|
| 314 |
+
: null;
|
| 315 |
+
return (
|
| 316 |
+
<Card
|
| 317 |
+
key={a.id}
|
| 318 |
+
className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
|
| 319 |
+
>
|
| 320 |
+
<CardContent className="p-4">
|
| 321 |
+
<div className="flex items-center justify-between gap-4">
|
| 322 |
+
<div className="flex-1 min-w-0">
|
| 323 |
+
<div className="flex items-center gap-2 mb-1">
|
| 324 |
+
{a.status === "completed" ? (
|
| 325 |
+
<MaterialIcon name="check_circle" className="text-[var(--matcha-600)] text-sm" />
|
| 326 |
+
) : a.status === "abandoned" ? (
|
| 327 |
+
<MaterialIcon name="cancel" className="text-[var(--warm-silver)] text-sm" />
|
| 328 |
+
) : (
|
| 329 |
+
<MaterialIcon name="hourglass_top" className="text-[var(--slushie-600)] text-sm" />
|
| 330 |
+
)}
|
| 331 |
+
<span className="text-sm font-semibold text-[var(--clay-black)] truncate">
|
| 332 |
+
{a.packageTitle ?? "Paket tidak diketahui"}
|
| 333 |
+
</span>
|
| 334 |
+
{a.examTypeName && (
|
| 335 |
+
<span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold shrink-0">
|
| 336 |
+
{a.examTypeName}
|
| 337 |
+
</span>
|
| 338 |
+
)}
|
| 339 |
+
</div>
|
| 340 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 341 |
+
{formatDateShort(a.startedAt)}
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div className="flex items-center gap-4 shrink-0">
|
| 345 |
+
{pct != null && (
|
| 346 |
+
<div className="text-right">
|
| 347 |
+
<div className="text-lg font-headline font-bold text-[var(--clay-black)]">
|
| 348 |
+
{pct}%
|
| 349 |
+
</div>
|
| 350 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 351 |
+
{a.totalScore}/{a.maxScore}
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
)}
|
| 355 |
+
{a.status === "completed" && (
|
| 356 |
+
<Link to="/attempt/$id" params={{ id: a.id }}>
|
| 357 |
+
<Button
|
| 358 |
+
variant="outline"
|
| 359 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 360 |
+
>
|
| 361 |
+
Hasil
|
| 362 |
+
</Button>
|
| 363 |
+
</Link>
|
| 364 |
+
)}
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
</CardContent>
|
| 368 |
+
</Card>
|
| 369 |
+
);
|
| 370 |
+
})}
|
| 371 |
+
</div>
|
| 372 |
+
</section>
|
| 373 |
+
)}
|
| 374 |
+
|
| 375 |
+
{/* Feature Cards - simplified, only show if new user */}
|
| 376 |
+
{isNewUser && (
|
| 377 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 378 |
+
<Link to="/bank" className="block">
|
| 379 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 380 |
+
<CardContent className="p-5">
|
| 381 |
+
<div className="flex items-center gap-3 mb-3">
|
| 382 |
+
<div className="h-9 w-9 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 383 |
+
<MaterialIcon name="database" className="text-lg text-[var(--clay-black)]" />
|
| 384 |
+
</div>
|
| 385 |
+
<h3 className="font-headline font-bold text-[var(--clay-black)]">Bank Soal</h3>
|
| 386 |
+
</div>
|
| 387 |
+
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 388 |
+
Simpan, publikasikan, dan buat paket dari soal pilihan.
|
| 389 |
+
</p>
|
| 390 |
+
</CardContent>
|
| 391 |
+
</Card>
|
| 392 |
+
</Link>
|
| 393 |
+
<Link to="/history" className="block">
|
| 394 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 395 |
+
<CardContent className="p-5">
|
| 396 |
+
<div className="flex items-center gap-3 mb-3">
|
| 397 |
+
<div className="h-9 w-9 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 398 |
+
<MaterialIcon name="history" className="text-lg text-[var(--lemon-800)]" />
|
| 399 |
+
</div>
|
| 400 |
+
<h3 className="font-headline font-bold text-[var(--clay-black)]">Riwayat</h3>
|
| 401 |
+
</div>
|
| 402 |
+
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 403 |
+
Lihat kembali hasil dan progres latihan kamu.
|
| 404 |
+
</p>
|
| 405 |
+
</CardContent>
|
| 406 |
+
</Card>
|
| 407 |
+
</Link>
|
| 408 |
+
<Link to="/settings" className="block">
|
| 409 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 410 |
+
<CardContent className="p-5">
|
| 411 |
+
<div className="flex items-center gap-3 mb-3">
|
| 412 |
+
<div className="h-9 w-9 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 413 |
+
<MaterialIcon name="settings" className="text-lg text-[var(--warm-charcoal)]" />
|
| 414 |
+
</div>
|
| 415 |
+
<h3 className="font-headline font-bold text-[var(--clay-black)]">Pengaturan</h3>
|
| 416 |
+
</div>
|
| 417 |
+
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 418 |
+
Kelola API key dan preferensi akun kamu.
|
| 419 |
+
</p>
|
| 420 |
+
</CardContent>
|
| 421 |
+
</Card>
|
| 422 |
+
</Link>
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
|
| 426 |
+
<TourGuide />
|
| 427 |
+
</div>
|
| 428 |
+
);
|
| 429 |
+
}
|
apps/web/src/routes/forgot-password.tsx
CHANGED
|
@@ -8,8 +8,10 @@ import { Label } from "@labas/ui/components/label";
|
|
| 8 |
import { toast } from "sonner";
|
| 9 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 10 |
import { trpc } from "@/utils/trpc";
|
|
|
|
| 11 |
|
| 12 |
export const Route = createFileRoute("/forgot-password")({
|
|
|
|
| 13 |
component: RouteComponent,
|
| 14 |
});
|
| 15 |
|
|
|
|
| 8 |
import { toast } from "sonner";
|
| 9 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 10 |
import { trpc } from "@/utils/trpc";
|
| 11 |
+
import { routeShell } from "@/lib/route-shell";
|
| 12 |
|
| 13 |
export const Route = createFileRoute("/forgot-password")({
|
| 14 |
+
staticData: routeShell.public,
|
| 15 |
component: RouteComponent,
|
| 16 |
});
|
| 17 |
|
apps/web/src/routes/{landing.lazy.tsx → index.lazy.tsx}
RENAMED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
import { LandingPage } from "@/components/routes/LandingPage";
|
| 3 |
|
| 4 |
-
export const Route = createLazyFileRoute("/
|
| 5 |
component: LandingPage,
|
| 6 |
});
|
|
|
|
| 1 |
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
import { LandingPage } from "@/components/routes/LandingPage";
|
| 3 |
|
| 4 |
+
export const Route = createLazyFileRoute("/")({
|
| 5 |
component: LandingPage,
|
| 6 |
});
|
apps/web/src/routes/index.tsx
CHANGED
|
@@ -1,397 +1,29 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
-
import {
|
| 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 { TourGuide } from "@/components/TourGuide";
|
| 9 |
|
| 10 |
export const Route = createFileRoute("/")({
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
beforeLoad: async () => {
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
-
if (!session.data.user.emailVerified) {
|
| 18 |
-
throw redirect({ to: "/verify-email", search: { email: session.data.user.email } });
|
| 19 |
-
}
|
| 20 |
-
if (!session.data.user.image) {
|
| 21 |
-
throw redirect({ to: "/setup-avatar" });
|
| 22 |
-
}
|
| 23 |
-
return { session };
|
| 24 |
},
|
| 25 |
});
|
| 26 |
-
|
| 27 |
-
function formatDateShort(dateStr: string | Date | null) {
|
| 28 |
-
if (!dateStr) return "-";
|
| 29 |
-
const d = new Date(dateStr);
|
| 30 |
-
return d.toLocaleDateString("id-ID", { day: "numeric", month: "short" });
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
function HomeComponent() {
|
| 34 |
-
const { session } = Route.useRouteContext();
|
| 35 |
-
const overview = useQuery(trpc.stats.overview.queryOptions());
|
| 36 |
-
const recentAttempts = useQuery(
|
| 37 |
-
trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
|
| 38 |
-
);
|
| 39 |
-
const featured = useQuery(
|
| 40 |
-
trpc.admin.listFeatured.queryOptions(),
|
| 41 |
-
);
|
| 42 |
-
|
| 43 |
-
const stats = overview.data;
|
| 44 |
-
const attempts = recentAttempts.data?.attempts ?? [];
|
| 45 |
-
const featuredPkg = featured.data?.packages ?? [];
|
| 46 |
-
const featuredQ = featured.data?.questions ?? [];
|
| 47 |
-
const hasFeatured = featuredPkg.length > 0 || featuredQ.length > 0;
|
| 48 |
-
|
| 49 |
-
const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
|
| 50 |
-
|
| 51 |
-
return (
|
| 52 |
-
<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)]">
|
| 53 |
-
{/* Header */}
|
| 54 |
-
<section className="mb-8">
|
| 55 |
-
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 56 |
-
Halo, {session.data?.user.name ?? "Pengguna"}!
|
| 57 |
-
</h1>
|
| 58 |
-
<p className="text-lg text-[var(--warm-charcoal)] mt-1">
|
| 59 |
-
{isNewUser ? "Siap mulai belajar? Pilih langkah di bawah ini." : "Lanjutkan latihanmu!"}
|
| 60 |
-
</p>
|
| 61 |
-
</section>
|
| 62 |
-
|
| 63 |
-
{/* Quick Start Banner for New Users */}
|
| 64 |
-
{isNewUser && (
|
| 65 |
-
<Card className="mb-8 bg-gradient-to-br from-[var(--matcha-300)]/40 to-[var(--slushie-500)]/30 border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
|
| 66 |
-
<CardContent className="p-6 md:p-8">
|
| 67 |
-
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-8">
|
| 68 |
-
<div className="shrink-0">
|
| 69 |
-
<div className="h-16 w-16 bg-[var(--clay-black)] rounded-[var(--radius-2xl)] flex items-center justify-center">
|
| 70 |
-
<MaterialIcon name="rocket_launch" className="text-3xl text-[var(--pure-white)]" />
|
| 71 |
-
</div>
|
| 72 |
-
</div>
|
| 73 |
-
<div className="flex-1">
|
| 74 |
-
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
|
| 75 |
-
Selamat Datang di Labas
|
| 76 |
-
</h2>
|
| 77 |
-
<p className="text-sm text-[var(--warm-charcoal)] mb-4">
|
| 78 |
-
Platform latihan ujian bahasa berbasis AI. Ikuti 3 langkah mudah untuk memulai:
|
| 79 |
-
</p>
|
| 80 |
-
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
| 81 |
-
<Link to="/generate" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--matcha-400)] transition-all group clay-hover">
|
| 82 |
-
<div className="h-9 w-9 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
|
| 83 |
-
<span className="text-xs font-black text-[var(--matcha-800)]">1</span>
|
| 84 |
-
</div>
|
| 85 |
-
<div className="flex-1 min-w-0">
|
| 86 |
-
<p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--matcha-800)]">Generate Soal</p>
|
| 87 |
-
<p className="text-xs text-[var(--warm-charcoal)]">Buat soal dengan AI</p>
|
| 88 |
-
</div>
|
| 89 |
-
<MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--matcha-800)]" />
|
| 90 |
-
</Link>
|
| 91 |
-
<Link to="/bank" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--slushie-500)] transition-all group clay-hover">
|
| 92 |
-
<div className="h-9 w-9 bg-[var(--slushie-500)]/30 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
|
| 93 |
-
<span className="text-xs font-black text-[var(--slushie-800)]">2</span>
|
| 94 |
-
</div>
|
| 95 |
-
<div className="flex-1 min-w-0">
|
| 96 |
-
<p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--slushie-800)]">Buat Paket</p>
|
| 97 |
-
<p className="text-xs text-[var(--warm-charcoal)]">Kumpulkan soal latihan</p>
|
| 98 |
-
</div>
|
| 99 |
-
<MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--slushie-800)]" />
|
| 100 |
-
</Link>
|
| 101 |
-
<Link to="/packages" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--lemon-600)] transition-all group clay-hover">
|
| 102 |
-
<div className="h-9 w-9 bg-[var(--lemon-400)]/50 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
|
| 103 |
-
<span className="text-xs font-black text-[var(--lemon-800)]">3</span>
|
| 104 |
-
</div>
|
| 105 |
-
<div className="flex-1 min-w-0">
|
| 106 |
-
<p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--lemon-800)]">Mulai Latihan</p>
|
| 107 |
-
<p className="text-xs text-[var(--warm-charcoal)]">Kerjakan paket soal</p>
|
| 108 |
-
</div>
|
| 109 |
-
<MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--lemon-800)]" />
|
| 110 |
-
</Link>
|
| 111 |
-
</div>
|
| 112 |
-
</div>
|
| 113 |
-
</div>
|
| 114 |
-
</CardContent>
|
| 115 |
-
</Card>
|
| 116 |
-
)}
|
| 117 |
-
|
| 118 |
-
{/* Quick Stats */}
|
| 119 |
-
<div data-tour="dashboard-stats" className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-10">
|
| 120 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 121 |
-
<CardContent className="p-5">
|
| 122 |
-
<div className="flex items-center gap-3">
|
| 123 |
-
<div className="h-11 w-11 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 124 |
-
<MaterialIcon name="assignment" className="text-lg text-[var(--matcha-800)]" />
|
| 125 |
-
</div>
|
| 126 |
-
<div>
|
| 127 |
-
<p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
|
| 128 |
-
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 129 |
-
{stats?.completedAttempts ?? 0}
|
| 130 |
-
</p>
|
| 131 |
-
</div>
|
| 132 |
-
</div>
|
| 133 |
-
</CardContent>
|
| 134 |
-
</Card>
|
| 135 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 136 |
-
<CardContent className="p-5">
|
| 137 |
-
<div className="flex items-center gap-3">
|
| 138 |
-
<div className="h-11 w-11 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 139 |
-
<MaterialIcon name="timer" className="text-lg text-[var(--clay-black)]" />
|
| 140 |
-
</div>
|
| 141 |
-
<div>
|
| 142 |
-
<p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
|
| 143 |
-
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 144 |
-
{stats ? `${Math.round(stats.totalTimeSpentSec / 60)}m` : "0m"}
|
| 145 |
-
</p>
|
| 146 |
-
</div>
|
| 147 |
-
</div>
|
| 148 |
-
</CardContent>
|
| 149 |
-
</Card>
|
| 150 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 151 |
-
<CardContent className="p-5">
|
| 152 |
-
<div className="flex items-center gap-3">
|
| 153 |
-
<div className="h-11 w-11 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 154 |
-
<MaterialIcon name="quiz" className="text-lg text-[var(--lemon-800)]" />
|
| 155 |
-
</div>
|
| 156 |
-
<div>
|
| 157 |
-
<p className="text-sm text-[var(--warm-charcoal)]">Soal Terjawab</p>
|
| 158 |
-
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 159 |
-
{stats?.totalQuestionsAnswered ?? 0}
|
| 160 |
-
</p>
|
| 161 |
-
</div>
|
| 162 |
-
</div>
|
| 163 |
-
</CardContent>
|
| 164 |
-
</Card>
|
| 165 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 166 |
-
<CardContent className="p-5">
|
| 167 |
-
<div className="flex items-center gap-3">
|
| 168 |
-
<div className="h-11 w-11 bg-[var(--pomegranate-400)]/30 rounded-[var(--radius-lg)] flex items-center justify-center">
|
| 169 |
-
<MaterialIcon name="trending_up" className="text-lg text-[var(--pomegranate-600)]" />
|
| 170 |
-
</div>
|
| 171 |
-
<div>
|
| 172 |
-
<p className="text-sm text-[var(--warm-charcoal)]">Akurasi</p>
|
| 173 |
-
<p className="text-xl font-bold text-[var(--clay-black)] font-headline">
|
| 174 |
-
{stats ? `${stats.overallAccuracyPct}%` : "-"}
|
| 175 |
-
</p>
|
| 176 |
-
</div>
|
| 177 |
-
</div>
|
| 178 |
-
</CardContent>
|
| 179 |
-
</Card>
|
| 180 |
-
</div>
|
| 181 |
-
|
| 182 |
-
{/* Quick Actions */}
|
| 183 |
-
<div className="flex flex-col sm:flex-row gap-3 mb-10">
|
| 184 |
-
<Link to="/generate" className="flex-1">
|
| 185 |
-
<Button className="w-full bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-12 clay-shadow clay-hover text-base">
|
| 186 |
-
<MaterialIcon name="auto_awesome" className="text-xl" />
|
| 187 |
-
<span className="ml-2">Generate Soal Baru</span>
|
| 188 |
-
</Button>
|
| 189 |
-
</Link>
|
| 190 |
-
<Link to="/packages" className="flex-1">
|
| 191 |
-
<Button variant="outline" className="w-full rounded-[var(--radius-lg)] h-12 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover text-base">
|
| 192 |
-
<MaterialIcon name="folder" className="text-xl" />
|
| 193 |
-
<span className="ml-2">Mulai Latihan</span>
|
| 194 |
-
</Button>
|
| 195 |
-
</Link>
|
| 196 |
-
<Link to="/analytics" className="flex-1">
|
| 197 |
-
<Button variant="outline" className="w-full rounded-[var(--radius-lg)] h-12 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover text-base">
|
| 198 |
-
<MaterialIcon name="analytics" className="text-xl" />
|
| 199 |
-
<span className="ml-2">Lihat Analitik</span>
|
| 200 |
-
</Button>
|
| 201 |
-
</Link>
|
| 202 |
-
</div>
|
| 203 |
-
|
| 204 |
-
{/* Editor's Pick */}
|
| 205 |
-
{hasFeatured && (
|
| 206 |
-
<section className="mb-10">
|
| 207 |
-
<div className="flex items-center justify-between mb-4">
|
| 208 |
-
<div className="flex items-center gap-2">
|
| 209 |
-
<MaterialIcon name="star" className="text-[var(--sunbeam-600)]" />
|
| 210 |
-
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 211 |
-
Editor's Pick
|
| 212 |
-
</h2>
|
| 213 |
-
</div>
|
| 214 |
-
<Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 215 |
-
Lihat Semua
|
| 216 |
-
</Link>
|
| 217 |
-
</div>
|
| 218 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 219 |
-
{featuredPkg.slice(0, 3).map((pkg) => (
|
| 220 |
-
<Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
|
| 221 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full transition-all hover:border-[var(--matcha-400)]">
|
| 222 |
-
<CardContent className="p-5">
|
| 223 |
-
<div className="flex items-start justify-between gap-2 mb-3">
|
| 224 |
-
<span className="px-2 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold shrink-0">
|
| 225 |
-
{pkg.examTypeName}
|
| 226 |
-
</span>
|
| 227 |
-
<span className="text-xs text-[var(--warm-silver)] whitespace-nowrap">
|
| 228 |
-
{pkg.usageCount}x digunakan
|
| 229 |
-
</span>
|
| 230 |
-
</div>
|
| 231 |
-
<h3 className="font-headline text-base font-bold text-[var(--clay-black)] mb-1 line-clamp-2">
|
| 232 |
-
{pkg.title}
|
| 233 |
-
</h3>
|
| 234 |
-
{pkg.description && (
|
| 235 |
-
<p className="text-xs text-[var(--warm-charcoal)] line-clamp-2 mb-3">
|
| 236 |
-
{pkg.description}
|
| 237 |
-
</p>
|
| 238 |
-
)}
|
| 239 |
-
<div className="flex items-center justify-between pt-3 border-t border-[var(--oat-border)]">
|
| 240 |
-
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
| 241 |
-
<span className="flex items-center gap-1">
|
| 242 |
-
<MaterialIcon name="quiz" className="text-xs" />
|
| 243 |
-
{pkg.totalQuestions}
|
| 244 |
-
</span>
|
| 245 |
-
<span className="flex items-center gap-1">
|
| 246 |
-
<MaterialIcon name="folder" className="text-xs" />
|
| 247 |
-
{pkg.totalSections}
|
| 248 |
-
</span>
|
| 249 |
-
{pkg.estimatedDurationMin && (
|
| 250 |
-
<span className="flex items-center gap-1">
|
| 251 |
-
<MaterialIcon name="timer" className="text-xs" />
|
| 252 |
-
{pkg.estimatedDurationMin}m
|
| 253 |
-
</span>
|
| 254 |
-
)}
|
| 255 |
-
</div>
|
| 256 |
-
<MaterialIcon name="play_circle" className="text-xl text-[var(--matcha-600)]" />
|
| 257 |
-
</div>
|
| 258 |
-
</CardContent>
|
| 259 |
-
</Card>
|
| 260 |
-
</Link>
|
| 261 |
-
))}
|
| 262 |
-
</div>
|
| 263 |
-
</section>
|
| 264 |
-
)}
|
| 265 |
-
|
| 266 |
-
{/* Recent Attempts */}
|
| 267 |
-
{attempts.length > 0 && (
|
| 268 |
-
<section className="mb-10">
|
| 269 |
-
<div className="flex items-center justify-between mb-4">
|
| 270 |
-
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 271 |
-
Latihan Terakhir
|
| 272 |
-
</h2>
|
| 273 |
-
<Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 274 |
-
Lihat Semua
|
| 275 |
-
</Link>
|
| 276 |
-
</div>
|
| 277 |
-
<div className="space-y-3">
|
| 278 |
-
{attempts.map((a) => {
|
| 279 |
-
const pct =
|
| 280 |
-
a.maxScore && a.maxScore > 0 && a.totalScore != null
|
| 281 |
-
? Math.round((a.totalScore / a.maxScore) * 100)
|
| 282 |
-
: null;
|
| 283 |
-
return (
|
| 284 |
-
<Card
|
| 285 |
-
key={a.id}
|
| 286 |
-
className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
|
| 287 |
-
>
|
| 288 |
-
<CardContent className="p-4">
|
| 289 |
-
<div className="flex items-center justify-between gap-4">
|
| 290 |
-
<div className="flex-1 min-w-0">
|
| 291 |
-
<div className="flex items-center gap-2 mb-1">
|
| 292 |
-
{a.status === "completed" ? (
|
| 293 |
-
<MaterialIcon name="check_circle" className="text-[var(--matcha-600)] text-sm" />
|
| 294 |
-
) : a.status === "abandoned" ? (
|
| 295 |
-
<MaterialIcon name="cancel" className="text-[var(--warm-silver)] text-sm" />
|
| 296 |
-
) : (
|
| 297 |
-
<MaterialIcon name="hourglass_top" className="text-[var(--slushie-600)] text-sm" />
|
| 298 |
-
)}
|
| 299 |
-
<span className="text-sm font-semibold text-[var(--clay-black)] truncate">
|
| 300 |
-
{a.packageTitle ?? "Paket tidak diketahui"}
|
| 301 |
-
</span>
|
| 302 |
-
{a.examTypeName && (
|
| 303 |
-
<span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold shrink-0">
|
| 304 |
-
{a.examTypeName}
|
| 305 |
-
</span>
|
| 306 |
-
)}
|
| 307 |
-
</div>
|
| 308 |
-
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 309 |
-
{formatDateShort(a.startedAt)}
|
| 310 |
-
</div>
|
| 311 |
-
</div>
|
| 312 |
-
<div className="flex items-center gap-4 shrink-0">
|
| 313 |
-
{pct != null && (
|
| 314 |
-
<div className="text-right">
|
| 315 |
-
<div className="text-lg font-headline font-bold text-[var(--clay-black)]">
|
| 316 |
-
{pct}%
|
| 317 |
-
</div>
|
| 318 |
-
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 319 |
-
{a.totalScore}/{a.maxScore}
|
| 320 |
-
</div>
|
| 321 |
-
</div>
|
| 322 |
-
)}
|
| 323 |
-
{a.status === "completed" && (
|
| 324 |
-
<Link to="/attempt/$id" params={{ id: a.id }}>
|
| 325 |
-
<Button
|
| 326 |
-
variant="outline"
|
| 327 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 328 |
-
>
|
| 329 |
-
Hasil
|
| 330 |
-
</Button>
|
| 331 |
-
</Link>
|
| 332 |
-
)}
|
| 333 |
-
</div>
|
| 334 |
-
</div>
|
| 335 |
-
</CardContent>
|
| 336 |
-
</Card>
|
| 337 |
-
);
|
| 338 |
-
})}
|
| 339 |
-
</div>
|
| 340 |
-
</section>
|
| 341 |
-
)}
|
| 342 |
-
|
| 343 |
-
{/* Feature Cards - simplified, only show if new user */}
|
| 344 |
-
{isNewUser && (
|
| 345 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 346 |
-
<Link to="/bank" className="block">
|
| 347 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 348 |
-
<CardContent className="p-5">
|
| 349 |
-
<div className="flex items-center gap-3 mb-3">
|
| 350 |
-
<div className="h-9 w-9 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 351 |
-
<MaterialIcon name="database" className="text-lg text-[var(--clay-black)]" />
|
| 352 |
-
</div>
|
| 353 |
-
<h3 className="font-headline font-bold text-[var(--clay-black)]">Bank Soal</h3>
|
| 354 |
-
</div>
|
| 355 |
-
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 356 |
-
Simpan, publikasikan, dan buat paket dari soal pilihan.
|
| 357 |
-
</p>
|
| 358 |
-
</CardContent>
|
| 359 |
-
</Card>
|
| 360 |
-
</Link>
|
| 361 |
-
<Link to="/history" className="block">
|
| 362 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 363 |
-
<CardContent className="p-5">
|
| 364 |
-
<div className="flex items-center gap-3 mb-3">
|
| 365 |
-
<div className="h-9 w-9 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 366 |
-
<MaterialIcon name="history" className="text-lg text-[var(--lemon-800)]" />
|
| 367 |
-
</div>
|
| 368 |
-
<h3 className="font-headline font-bold text-[var(--clay-black)]">Riwayat</h3>
|
| 369 |
-
</div>
|
| 370 |
-
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 371 |
-
Lihat kembali hasil dan progres latihan kamu.
|
| 372 |
-
</p>
|
| 373 |
-
</CardContent>
|
| 374 |
-
</Card>
|
| 375 |
-
</Link>
|
| 376 |
-
<Link to="/settings" className="block">
|
| 377 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
|
| 378 |
-
<CardContent className="p-5">
|
| 379 |
-
<div className="flex items-center gap-3 mb-3">
|
| 380 |
-
<div className="h-9 w-9 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
|
| 381 |
-
<MaterialIcon name="settings" className="text-lg text-[var(--warm-charcoal)]" />
|
| 382 |
-
</div>
|
| 383 |
-
<h3 className="font-headline font-bold text-[var(--clay-black)]">Pengaturan</h3>
|
| 384 |
-
</div>
|
| 385 |
-
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 386 |
-
Kelola API key dan preferensi akun kamu.
|
| 387 |
-
</p>
|
| 388 |
-
</CardContent>
|
| 389 |
-
</Card>
|
| 390 |
-
</Link>
|
| 391 |
-
</div>
|
| 392 |
-
)}
|
| 393 |
-
|
| 394 |
-
<TourGuide />
|
| 395 |
-
</div>
|
| 396 |
-
);
|
| 397 |
-
}
|
|
|
|
| 1 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
|
|
| 2 |
import { authClient } from "@/lib/auth-client";
|
| 3 |
+
import { routeShell } from "@/lib/route-shell";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export const Route = createFileRoute("/")({
|
| 6 |
+
staticData: routeShell.public,
|
| 7 |
+
head: () => ({
|
| 8 |
+
meta: [
|
| 9 |
+
{ title: "Labas — AI-Powered Exam Practice Platform" },
|
| 10 |
+
{ name: "description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
|
| 11 |
+
{ property: "og:title", content: "Labas — AI-Powered Exam Practice Platform" },
|
| 12 |
+
{ property: "og:description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
|
| 13 |
+
{ property: "og:url", content: "https://labas.rogasper.com/" },
|
| 14 |
+
],
|
| 15 |
+
links: [
|
| 16 |
+
{ rel: "canonical", href: "https://labas.rogasper.com/" },
|
| 17 |
+
],
|
| 18 |
+
}),
|
| 19 |
beforeLoad: async () => {
|
| 20 |
+
try {
|
| 21 |
+
const session = await authClient.getSession();
|
| 22 |
+
if (session?.data) {
|
| 23 |
+
throw redirect({ to: "/dashboard", replace: true });
|
| 24 |
+
}
|
| 25 |
+
} catch (e) {
|
| 26 |
+
if (e instanceof Response) throw e;
|
| 27 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
},
|
| 29 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/web/src/routes/landing.tsx
CHANGED
|
@@ -1,27 +1,9 @@
|
|
| 1 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
export const Route = createFileRoute("/landing")({
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
{ name: "description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
|
| 9 |
-
{ property: "og:title", content: "Labas — AI-Powered Exam Practice Platform" },
|
| 10 |
-
{ property: "og:description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
|
| 11 |
-
{ property: "og:url", content: "https://labas.rogasper.com/landing" },
|
| 12 |
-
],
|
| 13 |
-
links: [
|
| 14 |
-
{ rel: "canonical", href: "https://labas.rogasper.com/landing" },
|
| 15 |
-
],
|
| 16 |
-
}),
|
| 17 |
-
beforeLoad: async () => {
|
| 18 |
-
try {
|
| 19 |
-
const session = await authClient.getSession();
|
| 20 |
-
if (session?.data) {
|
| 21 |
-
throw redirect({ to: "/", replace: true });
|
| 22 |
-
}
|
| 23 |
-
} catch (e) {
|
| 24 |
-
if (e instanceof Response) throw e;
|
| 25 |
-
}
|
| 26 |
},
|
| 27 |
});
|
|
|
|
| 1 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 2 |
+
import { routeShell } from "@/lib/route-shell";
|
| 3 |
|
| 4 |
export const Route = createFileRoute("/landing")({
|
| 5 |
+
staticData: routeShell.public,
|
| 6 |
+
beforeLoad: () => {
|
| 7 |
+
throw redirect({ to: "/", replace: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
},
|
| 9 |
});
|
apps/web/src/routes/leaderboard.tsx
CHANGED
|
@@ -73,7 +73,7 @@ function LeaderboardPage() {
|
|
| 73 |
{/* Header */}
|
| 74 |
<div className="mb-8">
|
| 75 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 76 |
-
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 77 |
Beranda
|
| 78 |
</Link>
|
| 79 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
|
|
|
| 73 |
{/* Header */}
|
| 74 |
<div className="mb-8">
|
| 75 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 76 |
+
<Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
|
| 77 |
Beranda
|
| 78 |
</Link>
|
| 79 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
apps/web/src/routes/login.tsx
CHANGED
|
@@ -3,8 +3,10 @@ import { useState } from "react";
|
|
| 3 |
|
| 4 |
import SignInForm from "@/components/sign-in-form";
|
| 5 |
import SignUpForm from "@/components/sign-up-form";
|
|
|
|
| 6 |
|
| 7 |
export const Route = createFileRoute("/login")({
|
|
|
|
| 8 |
component: RouteComponent,
|
| 9 |
});
|
| 10 |
|
|
|
|
| 3 |
|
| 4 |
import SignInForm from "@/components/sign-in-form";
|
| 5 |
import SignUpForm from "@/components/sign-up-form";
|
| 6 |
+
import { routeShell } from "@/lib/route-shell";
|
| 7 |
|
| 8 |
export const Route = createFileRoute("/login")({
|
| 9 |
+
staticData: routeShell.public,
|
| 10 |
component: RouteComponent,
|
| 11 |
});
|
| 12 |
|
apps/web/src/routes/me.tsx
CHANGED
|
@@ -55,7 +55,7 @@ function MeComponent() {
|
|
| 55 |
{/* Header */}
|
| 56 |
<div className="mb-8">
|
| 57 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 58 |
-
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 59 |
Beranda
|
| 60 |
</Link>
|
| 61 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
|
|
|
| 55 |
{/* Header */}
|
| 56 |
<div className="mb-8">
|
| 57 |
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 58 |
+
<Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
|
| 59 |
Beranda
|
| 60 |
</Link>
|
| 61 |
<MaterialIcon name="chevron_right" className="text-xs" />
|
apps/web/src/routes/package.$id.attempt.$attemptId.tsx
CHANGED
|
@@ -6,8 +6,10 @@ import { useTestSession } from "@/hooks/use-test-session";
|
|
| 6 |
import { Button } from "@labas/ui/components/button";
|
| 7 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 8 |
import { AttemptTestView } from "@/components/test/AttemptTestView";
|
|
|
|
| 9 |
|
| 10 |
export const Route = createFileRoute("/package/$id/attempt/$attemptId")({
|
|
|
|
| 11 |
component: ContinueAttemptComponent,
|
| 12 |
beforeLoad: async () => {
|
| 13 |
const session = await authClient.getSession();
|
|
|
|
| 6 |
import { Button } from "@labas/ui/components/button";
|
| 7 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 8 |
import { AttemptTestView } from "@/components/test/AttemptTestView";
|
| 9 |
+
import { routeShell } from "@/lib/route-shell";
|
| 10 |
|
| 11 |
export const Route = createFileRoute("/package/$id/attempt/$attemptId")({
|
| 12 |
+
staticData: routeShell.fullscreen,
|
| 13 |
component: ContinueAttemptComponent,
|
| 14 |
beforeLoad: async () => {
|
| 15 |
const session = await authClient.getSession();
|
apps/web/src/routes/package.$id.index.tsx
CHANGED
|
@@ -65,73 +65,111 @@ function PackageDetailComponent() {
|
|
| 65 |
const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
|
| 66 |
|
| 67 |
return (
|
| 68 |
-
<div className="min-h-screen
|
| 69 |
-
{/*
|
| 70 |
-
<div className="
|
| 71 |
-
<
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</span>
|
| 91 |
-
|
| 92 |
-
<
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
<span className="
|
| 97 |
-
|
|
|
|
| 98 |
</span>
|
| 99 |
)}
|
| 100 |
-
</div>
|
| 101 |
-
</div>
|
| 102 |
-
|
| 103 |
-
<div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)]">
|
| 104 |
-
<Link
|
| 105 |
-
to="/profile/$userId"
|
| 106 |
-
params={{ userId: pkg.creatorUserId }}
|
| 107 |
-
className="flex items-center gap-1 hover:text-[var(--clay-black)] transition-colors"
|
| 108 |
-
>
|
| 109 |
-
<MaterialIcon name="person" className="text-sm" />
|
| 110 |
-
{pkg.creatorName ?? "Anonim"}
|
| 111 |
-
</Link>
|
| 112 |
-
<span className="flex items-center gap-1">
|
| 113 |
-
<MaterialIcon name="quiz" className="text-sm" />
|
| 114 |
-
{totalQuestions} soal
|
| 115 |
-
</span>
|
| 116 |
-
<span className="flex items-center gap-1">
|
| 117 |
-
<MaterialIcon name="folder" className="text-sm" />
|
| 118 |
-
{pkg.totalSections} section
|
| 119 |
-
</span>
|
| 120 |
-
{pkg.estimatedDurationMin && (
|
| 121 |
<span className="flex items-center gap-1">
|
| 122 |
-
<MaterialIcon name="
|
| 123 |
-
{pkg.
|
| 124 |
</span>
|
| 125 |
-
|
| 126 |
-
<span className="flex items-center gap-1">
|
| 127 |
-
<MaterialIcon name="trending_up" className="text-sm" />
|
| 128 |
-
{pkg.usageCount}x digunakan
|
| 129 |
-
</span>
|
| 130 |
</div>
|
| 131 |
</div>
|
| 132 |
|
| 133 |
{/* Sections */}
|
| 134 |
-
<div className="space-y-6">
|
| 135 |
{pkg.sections.map((section) => (
|
| 136 |
<Card key={section.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 137 |
<CardContent className="p-6">
|
|
@@ -181,40 +219,6 @@ function PackageDetailComponent() {
|
|
| 181 |
))}
|
| 182 |
</div>
|
| 183 |
|
| 184 |
-
{/* Actions */}
|
| 185 |
-
<div className="flex gap-3 mt-8">
|
| 186 |
-
<Link to="/package/$id/take" params={{ id }}>
|
| 187 |
-
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
|
| 188 |
-
<MaterialIcon name="play_arrow" />
|
| 189 |
-
<span className="ml-2">Mulai Latihan</span>
|
| 190 |
-
</Button>
|
| 191 |
-
</Link>
|
| 192 |
-
{pkg.isPublic && (
|
| 193 |
-
<Button
|
| 194 |
-
variant="outline"
|
| 195 |
-
onClick={() => {
|
| 196 |
-
const url = `${window.location.origin}/package/${id}`;
|
| 197 |
-
navigator.clipboard.writeText(url);
|
| 198 |
-
toast.success("Link paket disalin ke clipboard!");
|
| 199 |
-
}}
|
| 200 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 201 |
-
>
|
| 202 |
-
<MaterialIcon name="share" />
|
| 203 |
-
<span className="ml-2">Bagikan</span>
|
| 204 |
-
</Button>
|
| 205 |
-
)}
|
| 206 |
-
{isOwner && (
|
| 207 |
-
<Button
|
| 208 |
-
variant="outline"
|
| 209 |
-
onClick={() => setIsEditOpen(true)}
|
| 210 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 211 |
-
>
|
| 212 |
-
<MaterialIcon name="edit" />
|
| 213 |
-
<span className="ml-2">Edit Paket</span>
|
| 214 |
-
</Button>
|
| 215 |
-
)}
|
| 216 |
-
</div>
|
| 217 |
-
|
| 218 |
{isEditOpen && pkg && (
|
| 219 |
<EditPackageModal
|
| 220 |
initialTitle={pkg.title}
|
|
|
|
| 65 |
const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
|
| 66 |
|
| 67 |
return (
|
| 68 |
+
<div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
|
| 69 |
+
{/* Sticky header */}
|
| 70 |
+
<div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm">
|
| 71 |
+
<div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4">
|
| 72 |
+
{/* Breadcrumb */}
|
| 73 |
+
<div className="flex items-center gap-2 text-xs text-[var(--warm-charcoal)] mb-3">
|
| 74 |
+
<Link to="/bank" className="hover:text-[var(--clay-black)] transition-colors">Bank Soal</Link>
|
| 75 |
+
<MaterialIcon name="chevron_right" className="text-[10px]" />
|
| 76 |
+
<span className="text-[var(--clay-black)] font-medium truncate max-w-[240px]">{pkg.title}</span>
|
| 77 |
+
</div>
|
| 78 |
|
| 79 |
+
{/* Title row + actions */}
|
| 80 |
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
| 81 |
+
<div className="flex-1 min-w-0">
|
| 82 |
+
<div className="flex flex-wrap items-center gap-2 mb-1">
|
| 83 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 84 |
+
{pkg.examTypeName}
|
| 85 |
+
</span>
|
| 86 |
+
{pkg.isPublic ? (
|
| 87 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
|
| 88 |
+
Publik
|
| 89 |
+
</span>
|
| 90 |
+
) : (
|
| 91 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
|
| 92 |
+
Privat
|
| 93 |
+
</span>
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
<h1 className="text-2xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight leading-tight">
|
| 97 |
+
{pkg.title}
|
| 98 |
+
</h1>
|
| 99 |
+
{pkg.description && (
|
| 100 |
+
<p className="text-sm text-[var(--warm-charcoal)] mt-1 line-clamp-1">{pkg.description}</p>
|
| 101 |
+
)}
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
{/* Action buttons */}
|
| 105 |
+
<div className="flex items-center gap-2 shrink-0">
|
| 106 |
+
<Link to="/package/$id/take" params={{ id }}>
|
| 107 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
|
| 108 |
+
<MaterialIcon name="play_arrow" />
|
| 109 |
+
<span className="ml-1.5">Mulai Latihan</span>
|
| 110 |
+
</Button>
|
| 111 |
+
</Link>
|
| 112 |
+
{pkg.isPublic && (
|
| 113 |
+
<Button
|
| 114 |
+
variant="outline"
|
| 115 |
+
onClick={() => {
|
| 116 |
+
const url = `${window.location.origin}/package/${id}`;
|
| 117 |
+
navigator.clipboard.writeText(url);
|
| 118 |
+
toast.success("Link paket disalin ke clipboard!");
|
| 119 |
+
}}
|
| 120 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 121 |
+
>
|
| 122 |
+
<MaterialIcon name="share" />
|
| 123 |
+
<span className="ml-1.5 hidden sm:inline">Bagikan</span>
|
| 124 |
+
</Button>
|
| 125 |
+
)}
|
| 126 |
+
{isOwner && (
|
| 127 |
+
<Button
|
| 128 |
+
variant="outline"
|
| 129 |
+
onClick={() => setIsEditOpen(true)}
|
| 130 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 131 |
+
>
|
| 132 |
+
<MaterialIcon name="edit" />
|
| 133 |
+
<span className="ml-1.5 hidden sm:inline">Edit Paket</span>
|
| 134 |
+
</Button>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
</div>
|
| 138 |
+
|
| 139 |
+
{/* Stats row */}
|
| 140 |
+
<div className="flex flex-wrap gap-4 text-xs text-[var(--warm-charcoal)] mt-3">
|
| 141 |
+
<Link
|
| 142 |
+
to="/profile/$userId"
|
| 143 |
+
params={{ userId: pkg.creatorUserId }}
|
| 144 |
+
className="flex items-center gap-1 hover:text-[var(--clay-black)] transition-colors"
|
| 145 |
+
>
|
| 146 |
+
<MaterialIcon name="person" className="text-xs" />
|
| 147 |
+
{pkg.creatorName ?? "Anonim"}
|
| 148 |
+
</Link>
|
| 149 |
+
<span className="flex items-center gap-1">
|
| 150 |
+
<MaterialIcon name="quiz" className="text-xs" />
|
| 151 |
+
{totalQuestions} soal
|
| 152 |
</span>
|
| 153 |
+
<span className="flex items-center gap-1">
|
| 154 |
+
<MaterialIcon name="folder" className="text-xs" />
|
| 155 |
+
{pkg.totalSections} section
|
| 156 |
+
</span>
|
| 157 |
+
{pkg.estimatedDurationMin && (
|
| 158 |
+
<span className="flex items-center gap-1">
|
| 159 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 160 |
+
{pkg.estimatedDurationMin} menit
|
| 161 |
</span>
|
| 162 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
<span className="flex items-center gap-1">
|
| 164 |
+
<MaterialIcon name="trending_up" className="text-xs" />
|
| 165 |
+
{pkg.usageCount}x digunakan
|
| 166 |
</span>
|
| 167 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
|
| 171 |
{/* Sections */}
|
| 172 |
+
<div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8 space-y-6">
|
| 173 |
{pkg.sections.map((section) => (
|
| 174 |
<Card key={section.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 175 |
<CardContent className="p-6">
|
|
|
|
| 219 |
))}
|
| 220 |
</div>
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
{isEditOpen && pkg && (
|
| 223 |
<EditPackageModal
|
| 224 |
initialTitle={pkg.title}
|
apps/web/src/routes/package.$id.take.tsx
CHANGED
|
@@ -8,8 +8,10 @@ import { Button } from "@labas/ui/components/button";
|
|
| 8 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
import { AttemptTestView } from "@/components/test/AttemptTestView";
|
|
|
|
| 11 |
|
| 12 |
export const Route = createFileRoute("/package/$id/take")({
|
|
|
|
| 13 |
component: TakeTestComponent,
|
| 14 |
beforeLoad: async () => {
|
| 15 |
const session = await authClient.getSession();
|
|
|
|
| 8 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
import { AttemptTestView } from "@/components/test/AttemptTestView";
|
| 11 |
+
import { routeShell } from "@/lib/route-shell";
|
| 12 |
|
| 13 |
export const Route = createFileRoute("/package/$id/take")({
|
| 14 |
+
staticData: routeShell.fullscreen,
|
| 15 |
component: TakeTestComponent,
|
| 16 |
beforeLoad: async () => {
|
| 17 |
const session = await authClient.getSession();
|
apps/web/src/routes/setup-avatar.tsx
CHANGED
|
@@ -19,12 +19,14 @@ import {
|
|
| 19 |
HAIR_COLORS,
|
| 20 |
type DiceBearLoreleiOptions,
|
| 21 |
} from "@/lib/avatar-url";
|
|
|
|
| 22 |
|
| 23 |
function generateRandomSeeds(count: number): string[] {
|
| 24 |
return Array.from({ length: count }, () => randomSeed());
|
| 25 |
}
|
| 26 |
|
| 27 |
export const Route = createFileRoute("/setup-avatar")({
|
|
|
|
| 28 |
component: SetupAvatarComponent,
|
| 29 |
validateSearch: (
|
| 30 |
search: Record<string, unknown>,
|
|
@@ -38,7 +40,7 @@ export const Route = createFileRoute("/setup-avatar")({
|
|
| 38 |
throw redirect({ to: "/login" });
|
| 39 |
}
|
| 40 |
if (!search.redirectTo && session.data.user.image) {
|
| 41 |
-
throw redirect({ to: "/" });
|
| 42 |
}
|
| 43 |
return { session };
|
| 44 |
},
|
|
@@ -86,7 +88,7 @@ function SetupAvatarComponent() {
|
|
| 86 |
|
| 87 |
const handleSave = useCallback(async () => {
|
| 88 |
await updateMutation.mutateAsync({ image: avatarUrl });
|
| 89 |
-
navigate({ to: redirectTo || "/" });
|
| 90 |
}, [avatarUrl, updateMutation, navigate, redirectTo]);
|
| 91 |
|
| 92 |
const quickPickUrls = useMemo(
|
|
|
|
| 19 |
HAIR_COLORS,
|
| 20 |
type DiceBearLoreleiOptions,
|
| 21 |
} from "@/lib/avatar-url";
|
| 22 |
+
import { routeShell } from "@/lib/route-shell";
|
| 23 |
|
| 24 |
function generateRandomSeeds(count: number): string[] {
|
| 25 |
return Array.from({ length: count }, () => randomSeed());
|
| 26 |
}
|
| 27 |
|
| 28 |
export const Route = createFileRoute("/setup-avatar")({
|
| 29 |
+
staticData: routeShell.public,
|
| 30 |
component: SetupAvatarComponent,
|
| 31 |
validateSearch: (
|
| 32 |
search: Record<string, unknown>,
|
|
|
|
| 40 |
throw redirect({ to: "/login" });
|
| 41 |
}
|
| 42 |
if (!search.redirectTo && session.data.user.image) {
|
| 43 |
+
throw redirect({ to: "/dashboard" });
|
| 44 |
}
|
| 45 |
return { session };
|
| 46 |
},
|
|
|
|
| 88 |
|
| 89 |
const handleSave = useCallback(async () => {
|
| 90 |
await updateMutation.mutateAsync({ image: avatarUrl });
|
| 91 |
+
navigate({ to: redirectTo || "/dashboard" });
|
| 92 |
}, [avatarUrl, updateMutation, navigate, redirectTo]);
|
| 93 |
|
| 94 |
const quickPickUrls = useMemo(
|
apps/web/src/routes/verify-email.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { authClient } from "@/lib/auth-client";
|
| 10 |
import { trpc } from "@/utils/trpc";
|
|
|
|
| 11 |
import { z } from "zod";
|
| 12 |
|
| 13 |
const searchSchema = z.object({
|
|
@@ -15,6 +16,7 @@ const searchSchema = z.object({
|
|
| 15 |
});
|
| 16 |
|
| 17 |
export const Route = createFileRoute("/verify-email")({
|
|
|
|
| 18 |
component: RouteComponent,
|
| 19 |
validateSearch: searchSchema,
|
| 20 |
beforeLoad: async ({ search }) => {
|
|
@@ -102,7 +104,7 @@ function RouteComponent() {
|
|
| 102 |
toast.success("Email berhasil diverifikasi!");
|
| 103 |
const session = await authClient.getSession();
|
| 104 |
if (session.data) {
|
| 105 |
-
navigate({ to: "/" });
|
| 106 |
} else {
|
| 107 |
navigate({ to: "/login" });
|
| 108 |
}
|
|
|
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { authClient } from "@/lib/auth-client";
|
| 10 |
import { trpc } from "@/utils/trpc";
|
| 11 |
+
import { routeShell } from "@/lib/route-shell";
|
| 12 |
import { z } from "zod";
|
| 13 |
|
| 14 |
const searchSchema = z.object({
|
|
|
|
| 16 |
});
|
| 17 |
|
| 18 |
export const Route = createFileRoute("/verify-email")({
|
| 19 |
+
staticData: routeShell.public,
|
| 20 |
component: RouteComponent,
|
| 21 |
validateSearch: searchSchema,
|
| 22 |
beforeLoad: async ({ search }) => {
|
|
|
|
| 104 |
toast.success("Email berhasil diverifikasi!");
|
| 105 |
const session = await authClient.getSession();
|
| 106 |
if (session.data) {
|
| 107 |
+
navigate({ to: "/dashboard" });
|
| 108 |
} else {
|
| 109 |
navigate({ to: "/login" });
|
| 110 |
}
|