summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-05-29 16:25:31 +0700
committerpolwex <polwex@sortug.com>2025-05-29 16:25:31 +0700
commita03c92dc82ad527d7da6bbaa3c43000e2e5f0e69 (patch)
tree9a47cae250d043d31f751c1383bdcbe09d4bc9d8
parent7de09570c0d7907424c30f492207e80ff69e4061 (diff)
better better
-rw-r--r--bun.lock3
-rw-r--r--package.json1
-rw-r--r--src/actions/logintest/funcs.ts (renamed from src/pages/logintest/funcs.ts)0
-rw-r--r--src/actions/tones.ts47
-rw-r--r--src/components/logintest/Form.tsx (renamed from src/pages/logintest/Form.tsx)0
-rw-r--r--src/components/logintest/ServerForm.tsx (renamed from src/pages/logintest/ServerForm.tsx)0
-rw-r--r--src/components/ui/badge.tsx36
-rw-r--r--src/components/ui/tabs.tsx64
-rw-r--r--src/lib/db/index.ts106
-rw-r--r--src/lib/server/cookie.ts14
-rw-r--r--src/lib/services/srs_study.ts21
-rw-r--r--src/pages.gen.ts5
-rw-r--r--src/pages/logintest/index.tsx6
-rw-r--r--src/pages/study.tsx480
14 files changed, 735 insertions, 48 deletions
diff --git a/bun.lock b/bun.lock
index 92e40f2..aca40e3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
+ "@radix-ui/react-tabs": "^1.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookie": "^1.0.2",
@@ -242,6 +243,8 @@
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
+
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
diff --git a/package.json b/package.json
index 7d9d795..291dd1b 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
+ "@radix-ui/react-tabs": "^1.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookie": "^1.0.2",
diff --git a/src/pages/logintest/funcs.ts b/src/actions/logintest/funcs.ts
index 4ffd5ef..4ffd5ef 100644
--- a/src/pages/logintest/funcs.ts
+++ b/src/actions/logintest/funcs.ts
diff --git a/src/actions/tones.ts b/src/actions/tones.ts
index 8089453..7d85d1c 100644
--- a/src/actions/tones.ts
+++ b/src/actions/tones.ts
@@ -1,31 +1,31 @@
-'use server';
+"use server";
-import db from '@/lib/db';
-import { WordData } from '@/zoom/logic/types';
+import db from "@/lib/db";
+import { WordData } from "@/zoom/logic/types";
// Helper to extract tone from prosody - assuming prosody is an array of objects like [{tone: number}, ...]
const getTonesFromProsody = (prosody: any): number[] | null => {
if (Array.isArray(prosody) && prosody.length > 0) {
- return prosody.map(p => p.tone).filter(t => typeof t === 'number');
+ return prosody.map((p) => p.tone).filter((t) => typeof t === "number");
}
return null;
};
export async function fetchWordsByToneAndSyllables(
syllableCount: number,
- tones: (number | null)[] // Array of tones, one for each syllable. null means any tone.
+ tones: (number | null)[], // Array of tones, one for each syllable. null means any tone.
): Promise<WordData | null> {
if (syllableCount !== tones.length) {
console.error("Syllable count and tones array length mismatch");
return null;
}
- const queryParams: (string | number)[] = ['th', syllableCount, syllableCount]; // lang, syllables (for WHERE), syllables (for json_array_length)
+ const queryParams: (string | number)[] = ["th", syllableCount, syllableCount]; // lang, syllables (for WHERE), syllables (for json_array_length)
let toneConditions = "";
const toneClauses: string[] = [];
tones.forEach((tone, index) => {
- if (tone !== null && typeof tone === 'number') {
+ if (tone !== null && typeof tone === "number") {
// Assumes SQLite's json_extract function is available and prosody is like: [{"tone": 1}, {"tone": 3}, ...]
// Path for first syllable's tone: '$[0].tone'
toneClauses.push(`json_extract(prosody, '$[${index}].tone') = ?`);
@@ -34,11 +34,20 @@ export async function fetchWordsByToneAndSyllables(
});
if (toneClauses.length > 0) {
- toneConditions = `AND ${toneClauses.join(' AND ')}`;
+ toneConditions = `AND ${toneClauses.join(" AND ")}`;
}
const queryString = `
- SELECT id, spelling, prosody, syllables, lang, type, frequency, confidence, ipa, senses_array
+ SELECT id, spelling, prosody, syllables, lang, type, frequency, confidence, ipa,
+ (SELECT
+ json_group_array(json_object(
+ 'pos', pos,
+ 'senses', s.senses,
+ 'forms', forms,
+ 'etymology', etymology,
+ 'related', related)
+ ) FROM senses s WHERE s.parent_id = expressions.id
+ ) as senses_array
FROM expressions
WHERE lang = ?
AND syllables = ?
@@ -52,7 +61,7 @@ export async function fetchWordsByToneAndSyllables(
try {
const query = db.db.query(queryString);
- const row = query.get(...queryParams) as any;
+ const row = query.get(...queryParams) as any;
if (!row) return null;
@@ -69,13 +78,17 @@ export async function fetchWordsByToneAndSyllables(
confidence: row.confidence,
ipa: row.ipa ? JSON.parse(row.ipa) : [],
// Senses parsing is simplified here. Adjust if full sense data is needed.
- senses: row.senses_array ? JSON.parse(row.senses_array).map((s: any) => ({
- pos: s.pos,
- senses: typeof s.senses === 'string' ? JSON.parse(s.senses) : s.senses,
- forms: typeof s.forms === 'string' ? JSON.parse(s.forms) : s.forms,
- etymology: s.etymology,
- related: typeof s.related === 'string' ? JSON.parse(s.related) : s.related,
- })) : [],
+ senses: row.senses_array
+ ? JSON.parse(row.senses_array).map((s: any) => ({
+ pos: s.pos,
+ senses:
+ typeof s.senses === "string" ? JSON.parse(s.senses) : s.senses,
+ forms: typeof s.forms === "string" ? JSON.parse(s.forms) : s.forms,
+ etymology: s.etymology,
+ related:
+ typeof s.related === "string" ? JSON.parse(s.related) : s.related,
+ }))
+ : [],
};
return word;
} catch (error) {
diff --git a/src/pages/logintest/Form.tsx b/src/components/logintest/Form.tsx
index a593acb..a593acb 100644
--- a/src/pages/logintest/Form.tsx
+++ b/src/components/logintest/Form.tsx
diff --git a/src/pages/logintest/ServerForm.tsx b/src/components/logintest/ServerForm.tsx
index 8e629b8..8e629b8 100644
--- a/src/pages/logintest/ServerForm.tsx
+++ b/src/components/logintest/ServerForm.tsx
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..12daad7
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ )
+}
+
+export { Badge, badgeVariants } \ No newline at end of file
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..3d6f3ac
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,64 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+ return (
+ <TabsPrimitive.Root
+ data-slot="tabs"
+ className={cn("flex flex-col gap-2", className)}
+ {...props}
+ />
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.List>) {
+ return (
+ <TabsPrimitive.List
+ data-slot="tabs-list"
+ className={cn(
+ "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+ return (
+ <TabsPrimitive.Trigger
+ data-slot="tabs-trigger"
+ className={cn(
+ "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+ return (
+ <TabsPrimitive.Content
+ data-slot="tabs-content"
+ className={cn("flex-1 outline-none", className)}
+ {...props}
+ />
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index 5c13f85..2212583 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -3,7 +3,7 @@ import { getDBOffset, wordFactorial } from "../utils";
import type { AddSense, AddWord, Result, State } from "../types";
import { DEFAULT_SRS } from "../services/srs";
import { DBWord, WordData } from "@/zoom/logic/types";
-import { DeckResponse } from "../types/cards";
+import { CardResponse, DeckResponse } from "../types/cards";
const PAGE_SIZE = 100;
@@ -506,6 +506,110 @@ class DatabaseHandler {
`);
return query.all({ cid, userid });
}
+
+ /**
+ * Fetch a single card by ID with all related data
+ * @param cardId Card ID
+ * @param userId User ID
+ * @returns Card data with progress and expression information
+ */
+ fetchCardById(cardId: number, userId: number): Result<CardResponse> {
+ console.time("fetchCardById-total");
+
+ // Query to fetch card with expression and progress data
+ const query = this.db.query(`
+ SELECT
+ cards.id as cid, cards.text, cards.note,
+ up.id as upid,
+ up.repetition_count,
+ up.ease_factor,
+ up.interval,
+ up.next_review_date,
+ up.last_reviewed,
+ up.is_mastered,
+ e.id as eid,
+ e.*,
+ (CASE WHEN bm.word_id IS NULL THEN 0 ELSE 1 END) as is_bookmarked,
+ (SELECT
+ json_group_array(json_object(
+ 'pos', pos,
+ 'senses', s.senses,
+ 'forms', forms,
+ 'etymology', etymology,
+ 'related', related)
+ )
+ FROM senses s WHERE s.parent_id = e.id
+ ) as senses_array
+ FROM cards
+ JOIN cards_expressions ce ON cards.id = ce.card_id
+ JOIN expressions e ON e.id = ce.expression_id
+ LEFT JOIN user_progress up ON up.card_id = cards.id AND up.user_id = ?
+ LEFT JOIN bookmarks bm ON bm.word_id = e.id AND bm.user_id = ?
+ WHERE cards.id = ?
+ `);
+
+ const result = query.get(userId, userId, cardId);
+
+ if (!result) {
+ console.timeEnd("fetchCardById-total");
+ return { error: "Card not found" };
+ }
+
+ console.time("fetchCardById-json-processing");
+
+ // Process the row into a CardResponse object
+ const row: any = result;
+
+ // Process sense data
+ const sense_array = JSON.parse(row.senses_array);
+ const senses = sense_array.map((s: any) => {
+ const senses = JSON.parse(s.senses);
+ const related = JSON.parse(s.related);
+ const forms = JSON.parse(s.forms);
+ return { ...s, senses, related, forms };
+ });
+
+ // Create expression object
+ const expression = {
+ isBookmarked: row.is_bookmarked > 0,
+ ipa: JSON.parse(row.ipa),
+ prosody: JSON.parse(row.prosody),
+ syllables: row.syllables,
+ frequency: row.frequency,
+ type: row.type,
+ lang: row.lang,
+ spelling: row.spelling,
+ id: row.eid,
+ confidence: row.confidence,
+ senses,
+ };
+
+ // Create progress object (default if not exists)
+ const progress = !row.upid
+ ? DEFAULT_SRS
+ : {
+ repetitionCount: row.repetition_count,
+ easeFactor: row.ease_factor,
+ interval: row.interval,
+ nextReviewDate: row.next_review_date,
+ lastReviewed: row.last_reviewed,
+ isMastered: row.is_mastered,
+ };
+
+ // Create card response
+ const card = {
+ text: row.text,
+ note: row.note,
+ id: row.cid,
+ expression,
+ progress,
+ };
+
+ console.timeEnd("fetchCardById-json-processing");
+ console.timeEnd("fetchCardById-total");
+
+ return { ok: card };
+ }
//
// write
//
diff --git a/src/lib/server/cookie.ts b/src/lib/server/cookie.ts
index 32894b9..80f8f9f 100644
--- a/src/lib/server/cookie.ts
+++ b/src/lib/server/cookie.ts
@@ -7,16 +7,14 @@ const cookieMiddleware: Middleware = () => {
// Parse incoming cookies
const cookies = cookie.parse(ctx.req.headers.cookie || "");
const coki = cookies.sorlang;
-
+
// If cookie exists, fetch user data and set in context
if (coki) {
const userRow = db.fetchCookie(coki);
- if (userRow) {
- ctx.data.user = { id: userRow.id, name: userRow.name };
- console.log("User authenticated:", userRow.name);
- }
+ if (userRow) ctx.data.user = { id: userRow.id, name: userRow.name };
+ // console.log("User authenticated:", userRow.name);
}
-
+
// Uncomment to enable redirection for unauthenticated users
/*
if (!ctx.data.user && ctx.req.url.pathname !== "/login") {
@@ -27,9 +25,9 @@ const cookieMiddleware: Middleware = () => {
return;
}
*/
-
+
await next();
-
+
// Cookie setting is now handled by setCookieMiddleware
};
};
diff --git a/src/lib/services/srs_study.ts b/src/lib/services/srs_study.ts
index 9223722..0930795 100644
--- a/src/lib/services/srs_study.ts
+++ b/src/lib/services/srs_study.ts
@@ -309,27 +309,14 @@ export class SRSStudyService {
// Record the attempt
this.recordAttempt(userId, cardId, accuracy > 0.6 ? 1 : 0, reviewTime);
- // Fetch updated card data
- const updatedDeckResponse = this.db.fetchLesson({
- userId,
- lessonId: 0, // We don't care about lesson context here
- count: 1,
- page: 1,
- });
+ // Fetch updated card data using the fetchCardById method
+ const updatedCardResponse = this.db.fetchCardById(cardId, userId);
- if ("error" in updatedDeckResponse) {
+ if ("error" in updatedCardResponse) {
return { error: "Failed to fetch updated card data" };
}
- // Find the updated card
- const updatedCard = updatedDeckResponse.ok.cards.find(
- (c) => c.id === cardId,
- );
- if (!updatedCard) {
- return { error: "Failed to retrieve updated card" };
- }
-
- return updatedCard;
+ return updatedCardResponse.ok;
}
/**
diff --git a/src/pages.gen.ts b/src/pages.gen.ts
index 6695ceb..2d0d34e 100644
--- a/src/pages.gen.ts
+++ b/src/pages.gen.ts
@@ -18,6 +18,8 @@ import type { getConfig as File_Logout_getConfig } from './pages/logout';
// prettier-ignore
import type { getConfig as File_Db_getConfig } from './pages/db';
// prettier-ignore
+import type { getConfig as File_Study_getConfig } from './pages/study';
+// prettier-ignore
import type { getConfig as File_Form_getConfig } from './pages/form';
// prettier-ignore
import type { getConfig as File_Tones_getConfig } from './pages/tones';
@@ -40,6 +42,7 @@ type Page =
| ({ path: '/parse' } & GetConfigResponse<typeof File_Parse_getConfig>)
| ({ path: '/logout' } & GetConfigResponse<typeof File_Logout_getConfig>)
| ({ path: '/db' } & GetConfigResponse<typeof File_Db_getConfig>)
+| ({ path: '/study' } & GetConfigResponse<typeof File_Study_getConfig>)
| { path: '/test/client-modal'; render: 'dynamic' }
| { path: '/test/trigger-modal-button'; render: 'dynamic' }
| { path: '/test'; render: 'dynamic' }
@@ -48,9 +51,7 @@ type Page =
| ({ path: '/picker' } & GetConfigResponse<typeof File_Picker_getConfig>)
| { path: '/lessons'; render: 'dynamic' }
| ({ path: '/about' } & GetConfigResponse<typeof File_About_getConfig>)
-| { path: '/logintest/Form'; render: 'dynamic' }
| ({ path: '/logintest' } & GetConfigResponse<typeof File_LogintestIndex_getConfig>)
-| { path: '/logintest/ServerForm'; render: 'dynamic' }
| ({ path: '/' } & GetConfigResponse<typeof File_Index_getConfig>);
// prettier-ignore
diff --git a/src/pages/logintest/index.tsx b/src/pages/logintest/index.tsx
index df8bc08..5707c69 100644
--- a/src/pages/logintest/index.tsx
+++ b/src/pages/logintest/index.tsx
@@ -1,6 +1,6 @@
-import { Form } from "./Form";
-import { getMessage, greet } from "./funcs";
-import { ServerForm } from "./ServerForm";
+import { Form } from "../../components/logintest/Form";
+import { getMessage, greet } from "../../actions/logintest/funcs";
+import { ServerForm } from "../../components/logintest/ServerForm";
export default function HomePage() {
return (
diff --git a/src/pages/study.tsx b/src/pages/study.tsx
new file mode 100644
index 0000000..f9450a7
--- /dev/null
+++ b/src/pages/study.tsx
@@ -0,0 +1,480 @@
+import { getContextData } from "waku/middleware/context";
+import { Link } from "waku";
+import { getUserLessons, getUserStudyStats } from "@/actions/srs";
+import {
+ BookOpen,
+ GraduationCap,
+ Clock,
+ Star,
+ ChevronRight,
+ BrainCircuit,
+ Flame,
+ Layers,
+ CalendarDays
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+import Navbar from "@/components/Navbar";
+
+// This is a server component that gets the initial data
+export default async function StudyPage() {
+ const { user } = getContextData() as any;
+
+ // Redirect to login if not authenticated
+ if (!user) {
+ return (
+ <div className="min-h-screen bg-gray-50">
+ <Navbar user={null} />
+ <div className="container mx-auto py-16 px-4">
+ <Card className="max-w-md mx-auto p-6 text-center">
+ <h1 className="text-2xl font-bold mb-4">Login Required</h1>
+ <p className="text-gray-600 mb-6">
+ You need to be logged in to access your study dashboard.
+ </p>
+ <div className="flex flex-col space-y-4">
+ <Button asChild>
+ <a href="/login">Log in</a>
+ </Button>
+ <Button variant="outline" asChild>
+ <a href="/">Return to home page</a>
+ </Button>
+ </div>
+ </Card>
+ </div>
+ </div>
+ );
+ }
+
+ // Fetch user study data
+ let userStats;
+ let userLessons;
+
+ try {
+ // Get user stats and lessons in parallel
+ [userStats, userLessons] = await Promise.all([
+ getUserStudyStats(user.id),
+ getUserLessons(user.id)
+ ]);
+ } catch (error) {
+ console.error("Error fetching study data:", error);
+ userStats = {
+ totalCards: 0,
+ masteredCards: 0,
+ dueCards: 0,
+ averageEaseFactor: 2.5,
+ successRate: 0,
+ streakDays: 0
+ };
+ userLessons = [];
+ }
+
+ // Calculate overall progress
+ const overallProgress = userStats.totalCards > 0
+ ? Math.round((userStats.masteredCards / userStats.totalCards) * 100)
+ : 0;
+
+ // Sort lessons by different criteria
+ const dueLessons = [...userLessons].sort((a, b) => b.dueCards - a.dueCards).filter(l => l.dueCards > 0);
+ const inProgressLessons = userLessons.filter(lesson => lesson.progress > 0 && lesson.progress < 100);
+ const recentLessons = [...userLessons].sort((a, b) => b.id - a.id).slice(0, 4);
+
+ return (
+ <div className="min-h-screen bg-gray-50">
+ <Navbar user={user} />
+
+ <div className="container mx-auto py-8 px-4">
+ {/* Dashboard Header */}
+ <div className="mb-8">
+ <h1 className="text-3xl font-bold text-gray-900">Study Dashboard</h1>
+ <p className="text-gray-600 mt-2">
+ Track your progress, review due cards, and continue your language learning journey
+ </p>
+ </div>
+
+ {/* Stats Overview */}
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
+ <Card>
+ <CardContent className="p-6">
+ <div className="flex items-center justify-between mb-4">
+ <div className="bg-indigo-100 p-3 rounded-full">
+ <BrainCircuit className="h-6 w-6 text-indigo-600" />
+ </div>
+ <Badge variant="outline" className="bg-gray-100">
+ Total
+ </Badge>
+ </div>
+ <h3 className="text-2xl font-bold">{userStats.totalCards}</h3>
+ <p className="text-gray-500 text-sm">Total Cards</p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardContent className="p-6">
+ <div className="flex items-center justify-between mb-4">
+ <div className="bg-green-100 p-3 rounded-full">
+ <Star className="h-6 w-6 text-green-600" />
+ </div>
+ <Badge variant="outline" className="bg-gray-100">
+ {Math.round(userStats.masteredCards / Math.max(userStats.totalCards, 1) * 100)}%
+ </Badge>
+ </div>
+ <h3 className="text-2xl font-bold">{userStats.masteredCards}</h3>
+ <p className="text-gray-500 text-sm">Mastered Cards</p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardContent className="p-6">
+ <div className="flex items-center justify-between mb-4">
+ <div className="bg-amber-100 p-3 rounded-full">
+ <Clock className="h-6 w-6 text-amber-600" />
+ </div>
+ <Badge variant="outline" className={userStats.dueCards > 0 ? "bg-red-100 text-red-800" : "bg-gray-100"}>
+ {userStats.dueCards > 0 ? "Due Today" : "All Caught Up"}
+ </Badge>
+ </div>
+ <h3 className="text-2xl font-bold">{userStats.dueCards}</h3>
+ <p className="text-gray-500 text-sm">Cards Due for Review</p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardContent className="p-6">
+ <div className="flex items-center justify-between mb-4">
+ <div className="bg-blue-100 p-3 rounded-full">
+ <Flame className="h-6 w-6 text-blue-600" />
+ </div>
+ <Badge variant="outline" className="bg-gray-100">
+ Streak
+ </Badge>
+ </div>
+ <h3 className="text-2xl font-bold">{userStats.streakDays}</h3>
+ <p className="text-gray-500 text-sm">Days in a Row</p>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* Main Content Tabs */}
+ <Tabs defaultValue="all" className="mb-8">
+ <TabsList className="mb-6">
+ <TabsTrigger value="all">All Lessons</TabsTrigger>
+ <TabsTrigger value="due">
+ Due for Review {dueLessons.length > 0 && `(${dueLessons.length})`}
+ </TabsTrigger>
+ <TabsTrigger value="in-progress">In Progress</TabsTrigger>
+ <TabsTrigger value="stats">Study Stats</TabsTrigger>
+ </TabsList>
+
+ {/* All Lessons Tab */}
+ <TabsContent value="all">
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+ {userLessons.length > 0 ? (
+ userLessons.map((lesson) => (
+ <LessonCard
+ key={lesson.id}
+ lesson={lesson}
+ />
+ ))
+ ) : (
+ <div className="col-span-full text-center py-12">
+ <h3 className="text-lg font-medium text-gray-700 mb-2">No lessons available yet</h3>
+ <p className="text-gray-500 mb-6">
+ Start your language learning journey by adding lessons
+ </p>
+ <Button asChild>
+ <a href="/parse">Parse New Text</a>
+ </Button>
+ </div>
+ )}
+ </div>
+ </TabsContent>
+
+ {/* Due for Review Tab */}
+ <TabsContent value="due">
+ {dueLessons.length > 0 ? (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+ {dueLessons.map((lesson) => (
+ <LessonCard
+ key={lesson.id}
+ lesson={lesson}
+ showDueCardsBadge={true}
+ />
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="p-8 text-center">
+ <div className="bg-green-100 h-16 w-16 rounded-full flex items-center justify-center mx-auto mb-4">
+ <GraduationCap className="h-8 w-8 text-green-600" />
+ </div>
+ <h3 className="text-xl font-medium text-gray-800 mb-2">All caught up!</h3>
+ <p className="text-gray-600 mb-6">
+ You don't have any cards due for review right now. Check back later or start a new lesson.
+ </p>
+ <Button asChild>
+ <a href="/parse">Parse New Text</a>
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* In Progress Tab */}
+ <TabsContent value="in-progress">
+ {inProgressLessons.length > 0 ? (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+ {inProgressLessons.map((lesson) => (
+ <LessonCard
+ key={lesson.id}
+ lesson={lesson}
+ showProgress={true}
+ />
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="p-8 text-center">
+ <div className="bg-indigo-100 h-16 w-16 rounded-full flex items-center justify-center mx-auto mb-4">
+ <BookOpen className="h-8 w-8 text-indigo-600" />
+ </div>
+ <h3 className="text-xl font-medium text-gray-800 mb-2">No lessons in progress</h3>
+ <p className="text-gray-600 mb-6">
+ Start learning by selecting a lesson from the All Lessons tab.
+ </p>
+ <Button asChild>
+ <Link to="/">Browse Languages</Link>
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* Stats Tab */}
+ <TabsContent value="stats">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
+ <Card className="md:col-span-2">
+ <CardHeader>
+ <CardTitle>Overall Progress</CardTitle>
+ <CardDescription>Your language learning journey progress</CardDescription>
+ </CardHeader>
+ <CardContent className="p-6">
+ <div className="mb-4">
+ <div className="flex justify-between text-sm text-gray-600 mb-2">
+ <span>Progress</span>
+ <span>{overallProgress}%</span>
+ </div>
+ <Progress value={overallProgress} className="h-3" />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 mt-8">
+ <div className="flex flex-col">
+ <span className="text-sm text-gray-500">Success Rate</span>
+ <span className="text-2xl font-bold">{Math.round(userStats.successRate * 100)}%</span>
+ </div>
+ <div className="flex flex-col">
+ <span className="text-sm text-gray-500">Average Ease</span>
+ <span className="text-2xl font-bold">{userStats.averageEaseFactor.toFixed(1)}</span>
+ </div>
+ <div className="flex flex-col">
+ <span className="text-sm text-gray-500">Total Cards</span>
+ <span className="text-2xl font-bold">{userStats.totalCards}</span>
+ </div>
+ <div className="flex flex-col">
+ <span className="text-sm text-gray-500">Mastered</span>
+ <span className="text-2xl font-bold">{userStats.masteredCards}</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader>
+ <CardTitle>Study Streak</CardTitle>
+ <CardDescription>Keep the momentum going!</CardDescription>
+ </CardHeader>
+ <CardContent className="p-6">
+ <div className="flex flex-col items-center">
+ <div className="bg-indigo-100 w-20 h-20 rounded-full flex items-center justify-center mb-4">
+ <Flame className="h-10 w-10 text-indigo-600" />
+ </div>
+ <div className="text-4xl font-bold mb-1">{userStats.streakDays}</div>
+ <div className="text-gray-500 mb-4">Days in a row</div>
+ <Button variant="outline" className="w-full" asChild>
+ <Link to={dueLessons.length > 0 ? `/study/${dueLessons[0].id}` : "/study"}>
+ Keep the streak alive
+ </Link>
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card className="md:col-span-3">
+ <CardHeader>
+ <CardTitle>Recent Activity</CardTitle>
+ <CardDescription>Your latest learning progress</CardDescription>
+ </CardHeader>
+ <CardContent className="p-0">
+ <div className="divide-y">
+ {recentLessons.map(lesson => (
+ <div key={lesson.id} className="flex items-center justify-between py-4 px-6">
+ <div className="flex items-center">
+ <div className="bg-gray-100 p-2 rounded-lg mr-4">
+ <Layers className="h-5 w-5 text-gray-600" />
+ </div>
+ <div>
+ <div className="font-medium">{lesson.name}</div>
+ <div className="text-sm text-gray-500">
+ {lesson.description || `Lesson ${lesson.id}`}
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center">
+ <div className="text-right mr-4">
+ <div className="font-medium">{Math.round(lesson.progress)}%</div>
+ <div className="text-xs text-gray-500">
+ {lesson.masteredCards} / {lesson.totalCards} cards
+ </div>
+ </div>
+ <Button variant="ghost" size="icon" asChild>
+ <Link to={`/study/${lesson.id}`}>
+ <ChevronRight className="h-5 w-5" />
+ </Link>
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ {/* Quick Access Section */}
+ <div className="mb-8">
+ <h2 className="text-xl font-bold text-gray-900 mb-4">Quick Access</h2>
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
+ <Card className="hover:shadow-md transition-shadow duration-200">
+ <CardContent className="p-6 flex items-center">
+ <div className="bg-indigo-100 p-3 rounded-full mr-4">
+ <BookOpen className="h-6 w-6 text-indigo-600" />
+ </div>
+ <div className="flex-1">
+ <h3 className="font-medium">Start New Lesson</h3>
+ <p className="text-sm text-gray-500">Add text to study</p>
+ </div>
+ <Button variant="ghost" size="icon" asChild>
+ <Link to="/parse">
+ <ChevronRight className="h-5 w-5" />
+ </Link>
+ </Button>
+ </CardContent>
+ </Card>
+
+ <Card className="hover:shadow-md transition-shadow duration-200">
+ <CardContent className="p-6 flex items-center">
+ <div className="bg-amber-100 p-3 rounded-full mr-4">
+ <CalendarDays className="h-6 w-6 text-amber-600" />
+ </div>
+ <div className="flex-1">
+ <h3 className="font-medium">Review Due Cards</h3>
+ <p className="text-sm text-gray-500">{userStats.dueCards} cards waiting</p>
+ </div>
+ <Button variant="ghost" size="icon" asChild>
+ <Link to={dueLessons.length > 0 ? `/study/${dueLessons[0].id}` : "/study"}>
+ <ChevronRight className="h-5 w-5" />
+ </Link>
+ </Button>
+ </CardContent>
+ </Card>
+
+ <Card className="hover:shadow-md transition-shadow duration-200">
+ <CardContent className="p-6 flex items-center">
+ <div className="bg-green-100 p-3 rounded-full mr-4">
+ <GraduationCap className="h-6 w-6 text-green-600" />
+ </div>
+ <div className="flex-1">
+ <h3 className="font-medium">Track Progress</h3>
+ <p className="text-sm text-gray-500">View detailed statistics</p>
+ </div>
+ <Button variant="ghost" size="icon" asChild onClick={() => document.querySelector('[data-value="stats"]')?.click()}>
+ <span>
+ <ChevronRight className="h-5 w-5" />
+ </span>
+ </Button>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// Lesson Card Component
+function LessonCard({
+ lesson,
+ showDueCardsBadge = false,
+ showProgress = false
+}: {
+ lesson: any;
+ showDueCardsBadge?: boolean;
+ showProgress?: boolean;
+}) {
+ return (
+ <Card className="hover:shadow-md transition-shadow duration-300 h-full">
+ <CardHeader className="pb-2">
+ <div className="flex justify-between items-start">
+ <div>
+ <CardTitle className="text-lg">{lesson.name}</CardTitle>
+ <CardDescription className="line-clamp-1">
+ {lesson.description || `Lesson ${lesson.id}`}
+ </CardDescription>
+ </div>
+ {showDueCardsBadge && lesson.dueCards > 0 && (
+ <Badge variant="destructive">
+ {lesson.dueCards} due
+ </Badge>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent className="pb-4">
+ <div className="text-sm text-gray-500 mb-2 flex justify-between">
+ <span>{lesson.masteredCards} of {lesson.totalCards} mastered</span>
+ <span>{Math.round(lesson.progress)}% complete</span>
+ </div>
+ <Progress value={lesson.progress} className="h-2" />
+
+ {showProgress && (
+ <div className="grid grid-cols-2 gap-4 mt-4 text-sm">
+ <div className="flex flex-col">
+ <span className="text-gray-500">Due Cards</span>
+ <span className="font-medium">{lesson.dueCards}</span>
+ </div>
+ <div className="flex flex-col">
+ <span className="text-gray-500">Mastered</span>
+ <span className="font-medium">{lesson.masteredCards}</span>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ <CardFooter className="pt-0">
+ <Button className="w-full" asChild>
+ <Link to={`/study/${lesson.id}`}>
+ {lesson.dueCards > 0 ? "Review Due Cards" : "Continue Learning"}
+ </Link>
+ </Button>
+ </CardFooter>
+ </Card>
+ );
+}
+
+export const getConfig = async () => {
+ return {
+ render: "dynamic",
+ } as const;
+};