diff options
author | polwex <polwex@sortug.com> | 2025-05-29 16:25:31 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-05-29 16:25:31 +0700 |
commit | a03c92dc82ad527d7da6bbaa3c43000e2e5f0e69 (patch) | |
tree | 9a47cae250d043d31f751c1383bdcbe09d4bc9d8 | |
parent | 7de09570c0d7907424c30f492207e80ff69e4061 (diff) |
better better
-rw-r--r-- | bun.lock | 3 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/actions/logintest/funcs.ts (renamed from src/pages/logintest/funcs.ts) | 0 | ||||
-rw-r--r-- | src/actions/tones.ts | 47 | ||||
-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.tsx | 36 | ||||
-rw-r--r-- | src/components/ui/tabs.tsx | 64 | ||||
-rw-r--r-- | src/lib/db/index.ts | 106 | ||||
-rw-r--r-- | src/lib/server/cookie.ts | 14 | ||||
-rw-r--r-- | src/lib/services/srs_study.ts | 21 | ||||
-rw-r--r-- | src/pages.gen.ts | 5 | ||||
-rw-r--r-- | src/pages/logintest/index.tsx | 6 | ||||
-rw-r--r-- | src/pages/study.tsx | 480 |
14 files changed, 735 insertions, 48 deletions
@@ -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; +}; |